import { ArrowDown, ArrowLeft, CheckCircle2, Clock3, Image, RotateCcw, Shapes, Sparkles, XCircle, } from 'lucide-react'; import { type CSSProperties, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useState, } from 'react'; import type { DropSquareHoleShapeRequest, SquareHoleDropResponse, SquareHoleRunSnapshot, } from '../../../packages/shared/src/contracts/squareHoleRuntime'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type SquareHoleRuntimeShellProps = { run: SquareHoleRunSnapshot | null; isBusy?: boolean; error?: string | null; embedded?: boolean; onBack: () => void; onRestart: () => void; onDropShape: ( payload: DropSquareHoleShapeRequest, ) => Promise; onOptimisticRunChange?: (run: SquareHoleRunSnapshot) => void; onTimeExpired?: () => void; }; type PendingDrop = { clientEventId: string; holeId: string; }; type DragState = { pointerId: number; startX: number; startY: number; x: number; y: number; size: number; moved: boolean; }; function isRunning(run: SquareHoleRunSnapshot) { return run.status.toLowerCase() === 'running'; } function formatTimer(value: number) { const totalSeconds = Math.max(0, Math.ceil(value / 1000)); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; } function buildClientEventId(runId: string, holeId: string) { return `square-hole-drop-${runId}-${holeId}-${Date.now()}-${Math.round( Math.random() * 1_000_000, )}`; } function clampPercent(value: number) { return Math.min(92, Math.max(8, value * 100)); } function hashText(value: string) { let hash = 2166136261; for (let index = 0; index < value.length; index += 1) { hash ^= value.charCodeAt(index); hash = Math.imul(hash, 16777619); } return hash >>> 0; } function SquareHoleSettlement({ run, onBack, onRestart, }: { run: SquareHoleRunSnapshot; onBack: () => void; onRestart: () => void; }) { if (isRunning(run)) { return null; } const won = run.status.toLowerCase() === 'won'; const stopped = run.status.toLowerCase() === 'stopped'; const title = won ? '挑战完成' : stopped ? '已停止' : '本轮失败'; return (
{won ? : }

{title}

{run.completedShapeCount}/{run.totalShapeCount} · {run.score} 分

); } export function SquareHoleRuntimeShell({ run, isBusy = false, error = null, embedded = false, onBack, onRestart, onDropShape, onOptimisticRunChange, onTimeExpired, }: SquareHoleRuntimeShellProps) { const [pendingDrop, setPendingDrop] = useState(null); const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0); const [feedbackPulseId, setFeedbackPulseId] = useState(null); const [dragState, setDragState] = useState(null); const [isShapeArmed, setIsShapeArmed] = useState(false); const [dropError, setDropError] = useState(null); useEffect(() => { setTimeLeftMs(run?.remainingMs ?? 0); setIsShapeArmed(false); setDropError(null); }, [run?.remainingMs, run?.snapshotVersion]); useEffect(() => { if (!run || !isRunning(run)) { return undefined; } const timer = window.setInterval(() => { setTimeLeftMs((current) => { const next = Math.max(0, current - 1000); if (next <= 0) { onTimeExpired?.(); } return next; }); }, 1000); return () => window.clearInterval(timer); }, [onTimeExpired, run]); useEffect(() => { if (!feedbackPulseId) { return undefined; } const timer = window.setTimeout(() => setFeedbackPulseId(null), 620); return () => window.clearTimeout(timer); }, [feedbackPulseId]); const progressText = useMemo(() => { if (!run) { return '0/0'; } return `${run.completedShapeCount}/${run.totalShapeCount}`; }, [run]); const currentShape = run?.currentShape ?? null; const hintHole = useMemo(() => { if (!run || !currentShape) { return null; } const hintCandidates = run.holes.length > 1 ? run.holes.filter( (hole) => hole.holeId !== currentShape.targetHoleId, ) : run.holes; if (hintCandidates.length <= 0) { return null; } const seed = `${run.runId}:${run.snapshotVersion}:${run.completedShapeCount}:${currentShape.shapeId}`; return hintCandidates[hashText(seed) % hintCandidates.length] ?? null; }, [currentShape, run]); const arrowStyle = useMemo(() => { if (!hintHole) { return {}; } return { left: `${clampPercent(hintHole.x)}%`, top: `${clampPercent(hintHole.y)}%`, }; }, [hintHole]); const resolveHoleAtPoint = useCallback((clientX: number, clientY: number) => { const elements = document.elementsFromPoint(clientX, clientY); const holeElement = elements.find((element) => element instanceof HTMLElement ? element.dataset.squareHoleId : false, ) as HTMLElement | undefined; return holeElement?.dataset.squareHoleId ?? null; }, []); const handleShapePointerDown = (event: ReactPointerEvent) => { if (!run || !currentShape || !isRunning(run) || pendingDrop || isBusy) { return; } event.currentTarget.setPointerCapture(event.pointerId); setDragState({ pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, x: event.clientX, y: event.clientY, size: event.currentTarget.getBoundingClientRect().width, moved: false, }); }; const handleShapePointerMove = (event: ReactPointerEvent) => { if (!dragState || dragState.pointerId !== event.pointerId) { return; } setDragState((current) => current ? { ...current, x: event.clientX, y: event.clientY, moved: current.moved || Math.hypot( event.clientX - current.startX, event.clientY - current.startY, ) >= 6, } : current, ); }; const handleShapePointerEnd = (event: ReactPointerEvent) => { const currentDragState = dragState; if (!currentDragState || currentDragState.pointerId !== event.pointerId) { return; } event.currentTarget.releasePointerCapture?.(event.pointerId); const holeId = resolveHoleAtPoint(event.clientX, event.clientY); setDragState(null); if (holeId) { void dropToHole(holeId); return; } if (!currentDragState.moved) { setIsShapeArmed(true); return; } setIsShapeArmed(false); }; const dropToHole = async (holeId: string) => { if (!run || !isRunning(run) || pendingDrop || isBusy) { return; } const clientEventId = buildClientEventId(run.runId, holeId); setPendingDrop({ clientEventId, holeId }); setFeedbackPulseId(null); setIsShapeArmed(false); setDropError(null); try { const response = await onDropShape({ runId: run.runId, holeId, clientSnapshotVersion: run.snapshotVersion, clientEventId, droppedAtMs: Date.now(), }); setFeedbackPulseId(clientEventId); onOptimisticRunChange?.(response.run); } catch (caughtError) { setDropError( caughtError instanceof Error ? caughtError.message : '本次投入失败', ); } finally { setPendingDrop(null); } }; if (!run) { return (
{isBusy ? '载入中' : (error ?? '暂无运行态')}
); } const feedback = run.lastFeedback; return (
{run.backgroundImageSrc ? (
); } export default SquareHoleRuntimeShell;