565 lines
19 KiB
TypeScript
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;
|