This commit is contained in:
2026-04-27 14:23:19 +08:00
parent 09d3fe59b3
commit fa2dbb310b
75 changed files with 7363 additions and 1487 deletions

View File

@@ -0,0 +1,126 @@
/* @vitest-environment jsdom */
import { fireEvent, render, screen, within } from '@testing-library/react';
import { expect, test, vi } from 'vitest';
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { PuzzleRuntimeShell } from './PuzzleRuntimeShell';
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
useResolvedAssetReadUrl: () => ({
resolvedUrl: '',
isResolving: false,
shouldResolve: false,
}),
}));
vi.mock('../ResolvedAssetImage', () => ({
ResolvedAssetImage: () => null,
}));
const clearedRun: PuzzleRunSnapshot = {
runId: 'run-1',
entryProfileId: 'profile-1',
clearedLevelCount: 1,
currentLevelIndex: 1,
currentGridSize: 3,
playedProfileIds: ['profile-1'],
previousLevelTags: ['奇幻'],
recommendedNextProfileId: 'profile-2',
leaderboardEntries: [
{
rank: 1,
nickname: '测试作者',
elapsedMs: 12_340,
isCurrentPlayer: true,
},
{
rank: 2,
nickname: '星桥旅人',
elapsedMs: 18_120,
},
],
currentLevel: {
runId: 'run-1',
levelIndex: 1,
gridSize: 3,
profileId: 'profile-1',
levelName: '潮雾拼图',
authorDisplayName: '测试作者',
themeTags: ['奇幻'],
coverImageSrc: null,
status: 'cleared',
startedAtMs: 1000,
clearedAtMs: 13_340,
elapsedMs: 12_340,
leaderboardEntries: [
{
rank: 1,
nickname: '测试作者',
elapsedMs: 12_340,
isCurrentPlayer: true,
},
{
rank: 2,
nickname: '星桥旅人',
elapsedMs: 18_120,
},
],
board: {
rows: 3,
cols: 3,
selectedPieceId: null,
allTilesResolved: true,
mergedGroups: [],
pieces: Array.from({ length: 9 }, (_, index) => ({
pieceId: `piece-${index}`,
correctRow: Math.floor(index / 3),
correctCol: index % 3,
currentRow: Math.floor(index / 3),
currentCol: index % 3,
mergedGroupId: null,
})),
},
},
};
test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
const onAdvanceNextLevel = vi.fn();
render(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={onAdvanceNextLevel}
/>,
);
const dialog = screen.getByRole('dialog', { name: '通关完成' });
expect(within(dialog).getAllByText('0:12.34').length).toBeGreaterThan(0);
expect(within(dialog).getByText('排行榜')).toBeTruthy();
expect(within(dialog).getByText('#1')).toBeTruthy();
expect(within(dialog).getByText('测试作者')).toBeTruthy();
fireEvent.click(within(dialog).getByRole('button', { name: '下一关' }));
expect(onAdvanceNextLevel).toHaveBeenCalledTimes(1);
});
test('关闭通关弹窗后保留底部下一关入口', () => {
render(
<PuzzleRuntimeShell
run={clearedRun}
onBack={vi.fn()}
onSwapPieces={vi.fn()}
onDragPiece={vi.fn()}
onAdvanceNextLevel={vi.fn()}
/>,
);
fireEvent.click(screen.getByRole('button', { name: '关闭通关弹窗' }));
expect(screen.queryByRole('dialog', { name: '通关完成' })).toBeNull();
expect(screen.getByRole('button', { name: //u })).toBeTruthy();
});

View File

@@ -1,10 +1,11 @@
import { ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
import { useMemo, useRef, useState } from 'react';
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
@@ -27,9 +28,26 @@ type PuzzleBoardPieceViewModel = {
col: number;
correctRow: number;
correctCol: number;
mergedGroupId: string | null;
label: string;
};
type PuzzleMergedGroupViewModel = {
groupId: string;
pieceIds: string[];
anchorPieceId: string;
minRow: number;
minCol: number;
rowSpan: number;
colSpan: number;
pieces: Array<
PuzzleBoardPieceViewModel & {
localRow: number;
localCol: number;
}
>;
};
function boardCellKey(position: PuzzleCellPosition) {
return `${position.row}:${position.col}`;
}
@@ -46,6 +64,59 @@ function buildPieceLabel(pieceId: string) {
return fallback || '块';
}
function buildMergedGroupViewModels(
groups: PuzzleMergedGroupState[],
pieces: PuzzleBoardPieceViewModel[],
) {
const pieceById = new Map(pieces.map((piece) => [piece.pieceId, piece]));
return groups
.map<PuzzleMergedGroupViewModel | null>((group) => {
const groupPieces = group.pieceIds
.map((pieceId) => pieceById.get(pieceId) ?? null)
.filter((piece): piece is PuzzleBoardPieceViewModel => Boolean(piece));
if (groupPieces.length <= 1) {
return null;
}
const rows = groupPieces.map((piece) => piece.row);
const cols = groupPieces.map((piece) => piece.col);
const minRow = Math.min(...rows);
const maxRow = Math.max(...rows);
const minCol = Math.min(...cols);
const maxCol = Math.max(...cols);
const anchorPiece = groupPieces[0];
if (!anchorPiece) {
return null;
}
return {
groupId: group.groupId,
pieceIds: group.pieceIds,
anchorPieceId: anchorPiece.pieceId,
minRow,
minCol,
rowSpan: maxRow - minRow + 1,
colSpan: maxCol - minCol + 1,
pieces: groupPieces.map((piece) => ({
...piece,
localRow: piece.row - minRow,
localCol: piece.col - minCol,
})),
};
})
.filter((group): group is PuzzleMergedGroupViewModel => Boolean(group));
}
function formatElapsedMs(elapsedMs: number | null | undefined) {
const normalizedMs = Math.max(0, Math.round(elapsedMs ?? 0));
const totalSeconds = Math.floor(normalizedMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const centiseconds = Math.floor((normalizedMs % 1000) / 10);
return `${minutes}:${seconds.toString().padStart(2, '0')}.${centiseconds
.toString()
.padStart(2, '0')}`;
}
/**
* 拼图运行时壳层。
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
@@ -60,13 +131,24 @@ export function PuzzleRuntimeShell({
onAdvanceNextLevel,
}: PuzzleRuntimeShellProps) {
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [dragState, setDragState] = useState<{
const dragSessionRef = useRef<{
pieceId: string;
pointerId: number;
dragging: boolean;
startX: number;
startY: number;
currentX: number;
currentY: number;
} | null>(null);
const dragVisualTargetRef = useRef<{
pieceId: string;
groupId: string | null;
} | null>(null);
const dragVisualFrameRef = useRef<number | null>(null);
const dragOffsetRef = useRef<{ x: number; y: number } | null>(null);
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
const [dismissedClearKey, setDismissedClearKey] = useState<string | null>(null);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const board = currentLevel?.board ?? null;
@@ -84,20 +166,27 @@ export function PuzzleRuntimeShell({
col: piece.currentCol,
correctRow: piece.correctRow,
correctCol: piece.correctCol,
mergedGroupId: piece.mergedGroupId,
label: buildPieceLabel(piece.pieceId),
}));
}, [board]);
const mergedCellKeys = useMemo(() => {
const mergedGroups = useMemo(() => {
if (!board) {
return new Set<string>();
return [];
}
return new Set(
board.mergedGroups.flatMap((group) =>
group.occupiedCells.map((cell) => boardCellKey(cell)),
return buildMergedGroupViewModels(board.mergedGroups, pieces);
}, [board, pieces]);
const mergedCellKeys = useMemo(
() =>
new Set(
mergedGroups.flatMap((group) =>
group.pieces.map((piece) => boardCellKey(piece)),
),
),
);
}, [board]);
[mergedGroups],
);
const pieceByCell = useMemo(() => {
const map = new Map<string, PuzzleBoardPieceViewModel>();
@@ -106,6 +195,119 @@ export function PuzzleRuntimeShell({
}
return map;
}, [pieces]);
const pieceById = useMemo(
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
[pieces],
);
const resetDragVisualTarget = () => {
const dragVisualTarget = dragVisualTargetRef.current;
if (!dragVisualTarget) {
return;
}
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
if (pieceElement) {
pieceElement.style.transform = '';
pieceElement.style.willChange = '';
pieceElement.style.zIndex = '';
pieceElement.style.opacity = '';
}
if (dragVisualTarget.groupId) {
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
if (groupElement) {
groupElement.style.transform = '';
groupElement.style.willChange = '';
groupElement.style.zIndex = '';
groupElement.style.opacity = '';
}
}
dragVisualTargetRef.current = null;
};
const cancelDragVisualFrame = () => {
if (dragVisualFrameRef.current === null) {
return;
}
window.cancelAnimationFrame(dragVisualFrameRef.current);
dragVisualFrameRef.current = null;
};
const resetDragInteraction = () => {
cancelDragVisualFrame();
dragOffsetRef.current = null;
dragSessionRef.current = null;
resetDragVisualTarget();
};
const flushDragVisual = () => {
dragVisualFrameRef.current = null;
const dragSession = dragSessionRef.current;
if (!dragSession || !dragSession.dragging) {
resetDragVisualTarget();
return;
}
const piece = pieceById.get(dragSession.pieceId) ?? null;
const groupId = piece?.mergedGroupId ?? null;
const nextTarget = {
pieceId: dragSession.pieceId,
groupId,
};
const previousTarget = dragVisualTargetRef.current;
if (
previousTarget &&
(previousTarget.pieceId !== nextTarget.pieceId ||
previousTarget.groupId !== nextTarget.groupId)
) {
resetDragVisualTarget();
}
dragVisualTargetRef.current = nextTarget;
const offsetX = dragSession.currentX - dragSession.startX;
const offsetY = dragSession.currentY - dragSession.startY;
dragOffsetRef.current = { x: offsetX, y: offsetY };
if (groupId) {
const groupElement = groupElementRefMap.current.get(groupId);
if (groupElement) {
groupElement.style.willChange = 'transform';
groupElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.02)`;
groupElement.style.zIndex = '80';
groupElement.style.opacity = '0.95';
}
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
if (pieceElement) {
pieceElement.style.transform = '';
pieceElement.style.willChange = '';
pieceElement.style.zIndex = '';
pieceElement.style.opacity = '';
}
return;
}
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
if (pieceElement) {
pieceElement.style.willChange = 'transform';
pieceElement.style.transform = `translate3d(${offsetX}px, ${offsetY}px, 0) scale(1.03)`;
pieceElement.style.zIndex = '70';
pieceElement.style.opacity = '0.95';
}
};
const scheduleDragVisual = () => {
if (dragVisualFrameRef.current !== null) {
return;
}
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
};
useEffect(() => () => {
cancelDragVisualFrame();
resetDragVisualTarget();
}, []);
if (!run || !currentLevel || !board) {
return (
@@ -174,18 +376,19 @@ export function PuzzleRuntimeShell({
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
const currentDragState = dragState;
if (!currentDragState || currentDragState.pieceId !== pieceId) {
const currentDragSession = dragSessionRef.current;
if (!currentDragSession || currentDragSession.pieceId !== pieceId) {
return;
}
event.currentTarget.releasePointerCapture(event.pointerId);
event.currentTarget.releasePointerCapture?.(event.pointerId);
if (currentDragState.dragging) {
if (currentDragSession.dragging) {
const targetCell = resolveBoardCellFromPointer(
event.clientX,
event.clientY,
);
resetDragInteraction();
if (targetCell) {
onDragPiece({
pieceId,
@@ -194,18 +397,73 @@ export function PuzzleRuntimeShell({
});
}
setSelectedPieceId(null);
setDragState(null);
return;
}
setDragState(null);
resetDragInteraction();
handlePieceClick(pieceId);
};
const handlePiecePointerDown = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (isBusy) {
return;
}
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);
dragSessionRef.current = {
pieceId,
pointerId: event.pointerId,
dragging: false,
startX: event.clientX,
startY: event.clientY,
currentX: event.clientX,
currentY: event.clientY,
};
};
const handlePiecePointerMove = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
const dragSession = dragSessionRef.current;
if (
!dragSession ||
dragSession.pieceId !== pieceId ||
dragSession.pointerId !== event.pointerId
) {
return;
}
event.preventDefault();
const deltaX = event.clientX - dragSession.startX;
const deltaY = event.clientY - dragSession.startY;
const dragging = dragSession.dragging || Math.hypot(deltaX, deltaY) >= 8;
dragSession.dragging = dragging;
dragSession.currentX = event.clientX;
dragSession.currentY = event.clientY;
if (!dragging) {
return;
}
// 拖动中的视觉更新直接写入 DOM transform避免 pointermove 触发整盘 React 重渲染导致跟手延迟。
scheduleDragVisual();
};
const statusLabel =
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
: (run.leaderboardEntries ?? []);
const isClearResultOpen =
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
@@ -246,7 +504,8 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
<div
ref={boardRef}
className="grid aspect-square w-full max-w-[min(92vw,92vh)] rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
data-testid="puzzle-board"
className="relative grid aspect-square w-full max-w-[min(92vw,92vh)] touch-none select-none rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
style={{
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
}}
@@ -263,64 +522,55 @@ export function PuzzleRuntimeShell({
className="relative p-1"
>
<div
ref={(node) => {
if (!piece) {
return;
}
if (node) {
pieceElementRefMap.current.set(piece.pieceId, node);
return;
}
pieceElementRefMap.current.delete(piece.pieceId);
}}
data-piece-id={piece?.pieceId ?? undefined}
className={`flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
occupied
? isSelected
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
: isMerged
? 'border-emerald-200/55 bg-emerald-300/26 text-white'
? 'border-transparent bg-transparent text-white'
: 'border-white/18 bg-white/12 text-white'
: 'border-white/8 bg-black/18 text-white/20'
} ${
isMerged ? 'transition-colors' : 'transition-[background-color,border-color,box-shadow,opacity]'
}`}
onPointerDown={(event) => {
if (!piece || isBusy) {
if (!piece || isMerged) {
return;
}
event.currentTarget.setPointerCapture(event.pointerId);
setDragState({
pieceId: piece.pieceId,
pointerId: event.pointerId,
dragging: false,
startX: event.clientX,
startY: event.clientY,
});
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
if (
!piece ||
!dragState ||
dragState.pieceId !== piece.pieceId ||
dragState.pointerId !== event.pointerId ||
dragState.dragging
) {
if (!piece || isMerged) {
return;
}
const deltaX = event.clientX - dragState.startX;
const deltaY = event.clientY - dragState.startY;
if (Math.hypot(deltaX, deltaY) >= 8) {
setDragState((current) =>
current && current.pieceId === piece.pieceId
? {
...current,
dragging: true,
}
: current,
);
}
handlePiecePointerMove(piece.pieceId, event);
}}
onPointerUp={(event) => {
if (piece) {
if (piece && !isMerged) {
handlePiecePointerUp(piece.pieceId, event);
}
}}
onPointerCancel={() => {
setDragState(null);
resetDragInteraction();
}}
onLostPointerCapture={() => {
resetDragInteraction();
}}
>
{piece ? (
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
{resolvedCoverImage ? (
{isMerged ? null : resolvedCoverImage ? (
<div
className="absolute inset-0"
style={{
@@ -341,9 +591,11 @@ export function PuzzleRuntimeShell({
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(251,191,36,0.4),rgba(76,29,19,0.72))]" />
)}
<div className="absolute inset-0 bg-black/10" />
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
{!isMerged ? (
<div className="absolute bottom-1 right-1 rounded-full bg-black/38 px-1.5 py-0.5 text-[10px] font-black text-white/86">
{piece.label}
</div>
</div>
) : null}
</div>
) : (
''
@@ -352,22 +604,97 @@ export function PuzzleRuntimeShell({
</div>
);
})}
{mergedGroups.map((group) => (
<div
key={group.groupId}
ref={(node) => {
if (node) {
groupElementRefMap.current.set(group.groupId, node);
return;
}
groupElementRefMap.current.delete(group.groupId);
}}
className="pointer-events-none absolute z-10 p-1"
style={{
left: `${(group.minCol / board.cols) * 100}%`,
top: `${(group.minRow / board.rows) * 100}%`,
width: `${(group.colSpan / board.cols) * 100}%`,
height: `${(group.rowSpan / board.rows) * 100}%`,
}}
>
<div
className="pointer-events-none relative grid h-full w-full touch-none overflow-visible active:scale-[0.992]"
style={{
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
}}
>
{group.pieces.map((piece) => (
<div
key={piece.pieceId}
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10"
style={{
gridColumn: piece.localCol + 1,
gridRow: piece.localRow + 1,
}}
onPointerDown={(event) => {
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
handlePiecePointerMove(piece.pieceId, event);
}}
onPointerUp={(event) => {
handlePiecePointerUp(piece.pieceId, event);
}}
onPointerCancel={() => {
resetDragInteraction();
}}
onLostPointerCapture={() => {
resetDragInteraction();
}}
>
{resolvedCoverImage ? (
<div
className="absolute inset-0"
style={{
backgroundImage: `url("${resolvedCoverImage}")`,
backgroundSize: `${board.cols * 100}% ${board.rows * 100}%`,
backgroundPosition: `${
board.cols > 1
? (piece.correctCol / (board.cols - 1)) * 100
: 0
}% ${
board.rows > 1
? (piece.correctRow / (board.rows - 1)) * 100
: 0
}%`,
}}
/>
) : (
<div className="absolute inset-0 bg-[linear-gradient(145deg,rgba(52,211,153,0.38),rgba(6,78,59,0.68))]" />
)}
<div className="absolute inset-0 bg-black/8" />
</div>
))}
<div className="pointer-events-none absolute inset-0 rounded-[1rem] ring-2 ring-emerald-100/58 shadow-[0_0_0_1px_rgba(16,185,129,0.2),0_14px_32px_rgba(6,78,59,0.24)]" />
</div>
</div>
))}
</div>
</div>
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-4 py-4">
<div className="max-w-[18rem] rounded-[1.1rem] bg-black/28 px-4 py-3 text-xs leading-6 text-white/74 backdrop-blur">
{selectedPieceId
? '已选择一块,再点另一块可交换;也可以直接拖到目标位置。'
: '点击两块可交换,拖动单块或合并块到目标格继续推进。'}
</div>
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-end gap-3 px-4 py-4">
<div className="flex flex-col items-end gap-2">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{selectedPieceId && currentLevel.status !== 'cleared' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
) : null}
{nextAvailable ? (
<button
type="button"
@@ -389,6 +716,107 @@ export function PuzzleRuntimeShell({
)}
</div>
</div>
{isClearResultOpen ? (
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-clear-result-title"
className="flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
>
<header className="flex items-start justify-between gap-3 border-b border-white/10 px-5 py-4">
<div className="min-w-0">
<div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
<Trophy className="h-4 w-4" />
</div>
<h2
id="puzzle-clear-result-title"
className="truncate text-lg font-black text-white"
>
</h2>
<div className="mt-1 line-clamp-1 text-xs text-white/62">
{currentLevel.levelName}
</div>
</div>
<button
type="button"
aria-label="关闭通关弹窗"
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/8 text-white/72 transition hover:bg-white/14 hover:text-white"
onClick={() => {
setDismissedClearKey(clearResultKey);
}}
>
<X className="h-4 w-4" />
</button>
</header>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="flex items-center justify-between gap-4 rounded-[1rem] border border-amber-200/24 bg-amber-200/10 px-4 py-3">
<div className="flex items-center gap-3">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-black/24 text-amber-100">
<Clock className="h-4 w-4" />
</span>
<span className="text-sm font-semibold text-white/72">
</span>
</div>
<span className="font-mono text-xl font-black text-amber-100">
{formatElapsedMs(currentLevel.elapsedMs)}
</span>
</div>
<div className="mt-4">
<div className="mb-2 text-sm font-bold text-white"></div>
<div className="overflow-hidden rounded-[1rem] border border-white/10">
<div className="grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] bg-white/6 px-3 py-2 text-[11px] font-bold text-white/48">
<span></span>
<span></span>
<span className="text-right"></span>
</div>
<div className="max-h-56 overflow-y-auto">
{leaderboardEntries.map((entry) => (
<div
key={`${entry.rank}:${entry.nickname}:${entry.elapsedMs}`}
className={`grid grid-cols-[3.5rem_minmax(0,1fr)_6rem] items-center px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'bg-amber-200/14 text-amber-50'
: 'border-t border-white/8 text-white/78'
}`}
>
<span className="font-mono font-black">#{entry.rank}</span>
<span className="truncate font-semibold">
{entry.nickname}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
</div>
))}
</div>
</div>
</div>
</div>
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
disabled={isBusy || !nextAvailable}
onClick={onAdvanceNextLevel}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-45"
>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</button>
</footer>
</section>
</div>
) : null}
</div>
</div>
);