Files
Genarrative/src/components/square-hole-runtime/SquareHoleRuntimeShell.tsx
2026-05-08 20:48:29 +08:00

565 lines
19 KiB
TypeScript

import {
ArrowDown,
ArrowLeft,
CheckCircle2,
Clock3,
Image,
RotateCcw,
Shapes,
Sparkles,
XCircle,
} from 'lucide-react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import type {
DropSquareHoleShapeRequest,
SquareHoleDropResponse,
SquareHoleRunSnapshot,
} from '../../../packages/shared/src/contracts/squareHoleRuntime';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type SquareHoleRuntimeShellProps = {
run: SquareHoleRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
embedded?: boolean;
onBack: () => void;
onRestart: () => void;
onDropShape: (
payload: DropSquareHoleShapeRequest,
) => Promise<SquareHoleDropResponse>;
onOptimisticRunChange?: (run: SquareHoleRunSnapshot) => void;
onTimeExpired?: () => void;
};
type PendingDrop = {
clientEventId: string;
holeId: string;
};
type DragState = {
pointerId: number;
startX: number;
startY: number;
x: number;
y: number;
size: number;
moved: boolean;
};
function isRunning(run: SquareHoleRunSnapshot) {
return run.status.toLowerCase() === 'running';
}
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 buildClientEventId(runId: string, holeId: string) {
return `square-hole-drop-${runId}-${holeId}-${Date.now()}-${Math.round(
Math.random() * 1_000_000,
)}`;
}
function clampPercent(value: number) {
return Math.min(92, Math.max(8, value * 100));
}
function hashText(value: string) {
let hash = 2166136261;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 16777619);
}
return hash >>> 0;
}
function SquareHoleSettlement({
run,
onBack,
onRestart,
}: {
run: SquareHoleRunSnapshot;
onBack: () => void;
onRestart: () => void;
}) {
if (isRunning(run)) {
return null;
}
const won = run.status.toLowerCase() === 'won';
const stopped = run.status.toLowerCase() === 'stopped';
const title = won ? '挑战完成' : stopped ? '已停止' : '本轮失败';
return (
<div className="absolute inset-0 z-[80] flex items-center justify-center bg-slate-950/64 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">
{run.completedShapeCount}/{run.totalShapeCount} · {run.score}
</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 SquareHoleRuntimeShell({
run,
isBusy = false,
error = null,
embedded = false,
onBack,
onRestart,
onDropShape,
onOptimisticRunChange,
onTimeExpired,
}: SquareHoleRuntimeShellProps) {
const [pendingDrop, setPendingDrop] = useState<PendingDrop | null>(null);
const [timeLeftMs, setTimeLeftMs] = useState(run?.remainingMs ?? 0);
const [feedbackPulseId, setFeedbackPulseId] = useState<string | null>(null);
const [dragState, setDragState] = useState<DragState | null>(null);
const [isShapeArmed, setIsShapeArmed] = useState(false);
const [dropError, setDropError] = useState<string | null>(null);
useEffect(() => {
setTimeLeftMs(run?.remainingMs ?? 0);
setIsShapeArmed(false);
setDropError(null);
}, [run?.remainingMs, run?.snapshotVersion]);
useEffect(() => {
if (!run || !isRunning(run)) {
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 (!feedbackPulseId) {
return undefined;
}
const timer = window.setTimeout(() => setFeedbackPulseId(null), 620);
return () => window.clearTimeout(timer);
}, [feedbackPulseId]);
const progressText = useMemo(() => {
if (!run) {
return '0/0';
}
return `${run.completedShapeCount}/${run.totalShapeCount}`;
}, [run]);
const currentShape = run?.currentShape ?? null;
const hintHole = useMemo(() => {
if (!run || !currentShape) {
return null;
}
const hintCandidates =
run.holes.length > 1
? run.holes.filter(
(hole) => hole.holeId !== currentShape.targetHoleId,
)
: run.holes;
if (hintCandidates.length <= 0) {
return null;
}
const seed = `${run.runId}:${run.snapshotVersion}:${run.completedShapeCount}:${currentShape.shapeId}`;
return hintCandidates[hashText(seed) % hintCandidates.length] ?? null;
}, [currentShape, run]);
const arrowStyle = useMemo<CSSProperties>(() => {
if (!hintHole) {
return {};
}
return {
left: `${clampPercent(hintHole.x)}%`,
top: `${clampPercent(hintHole.y)}%`,
};
}, [hintHole]);
const resolveHoleAtPoint = useCallback((clientX: number, clientY: number) => {
const elements = document.elementsFromPoint(clientX, clientY);
const holeElement = elements.find((element) =>
element instanceof HTMLElement ? element.dataset.squareHoleId : false,
) as HTMLElement | undefined;
return holeElement?.dataset.squareHoleId ?? null;
}, []);
const handleShapePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!run || !currentShape || !isRunning(run) || pendingDrop || isBusy) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setDragState({
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
x: event.clientX,
y: event.clientY,
size: event.currentTarget.getBoundingClientRect().width,
moved: false,
});
};
const handleShapePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!dragState || dragState.pointerId !== event.pointerId) {
return;
}
setDragState((current) =>
current
? {
...current,
x: event.clientX,
y: event.clientY,
moved:
current.moved ||
Math.hypot(
event.clientX - current.startX,
event.clientY - current.startY,
) >= 6,
}
: current,
);
};
const handleShapePointerEnd = (event: ReactPointerEvent<HTMLDivElement>) => {
const currentDragState = dragState;
if (!currentDragState || currentDragState.pointerId !== event.pointerId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
const holeId = resolveHoleAtPoint(event.clientX, event.clientY);
setDragState(null);
if (holeId) {
void dropToHole(holeId);
return;
}
if (!currentDragState.moved) {
setIsShapeArmed(true);
return;
}
setIsShapeArmed(false);
};
const dropToHole = async (holeId: string) => {
if (!run || !isRunning(run) || pendingDrop || isBusy) {
return;
}
const clientEventId = buildClientEventId(run.runId, holeId);
setPendingDrop({ clientEventId, holeId });
setFeedbackPulseId(null);
setIsShapeArmed(false);
setDropError(null);
try {
const response = await onDropShape({
runId: run.runId,
holeId,
clientSnapshotVersion: run.snapshotVersion,
clientEventId,
droppedAtMs: Date.now(),
});
setFeedbackPulseId(clientEventId);
onOptimisticRunChange?.(response.run);
} catch (caughtError) {
setDropError(
caughtError instanceof Error ? caughtError.message : '本次投入失败',
);
} finally {
setPendingDrop(null);
}
};
if (!run) {
return (
<div
className={`flex ${embedded ? 'h-full min-h-0 w-full' : 'min-h-dvh'} items-center justify-center bg-slate-950 text-white`}
>
{isBusy ? '载入中' : (error ?? '暂无运行态')}
</div>
);
}
const feedback = run.lastFeedback;
return (
<main
className={`relative flex ${embedded ? 'h-full min-h-0' : 'min-h-dvh'} w-full justify-center overflow-hidden bg-[#101827] text-white`}
>
{run.backgroundImageSrc ? (
<ResolvedAssetImage
src={run.backgroundImageSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-[radial-gradient(circle_at_50%_10%,rgba(125,211,252,0.32),transparent_27%),radial-gradient(circle_at_80%_80%,rgba(248,113,113,0.24),transparent_34%),linear-gradient(180deg,#1f3a5f_0%,#152238_48%,#111827_100%)]" />
)}
<div className="absolute inset-0 bg-slate-950/42" />
{dragState && currentShape ? (
<div
className="pointer-events-none fixed z-[95] grid overflow-hidden rounded-[1.15rem] border border-white/18 bg-white/92 shadow-[0_24px_56px_rgba(15,23,42,0.46)]"
style={{
left: dragState.x,
top: dragState.y,
width: dragState.size,
height: dragState.size,
transform: 'translate(-50%, -50%)',
}}
aria-hidden="true"
>
{currentShape.imageSrc ? (
<ResolvedAssetImage
src={currentShape.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
<Image size={30} />
</span>
)}
</div>
) : null}
<div
className={`relative flex ${embedded ? 'h-full min-h-0' : '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, 24rem)',
}}
>
<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/24 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/26 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/24 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/20 px-2 py-2 backdrop-blur">
{progressText}
</div>
<div className="rounded-2xl border border-white/14 bg-black/20 px-2 py-2 backdrop-blur">
{run.score}
</div>
<div className="rounded-2xl border border-white/14 bg-black/20 px-2 py-2 backdrop-blur">
{run.combo}
</div>
</section>
<section className="relative mt-3 min-h-[22rem] overflow-hidden rounded-[1.5rem] border border-white/14 bg-black/18 p-3 shadow-[0_18px_42px_rgba(15,23,42,0.28)] backdrop-blur">
<div className="absolute inset-3 rounded-[1.25rem] border border-white/10 bg-white/8" />
{hintHole ? (
<div
className="square-hole-runtime__target-arrow pointer-events-none absolute z-20 -translate-x-1/2 -translate-y-[138%] text-cyan-100 drop-shadow-[0_4px_16px_rgba(103,232,249,0.65)]"
style={arrowStyle}
aria-hidden="true"
>
<ArrowDown size={34} strokeWidth={3.2} />
</div>
) : null}
{run.holes.map((hole) => {
const isPending = pendingDrop?.holeId === hole.holeId;
const isHint = hintHole?.holeId === hole.holeId;
return (
<button
key={hole.holeId}
type="button"
data-square-hole-id={hole.holeId}
disabled={!isRunning(run) || Boolean(pendingDrop) || isBusy}
onClick={() => {
void dropToHole(hole.holeId);
}}
className={`absolute flex h-24 w-24 -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center rounded-[1.35rem] border border-white/14 bg-black/34 p-2 text-white shadow-[0_12px_28px_rgba(15,23,42,0.24)] backdrop-blur transition disabled:cursor-not-allowed disabled:opacity-58 ${
isPending || isHint || isShapeArmed
? 'ring-2 ring-cyan-200/70'
: ''
}`}
style={{
left: `${clampPercent(hole.x)}%`,
top: `${clampPercent(hole.y)}%`,
}}
aria-label={`投入 ${hole.label}`}
>
<span className="grid h-14 w-14 place-items-center overflow-hidden rounded-2xl bg-white/88">
{hole.imageSrc ? (
<ResolvedAssetImage
src={hole.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
<Image size={22} />
</span>
)}
</span>
<span className="mt-2 block truncate text-sm font-black">
{hole.label}
</span>
</button>
);
})}
</section>
<section className="relative mt-3 min-h-[9.4rem] rounded-[1.45rem] border border-white/14 bg-black/24 p-3 backdrop-blur">
{currentShape ? (
<div className="flex h-full flex-col items-center justify-center gap-3">
<div
className={`relative h-24 w-24 touch-none select-none overflow-hidden shadow-[0_18px_38px_rgba(15,23,42,0.34)] transition ${
dragState
? 'opacity-35'
: isShapeArmed
? 'ring-4 ring-cyan-200/70 active:scale-[0.98]'
: 'active:scale-[0.98]'
} rounded-[1.15rem] border border-white/18 bg-white/92`}
style={{
cursor:
isRunning(run) && !pendingDrop && !isBusy
? 'grab'
: 'default',
}}
onPointerDown={handleShapePointerDown}
onPointerMove={handleShapePointerMove}
onPointerUp={handleShapePointerEnd}
onPointerCancel={handleShapePointerEnd}
role="button"
tabIndex={0}
aria-label={`拖拽${currentShape.label}`}
>
{currentShape.imageSrc ? (
<ResolvedAssetImage
src={currentShape.imageSrc}
alt=""
aria-hidden="true"
className="h-full w-full object-cover"
/>
) : (
<span className="grid h-full w-full place-items-center bg-slate-100 text-slate-500">
<Image size={32} />
</span>
)}
</div>
<div className="flex min-w-0 items-center gap-2 text-base font-black">
<Shapes size={18} />
<span className="truncate">{currentShape.label}</span>
</div>
</div>
) : (
<div className="flex h-full items-center justify-center text-sm font-bold text-white/70">
</div>
)}
</section>
<section className="mt-auto min-h-[3.5rem] pt-3">
{feedback ? (
<div
className={`rounded-[1.2rem] border px-3 py-2 text-center text-sm font-black ${
feedback.accepted
? 'border-emerald-200/35 bg-emerald-400/18 text-emerald-50'
: 'border-rose-200/35 bg-rose-400/18 text-rose-50'
}`}
>
{feedback.message}
</div>
) : dropError || error ? (
<div className="rounded-[1.2rem] border border-rose-200/35 bg-rose-400/18 px-3 py-2 text-center text-sm font-black text-rose-50">
{dropError ?? error}
</div>
) : null}
</section>
</div>
{feedbackPulseId && feedback?.accepted ? (
<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-cyan-100 shadow-[0_0_42px_rgba(255,255,255,0.72)] backdrop-blur-sm">
<Sparkles size={42} />
</div>
</div>
) : null}
<SquareHoleSettlement run={run} onBack={onBack} onRestart={onRestart} />
</main>
);
}
export default SquareHoleRuntimeShell;