768 lines
22 KiB
TypeScript
768 lines
22 KiB
TypeScript
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<Match3DClickItemResult>;
|
|
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<string, Match3DGeometryAsset> = {
|
|
'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 <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
|
case 'triangle':
|
|
return <path d="M50 12 L89 84 H11Z" {...shapeProps} />;
|
|
case 'diamond':
|
|
return <path d="M50 9 L91 50 L50 91 L9 50Z" {...shapeProps} />;
|
|
case 'square':
|
|
return <rect x="16" y="16" width="68" height="68" rx="8" {...shapeProps} />;
|
|
case 'star':
|
|
return (
|
|
<path
|
|
d="M50 8 L61 36 L91 38 L68 58 L76 88 L50 72 L24 88 L32 58 L9 38 L39 36Z"
|
|
{...shapeProps}
|
|
/>
|
|
);
|
|
case 'hexagon':
|
|
return <path d="M28 12 H72 L94 50 L72 88 H28 L6 50Z" {...shapeProps} />;
|
|
case 'capsule':
|
|
return <rect x="10" y="28" width="80" height="44" rx="22" {...shapeProps} />;
|
|
case 'heart':
|
|
return (
|
|
<path
|
|
d="M50 86 C25 66 13 52 17 34 C20 18 40 16 50 31 C60 16 80 18 83 34 C87 52 75 66 50 86Z"
|
|
{...shapeProps}
|
|
/>
|
|
);
|
|
case 'trapezoid':
|
|
return <path d="M27 18 H73 L90 82 H10Z" {...shapeProps} />;
|
|
case 'parallelogram':
|
|
return <path d="M34 16 H88 L66 84 H12Z" {...shapeProps} />;
|
|
default:
|
|
return <circle cx="50" cy="50" r="36" {...shapeProps} />;
|
|
}
|
|
}
|
|
|
|
function Match3DVisualIcon({
|
|
visualKey,
|
|
className = '',
|
|
}: {
|
|
visualKey: string;
|
|
className?: string;
|
|
}) {
|
|
const asset = resolveGeometryAsset(visualKey);
|
|
|
|
return (
|
|
<svg
|
|
className={`pointer-events-none h-full w-full drop-shadow-[0_5px_7px_rgba(15,23,42,0.36)] ${className}`}
|
|
viewBox="0 0 100 100"
|
|
aria-hidden
|
|
focusable={false}
|
|
data-testid={`match3d-visual-${visualKey}`}
|
|
data-shape={asset.shape}
|
|
>
|
|
{renderGeometryShape(asset)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<button
|
|
type="button"
|
|
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center bg-transparent p-0 transition-all duration-300 ${itemStateClass}`}
|
|
style={{
|
|
left: `${frame.x * 100}%`,
|
|
top: `${frame.y * 100}%`,
|
|
width: size,
|
|
height: size,
|
|
zIndex: item.layer + 10,
|
|
}}
|
|
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
|
data-testid={`match3d-item-${item.itemInstanceId}`}
|
|
disabled={
|
|
disabled || !item.clickable || !isItemState(item.state, 'in_board')
|
|
}
|
|
onClick={() => onClick(item)}
|
|
>
|
|
<Match3DVisualIcon visualKey={item.visualKey} className="relative z-10" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Match3DTrayToken({ slot }: { slot: Match3DTraySlot }) {
|
|
if (!slot.visualKey) {
|
|
return (
|
|
<span className="h-full w-full rounded-xl border border-dashed border-slate-300/35 bg-white/8" />
|
|
);
|
|
}
|
|
const visualSeed = resolveVisualSeed(slot.visualKey);
|
|
return (
|
|
<span
|
|
className="flex h-full w-full items-center justify-center p-1"
|
|
aria-label={visualSeed.label}
|
|
>
|
|
<Match3DVisualIcon visualKey={slot.visualKey} />
|
|
</span>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/62 px-5 backdrop-blur-sm">
|
|
<section
|
|
className="w-full max-w-sm rounded-[1.5rem] border border-white/18 bg-white/94 p-5 text-slate-950 shadow-[0_26px_70px_rgba(15,23,42,0.34)]"
|
|
role="dialog"
|
|
aria-label={title}
|
|
>
|
|
<div className="mb-4 flex items-center gap-3">
|
|
<span
|
|
className={`flex h-11 w-11 items-center justify-center rounded-full ${
|
|
won
|
|
? 'bg-emerald-100 text-emerald-700'
|
|
: 'bg-rose-100 text-rose-700'
|
|
}`}
|
|
>
|
|
{won ? <CheckCircle2 size={24} /> : <XCircle size={24} />}
|
|
</span>
|
|
<div>
|
|
<h2 className="text-xl font-black">{title}</h2>
|
|
<p className="text-sm font-semibold text-slate-500">
|
|
{description}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-black text-slate-700"
|
|
onClick={onBack}
|
|
>
|
|
返回
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="rounded-xl bg-slate-950 px-4 py-3 text-sm font-black text-white"
|
|
onClick={onRestart}
|
|
>
|
|
再来一局
|
|
</button>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function Match3DRuntimeShell({
|
|
run,
|
|
isBusy = false,
|
|
error = null,
|
|
onBack,
|
|
onRestart,
|
|
onOptimisticRunChange,
|
|
onClickItem,
|
|
onTimeExpired,
|
|
}: Match3DRuntimeShellProps) {
|
|
const stageRef = useRef<HTMLDivElement | null>(null);
|
|
const [pendingClick, setPendingClick] = useState<PendingClick | null>(null);
|
|
const [feedbackEvent, setFeedbackEvent] =
|
|
useState<Match3DFeedbackEvent | null>(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<HTMLDivElement>) => {
|
|
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 (
|
|
<div className="flex min-h-dvh items-center justify-center bg-slate-950 text-white">
|
|
{isBusy ? '载入中' : (error ?? '暂无运行态')}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="relative flex min-h-dvh w-full justify-center overflow-hidden bg-[#16221f] text-white">
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_12%,rgba(255,255,255,0.22),transparent_26%),linear-gradient(180deg,#b8e28d_0%,#377569_52%,#14201f_100%)]" />
|
|
<div className="relative flex min-h-dvh w-full max-w-md flex-col px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]">
|
|
<header className="flex items-center justify-between gap-2">
|
|
<button
|
|
type="button"
|
|
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
|
|
onClick={onBack}
|
|
aria-label="返回"
|
|
>
|
|
<ArrowLeft size={20} />
|
|
</button>
|
|
<div className="flex items-center gap-2 rounded-full border border-white/16 bg-black/24 px-3 py-2 text-sm font-black backdrop-blur">
|
|
<Clock3 size={16} />
|
|
<span>{formatTimer(timeLeftMs)}</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="flex h-10 w-10 items-center justify-center rounded-full border border-white/16 bg-black/22 text-white backdrop-blur"
|
|
onClick={onRestart}
|
|
aria-label="重新开始"
|
|
>
|
|
<RotateCcw size={18} />
|
|
</button>
|
|
</header>
|
|
|
|
<section className="mt-3 grid grid-cols-3 gap-2 text-center text-[0.72rem] font-black">
|
|
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
|
{progressText}
|
|
</div>
|
|
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
|
{run.clearCount} 组
|
|
</div>
|
|
<div className="rounded-2xl border border-white/14 bg-black/18 px-2 py-2 backdrop-blur">
|
|
v{run.snapshotVersion}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="relative mt-3 flex flex-1 min-h-0 items-center justify-center">
|
|
<div
|
|
ref={stageRef}
|
|
className="relative aspect-square w-full max-w-[min(92vw,58dvh)] overflow-hidden rounded-full border-[10px] border-[#e6d19b] bg-[radial-gradient(circle_at_50%_42%,#f2d993_0%,#c88f43_56%,#835223_100%)] shadow-[inset_0_8px_34px_rgba(72,41,16,0.34),0_22px_42px_rgba(15,23,42,0.28)]"
|
|
onPointerDown={handleBoardPointerDown}
|
|
data-testid="match3d-board"
|
|
>
|
|
<div className="pointer-events-none absolute inset-[7%] z-0 rounded-full border border-white/22 bg-[radial-gradient(circle_at_44%_35%,rgba(255,255,255,0.22),transparent_28%)]" />
|
|
{run.items.map((item) => (
|
|
<Match3DToken
|
|
key={item.itemInstanceId}
|
|
item={item}
|
|
disabled={Boolean(pendingClick)}
|
|
onClick={handleItemClick}
|
|
/>
|
|
))}
|
|
{feedbackEvent?.kind === 'cleared' ? (
|
|
<div className="pointer-events-none absolute inset-0 z-[70] flex items-center justify-center">
|
|
<div className="flex h-24 w-24 items-center justify-center rounded-full bg-white/24 text-amber-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
|
|
<Sparkles size={42} />
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mt-3 rounded-[1.35rem] border border-white/14 bg-black/24 p-2 shadow-[0_16px_36px_rgba(15,23,42,0.24)] backdrop-blur">
|
|
<div className="grid grid-cols-7 gap-1.5" data-testid="match3d-tray">
|
|
{run.traySlots.map((slot) => (
|
|
<div
|
|
key={slot.slotIndex}
|
|
className="aspect-square min-w-0 rounded-xl bg-white/10 p-1"
|
|
data-testid="match3d-tray-slot"
|
|
>
|
|
<Match3DTrayToken slot={slot} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{feedbackEvent?.kind === 'rejected' ? (
|
|
<div className="pointer-events-none absolute left-1/2 top-24 z-[90] -translate-x-1/2 rounded-full border border-rose-200/60 bg-rose-500/88 px-4 py-2 text-xs font-black text-white shadow-lg">
|
|
已校正
|
|
</div>
|
|
) : null}
|
|
|
|
<Match3DSettlement run={run} onBack={onBack} onRestart={onRestart} />
|
|
</main>
|
|
);
|
|
}
|
|
|
|
export default Match3DRuntimeShell;
|