import { ArrowLeft, CheckCircle2, Clock3, RotateCcw, Sparkles, XCircle, } from 'lucide-react'; import { type PointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import type { Match3DClickItemRequest, Match3DClickItemResult, Match3DItemSnapshot, Match3DRunSnapshot, Match3DTraySlot, } from '../../../packages/shared/src/contracts/match3dRuntime'; import { Match3DVisualIcon, resolveVisualSeed, } from './match3dVisualAssets'; import { Match3DPhysicsBoard, Match3DTrayPreviewBoard, } from './Match3DPhysicsBoard'; import { isItemState, isRunState, resolveRenderableItemFrame, } from './match3dRuntimePresentation'; 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 resolveTrayPreviewItem( run: Match3DRunSnapshot, slot: Match3DTraySlot, ) { if (!slot.itemInstanceId) { return null; } const item = run.items.find( (entry) => entry.itemInstanceId === slot.itemInstanceId, ); if (!item) { return null; } return { ...item, itemTypeId: slot.itemTypeId ?? item.itemTypeId, visualKey: slot.visualKey ?? item.visualKey, }; } const MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT = true; 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 buildClientEventId(itemInstanceId: string) { return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round( Math.random() * 1_000_000, )}`; } function isPointInsideCircle( pointX: number, pointY: number, item: Match3DItemSnapshot, ) { const frame = resolveRenderableItemFrame(item); return Math.hypot(pointX - frame.x, pointY - frame.y) <= frame.radius; } function findHitItem(run: Match3DRunSnapshot, pointX: number, pointY: number) { return run.items .filter( (item) => isItemState(item.state, 'in_board') && 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 frame = resolveRenderableItemFrame(item); const size = `${frame.radius * 200}%`; const itemStateClass = isItemState(item.state, 'flying') ? 'scale-75 opacity-0' : item.clickable ? 'cursor-pointer opacity-100 hover:scale-105 active:scale-95' : 'opacity-48'; if ( !isItemState(item.state, 'in_board') && !isItemState(item.state, 'flying') ) { return null; } return ( ); } function Match3DTrayToken({ slot, use3DPreview, }: { slot: Match3DTraySlot; use3DPreview: boolean; }) { if (!slot.visualKey) { return ( ); } const visualSeed = resolveVisualSeed(slot.visualKey); const fallback = ; return ( {fallback} ); } function Match3DSettlement({ run, onBack, onRestart, }: { run: Match3DRunSnapshot; onBack: () => void; onRestart: () => void; }) { if (isRunState(run.status, 'running')) { return null; } const won = isRunState(run.status, 'won'); const stopped = isRunState(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); const [force2DRender, setForce2DRender] = useState(() => { if (typeof window === 'undefined') { return true; } const params = new URLSearchParams(window.location.search); return ( params.get('match3dRender') === '2d' || params.get('match3d3d') === 'off' || !MATCH3D_ENABLE_3D_GEOMETRY_EXPERIMENT ); }); useEffect(() => { setTimeLeftMs(run?.remainingMs ?? 0); }, [run?.remainingMs, run?.snapshotVersion]); useEffect(() => { if (!run || !isRunState(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 shouldUse3DRender = !force2DRender; const handleTrayPreviewFallback = useCallback(() => { setForce2DRender(true); }, []); const trayPreviewItems = useMemo(() => { if (!run) { return []; } return run.traySlots.map((slot) => resolveTrayPreviewItem(run, slot)); }, [run]); const handleItemClick = async (item: Match3DItemSnapshot) => { if (!run || !isRunState(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 || !isRunState(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}
{shouldUse3DRender ? ( { void handleItemClick(item); }} onFallback={() => setForce2DRender(true)} /> ) : ( run.items.map((item) => ( )) )} {feedbackEvent?.kind === 'cleared' ? (
) : null}
{shouldUse3DRender ? ( ) : null} {run.traySlots.map((slot) => { return (
); })}
{feedbackEvent?.kind === 'rejected' ? (
已校正
) : null}
); } export default Match3DRuntimeShell;