feat: add shared runtime input device layer
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
This commit is contained in:
@@ -857,6 +857,19 @@ function shouldUseLocalPuzzleOnboardingFallback(error: unknown) {
|
||||
);
|
||||
}
|
||||
|
||||
function isMissingPuzzleWorkError(error: unknown) {
|
||||
return (
|
||||
(error instanceof ApiClientError &&
|
||||
error.status === 404 &&
|
||||
(error.code === 'NOT_FOUND' ||
|
||||
error.message.includes('资源不存在') ||
|
||||
error.message.includes('未找到'))) ||
|
||||
(error instanceof Error &&
|
||||
(error.message.includes('资源不存在') ||
|
||||
error.message.includes('未找到拼图作品')))
|
||||
);
|
||||
}
|
||||
|
||||
function hasSeenPuzzleOnboarding() {
|
||||
if (typeof window === 'undefined') {
|
||||
return true;
|
||||
@@ -4062,6 +4075,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (isMissingPuzzleWorkError(error)) {
|
||||
setSelectedPuzzleDetail(null);
|
||||
setPuzzleDetailReturnTarget(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
return false;
|
||||
}
|
||||
|
||||
const message = resolvePuzzleErrorMessage(error, '启动拼图玩法失败。');
|
||||
setPuzzleError(message);
|
||||
if (mirrorErrorToPublicDetail) {
|
||||
@@ -4077,8 +4103,8 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
setPlatformTab,
|
||||
setSelectionStage,
|
||||
startPuzzleRun,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5517,6 +5543,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
setPuzzleDetailReturnTarget(returnTarget);
|
||||
openPublicWorkDetail(mapPuzzleWorkToPublicWorkDetail(item));
|
||||
} catch (error) {
|
||||
if (isMissingPuzzleWorkError(error)) {
|
||||
setSelectedPuzzleDetail(null);
|
||||
setPuzzleDetailReturnTarget(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setPublicWorkDetailError(
|
||||
resolvePuzzleErrorMessage(error, '读取拼图详情失败。'),
|
||||
);
|
||||
@@ -5531,6 +5570,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
setPlatformTab,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
@@ -5722,6 +5762,19 @@ export function PlatformEntryFlowShellImpl({
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMissingPuzzleWorkError(error)) {
|
||||
setSelectedPuzzleDetail(null);
|
||||
setPuzzleDetailReturnTarget(null);
|
||||
setPuzzleRun(null);
|
||||
setPuzzleRuntimeAuthMode('default');
|
||||
setPuzzleError(null);
|
||||
setPublicWorkDetailError(null);
|
||||
setPlatformTab('home');
|
||||
setSelectionStage('platform');
|
||||
pushAppHistoryPath('/');
|
||||
return;
|
||||
}
|
||||
|
||||
setPuzzleError(resolvePuzzleErrorMessage(error, '读取拼图详情失败。'));
|
||||
} finally {
|
||||
setIsPuzzleBusy(false);
|
||||
@@ -5732,6 +5785,7 @@ export function PlatformEntryFlowShellImpl({
|
||||
resolvePuzzleErrorMessage,
|
||||
setIsPuzzleBusy,
|
||||
setPuzzleError,
|
||||
setPlatformTab,
|
||||
setSelectionStage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -207,8 +207,11 @@ test('拼图界面在 mocap open_palm 时显示体感光标', () => {
|
||||
|
||||
const cursor = screen.getByTestId('puzzle-mocap-cursor');
|
||||
expect(cursor).toBeTruthy();
|
||||
expect(cursor).toHaveStyle({left: '42%', top: '58%'});
|
||||
expect(Number.parseFloat(cursor.style.left)).toBeCloseTo(42);
|
||||
expect(Number.parseFloat(cursor.style.top)).toBeCloseTo(58);
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
});
|
||||
|
||||
test('抓握时会触发拖拽提交并在松开时落子', () => {
|
||||
@@ -301,6 +304,144 @@ test('抓握时会触发拖拽提交并在松开时落子', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('mocap 抓握合并大块时按大块锚点提交拖拽', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.2;
|
||||
mocapMock.y = 0.2;
|
||||
const onDragPiece = vi.fn();
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 300_000,
|
||||
timeLimitMs: 300_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
mergedGroups: [
|
||||
{
|
||||
groupId: 'group-large',
|
||||
pieceIds: ['piece-0', 'piece-1', 'piece-3'],
|
||||
occupiedCells: [
|
||||
{ row: 0, col: 0 },
|
||||
{ row: 0, col: 1 },
|
||||
{ row: 1, col: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece) =>
|
||||
['piece-0', 'piece-1', 'piece-3'].includes(piece.pieceId)
|
||||
? { ...piece, mergedGroupId: 'group-large' }
|
||||
: piece,
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(() => 1),
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
const { container, rerender, unmount } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const board = container.querySelector(
|
||||
'[data-testid="puzzle-board"]',
|
||||
) as HTMLElement | null;
|
||||
if (!board) {
|
||||
throw new Error('缺少测试棋盘');
|
||||
}
|
||||
board.getBoundingClientRect = () =>
|
||||
({
|
||||
x: 0,
|
||||
y: 0,
|
||||
left: 0,
|
||||
top: 0,
|
||||
right: 300,
|
||||
bottom: 300,
|
||||
width: 300,
|
||||
height: 300,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect;
|
||||
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.2;
|
||||
mocapMock.y = 0.2;
|
||||
rerender(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
mocapMock.x = 0.7;
|
||||
mocapMock.y = 0.7;
|
||||
rerender(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
mocapMock.state = 'open_palm';
|
||||
rerender(
|
||||
<AuthUiContext.Provider value={createAuthValue()}>
|
||||
<PuzzleRuntimeShell
|
||||
run={mergedRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={onDragPiece}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>
|
||||
</AuthUiContext.Provider>,
|
||||
);
|
||||
|
||||
expect(onDragPiece).toHaveBeenCalledTimes(1);
|
||||
expect(onDragPiece).toHaveBeenCalledWith({
|
||||
pieceId: 'piece-0',
|
||||
targetRow: 2,
|
||||
targetCol: 2,
|
||||
});
|
||||
|
||||
unmount();
|
||||
Object.defineProperty(window, 'requestAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalRequestAnimationFrame,
|
||||
});
|
||||
Object.defineProperty(window, 'cancelAnimationFrame', {
|
||||
configurable: true,
|
||||
value: originalCancelAnimationFrame,
|
||||
});
|
||||
mocapMock.state = 'grab';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
});
|
||||
|
||||
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useFakeTimers();
|
||||
const onAdvanceNextLevel = vi.fn();
|
||||
@@ -820,6 +961,9 @@ test('移动端点击拼图片时立即触发一次震动反馈', () => {
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
||||
const vibrate = vi.fn();
|
||||
mocapMock.state = 'open_palm';
|
||||
mocapMock.x = 0.42;
|
||||
mocapMock.y = 0.58;
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4078,6 +4078,54 @@ test('public code search opens a published puzzle by PZ code', async () => {
|
||||
expect(getRpgEntryWorldGalleryDetailByCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('missing puzzle public detail returns to platform home', async () => {
|
||||
const user = userEvent.setup();
|
||||
const missingPuzzleWork = {
|
||||
workId: 'puzzle-work-missing-1',
|
||||
profileId: 'puzzle-profile-missing-1',
|
||||
ownerUserId: 'user-2',
|
||||
sourceSessionId: null,
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '失效拼图',
|
||||
summary: '这个作品已经不可用。',
|
||||
themeTags: ['失效'],
|
||||
coverImageSrc: null,
|
||||
coverAssetId: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: '2026-04-25T09:00:00.000Z',
|
||||
publishedAt: '2026-04-25T09:00:00.000Z',
|
||||
playCount: 1,
|
||||
remixCount: 0,
|
||||
likeCount: 0,
|
||||
publishReady: true,
|
||||
} satisfies PuzzleWorkSummary;
|
||||
|
||||
vi.mocked(listPuzzleGallery).mockResolvedValue({
|
||||
items: [missingPuzzleWork],
|
||||
});
|
||||
vi.mocked(getPuzzleGalleryDetail).mockRejectedValueOnce(
|
||||
new ApiClientError({
|
||||
message: '资源不存在',
|
||||
status: 404,
|
||||
code: 'NOT_FOUND',
|
||||
}),
|
||||
);
|
||||
|
||||
render(<TestWrapper />);
|
||||
await openDiscoverHub(user);
|
||||
|
||||
const workCards = await screen.findAllByRole('button', { name: /失效拼图/u });
|
||||
await user.click(workCards[0]!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(window.location.pathname).toBe('/');
|
||||
});
|
||||
expect(getPlatformTabPanel('home').getAttribute('aria-hidden')).toBe('false');
|
||||
expect(screen.queryByText('详情')).toBeNull();
|
||||
expect(screen.queryByText('资源不存在')).toBeNull();
|
||||
expect(startPuzzleRun).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('public code search opens a published big fish work by BF code', async () => {
|
||||
const user = userEvent.setup();
|
||||
const bigFishWork: BigFishWorkSummary = {
|
||||
|
||||
19
src/services/input-devices/index.ts
Normal file
19
src/services/input-devices/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
createRuntimeDragInputController,
|
||||
type RuntimeDragInputControllerOptions,
|
||||
type RuntimeDragInputMove,
|
||||
type RuntimeDragInputPress,
|
||||
type RuntimeDragInputRelease,
|
||||
type RuntimeDragInputSession,
|
||||
type RuntimeInputDeviceKind,
|
||||
type RuntimeInputPoint,
|
||||
} from './runtimeDragInputController';
|
||||
export {
|
||||
createRuntimeInputPointFromClient,
|
||||
createRuntimeInputPointFromNormalized,
|
||||
readRuntimeInputElementBounds,
|
||||
resolveRuntimeInputGridCell,
|
||||
type RuntimeInputBounds,
|
||||
type RuntimeInputGridCell,
|
||||
type RuntimeInputGridSpec,
|
||||
} from './runtimeInputGeometry';
|
||||
161
src/services/input-devices/runtimeDragInputController.test.ts
Normal file
161
src/services/input-devices/runtimeDragInputController.test.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { describe, expect, test, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createRuntimeDragInputController,
|
||||
createRuntimeInputPointFromNormalized,
|
||||
resolveRuntimeInputGridCell,
|
||||
} from './index';
|
||||
|
||||
describe('runtime drag input controller', () => {
|
||||
test('pointer-like short press remains a tap', () => {
|
||||
const onTap = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 12,
|
||||
onTap,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'pointer:1',
|
||||
deviceKind: 'pointer',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.move({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 14, clientY: 14 },
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 14, clientY: 14 },
|
||||
});
|
||||
|
||||
expect(onTap).toHaveBeenCalledTimes(1);
|
||||
expect(onTap).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ targetId: 'piece-1', dragging: false }),
|
||||
);
|
||||
expect(onDrop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('device adapters can force continuous drag semantics', () => {
|
||||
const onDragStart = vi.fn();
|
||||
const onDragMove = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 100,
|
||||
onDragStart,
|
||||
onDragMove,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'mocap:hand',
|
||||
deviceKind: 'mocap',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.move({
|
||||
inputId: 'mocap:hand',
|
||||
point: { clientX: 11, clientY: 11 },
|
||||
forceDragging: true,
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'mocap:hand',
|
||||
point: { clientX: 12, clientY: 12 },
|
||||
});
|
||||
|
||||
expect(onDragStart).toHaveBeenCalledTimes(1);
|
||||
expect(onDragMove).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deviceKind: 'mocap', dragging: true }),
|
||||
);
|
||||
});
|
||||
|
||||
test('device adapters can force drop on release without converting to tap', () => {
|
||||
const onTap = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 100,
|
||||
onTap,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'mocap:hand',
|
||||
deviceKind: 'mocap',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'mocap:hand',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
forceDrop: true,
|
||||
});
|
||||
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
targetId: 'piece-1',
|
||||
forceDrop: true,
|
||||
dragging: false,
|
||||
}),
|
||||
);
|
||||
expect(onTap).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('input-scoped cancel keeps unrelated active sessions alive', () => {
|
||||
const onCancel = vi.fn();
|
||||
const onDrop = vi.fn();
|
||||
const controller = createRuntimeDragInputController({
|
||||
dragThresholdPx: 1,
|
||||
onCancel,
|
||||
onDrop,
|
||||
});
|
||||
|
||||
controller.press({
|
||||
targetId: 'piece-1',
|
||||
inputId: 'pointer:1',
|
||||
deviceKind: 'pointer',
|
||||
point: { clientX: 10, clientY: 10 },
|
||||
});
|
||||
controller.cancel('mocap:hand');
|
||||
controller.move({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 20, clientY: 20 },
|
||||
});
|
||||
controller.release({
|
||||
inputId: 'pointer:1',
|
||||
point: { clientX: 20, clientY: 20 },
|
||||
});
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
expect(onDrop).toHaveBeenCalledTimes(1);
|
||||
expect(onDrop).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ inputId: 'pointer:1', targetId: 'piece-1' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runtime input geometry', () => {
|
||||
test('normalised device coordinates map into client coordinates and grid cells', () => {
|
||||
const point = createRuntimeInputPointFromNormalized(0.75, 0.25, {
|
||||
left: 20,
|
||||
top: 10,
|
||||
width: 200,
|
||||
height: 100,
|
||||
});
|
||||
|
||||
expect(point).toEqual({
|
||||
clientX: 170,
|
||||
clientY: 35,
|
||||
normalizedX: 0.75,
|
||||
normalizedY: 0.25,
|
||||
});
|
||||
expect(resolveRuntimeInputGridCell(point, { rows: 4, cols: 4 })).toEqual({
|
||||
row: 1,
|
||||
col: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/services/input-devices/runtimeDragInputController.ts
Normal file
168
src/services/input-devices/runtimeDragInputController.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
export type RuntimeInputDeviceKind = 'pointer' | 'mocap' | 'keyboard' | 'unknown';
|
||||
|
||||
export type RuntimeInputPoint = {
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
normalizedX?: number;
|
||||
normalizedY?: number;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputSession<TTargetId extends string = string> = {
|
||||
targetId: TTargetId;
|
||||
inputId: string;
|
||||
deviceKind: RuntimeInputDeviceKind;
|
||||
startPoint: RuntimeInputPoint;
|
||||
currentPoint: RuntimeInputPoint;
|
||||
dragging: boolean;
|
||||
forceDrop: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputPress<TTargetId extends string = string> = {
|
||||
targetId: TTargetId;
|
||||
inputId: string;
|
||||
deviceKind: RuntimeInputDeviceKind;
|
||||
point: RuntimeInputPoint;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputMove = {
|
||||
inputId: string;
|
||||
point: RuntimeInputPoint;
|
||||
forceDragging?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputRelease = {
|
||||
inputId: string;
|
||||
point: RuntimeInputPoint;
|
||||
forceDrop?: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeDragInputControllerOptions<
|
||||
TTargetId extends string = string,
|
||||
> = {
|
||||
dragThresholdPx?: number;
|
||||
onPress?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onDragStart?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onDragMove?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onDrop?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onTap?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
onCancel?: (session: RuntimeDragInputSession<TTargetId>) => void;
|
||||
};
|
||||
|
||||
const DEFAULT_DRAG_THRESHOLD_PX = 8;
|
||||
|
||||
function clonePoint(point: RuntimeInputPoint): RuntimeInputPoint {
|
||||
return { ...point };
|
||||
}
|
||||
|
||||
function shouldStartDragging(
|
||||
session: RuntimeDragInputSession,
|
||||
point: RuntimeInputPoint,
|
||||
thresholdPx: number,
|
||||
forceDragging = false,
|
||||
) {
|
||||
if (session.dragging || forceDragging) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
Math.hypot(
|
||||
point.clientX - session.startPoint.clientX,
|
||||
point.clientY - session.startPoint.clientY,
|
||||
) >= thresholdPx
|
||||
);
|
||||
}
|
||||
|
||||
export function createRuntimeDragInputController<
|
||||
TTargetId extends string = string,
|
||||
>(initialOptions: RuntimeDragInputControllerOptions<TTargetId> = {}) {
|
||||
let options = initialOptions;
|
||||
let session: RuntimeDragInputSession<TTargetId> | null = null;
|
||||
|
||||
const setOptions = (
|
||||
nextOptions: RuntimeDragInputControllerOptions<TTargetId>,
|
||||
) => {
|
||||
options = nextOptions;
|
||||
};
|
||||
|
||||
const cancel = (inputId?: string) => {
|
||||
if (inputId && session?.inputId !== inputId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeSession = session;
|
||||
session = null;
|
||||
if (activeSession) {
|
||||
options.onCancel?.(activeSession);
|
||||
}
|
||||
};
|
||||
|
||||
const press = (input: RuntimeDragInputPress<TTargetId>) => {
|
||||
cancel();
|
||||
session = {
|
||||
targetId: input.targetId,
|
||||
inputId: input.inputId,
|
||||
deviceKind: input.deviceKind,
|
||||
startPoint: clonePoint(input.point),
|
||||
currentPoint: clonePoint(input.point),
|
||||
dragging: false,
|
||||
forceDrop: false,
|
||||
};
|
||||
options.onPress?.(session);
|
||||
return session;
|
||||
};
|
||||
|
||||
const move = (input: RuntimeDragInputMove) => {
|
||||
if (!session || session.inputId !== input.inputId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wasDragging = session.dragging;
|
||||
session = {
|
||||
...session,
|
||||
currentPoint: clonePoint(input.point),
|
||||
dragging: shouldStartDragging(
|
||||
session,
|
||||
input.point,
|
||||
options.dragThresholdPx ?? DEFAULT_DRAG_THRESHOLD_PX,
|
||||
input.forceDragging,
|
||||
),
|
||||
};
|
||||
|
||||
if (!wasDragging && session.dragging) {
|
||||
options.onDragStart?.(session);
|
||||
}
|
||||
if (session.dragging) {
|
||||
options.onDragMove?.(session);
|
||||
}
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
const release = (input: RuntimeDragInputRelease) => {
|
||||
if (!session || session.inputId !== input.inputId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedSession = {
|
||||
...session,
|
||||
currentPoint: clonePoint(input.point),
|
||||
forceDrop: input.forceDrop === true,
|
||||
};
|
||||
session = null;
|
||||
if (completedSession.dragging || completedSession.forceDrop) {
|
||||
options.onDrop?.(completedSession);
|
||||
} else {
|
||||
options.onTap?.(completedSession);
|
||||
}
|
||||
return completedSession;
|
||||
};
|
||||
|
||||
return {
|
||||
cancel,
|
||||
getSession: () => session,
|
||||
move,
|
||||
press,
|
||||
release,
|
||||
setOptions,
|
||||
};
|
||||
}
|
||||
142
src/services/input-devices/runtimeInputGeometry.ts
Normal file
142
src/services/input-devices/runtimeInputGeometry.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import type { RuntimeInputPoint } from './runtimeDragInputController';
|
||||
|
||||
export type RuntimeInputBounds = {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type RuntimeInputGridSpec = {
|
||||
rows: number;
|
||||
cols: number;
|
||||
};
|
||||
|
||||
export type RuntimeInputGridCell = {
|
||||
row: number;
|
||||
col: number;
|
||||
};
|
||||
|
||||
function isFiniteNumber(value: number) {
|
||||
return Number.isFinite(value);
|
||||
}
|
||||
|
||||
function clamp01(value: number) {
|
||||
return Math.min(1, Math.max(0, value));
|
||||
}
|
||||
|
||||
function hasUsableBounds(
|
||||
bounds: RuntimeInputBounds | null | undefined,
|
||||
): bounds is RuntimeInputBounds {
|
||||
return Boolean(
|
||||
bounds &&
|
||||
isFiniteNumber(bounds.left) &&
|
||||
isFiniteNumber(bounds.top) &&
|
||||
isFiniteNumber(bounds.width) &&
|
||||
isFiniteNumber(bounds.height) &&
|
||||
bounds.width > 0 &&
|
||||
bounds.height > 0,
|
||||
);
|
||||
}
|
||||
|
||||
export function readRuntimeInputElementBounds(
|
||||
element: Element | null | undefined,
|
||||
): RuntimeInputBounds | null {
|
||||
if (!element) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
left: rect.left,
|
||||
top: rect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeInputPointFromClient(
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
bounds?: RuntimeInputBounds | null,
|
||||
): RuntimeInputPoint {
|
||||
if (!hasUsableBounds(bounds)) {
|
||||
return { clientX, clientY };
|
||||
}
|
||||
|
||||
return {
|
||||
clientX,
|
||||
clientY,
|
||||
normalizedX: (clientX - bounds.left) / bounds.width,
|
||||
normalizedY: (clientY - bounds.top) / bounds.height,
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeInputPointFromNormalized(
|
||||
normalizedX: number,
|
||||
normalizedY: number,
|
||||
bounds?: RuntimeInputBounds | null,
|
||||
): RuntimeInputPoint {
|
||||
const x = clamp01(normalizedX);
|
||||
const y = clamp01(normalizedY);
|
||||
if (!hasUsableBounds(bounds)) {
|
||||
return {
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
normalizedX: x,
|
||||
normalizedY: y,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
clientX: bounds.left + x * bounds.width,
|
||||
clientY: bounds.top + y * bounds.height,
|
||||
normalizedX: x,
|
||||
normalizedY: y,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveRuntimeInputGridCell(
|
||||
point: RuntimeInputPoint,
|
||||
grid: RuntimeInputGridSpec,
|
||||
bounds?: RuntimeInputBounds | null,
|
||||
): RuntimeInputGridCell | null {
|
||||
if (grid.rows <= 0 || grid.cols <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedX =
|
||||
typeof point.normalizedX === 'number'
|
||||
? point.normalizedX
|
||||
: hasUsableBounds(bounds)
|
||||
? (point.clientX - bounds.left) / bounds.width
|
||||
: null;
|
||||
const normalizedY =
|
||||
typeof point.normalizedY === 'number'
|
||||
? point.normalizedY
|
||||
: hasUsableBounds(bounds)
|
||||
? (point.clientY - bounds.top) / bounds.height
|
||||
: null;
|
||||
|
||||
if (
|
||||
normalizedX === null ||
|
||||
normalizedY === null ||
|
||||
!isFiniteNumber(normalizedX) ||
|
||||
!isFiniteNumber(normalizedY) ||
|
||||
normalizedX < 0 ||
|
||||
normalizedX > 1 ||
|
||||
normalizedY < 0 ||
|
||||
normalizedY > 1
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
row: Math.min(grid.rows - 1, Math.floor(normalizedY * grid.rows)),
|
||||
col: Math.min(grid.cols - 1, Math.floor(normalizedX * grid.cols)),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user