import { ArrowLeft, ArrowRight, Clock, Eye, Lightbulb, Loader2, Snowflake, Sparkles, Trophy, } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import type { DragPuzzlePieceRequest, PuzzleBoardSnapshot, PuzzleCellPosition, PuzzleMergedGroupState, PuzzleRecommendedNextWork, PuzzleRunSnapshot, PuzzleRuntimeLevelSnapshot, PuzzleRuntimePropKind, SwapPuzzlePiecesRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets'; import { useAuthUi } from '../auth/AuthUiContext'; import { PixelIcon } from '../PixelIcon'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type PuzzleRuntimeShellProps = { run: PuzzleRunSnapshot | null; isBusy?: boolean; error?: string | null; onBack: () => void; 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 buildLocalCellKey(row: number, col: number) { return `${row}:${col}`; } export function resolveDraggedPieceCellLayer( pieceId: string | null | undefined, draggingPieceId: string | null, isMerged: boolean, ) { if (!pieceId || isMerged || pieceId !== draggingPieceId) { return undefined; } return 80; } export function resolveDraggedPieceLayer( pieceId: string | null | undefined, draggingPieceId: string | null, isMerged: boolean, ) { if (!pieceId || isMerged || pieceId !== draggingPieceId) { return undefined; } return 81; } export function resolveDraggedMergedGroupLayer( groupId: string, draggingGroupId: string | null, ) { return groupId === draggingGroupId ? 90 : undefined; } function resolveMergedPieceOutlineClass( group: PuzzleMergedGroupViewModel, piece: PuzzleMergedGroupViewModel['pieces'][number], ) { const groupCellKeys = new Set( group.pieces.map((groupPiece) => buildLocalCellKey(groupPiece.localRow, groupPiece.localCol), ), ); const hasCell = (row: number, col: number) => groupCellKeys.has(buildLocalCellKey(row, col)); const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col); const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1); const hasBottomBoundary = (row: number, col: number) => !hasCell(row + 1, col); const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1); const hasTopEdge = !groupCellKeys.has( buildLocalCellKey(piece.localRow - 1, piece.localCol), ); const hasRightEdge = !groupCellKeys.has( buildLocalCellKey(piece.localRow, piece.localCol + 1), ); const hasBottomEdge = !groupCellKeys.has( buildLocalCellKey(piece.localRow + 1, piece.localCol), ); const hasLeftEdge = !groupCellKeys.has( buildLocalCellKey(piece.localRow, piece.localCol - 1), ); const topLeftRadius = hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : (!hasTopEdge && !hasLeftEdge) || (hasTopEdge && !hasLeftEdge && !hasTopBoundary(piece.localRow, piece.localCol - 1)) || (hasLeftEdge && !hasTopEdge && !hasLeftBoundary(piece.localRow - 1, piece.localCol)) ? 'rounded-tl-[0.35rem]' : 'rounded-tl-none'; const topRightRadius = hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : (!hasTopEdge && !hasRightEdge) || (hasTopEdge && !hasRightEdge && !hasTopBoundary(piece.localRow, piece.localCol + 1)) || (hasRightEdge && !hasTopEdge && !hasRightBoundary(piece.localRow - 1, piece.localCol)) ? 'rounded-tr-[0.35rem]' : 'rounded-tr-none'; const bottomRightRadius = hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : (!hasBottomEdge && !hasRightEdge) || (hasBottomEdge && !hasRightEdge && !hasBottomBoundary(piece.localRow, piece.localCol + 1)) || (hasRightEdge && !hasBottomEdge && !hasRightBoundary(piece.localRow + 1, piece.localCol)) ? 'rounded-br-[0.35rem]' : 'rounded-br-none'; const bottomLeftRadius = hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : (!hasBottomEdge && !hasLeftEdge) || (hasBottomEdge && !hasLeftEdge && !hasBottomBoundary(piece.localRow, piece.localCol - 1)) || (hasLeftEdge && !hasBottomEdge && !hasLeftBoundary(piece.localRow + 1, piece.localCol)) ? 'rounded-bl-[0.35rem]' : 'rounded-bl-none'; return [ hasTopEdge ? 'border-t-2' : 'border-t-0', hasRightEdge ? 'border-r-2' : 'border-r-0', hasBottomEdge ? 'border-b-2' : 'border-b-0', hasLeftEdge ? 'border-l-2' : 'border-l-0', topLeftRadius, topRightRadius, bottomRightRadius, bottomLeftRadius, ].join(' '); } 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 resolveAuthorAvatarLabel(authorDisplayName: string) { return authorDisplayName.trim().slice(0, 1) || '玩'; } 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; 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; }; 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, onBack, onSwapPieces, onDragPiece, onAdvanceNextLevel, onRestartLevel, onPauseChange, onUseProp, onTimeExpired, }: PuzzleRuntimeShellProps) { const authUi = useAuthUi(); const [selectedPieceId, setSelectedPieceId] = useState(null); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = 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 [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; pointerId: number; 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 [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 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 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 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 resetDragInteraction = () => { cancelDragVisualFrame(); dragOffsetRef.current = null; dragSessionRef.current = null; resetDragVisualTarget(); }; const flushDragVisual = () => { dragVisualFrameRef.current = null; const dragSession = dragSessionRef.current; if (!dragSession || !dragSession.dragging) { resetDragVisualTarget(); return; } const piece = pieceById.get(dragSession.pieceId) ?? null; const 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 || 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 !== 'playing') { 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]); if (!run || !currentLevel || !board) { return (
正在进入拼图关卡
); } 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 handlePiecePointerUp = ( pieceId: string, event: React.PointerEvent, ) => { const currentDragSession = dragSessionRef.current; if (!currentDragSession || currentDragSession.pieceId !== pieceId) { return; } 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); }; const handlePiecePointerDown = ( pieceId: string, event: React.PointerEvent, ) => { if (isInteractionLocked) { return; } 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, }; }; const handlePiecePointerMove = ( pieceId: string, event: React.PointerEvent, ) => { const dragSession = dragSessionRef.current; if ( !dragSession || dragSession.pieceId !== pieceId || dragSession.pointerId !== event.pointerId ) { return; } 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(); }; 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 authorAvatarLabel = resolveAuthorAvatarLabel( currentLevel.authorDisplayName, ); const leaderboardEntries = (currentLevel.leaderboardEntries ?? []).length > 0 ? currentLevel.leaderboardEntries : (run.leaderboardEntries ?? []); const isClearResultOpen = currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey && isClearResultReady; const isInteractionLocked = isBusy || runtimeStatus !== 'playing' || Boolean(propDialog); 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); try { await pauseChangePromiseRef.current; const 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') { setIsFreezeEffectVisible(true); window.setTimeout(() => { setIsFreezeEffectVisible(false); }, 900); } if (propKind === 'extendTime') { setTimerNowMs(Date.now()); } }; return (
{currentLevel.coverImageSrc ? (