feat: add shared runtime input device layer
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-05-10 17:50:00 +08:00
parent 643161a168
commit 86fc382413
12 changed files with 1095 additions and 179 deletions

View File

@@ -23,6 +23,15 @@ import type {
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
createRuntimeDragInputController,
createRuntimeInputPointFromClient,
createRuntimeInputPointFromNormalized,
readRuntimeInputElementBounds,
resolveRuntimeInputGridCell,
type RuntimeDragInputSession,
type RuntimeInputPoint,
} from '../../services/input-devices';
import { useMocapInput } from '../../services/useMocapInput';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
@@ -211,6 +220,7 @@ const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
const PUZZLE_MOCAP_DRAG_INPUT_ID = 'mocap:primary-hand';
const shownExitRemodelPromptProfileIds = new Set<string>();
@@ -290,6 +300,11 @@ type PuzzleMocapCursorState = {
state: string;
};
type PuzzleRuntimeDragTargetState = {
pieceId: string;
groupId: string | null;
};
function triggerPuzzlePiecePressHapticFeedback() {
if (typeof navigator === 'undefined') {
return;
@@ -328,6 +343,8 @@ export function PuzzleRuntimeShell({
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const selectedPieceIdRef = useRef<string | null>(null);
const selectedPieceBeforeInputRef = useRef<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
useState(false);
@@ -354,7 +371,7 @@ export function PuzzleRuntimeShell({
const timeExpiredSyncKeyRef = useRef<string | null>(null);
const dragSessionRef = useRef<{
pieceId: string;
pointerId: number;
inputId: string;
dragging: boolean;
startX: number;
startY: number;
@@ -377,7 +394,10 @@ export function PuzzleRuntimeShell({
const [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
null,
);
const mocapDragRef = useRef<{pieceId: string} | null>(null);
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController<string>(),
);
const draggingTargetRef = useRef<PuzzleRuntimeDragTargetState | null>(null);
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
null,
);
@@ -400,6 +420,8 @@ export function PuzzleRuntimeShell({
? 'failed'
: currentLevel.status
: 'playing';
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
@@ -409,12 +431,19 @@ export function PuzzleRuntimeShell({
currentLevel?.coverImageSrc ?? null,
);
const mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
const primaryMocapHandState = primaryMocapHand?.state;
const primaryMocapHandX = primaryMocapHand?.x;
const primaryMocapHandY = primaryMocapHand?.y;
const mocapActionsLabel =
mocapInput.latestCommand?.actions.length
? mocapInput.latestCommand.actions.join(', ')
: '无';
const mocapHandLabel = mocapInput.latestCommand?.primaryHand
? `${mocapInput.latestCommand.primaryHand.state} @ ${mocapInput.latestCommand.primaryHand.x.toFixed(2)}, ${mocapInput.latestCommand.primaryHand.y.toFixed(2)}`
const mocapHandLabel =
primaryMocapHandState &&
typeof primaryMocapHandX === 'number' &&
typeof primaryMocapHandY === 'number'
? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}`
: '无';
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
? mocapInput.latestCommand.parseWarnings.join('')
@@ -425,6 +454,11 @@ export function PuzzleRuntimeShell({
currentLevelRef.current = currentLevel;
}, [currentLevel]);
const commitSelectedPieceId = (pieceId: string | null) => {
selectedPieceIdRef.current = pieceId;
setSelectedPieceId(pieceId);
};
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
if (!board) {
return [];
@@ -586,13 +620,18 @@ export function PuzzleRuntimeShell({
dragVisualFrameRef.current = null;
};
const resetDragInteraction = () => {
const resetDragInteractionState = () => {
cancelDragVisualFrame();
dragOffsetRef.current = null;
dragSessionRef.current = null;
draggingTargetRef.current = null;
resetDragVisualTarget();
};
const resetDragInteraction = () => {
runtimeDragInputControllerRef.current.cancel();
};
const flushDragVisual = () => {
dragVisualFrameRef.current = null;
const dragSession = dragSessionRef.current;
@@ -602,7 +641,8 @@ export function PuzzleRuntimeShell({
}
const piece = pieceById.get(dragSession.pieceId) ?? null;
const groupId = piece?.mergedGroupId ?? null;
const groupId =
draggingTargetRef.current?.groupId ?? piece?.mergedGroupId ?? null;
const nextTarget = {
pieceId: dragSession.pieceId,
groupId,
@@ -808,6 +848,221 @@ export function PuzzleRuntimeShell({
];
}, [clearResultKey, currentLevel, dismissedClearKey]);
const handlePieceTap = (
pieceId: string,
selectedPieceIdBeforeInput: string | null,
) => {
if (isInteractionLocked) {
return;
}
if (!selectedPieceIdBeforeInput) {
commitSelectedPieceId(pieceId);
return;
}
if (selectedPieceIdBeforeInput === pieceId) {
commitSelectedPieceId(null);
return;
}
onSwapPieces({
firstPieceId: selectedPieceIdBeforeInput,
secondPieceId: pieceId,
});
commitSelectedPieceId(null);
};
const resolvePuzzleRuntimeDragTarget = (
pieceId: string,
): PuzzleRuntimeDragTargetState | null => {
const sourcePiece = pieceById.get(pieceId) ?? null;
if (!sourcePiece) {
return null;
}
return {
pieceId: sourcePiece.pieceId,
groupId: sourcePiece.mergedGroupId ?? null,
};
};
const commitPuzzleRuntimeDrag = (
target: PuzzleRuntimeDragTargetState | null,
point: RuntimeInputPoint,
) => {
const dragSession = dragSessionRef.current;
if (!target || !dragSession) {
return;
}
const targetCell = board
? resolveRuntimeInputGridCell(point, board)
: null;
if (!targetCell) {
return;
}
onDragPiece({
pieceId: target.pieceId,
targetRow: targetCell.row,
targetCol: targetCell.col,
});
};
const resolveBoardInputPointFromClient = (
clientX: number,
clientY: number,
) =>
createRuntimeInputPointFromClient(
clientX,
clientY,
readRuntimeInputElementBounds(boardRef.current),
);
const resolveBoardInputPointFromNormalized = (
normalizedX: number,
normalizedY: number,
) =>
createRuntimeInputPointFromNormalized(
normalizedX,
normalizedY,
readRuntimeInputElementBounds(boardRef.current),
);
const syncRuntimeDragFromController = (
session: RuntimeDragInputSession<string> | null,
) => {
if (!session) {
return;
}
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
dragSessionRef.current = {
pieceId: session.targetId,
inputId: session.inputId,
dragging: session.dragging,
startX: session.startPoint.clientX,
startY: session.startPoint.clientY,
currentX: session.currentPoint.clientX,
currentY: session.currentPoint.clientY,
};
if (session.dragging) {
flushDragVisual();
scheduleDragVisual();
}
};
runtimeDragInputControllerRef.current.setOptions({
dragThresholdPx: 8,
onPress: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
syncRuntimeDragFromController(session);
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
commitSelectedPieceId(session.targetId);
triggerPuzzlePiecePressHapticFeedback();
},
onDragStart: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
syncRuntimeDragFromController(session);
},
onDragMove: (session) => {
syncRuntimeDragFromController(session);
},
onDrop: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
syncRuntimeDragFromController(session);
commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint);
commitSelectedPieceId(null);
selectedPieceBeforeInputRef.current = null;
resetDragInteractionState();
},
onTap: (session) => {
handlePieceTap(session.targetId, selectedPieceBeforeInputRef.current);
selectedPieceBeforeInputRef.current = null;
resetDragInteractionState();
},
onCancel: () => {
commitSelectedPieceId(selectedPieceBeforeInputRef.current);
selectedPieceBeforeInputRef.current = null;
resetDragInteractionState();
},
});
useEffect(() => {
const activeSession = runtimeDragInputControllerRef.current.getSession();
if (!board || runtimeStatus !== 'playing' || isInteractionLocked) {
runtimeDragInputControllerRef.current.cancel();
setMocapCursor(null);
return;
}
if (
!primaryMocapHandState ||
typeof primaryMocapHandX !== 'number' ||
typeof primaryMocapHandY !== 'number'
) {
runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID);
setMocapCursor(null);
return;
}
setMocapCursor({
x: primaryMocapHandX,
y: primaryMocapHandY,
state: primaryMocapHandState,
});
const handPoint = resolveBoardInputPointFromNormalized(
primaryMocapHandX,
primaryMocapHandY,
);
if (primaryMocapHandState === 'grab') {
if (activeSession?.inputId !== PUZZLE_MOCAP_DRAG_INPUT_ID) {
const sourceCell = resolveRuntimeInputGridCell(handPoint, board);
const sourcePiece = sourceCell
? pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null
: null;
if (!sourcePiece) {
runtimeDragInputControllerRef.current.cancel(
PUZZLE_MOCAP_DRAG_INPUT_ID,
);
return;
}
runtimeDragInputControllerRef.current.press({
targetId: sourcePiece.pieceId,
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
deviceKind: 'mocap',
point: handPoint,
});
return;
}
runtimeDragInputControllerRef.current.move({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: handPoint,
forceDragging: true,
});
return;
}
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
runtimeDragInputControllerRef.current.release({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: handPoint,
forceDrop: activeSession.deviceKind === 'mocap',
});
}
}, [
board,
isInteractionLocked,
pieceByCell,
primaryMocapHandState,
primaryMocapHandX,
primaryMocapHandY,
runtimeStatus,
]);
if (!run || !currentLevel || !board) {
return (
<div
@@ -821,131 +1076,12 @@ export function PuzzleRuntimeShell({
);
}
const handlePieceClick = (pieceId: string) => {
if (isInteractionLocked) {
return;
}
if (!selectedPieceId) {
setSelectedPieceId(pieceId);
return;
}
if (selectedPieceId === pieceId) {
setSelectedPieceId(null);
return;
}
onSwapPieces({
firstPieceId: selectedPieceId,
secondPieceId: pieceId,
});
setSelectedPieceId(null);
};
const resolveBoardCellFromPointer = (clientX: number, clientY: number) => {
const boardElement = boardRef.current;
if (!boardElement) {
return null;
}
const rect = boardElement.getBoundingClientRect();
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
) {
return null;
}
const relativeX = clientX - rect.left;
const relativeY = clientY - rect.top;
const col = Math.min(
board.cols - 1,
Math.max(0, Math.floor((relativeX / rect.width) * board.cols)),
);
const row = Math.min(
board.rows - 1,
Math.max(0, Math.floor((relativeY / rect.height) * board.rows)),
);
return { row, col };
};
const resolveMocapTargetCell = (x: number, y: number) => ({
row: Math.min(board.rows - 1, Math.max(0, Math.floor(y * board.rows))),
col: Math.min(board.cols - 1, Math.max(0, Math.floor(x * board.cols))),
});
const handleMocapInputCommand = () => {
const hand = mocapInput.latestCommand?.primaryHand;
if (runtimeStatus !== 'playing' || isInteractionLocked || !hand) {
mocapDragRef.current = null;
setMocapCursor(null);
return;
}
setMocapCursor({x: hand.x, y: hand.y, state: hand.state});
if (hand.state === 'grab') {
if (mocapDragRef.current) {
return;
}
const sourceCell = resolveMocapTargetCell(hand.x, hand.y);
const sourcePiece = pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null;
if (!sourcePiece || sourcePiece.mergedGroupId) {
return;
}
mocapDragRef.current = {pieceId: sourcePiece.pieceId};
setSelectedPieceId(sourcePiece.pieceId);
triggerPuzzlePiecePressHapticFeedback();
return;
}
const draggingPiece = mocapDragRef.current;
if (!draggingPiece) {
return;
}
const targetCell = resolveMocapTargetCell(hand.x, hand.y);
mocapDragRef.current = null;
setSelectedPieceId(null);
onDragPiece({
pieceId: draggingPiece.pieceId,
targetRow: targetCell.row,
targetCol: targetCell.col,
});
};
const handlePiecePointerUp = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
const currentDragSession = dragSessionRef.current;
if (!currentDragSession || currentDragSession.pieceId !== pieceId) {
return;
}
const handlePiecePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId);
if (currentDragSession.dragging) {
const targetCell = resolveBoardCellFromPointer(
event.clientX,
event.clientY,
);
resetDragInteraction();
if (targetCell) {
onDragPiece({
pieceId,
targetRow: targetCell.row,
targetCol: targetCell.col,
});
}
setSelectedPieceId(null);
return;
}
resetDragInteraction();
handlePieceClick(pieceId);
runtimeDragInputControllerRef.current.release({
inputId: `pointer:${event.pointerId}`,
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
});
};
const handlePiecePointerDown = (
@@ -958,46 +1094,20 @@ export function PuzzleRuntimeShell({
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);
// 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。
triggerPuzzlePiecePressHapticFeedback();
dragSessionRef.current = {
pieceId,
pointerId: event.pointerId,
dragging: false,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
};
runtimeDragInputControllerRef.current.press({
targetId: pieceId,
inputId: `pointer:${event.pointerId}`,
deviceKind: 'pointer',
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
});
};
const handlePiecePointerMove = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
const dragSession = dragSessionRef.current;
if (
!dragSession ||
dragSession.pieceId !== pieceId ||
dragSession.pointerId !== event.pointerId
) {
return;
}
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
const deltaX = event.clientX - dragSession.startX;
const deltaY = event.clientY - dragSession.startY;
const dragging = dragSession.dragging || Math.hypot(deltaX, deltaY) >= 8;
dragSession.dragging = dragging;
dragSession.currentX = event.clientX;
dragSession.currentY = event.clientY;
if (!dragging) {
return;
}
// 首帧拖拽反馈立即落到 DOM确保层级提升不会滞后一帧后续仍保留 raf 兜底连续刷新。
flushDragVisual();
scheduleDragVisual();
runtimeDragInputControllerRef.current.move({
inputId: `pointer:${event.pointerId}`,
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
});
};
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
@@ -1037,8 +1147,6 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const handleBackRequest = () => {
if (hideExitControls) {
return;
@@ -1150,10 +1258,6 @@ export function PuzzleRuntimeShell({
}
};
useEffect(() => {
handleMocapInputCommand();
}, [mocapInput.latestCommand?.primaryHand]);
return (
<div
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
@@ -1311,11 +1415,11 @@ export function PuzzleRuntimeShell({
if (!piece || isMerged) {
return;
}
handlePiecePointerMove(piece.pieceId, event);
handlePiecePointerMove(event);
}}
onPointerUp={(event) => {
if (piece && !isMerged) {
handlePiecePointerUp(piece.pieceId, event);
handlePiecePointerUp(event);
}
}}
onPointerCancel={() => {
@@ -1461,10 +1565,10 @@ export function PuzzleRuntimeShell({
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
handlePiecePointerMove(piece.pieceId, event);
handlePiecePointerMove(event);
}}
onPointerUp={(event) => {
handlePiecePointerUp(piece.pieceId, event);
handlePiecePointerUp(event);
}}
onPointerCancel={() => {
resetDragInteraction();