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 = {
|
||||
|
||||
Reference in New Issue
Block a user