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[]; }; type Match3DVisualSeed = (typeof MATCH3D_VISUAL_SEEDS)[number]; type Match3DGeometryShape = | 'circle' | 'triangle' | 'diamond' | 'square' | 'star' | 'hexagon' | 'capsule' | 'heart' | 'trapezoid' | 'parallelogram'; type Match3DGeometryAsset = { shape: Match3DGeometryShape; fill: string; stroke: string; }; const MATCH3D_RENDER_CENTER = 0.5; const MATCH3D_RENDER_RADIUS = 0.5; const MATCH3D_RENDER_SAFE_MARGIN = 0.035; const MATCH3D_GEOMETRY_ASSETS: Record = { 'watermelon-green': { shape: 'circle', fill: '#16a34a', stroke: '#14532d', }, 'apple-red': { shape: 'heart', fill: '#ef4444', stroke: '#991b1b', }, 'banana-yellow': { shape: 'parallelogram', fill: '#facc15', stroke: '#a16207', }, 'grape-purple': { shape: 'star', fill: '#8b5cf6', stroke: '#5b21b6', }, 'melon-green': { shape: 'hexagon', fill: '#84cc16', stroke: '#3f6212', }, 'berry-blue': { shape: 'diamond', fill: '#2563eb', stroke: '#1e3a8a', }, 'peach-pink': { shape: 'trapezoid', fill: '#fb7185', stroke: '#be123c', }, 'plum-indigo': { shape: 'capsule', fill: '#4f46e5', stroke: '#312e81', }, 'lime-lime': { shape: 'square', fill: '#65a30d', stroke: '#365314', }, 'orange-orange': { shape: 'triangle', fill: '#f97316', stroke: '#9a3412', }, 'pear-cyan': { shape: 'parallelogram', fill: '#06b6d4', stroke: '#155e75', }, red_circle: { shape: 'circle', fill: '#ef4444', stroke: '#991b1b', }, yellow_triangle: { shape: 'triangle', fill: '#facc15', stroke: '#a16207', }, purple_diamond: { shape: 'diamond', fill: '#7c3aed', stroke: '#4c1d95', }, green_square: { shape: 'square', fill: '#16a34a', stroke: '#14532d', }, blue_star: { shape: 'star', fill: '#0ea5e9', stroke: '#075985', }, orange_hexagon: { shape: 'hexagon', fill: '#f97316', stroke: '#9a3412', }, cyan_capsule: { shape: 'capsule', fill: '#06b6d4', stroke: '#155e75', }, pink_heart: { shape: 'heart', fill: '#ec4899', stroke: '#9d174d', }, lime_leaf: { shape: 'trapezoid', fill: '#84cc16', stroke: '#3f6212', }, white_moon: { shape: 'parallelogram', fill: '#e2e8f0', stroke: '#64748b', }, }; const MATCH3D_UNKNOWN_GEOMETRY_ASSETS: Match3DGeometryAsset[] = [ { shape: 'circle', fill: '#f43f5e', stroke: '#9f1239' }, { shape: 'triangle', fill: '#f59e0b', stroke: '#92400e' }, { shape: 'diamond', fill: '#8b5cf6', stroke: '#5b21b6' }, { shape: 'star', fill: '#10b981', stroke: '#065f46' }, { shape: 'trapezoid', fill: '#0ea5e9', stroke: '#075985' }, { shape: 'parallelogram', fill: '#14b8a6', stroke: '#115e59' }, ]; const MATCH3D_UNKNOWN_VISUAL_SEEDS: Match3DVisualSeed[] = [ { itemTypeId: 'unknown-rose', visualKey: 'unknown-rose', colorClassName: 'from-rose-400 to-red-600', label: '一', }, { itemTypeId: 'unknown-amber', visualKey: 'unknown-amber', colorClassName: 'from-yellow-300 to-amber-500', label: '二', }, { itemTypeId: 'unknown-violet', visualKey: 'unknown-violet', colorClassName: 'from-violet-400 to-purple-700', label: '三', }, { itemTypeId: 'unknown-emerald', visualKey: 'unknown-emerald', colorClassName: 'from-emerald-300 to-green-600', label: '四', }, { itemTypeId: 'unknown-sky', visualKey: 'unknown-sky', colorClassName: 'from-sky-300 to-blue-600', label: '五', }, ]; 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 hashVisualKey(visualKey: string) { let hash = 0; for (const char of visualKey) { hash = (hash * 31 + char.charCodeAt(0)) >>> 0; } return hash; } function resolveVisualSeed(visualKey: string) { const knownSeed = MATCH3D_VISUAL_SEEDS.find( (seed) => seed.visualKey === visualKey, ); if (knownSeed) { return knownSeed; } return MATCH3D_UNKNOWN_VISUAL_SEEDS[ hashVisualKey(visualKey) % MATCH3D_UNKNOWN_VISUAL_SEEDS.length ]!; } function resolveGeometryAsset(visualKey: string): Match3DGeometryAsset { return ( MATCH3D_GEOMETRY_ASSETS[visualKey] ?? MATCH3D_UNKNOWN_GEOMETRY_ASSETS[ hashVisualKey(visualKey) % MATCH3D_UNKNOWN_GEOMETRY_ASSETS.length ]! ); } function renderGeometryShape(asset: Match3DGeometryAsset) { const shapeProps = { fill: asset.fill, stroke: asset.stroke, strokeWidth: 6, strokeLinejoin: 'round' as const, }; switch (asset.shape) { case 'circle': return ; case 'triangle': return ; case 'diamond': return ; case 'square': return ; case 'star': return ( ); case 'hexagon': return ; case 'capsule': return ; case 'heart': return ( ); case 'trapezoid': return ; case 'parallelogram': return ; default: return ; } } function Match3DVisualIcon({ visualKey, className = '', }: { visualKey: string; className?: string; }) { const asset = resolveGeometryAsset(visualKey); return ( {renderGeometryShape(asset)} ); } function resolveRenderableItemFrame(item: Match3DItemSnapshot) { const maxRadius = MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN; const radius = Math.min( Math.max(Number.isFinite(item.radius) ? item.radius : 0.06, 0.035), maxRadius, ); const rawX = Number.isFinite(item.x) ? item.x : MATCH3D_RENDER_CENTER; const rawY = Number.isFinite(item.y) ? item.y : MATCH3D_RENDER_CENTER; const dx = rawX - MATCH3D_RENDER_CENTER; const dy = rawY - MATCH3D_RENDER_CENTER; const distance = Math.hypot(dx, dy); const maxDistance = Math.max( 0, MATCH3D_RENDER_RADIUS - MATCH3D_RENDER_SAFE_MARGIN - radius, ); if (distance <= maxDistance || distance <= 0) { return { x: rawX, y: rawY, radius }; } const ratio = maxDistance / distance; return { x: MATCH3D_RENDER_CENTER + dx * ratio, y: MATCH3D_RENDER_CENTER + dy * ratio, radius, }; } function buildClientEventId(itemInstanceId: string) { return `match3d-click-${itemInstanceId}-${Date.now()}-${Math.round( Math.random() * 1_000_000, )}`; } function isRunState( status: Match3DRunSnapshot['status'], expected: 'running' | 'won' | 'failed' | 'stopped', ) { return String(status).toLowerCase() === expected; } function isItemState( state: Match3DItemSnapshot['state'], expected: 'in_board' | 'in_tray' | 'cleared' | 'flying', ) { return ( String(state) .replace(/([a-z])([A-Z])/gu, '$1_$2') .toLowerCase() === expected ); } 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 }: { slot: Match3DTraySlot }) { if (!slot.visualKey) { return ( ); } const visualSeed = resolveVisualSeed(slot.visualKey); return ( ); } 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); 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 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}
{run.items.map((item) => ( ))} {feedbackEvent?.kind === 'cleared' ? (
) : null}
{run.traySlots.map((slot) => (
))}
{feedbackEvent?.kind === 'rejected' ? (
已校正
) : null}
); } export default Match3DRuntimeShell;