import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react'; import { useMemo, useRef, useState } from 'react'; import type { DragPuzzlePieceRequest, PuzzleBoardSnapshot, PuzzleCellPosition, PuzzleRunSnapshot, SwapPuzzlePiecesRequest, } from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl'; 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: () => void; }; type PuzzleBoardPieceViewModel = { pieceId: string; row: number; col: number; correctRow: number; correctCol: number; label: string; }; 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 buildPieceLabel(pieceId: string) { const fallback = pieceId.slice(-2).toUpperCase(); return fallback || '块'; } /** * 拼图运行时壳层。 * 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。 */ export function PuzzleRuntimeShell({ run, isBusy = false, error = null, onBack, onSwapPieces, onDragPiece, onAdvanceNextLevel, }: PuzzleRuntimeShellProps) { const [selectedPieceId, setSelectedPieceId] = useState(null); const [dragState, setDragState] = useState<{ pieceId: string; pointerId: number; dragging: boolean; startX: number; startY: number; } | null>(null); const boardRef = useRef(null); const currentLevel = run?.currentLevel ?? null; const board = currentLevel?.board ?? null; 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, label: buildPieceLabel(piece.pieceId), })); }, [board]); const mergedCellKeys = useMemo(() => { if (!board) { return new Set(); } return new Set( board.mergedGroups.flatMap((group) => group.occupiedCells.map((cell) => boardCellKey(cell)), ), ); }, [board]); const pieceByCell = useMemo(() => { const map = new Map(); for (const piece of pieces) { map.set(`${piece.row}:${piece.col}`, piece); } return map; }, [pieces]); if (!run || !currentLevel || !board) { return (
正在进入拼图关卡
); } const handlePieceClick = (pieceId: string) => { if (isBusy) { 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 currentDragState = dragState; if (!currentDragState || currentDragState.pieceId !== pieceId) { return; } event.currentTarget.releasePointerCapture(event.pointerId); if (currentDragState.dragging) { const targetCell = resolveBoardCellFromPointer( event.clientX, event.clientY, ); if (targetCell) { onDragPiece({ pieceId, targetRow: targetCell.row, targetCol: targetCell.col, }); } setSelectedPieceId(null); setDragState(null); return; } setDragState(null); handlePieceClick(pieceId); }; const statusLabel = currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`; const nextAvailable = currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId); return (
{currentLevel.coverImageSrc ? (