import { ArrowLeft, ArrowRight, ChevronDown, ChevronUp, Clock, Eye, Lightbulb, Loader2, Snowflake, Sparkles, Trophy, } from 'lucide-react'; import { useEffect, useId, useMemo, useRef, useState } from 'react'; import type { DragPuzzlePieceRequest, PuzzleBoardSnapshot, PuzzleCellPosition, PuzzleMergedGroupState, PuzzleRecommendedNextWork, PuzzleRunSnapshot, PuzzleRuntimeLevelSnapshot, PuzzleRuntimePropKind, SwapPuzzlePiecesRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import { isDebugMode } from '../../config/debugMode'; 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'; import { PixelIcon } from '../PixelIcon'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; import { buildMergedGroupClipPath, buildMergedGroupOutlinePath, resolveDraggedMergedGroupLayer, resolveDraggedPieceCellLayer, resolveDraggedPieceLayer, sanitizeSvgId, } from './puzzleRuntimeShape'; type PuzzleRuntimeShellProps = { run: PuzzleRunSnapshot | null; isBusy?: boolean; error?: string | null; hideBackButton?: boolean; hideExitControls?: boolean; embedded?: boolean; onBack: () => void; onRemodelWork?: (profileId: string) => void | Promise; onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void; onDragPiece: (payload: DragPuzzlePieceRequest) => void; onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void; onRestartLevel?: () => void | Promise; onPauseChange?: (paused: boolean) => void | Promise; onUseProp?: ( propKind: PuzzleRuntimePropKind, ) => Promise; onTimeExpired?: () => void | Promise; }; export type PuzzleNextLevelTarget = { profileId?: string; levelId?: string | null; }; type PuzzleBoardPieceViewModel = { pieceId: string; row: number; col: number; correctRow: number; correctCol: number; mergedGroupId: string | null; }; type PuzzleMergedGroupViewModel = { groupId: string; pieceIds: string[]; anchorPieceId: string; minRow: number; minCol: number; rowSpan: number; colSpan: number; pieces: Array< PuzzleBoardPieceViewModel & { localRow: number; localCol: number; } >; }; function boardCellKey(position: PuzzleCellPosition) { return `${position.row}:${position.col}`; } function buildBoardCells(board: PuzzleBoardSnapshot) { return Array.from({ length: board.rows * board.cols }, (_, index) => ({ row: Math.floor(index / board.cols), col: index % board.cols, })); } function buildMergedGroupViewModels( groups: PuzzleMergedGroupState[], pieces: PuzzleBoardPieceViewModel[], ) { const pieceById = new Map(pieces.map((piece) => [piece.pieceId, piece])); return groups .map((group) => { const groupPieces = group.pieceIds .map((pieceId) => pieceById.get(pieceId) ?? null) .filter((piece): piece is PuzzleBoardPieceViewModel => Boolean(piece)); if (groupPieces.length <= 1) { return null; } const rows = groupPieces.map((piece) => piece.row); const cols = groupPieces.map((piece) => piece.col); const minRow = Math.min(...rows); const maxRow = Math.max(...rows); const minCol = Math.min(...cols); const maxCol = Math.max(...cols); const anchorPiece = groupPieces[0]; if (!anchorPiece) { return null; } return { groupId: group.groupId, pieceIds: group.pieceIds, anchorPieceId: anchorPiece.pieceId, minRow, minCol, rowSpan: maxRow - minRow + 1, colSpan: maxCol - minCol + 1, pieces: groupPieces.map((piece) => ({ ...piece, localRow: piece.row - minRow, localCol: piece.col - minCol, })), }; }) .filter((group): group is PuzzleMergedGroupViewModel => Boolean(group)); } function formatElapsedMs(elapsedMs: number | null | undefined) { const normalizedMs = Math.max(0, Math.round(elapsedMs ?? 0)); const totalSeconds = Math.floor(normalizedMs / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; const centiseconds = Math.floor((normalizedMs % 1000) / 10); return `${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds .toString() .padStart(2, '0')}`; } function formatTimerMs(value: number | null | undefined) { const normalizedMs = Math.max(0, Math.ceil((value ?? 0) / 1000) * 1000); const totalSeconds = Math.floor(normalizedMs / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; } function resolveActiveFreezeElapsedMs( level: PuzzleRuntimeLevelSnapshot, nowMs: number, ) { if (!level.freezeStartedAtMs || !level.freezeUntilMs) { return 0; } return Math.max( 0, Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs, ); } function resolveRuntimeRemainingMs( level: PuzzleRuntimeLevelSnapshot, nowMs: number, uiPauseStartedAtMs: number | null, ) { if (level.status !== 'playing') { return level.remainingMs; } const timeLimitMs = level.timeLimitMs || level.remainingMs; const snapshotPauseElapsedMs = level.pauseStartedAtMs ? Math.max(0, nowMs - level.pauseStartedAtMs) : 0; const optimisticPauseElapsedMs = !level.pauseStartedAtMs && uiPauseStartedAtMs ? Math.max(0, nowMs - uiPauseStartedAtMs) : 0; const effectiveElapsedMs = Math.max( 0, nowMs - level.startedAtMs - level.pausedAccumulatedMs - snapshotPauseElapsedMs - optimisticPauseElapsedMs - level.freezeAccumulatedMs - resolveActiveFreezeElapsedMs(level, nowMs), ); return Math.max(0, timeLimitMs - effectiveElapsedMs); } const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6; const PUZZLE_CLEAR_FLASH_DURATION_MS = 900; const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500; const PUZZLE_MERGE_FLASH_DURATION_MS = 720; 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 PUZZLE_MOCAP_CURSOR_FRAME_MS = 1000 / 60; const shownExitRemodelPromptProfileIds = new Set(); function buildExitRemodelPromptStorageKey(profileId: string) { return `${PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX}:${encodeURIComponent( profileId, )}`; } function hasSeenExitRemodelPrompt(profileId: string) { const normalizedProfileId = profileId.trim(); if (!normalizedProfileId) { return true; } if (shownExitRemodelPromptProfileIds.has(normalizedProfileId)) { if (typeof window === 'undefined') { return true; } } try { const seen = window.localStorage.getItem( buildExitRemodelPromptStorageKey(normalizedProfileId), ) === '1'; if (seen) { shownExitRemodelPromptProfileIds.add(normalizedProfileId); } return seen; } catch { return shownExitRemodelPromptProfileIds.has(normalizedProfileId); } } function markExitRemodelPromptSeen(profileId: string) { const normalizedProfileId = profileId.trim(); if (!normalizedProfileId) { return; } shownExitRemodelPromptProfileIds.add(normalizedProfileId); if (typeof window === 'undefined') { return; } try { window.localStorage.setItem( buildExitRemodelPromptStorageKey(normalizedProfileId), '1', ); } catch { // 中文注释:隐私模式下 localStorage 可能不可写,内存集合足够兜底本次挂载周期。 } } type PuzzlePropDialogState = { propKind: PuzzleRuntimePropKind; title: string; }; type PuzzleMergeFlashState = { key: string; groupId: string; leftPercent: number; topPercent: number; }; type PuzzleHintDemoState = { key: string; pieceIds: string[]; offsetXPercent: number; offsetYPercent: number; }; type PuzzleMocapCursorState = { x: number; y: number; state: string; }; type PuzzleMocapCursorSample = PuzzleMocapCursorState & { receivedAtMs: number; }; type PuzzleRuntimeDragTargetState = { pieceId: string; groupId: string | null; }; function triggerPuzzlePiecePressHapticFeedback() { if (typeof navigator === 'undefined') { return; } const vibrate = navigator.vibrate; if (typeof vibrate !== 'function') { return; } vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]); } /** * 拼图运行时壳层。 * 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。 * 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。 */ export function PuzzleRuntimeShell({ run, isBusy = false, error = null, hideBackButton = false, hideExitControls = false, embedded = false, onBack, onRemodelWork, onSwapPieces, onDragPiece, onAdvanceNextLevel, onRestartLevel, onPauseChange, onUseProp, onTimeExpired, }: PuzzleRuntimeShellProps) { const mergedGroupSvgIdPrefix = sanitizeSvgId(useId()); const authUi = useAuthUi(); const [selectedPieceId, setSelectedPieceId] = useState(null); const selectedPieceIdRef = useRef(null); const selectedPieceBeforeInputRef = useRef(null); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = useState(false); const [propDialog, setPropDialog] = useState( null, ); const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] = useState(false); const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false); const [isPropConfirming, setIsPropConfirming] = useState(false); const [propConfirmError, setPropConfirmError] = useState(null); const [isMocapDebugExpanded, setIsMocapDebugExpanded] = useState(false); const [hintDemo, setHintDemo] = useState(null); const [mergeFlash, setMergeFlash] = useState( null, ); const [timerNowMs, setTimerNowMs] = useState(() => Date.now()); const [uiPauseStartedAtMs, setUiPauseStartedAtMs] = useState( null, ); const onPauseChangeRef = useRef(onPauseChange); const onTimeExpiredRef = useRef(onTimeExpired); const previousUiPauseActiveRef = useRef(false); const pauseChangePromiseRef = useRef>(Promise.resolve()); const timeExpiredSyncKeyRef = useRef(null); const dragSessionRef = useRef<{ pieceId: string; inputId: string; dragging: boolean; startX: number; startY: number; currentX: number; currentY: number; } | null>(null); const dragVisualTargetRef = useRef<{ pieceId: string; groupId: string | null; } | null>(null); const dragVisualFrameRef = useRef(null); const dragOffsetRef = useRef<{ x: number; y: number } | null>(null); const pieceCellElementRefMap = useRef(new Map()); const pieceElementRefMap = useRef(new Map()); const groupElementRefMap = useRef(new Map()); const [dragRenderTarget, setDragRenderTarget] = useState<{ pieceId: string; groupId: string | null; } | null>(null); const [mocapCursor, setMocapCursor] = useState( null, ); const mocapCursorPreviousSampleRef = useRef( null, ); const mocapCursorTargetSampleRef = useRef(null); const mocapCursorIntervalRef = useRef(null); const updateMocapCursorSampleRef = useRef<( nextSample: PuzzleMocapCursorSample, ) => void>(() => {}); const runtimeDragInputControllerRef = useRef( createRuntimeDragInputController(), ); const draggingTargetRef = useRef(null); const [dismissedClearKey, setDismissedClearKey] = useState( null, ); const [isClearFlashVisible, setIsClearFlashVisible] = useState(false); const [isClearResultReady, setIsClearResultReady] = useState(false); const clearPresentationKeyRef = useRef(null); const clearPresentationTimeoutIdsRef = useRef([]); const mergeGroupSignatureRef = useRef(null); const hintDemoTimeoutRef = useRef(null); const mergeFlashTimeoutRef = useRef(null); const boardRef = useRef(null); const currentLevel = run?.currentLevel ?? null; const currentLevelRef = useRef(currentLevel); const board = currentLevel?.board ?? null; const displayRemainingMs = currentLevel ? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs) : 0; const runtimeStatus = currentLevel ? currentLevel.status === 'playing' && displayRemainingMs <= 0 ? 'failed' : currentLevel.status : 'playing'; const isInteractionLocked = isBusy || runtimeStatus !== 'playing' || Boolean(propDialog); const clearResultKey = currentLevel ? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}` : null; const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME; const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {}); const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl( 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 = primaryMocapHandState && typeof primaryMocapHandX === 'number' && typeof primaryMocapHandY === 'number' ? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}` : '无'; const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length ? mocapInput.latestCommand.parseWarnings.join(';') : '无'; const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到'; const shouldShowMocapDebugPanel = isDebugMode(); useEffect(() => { currentLevelRef.current = currentLevel; }, [currentLevel]); const commitSelectedPieceId = (pieceId: string | null) => { selectedPieceIdRef.current = pieceId; setSelectedPieceId(pieceId); }; const pieces = useMemo(() => { if (!board) { return []; } return board.pieces.map((piece) => ({ pieceId: piece.pieceId, row: piece.currentRow, col: piece.currentCol, correctRow: piece.correctRow, correctCol: piece.correctCol, mergedGroupId: piece.mergedGroupId, })); }, [board]); const mergedGroups = useMemo(() => { if (!board) { return []; } return buildMergedGroupViewModels(board.mergedGroups, pieces); }, [board, pieces]); const largestMovableGroup = useMemo(() => { const groups = mergedGroups.filter((group) => group.pieces.some( (piece) => piece.row !== piece.correctRow || piece.col !== piece.correctCol, ), ); return ( groups.sort( (left, right) => right.pieceIds.length - left.pieceIds.length || left.minRow - right.minRow || left.minCol - right.minCol, )[0] ?? null ); }, [mergedGroups]); const mergedCellKeys = useMemo( () => new Set( mergedGroups.flatMap((group) => group.pieces.map((piece) => boardCellKey(piece)), ), ), [mergedGroups], ); const pieceByCell = useMemo(() => { const map = new Map(); for (const piece of pieces) { map.set(`${piece.row}:${piece.col}`, piece); } return map; }, [pieces]); const pieceById = useMemo( () => new Map(pieces.map((piece) => [piece.pieceId, piece])), [pieces], ); useEffect(() => { const signature = board?.mergedGroups .map( (group) => `${group.groupId}:${group.pieceIds.slice().sort().join(',')}`, ) .sort() .join('|') ?? ''; const previousSignature = mergeGroupSignatureRef.current; mergeGroupSignatureRef.current = signature; if (!previousSignature || !board || currentLevel?.status !== 'playing') { return; } const previousGroupSizes = new Map( previousSignature .split('|') .filter(Boolean) .map((entry) => { const [groupId, pieceIds = ''] = entry.split(':'); return [groupId, pieceIds.split(',').filter(Boolean).length] as const; }), ); const newGroup = mergedGroups.find( (group) => group.pieceIds.length > 1 && group.pieceIds.length > (previousGroupSizes.get(group.groupId) ?? 0), ); if (!newGroup) { return; } if (mergeFlashTimeoutRef.current !== null) { window.clearTimeout(mergeFlashTimeoutRef.current); } setMergeFlash({ key: `${newGroup.groupId}:${Date.now()}`, groupId: newGroup.groupId, leftPercent: ((newGroup.minCol + newGroup.colSpan / 2) / board.cols) * 100, topPercent: ((newGroup.minRow + newGroup.rowSpan / 2) / board.rows) * 100, }); mergeFlashTimeoutRef.current = window.setTimeout(() => { setMergeFlash(null); }, PUZZLE_MERGE_FLASH_DURATION_MS); }, [board, currentLevel?.status, mergedGroups]); const resolvePieceCellElement = (pieceId: string) => { const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null; const pieceCellElement = (pieceElement?.parentElement as HTMLDivElement | null) ?? pieceCellElementRefMap.current.get(pieceId) ?? null; return pieceCellElement; }; const resetDragVisualTarget = () => { const dragVisualTarget = dragVisualTargetRef.current; setDragRenderTarget(null); if (!dragVisualTarget) { return; } const pieceElement = pieceElementRefMap.current.get( dragVisualTarget.pieceId, ); const pieceCellElement = resolvePieceCellElement(dragVisualTarget.pieceId); if (pieceCellElement) { pieceCellElement.style.zIndex = ''; } if (pieceElement) { pieceElement.style.transform = ''; pieceElement.style.willChange = ''; pieceElement.style.zIndex = ''; pieceElement.style.opacity = ''; } if (dragVisualTarget.groupId) { const groupElement = groupElementRefMap.current.get( dragVisualTarget.groupId, ); if (groupElement) { groupElement.style.transform = ''; groupElement.style.willChange = ''; groupElement.style.zIndex = ''; groupElement.style.opacity = ''; } } dragVisualTargetRef.current = null; }; const cancelDragVisualFrame = () => { if (dragVisualFrameRef.current === null) { return; } window.cancelAnimationFrame(dragVisualFrameRef.current); dragVisualFrameRef.current = null; }; 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; if (!dragSession || !dragSession.dragging) { resetDragVisualTarget(); return; } const piece = pieceById.get(dragSession.pieceId) ?? null; const groupId = draggingTargetRef.current?.groupId ?? piece?.mergedGroupId ?? null; const nextTarget = { pieceId: dragSession.pieceId, groupId, }; const previousTarget = dragVisualTargetRef.current; if ( previousTarget && (previousTarget.pieceId !== nextTarget.pieceId || previousTarget.groupId !== nextTarget.groupId) ) { resetDragVisualTarget(); } dragVisualTargetRef.current = nextTarget; setDragRenderTarget((currentTarget) => { if ( currentTarget?.pieceId === nextTarget.pieceId && currentTarget.groupId === nextTarget.groupId ) { return currentTarget; } return nextTarget; }); const offsetX = dragSession.currentX - dragSession.startX; const offsetY = dragSession.currentY - dragSession.startY; dragOffsetRef.current = { x: offsetX, y: offsetY }; if (groupId) { const groupElement = groupElementRefMap.current.get(groupId); if (groupElement) { // 合并块拖动时直接提升整个组容器层级,确保完整拼块永远压在单块之上。 groupElement.style.willChange = 'transform'; groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`; groupElement.style.zIndex = '90'; groupElement.style.opacity = '0.95'; } const pieceCellElement = resolvePieceCellElement(dragSession.pieceId); if (pieceCellElement) { pieceCellElement.style.zIndex = ''; } const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId); if (pieceElement) { pieceElement.style.transform = ''; pieceElement.style.willChange = ''; pieceElement.style.zIndex = ''; pieceElement.style.opacity = ''; } return; } const pieceCellElement = resolvePieceCellElement(dragSession.pieceId); if (pieceCellElement) { // 单块拖动时提升所属格子的堆叠层级,避免被后绘制的拼块或合并块遮住。 pieceCellElement.style.zIndex = '80'; } const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId); if (pieceElement) { pieceElement.style.willChange = 'transform'; pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`; pieceElement.style.zIndex = '81'; pieceElement.style.opacity = '0.95'; } }; const scheduleDragVisual = () => { if (dragVisualFrameRef.current !== null) { return; } dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual); }; useEffect( () => () => { cancelDragVisualFrame(); resetDragVisualTarget(); }, [], ); const clearPresentationTimeouts = () => { for (const timeoutId of clearPresentationTimeoutIdsRef.current) { window.clearTimeout(timeoutId); } clearPresentationTimeoutIdsRef.current = []; }; useEffect( () => () => { clearPresentationTimeouts(); }, [], ); useEffect(() => { onPauseChangeRef.current = onPauseChange; }, [onPauseChange]); useEffect(() => { onTimeExpiredRef.current = onTimeExpired; }, [onTimeExpired]); const isUiPauseActive = isSettingsPanelOpen || isExitRemodelPromptOpen || Boolean(propDialog) || isOriginalOverlayVisible; useEffect(() => { if (previousUiPauseActiveRef.current === isUiPauseActive) { return; } previousUiPauseActiveRef.current = isUiPauseActive; setUiPauseStartedAtMs((currentValue) => isUiPauseActive ? (currentValue ?? Date.now()) : null, ); pauseChangePromiseRef.current = Promise.resolve( onPauseChangeRef.current?.(isUiPauseActive), ).catch(() => undefined); }, [isUiPauseActive]); useEffect(() => { if (!currentLevel || currentLevel.status !== 'playing') { return; } const timerId = window.setInterval(() => { setTimerNowMs(Date.now()); }, 250); return () => window.clearInterval(timerId); }, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]); useEffect(() => { if (!run || !currentLevel || currentLevel.status === 'cleared') { return; } if (displayRemainingMs > 0) { return; } const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`; if (timeExpiredSyncKeyRef.current === syncKey) { return; } timeExpiredSyncKeyRef.current = syncKey; void onTimeExpiredRef.current?.(); }, [ currentLevel?.levelIndex, currentLevel?.startedAtMs, currentLevel?.status, displayRemainingMs, run?.runId, ]); useEffect( () => () => { if (hintDemoTimeoutRef.current !== null) { window.clearTimeout(hintDemoTimeoutRef.current); } if (mergeFlashTimeoutRef.current !== null) { window.clearTimeout(mergeFlashTimeoutRef.current); } }, [], ); useEffect(() => { if (!currentLevel || !clearResultKey) { clearPresentationKeyRef.current = null; clearPresentationTimeouts(); setIsClearFlashVisible(false); setIsClearResultReady(false); return; } if (currentLevel.status !== 'cleared') { clearPresentationKeyRef.current = null; clearPresentationTimeouts(); setIsClearFlashVisible(false); setIsClearResultReady(false); return; } if ( dismissedClearKey === clearResultKey || clearPresentationKeyRef.current === clearResultKey ) { return; } // 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。 clearPresentationKeyRef.current = clearResultKey; clearPresentationTimeouts(); setIsClearFlashVisible(true); setIsClearResultReady(false); clearPresentationTimeoutIdsRef.current = [ window.setTimeout(() => { setIsClearFlashVisible(false); }, PUZZLE_CLEAR_FLASH_DURATION_MS), window.setTimeout(() => { setIsClearResultReady(true); }, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS), ]; }, [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 resetMocapCursorInterpolation = () => { mocapCursorPreviousSampleRef.current = null; mocapCursorTargetSampleRef.current = null; setMocapCursor(null); }; updateMocapCursorSampleRef.current = (nextSample: PuzzleMocapCursorSample) => { const previousTarget = mocapCursorTargetSampleRef.current; mocapCursorPreviousSampleRef.current = previousTarget ?? nextSample; mocapCursorTargetSampleRef.current = nextSample; if (!previousTarget) { setMocapCursor(nextSample); } }; const syncRuntimeDragFromController = ( session: RuntimeDragInputSession | 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(); resetMocapCursorInterpolation(); return; } if ( !primaryMocapHandState || typeof primaryMocapHandX !== 'number' || typeof primaryMocapHandY !== 'number' ) { runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID); resetMocapCursorInterpolation(); return; } const nextSample = { x: primaryMocapHandX, y: primaryMocapHandY, state: primaryMocapHandState, receivedAtMs: performance.now(), }; updateMocapCursorSampleRef.current(nextSample); const handPoint = resolveBoardInputPointFromNormalized(nextSample.x, nextSample.y); 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, ]); useEffect(() => { if (!board || runtimeStatus !== 'playing') { if (mocapCursorIntervalRef.current !== null) { window.clearInterval(mocapCursorIntervalRef.current); mocapCursorIntervalRef.current = null; } return; } const tickMocapCursor = () => { const targetSample = mocapCursorTargetSampleRef.current; if (!targetSample) { return; } const previousSample = mocapCursorPreviousSampleRef.current ?? targetSample; const durationMs = Math.max( PUZZLE_MOCAP_CURSOR_FRAME_MS, targetSample.receivedAtMs - previousSample.receivedAtMs, ); const progress = targetSample.receivedAtMs === previousSample.receivedAtMs ? 1 : Math.min( 1, Math.max(0, (performance.now() - targetSample.receivedAtMs) / durationMs), ); const nextCursor = { x: previousSample.x + (targetSample.x - previousSample.x) * progress, y: previousSample.y + (targetSample.y - previousSample.y) * progress, state: targetSample.state, }; const nextPoint = resolveBoardInputPointFromNormalized( nextCursor.x, nextCursor.y, ); setMocapCursor(nextCursor); const activeSession = runtimeDragInputControllerRef.current.getSession(); if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) { runtimeDragInputControllerRef.current.move({ inputId: PUZZLE_MOCAP_DRAG_INPUT_ID, point: nextPoint, forceDragging: true, }); } }; tickMocapCursor(); mocapCursorIntervalRef.current = window.setInterval( tickMocapCursor, PUZZLE_MOCAP_CURSOR_FRAME_MS, ); return () => { if (mocapCursorIntervalRef.current !== null) { window.clearInterval(mocapCursorIntervalRef.current); mocapCursorIntervalRef.current = null; } }; }, [board, runtimeStatus]); if (!run || !currentLevel || !board) { return (
正在进入拼图关卡
); } const handlePiecePointerUp = (event: React.PointerEvent) => { event.currentTarget.releasePointerCapture?.(event.pointerId); runtimeDragInputControllerRef.current.release({ inputId: `pointer:${event.pointerId}`, point: resolveBoardInputPointFromClient(event.clientX, event.clientY), }); }; const handlePiecePointerDown = ( pieceId: string, event: React.PointerEvent, ) => { if (isInteractionLocked) { return; } event.preventDefault(); resetDragInteraction(); event.currentTarget.setPointerCapture?.(event.pointerId); runtimeDragInputControllerRef.current.press({ targetId: pieceId, inputId: `pointer:${event.pointerId}`, deviceKind: 'pointer', point: resolveBoardInputPointFromClient(event.clientX, event.clientY), }); }; const handlePiecePointerMove = (event: React.PointerEvent) => { event.preventDefault(); runtimeDragInputControllerRef.current.move({ inputId: `pointer:${event.pointerId}`, point: resolveBoardInputPointFromClient(event.clientX, event.clientY), }); }; const draggingPieceId = dragRenderTarget?.pieceId ?? null; const draggingGroupId = dragRenderTarget?.groupId ?? null; const freezeRemainingMs = currentLevel.freezeUntilMs && currentLevel.status === 'playing' ? Math.max(0, currentLevel.freezeUntilMs - timerNowMs) : 0; const statusLabel = runtimeStatus === 'cleared' ? '已通关' : runtimeStatus === 'failed' ? '失败' : '进行中'; const nextLevelMode = run.nextLevelMode ?? 'none'; const recommendedNextWorks = run.recommendedNextWorks ?? []; const hasSimilarWorkChoices = nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0; const canAdvanceDefaultNextLevel = currentLevel.status === 'cleared' && (nextLevelMode === 'sameWork' || (nextLevelMode === 'similarWorks' ? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) && !hasSimilarWorkChoices : Boolean(run.recommendedNextProfileId))); const canShowNextAction = canAdvanceDefaultNextLevel || hasSimilarWorkChoices; const levelLabel = `第 ${currentLevel.levelIndex} 关`; const exitPromptProfileId = currentLevel.profileId.trim(); const shouldHideBackButton = hideBackButton || hideExitControls; const leaderboardEntries = (currentLevel.leaderboardEntries ?? []).length > 0 ? currentLevel.leaderboardEntries : (run.leaderboardEntries ?? []); const isClearResultOpen = currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey && isClearResultReady; const handleBackRequest = () => { if (hideExitControls) { return; } if ( onRemodelWork && exitPromptProfileId && !hasSeenExitRemodelPrompt(exitPromptProfileId) ) { markExitRemodelPromptSeen(exitPromptProfileId); setIsExitRemodelPromptOpen(true); return; } onBack(); }; const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => { const canOpen = propKind === 'extendTime' ? runtimeStatus === 'failed' : runtimeStatus === 'playing'; if (!canOpen) { return; } setPropConfirmError(null); setPropDialog({ propKind, title }); }; const playHintDemo = () => { const targetGroup = largestMovableGroup; const targetPieces = targetGroup?.pieces ?? []; const fallbackPiece = pieces.find( (piece) => piece.row !== piece.correctRow || piece.col !== piece.correctCol, ); const anchorPiece = targetPieces[0] ?? fallbackPiece ?? null; if (!anchorPiece) { return; } const pieceIds = targetPieces.length > 0 ? targetPieces.map((piece) => piece.pieceId) : [anchorPiece.pieceId]; const offsetXPercent = ((anchorPiece.correctCol - anchorPiece.col) / board.cols) * 100; const offsetYPercent = ((anchorPiece.correctRow - anchorPiece.row) / board.rows) * 100; setHintDemo({ key: `${anchorPiece.pieceId}:${Date.now()}`, pieceIds, offsetXPercent, offsetYPercent, }); if (hintDemoTimeoutRef.current !== null) { window.clearTimeout(hintDemoTimeoutRef.current); } hintDemoTimeoutRef.current = window.setTimeout(() => { setHintDemo(null); }, PUZZLE_HINT_DEMO_DURATION_MS); }; const handleConfirmProp = async () => { if (!propDialog) { return; } const propKind = propDialog.propKind; setIsPropConfirming(true); setPropConfirmError(null); let useResult: PuzzleRunSnapshot | null | void = null; try { await pauseChangePromiseRef.current; useResult = await onUseProp?.(propKind); if (useResult === null) { return; } setPropDialog(null); } catch (error) { setPropConfirmError( error instanceof Error ? error.message : '使用拼图道具失败', ); return; } finally { setIsPropConfirming(false); } if (propKind === 'hint') { playHintDemo(); } if (propKind === 'reference') { setIsOriginalOverlayVisible(true); } if (propKind === 'freezeTime') { // 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态; // 这种边界同步只关闭确认窗,不再播放冻结成功反馈。 const resultLevel = useResult && typeof useResult === 'object' ? useResult.currentLevel : currentLevelRef.current; if (resultLevel?.status === 'playing') { setIsFreezeEffectVisible(true); window.setTimeout(() => { setIsFreezeEffectVisible(false); }, 900); } } if (propKind === 'extendTime') { setTimerNowMs(Date.now()); } }; return (
{currentLevel.coverImageSrc ? (