454
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal file
454
src/components/match3d-runtime/Match3DRuntimeShell.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
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[];
|
||||
};
|
||||
|
||||
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 (
|
||||
<button
|
||||
type="button"
|
||||
className={`absolute flex -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-sm font-black text-white shadow-[0_10px_18px_rgba(15,23,42,0.32)] transition-all duration-300 [text-shadow:0_1px_2px_rgba(15,23,42,0.65)] ${itemStateClass}`}
|
||||
style={{
|
||||
left: `${item.x * 100}%`,
|
||||
top: `${item.y * 100}%`,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: item.layer,
|
||||
}}
|
||||
aria-label={`${visualSeed.label} ${item.clickable ? '可点击' : '被遮挡'}`}
|
||||
data-testid={`match3d-item-${item.itemInstanceId}`}
|
||||
disabled={disabled || !item.clickable || item.state !== 'InBoard'}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
<span className="relative z-10">{visualSeed.label}</span>
|
||||
<span className="absolute inset-[16%] rounded-full bg-white/24" />
|
||||
<span className="absolute left-[18%] top-[14%] h-[18%] w-[28%] rounded-full bg-white/42 blur-[1px]" />
|
||||
</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 rounded-xl border border-white/35 bg-gradient-to-br ${visualSeed.colorClassName} text-xs font-black text-white shadow-[0_8px_16px_rgba(15,23,42,0.24)] [text-shadow:0_1px_2px_rgba(15,23,42,0.62)]`}
|
||||
>
|
||||
{visualSeed.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<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 || 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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<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="absolute inset-[7%] 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;
|
||||
Reference in New Issue
Block a user