Files
Genarrative/src/components/match3d-runtime/Match3DRuntimeShell.tsx
五香丸子 2252afb080
Some checks failed
CI / verify (push) Has been cancelled
fix: improve match3d tray preview readability
2026-05-05 17:17:27 +08:00

568 lines
17 KiB
TypeScript

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<Match3DClickItemResult>;
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 (
<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,
use3DPreview,
}: {
slot: Match3DTraySlot;
use3DPreview: boolean;
}) {
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);
const fallback = <Match3DVisualIcon visualKey={slot.visualKey} />;
return (
<span
className="flex h-full w-full items-center justify-center p-1"
aria-label={visualSeed.label}
>
<span className={use3DPreview ? 'opacity-0' : 'opacity-100'}>
{fallback}
</span>
</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);
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<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 min-w-0 flex-col overflow-hidden px-3 pb-[calc(env(safe-area-inset-bottom,0px)+0.8rem)] pt-[calc(env(safe-area-inset-top,0px)+0.65rem)]"
style={{
boxSizing: 'border-box',
maxWidth: '100vw',
width: 'min(100vw, 23.5rem)',
}}
>
<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 w-full min-w-0 grid-cols-3 gap-2 overflow-hidden 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 max-w-full 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)] ${
shouldUse3DRender ? 'overflow-visible' : 'overflow-hidden'
}`}
style={{
width: 'min(92vw, 58dvh, 100%)',
}}
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%)]" />
{shouldUse3DRender ? (
<Match3DPhysicsBoard
run={run}
disabled={Boolean(pendingClick)}
onClickItem={(item) => {
void handleItemClick(item);
}}
onFallback={() => setForce2DRender(true)}
/>
) : (
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 w-full min-w-0 overflow-hidden 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="relative grid grid-cols-7 gap-1.5"
data-testid="match3d-tray"
>
{shouldUse3DRender ? (
<Match3DTrayPreviewBoard
onFallback={handleTrayPreviewFallback}
referenceItems={run.items}
slotItems={trayPreviewItems}
/>
) : null}
{run.traySlots.map((slot) => {
return (
<div
key={slot.slotIndex}
className="relative z-0 h-14 min-w-0 rounded-xl bg-white/10 p-1 sm:h-16"
data-testid="match3d-tray-slot"
>
<Match3DTrayToken
slot={slot}
use3DPreview={shouldUse3DRender}
/>
</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;