import { ArrowLeft, CheckCircle2, Clock3, RotateCcw, Sparkles, XCircle, } from 'lucide-react'; import { type PointerEvent, useEffect, useMemo, useRef, useState } from 'react'; import type { Match3DClickItemRequest, Match3DClickItemResult, Match3DItemSnapshot, Match3DRunSnapshot, Match3DTraySlot, } from '../../../packages/shared/src/contracts/match3dRuntime'; import { MATCH3D_VISUAL_SEEDS } from '../../services/match3d-runtime'; type Match3DRuntimeShellProps = { run: Match3DRunSnapshot | null; isBusy?: boolean; error?: string | null; onBack: () => void; onRestart: () => void; onOptimisticRunChange: (run: Match3DRunSnapshot) => void; onClickItem: ( payload: Match3DClickItemRequest, ) => Promise; onTimeExpired?: () => void; }; type PendingClick = { clientEventId: string; itemInstanceId: string; previousRun: Match3DRunSnapshot; }; type Match3DFeedbackEvent = { id: string; kind: 'cleared' | 'rejected'; itemIds: string[]; }; 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 formatElapsed(startedAtMs: number, remainingMs: number, durationLimitMs: number) { const elapsedMs = Math.max(0, durationLimitMs - remainingMs); const totalSeconds = Math.floor(elapsedMs / 1000); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; return `${minutes}:${seconds.toString().padStart(2, '0')}`; } function resolveVisualSeed(visualKey: string) { return ( MATCH3D_VISUAL_SEEDS.find((seed) => seed.visualKey === visualKey) ?? MATCH3D_VISUAL_SEEDS[0]! ); } function buildClientEventId(itemInstanceId: string) { return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round( Math.random() * 1_000_000, )}`; } function isPointInsideCircle( pointX: number, pointY: number, item: Match3DItemSnapshot, ) { return Math.hypot(pointX - item.x, pointY - item.y) <= item.radius; } function findHitItem( run: Match3DRunSnapshot, pointX: number, pointY: number, ) { return run.items .filter( (item) => item.state === 'InBoard' && item.clickable && isPointInsideCircle(pointX, pointY, item), ) .sort((left, right) => right.layer - left.layer)[0]; } function buildOptimisticRun( run: Match3DRunSnapshot, item: Match3DItemSnapshot, ) { const nextSlot = run.traySlots.find((slot) => !slot.itemInstanceId); if (!nextSlot) { return run; } return { ...run, items: run.items.map((entry) => entry.itemInstanceId === item.itemInstanceId ? { ...entry, state: 'Flying' as const, clickable: false, } : entry, ), traySlots: run.traySlots.map((slot) => slot.slotIndex === nextSlot.slotIndex ? { slotIndex: slot.slotIndex, itemInstanceId: item.itemInstanceId, itemTypeId: item.itemTypeId, visualKey: item.visualKey, } : slot, ), }; } function Match3DToken({ item, disabled, onClick, }: { item: Match3DItemSnapshot; disabled: boolean; onClick: (item: Match3DItemSnapshot) => void; }) { const visualSeed = resolveVisualSeed(item.visualKey); const size = `${item.radius * 200}%`; const itemStateClass = item.state === 'Flying' ? 'scale-75 opacity-0' : item.clickable ? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95' : 'opacity-48'; if (item.state !== 'InBoard' && item.state !== 'Flying') { return null; } return ( ); } function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) { if (!slot.visualKey) { return ; } const visualSeed = resolveVisualSeed(slot.visualKey); return ( {visualSeed.label} ); } function Match3DSettlement({ run, onBack, onRestart, }: { run: Match3DRunSnapshot; onBack: () => void; onRestart: () => void; }) { if (run.status === 'Running') { return null; } const won = run.status === 'Won'; const stopped = run.status === 'Stopped'; const title = won ? '通关完成' : stopped ? '已停止' : '本轮失败'; const description = won ? `用时 ${formatElapsed(run.startedAtMs, run.remainingMs, run.durationLimitMs)}` : `已清除 ${run.clearedItemCount}/${run.totalItemCount}`; return (
{won ? : }

{title}

{description}

); } export function Match3DRuntimeShell({ run, isBusy = false, error = null, onBack, onRestart, onOptimisticRunChange, onClickItem, onTimeExpired, }: Match3DRuntimeShellProps) { const stageRef = useRef(null); const [pendingClick, setPendingClick] = useState(null); const [feedbackEvent, setFeedbackEvent] = useState( null, ); const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0); useEffect(() => { setTimeLeftMs(run?.remainingMs ?? 0); }, [run?.remainingMs, run?.snapshotVersion]); useEffect(() => { if (!run || run.status !== 'Running') { 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 (!feedbackEvent) { return undefined; } const timer = window.setTimeout(() => setFeedbackEvent(null), 520); return () => window.clearTimeout(timer); }, [feedbackEvent]); const progressText = useMemo(() => { if (!run) { return '0/0'; } return `${run.clearedItemCount}/${run.totalItemCount}`; }, [run]); const handleItemClick = async (item: Match3DItemSnapshot) => { if (!run || run.status !== 'Running' || pendingClick) { return; } const optimisticRun = buildOptimisticRun(run, item); const clientEventId = buildClientEventId(item.itemInstanceId); // 中文注释:先更新前端即时反馈,再等待后端确认;确认失败时用权威快照回滚校正。 setPendingClick({ clientEventId, itemInstanceId: item.itemInstanceId, previousRun: run, }); onOptimisticRunChange(optimisticRun); const result = await onClickItem({ runId: run.runId, itemInstanceId: item.itemInstanceId, clientSnapshotVersion: run.snapshotVersion, clientEventId, clickedAtMs: Date.now(), }); if (result.status === 'Accepted') { if (result.clearedItemInstanceIds.length > 0) { setFeedbackEvent({ id: clientEventId, kind: 'cleared', itemIds: result.clearedItemInstanceIds, }); } onOptimisticRunChange(result.run); } else { setFeedbackEvent({ id: clientEventId, kind: 'rejected', itemIds: [item.itemInstanceId], }); onOptimisticRunChange(result.run ?? run); } setPendingClick(null); }; const handleBoardPointerDown = (event: PointerEvent) => { if (!run || run.status !== 'Running' || pendingClick) { return; } const rect = stageRef.current?.getBoundingClientRect(); if (!rect) { return; } const pointX = (event.clientX - rect.left) / rect.width; const pointY = (event.clientY - rect.top) / rect.height; const item = findHitItem(run, pointX, pointY); if (item) { void handleItemClick(item); } }; if (!run) { return (
{isBusy ? '载入中' : error ?? '暂无运行态'}
); } return (
{formatTimer(timeLeftMs)}
{progressText}
{run.clearCount} 组
v{run.snapshotVersion}
{run.items.map((item) => ( ))} {feedbackEvent?.kind === 'cleared' ? (
) : null}
{run.traySlots.map((slot) => (
))}
{feedbackEvent?.kind === 'rejected' ? (
已校正
) : null}
); } export default Match3DRuntimeShell;