1
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy, X } from 'lucide-react';
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -9,7 +9,10 @@ import type {
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
|
||||
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
import { PixelIcon } from '../PixelIcon';
|
||||
import { ResolvedAssetImage } from '../ResolvedAssetImage';
|
||||
|
||||
type PuzzleRuntimeShellProps = {
|
||||
@@ -29,7 +32,6 @@ type PuzzleBoardPieceViewModel = {
|
||||
correctRow: number;
|
||||
correctCol: number;
|
||||
mergedGroupId: string | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type PuzzleMergedGroupViewModel = {
|
||||
@@ -59,9 +61,41 @@ function buildBoardCells(board: PuzzleBoardSnapshot) {
|
||||
}));
|
||||
}
|
||||
|
||||
function buildPieceLabel(pieceId: string) {
|
||||
const fallback = pieceId.slice(-2).toUpperCase();
|
||||
return fallback || '块';
|
||||
function buildLocalCellKey(row: number, col: number) {
|
||||
return `${row}:${col}`;
|
||||
}
|
||||
|
||||
function resolveMergedPieceOutlineClass(
|
||||
group: PuzzleMergedGroupViewModel,
|
||||
piece: PuzzleMergedGroupViewModel['pieces'][number],
|
||||
) {
|
||||
const groupCellKeys = new Set(
|
||||
group.pieces.map((groupPiece) =>
|
||||
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
|
||||
),
|
||||
);
|
||||
const hasTopEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow - 1, piece.localCol),
|
||||
);
|
||||
const hasRightEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow, piece.localCol + 1),
|
||||
);
|
||||
const hasBottomEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow + 1, piece.localCol),
|
||||
);
|
||||
const hasLeftEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow, piece.localCol - 1),
|
||||
);
|
||||
return [
|
||||
hasTopEdge ? 'border-t-2' : 'border-t-0',
|
||||
hasRightEdge ? 'border-r-2' : 'border-r-0',
|
||||
hasBottomEdge ? 'border-b-2' : 'border-b-0',
|
||||
hasLeftEdge ? 'border-l-2' : 'border-l-0',
|
||||
hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none',
|
||||
hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none',
|
||||
hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none',
|
||||
hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none',
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
function buildMergedGroupViewModels(
|
||||
@@ -117,6 +151,10 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
|
||||
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
|
||||
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
* 前端仅维护轻量选中态与拖拽目标,交换、合并、拆分与通关全部以后端快照为准。
|
||||
@@ -130,7 +168,9 @@ export function PuzzleRuntimeShell({
|
||||
onDragPiece,
|
||||
onAdvanceNextLevel,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const dragSessionRef = useRef<{
|
||||
pieceId: string;
|
||||
pointerId: number;
|
||||
@@ -148,10 +188,21 @@ export function PuzzleRuntimeShell({
|
||||
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 [dismissedClearKey, setDismissedClearKey] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isClearFlashVisible, setIsClearFlashVisible] = useState(false);
|
||||
const [isClearResultReady, setIsClearResultReady] = useState(false);
|
||||
const clearPresentationKeyRef = useRef<string | null>(null);
|
||||
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentLevel = run?.currentLevel ?? null;
|
||||
const board = currentLevel?.board ?? null;
|
||||
const clearResultKey = currentLevel
|
||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||
: null;
|
||||
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
|
||||
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
|
||||
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
|
||||
currentLevel?.coverImageSrc ?? null,
|
||||
);
|
||||
@@ -167,7 +218,6 @@ export function PuzzleRuntimeShell({
|
||||
correctRow: piece.correctRow,
|
||||
correctCol: piece.correctCol,
|
||||
mergedGroupId: piece.mergedGroupId,
|
||||
label: buildPieceLabel(piece.pieceId),
|
||||
}));
|
||||
}, [board]);
|
||||
|
||||
@@ -206,7 +256,9 @@ export function PuzzleRuntimeShell({
|
||||
return;
|
||||
}
|
||||
|
||||
const pieceElement = pieceElementRefMap.current.get(dragVisualTarget.pieceId);
|
||||
const pieceElement = pieceElementRefMap.current.get(
|
||||
dragVisualTarget.pieceId,
|
||||
);
|
||||
if (pieceElement) {
|
||||
pieceElement.style.transform = '';
|
||||
pieceElement.style.willChange = '';
|
||||
@@ -215,7 +267,9 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
|
||||
if (dragVisualTarget.groupId) {
|
||||
const groupElement = groupElementRefMap.current.get(dragVisualTarget.groupId);
|
||||
const groupElement = groupElementRefMap.current.get(
|
||||
dragVisualTarget.groupId,
|
||||
);
|
||||
if (groupElement) {
|
||||
groupElement.style.transform = '';
|
||||
groupElement.style.willChange = '';
|
||||
@@ -304,10 +358,66 @@ export function PuzzleRuntimeShell({
|
||||
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
|
||||
};
|
||||
|
||||
useEffect(() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
}, []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
cancelDragVisualFrame();
|
||||
resetDragVisualTarget();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearPresentationTimeouts = () => {
|
||||
for (const timeoutId of clearPresentationTimeoutIdsRef.current) {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
clearPresentationTimeoutIdsRef.current = [];
|
||||
};
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearPresentationTimeouts();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevel || !clearResultKey) {
|
||||
clearPresentationKeyRef.current = null;
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(false);
|
||||
setIsClearResultReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentLevel.status !== 'cleared') {
|
||||
clearPresentationKeyRef.current = null;
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(false);
|
||||
setIsClearResultReady(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
dismissedClearKey === clearResultKey ||
|
||||
clearPresentationKeyRef.current === clearResultKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 通关后先保留完整画面,再播放对角线闪光,最后延迟弹出结算弹窗。
|
||||
clearPresentationKeyRef.current = clearResultKey;
|
||||
clearPresentationTimeouts();
|
||||
setIsClearFlashVisible(true);
|
||||
setIsClearResultReady(false);
|
||||
clearPresentationTimeoutIdsRef.current = [
|
||||
window.setTimeout(() => {
|
||||
setIsClearFlashVisible(false);
|
||||
}, PUZZLE_CLEAR_FLASH_DURATION_MS),
|
||||
window.setTimeout(() => {
|
||||
setIsClearResultReady(true);
|
||||
}, PUZZLE_CLEAR_FLASH_DURATION_MS + PUZZLE_CLEAR_DIALOG_DELAY_MS),
|
||||
];
|
||||
}, [clearResultKey, currentLevel, dismissedClearKey]);
|
||||
|
||||
if (!run || !currentLevel || !board) {
|
||||
return (
|
||||
@@ -453,17 +563,18 @@ export function PuzzleRuntimeShell({
|
||||
scheduleDragVisual();
|
||||
};
|
||||
|
||||
const statusLabel =
|
||||
currentLevel.status === 'cleared' ? '已通关' : `${board.rows}x${board.cols}`;
|
||||
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
|
||||
const nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
const clearResultKey = `${run.runId}:${currentLevel.profileId}:${currentLevel.levelIndex}`;
|
||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||
const leaderboardEntries =
|
||||
(currentLevel.leaderboardEntries ?? []).length > 0
|
||||
? currentLevel.leaderboardEntries
|
||||
: (run.leaderboardEntries ?? []);
|
||||
const isClearResultOpen =
|
||||
currentLevel.status === 'cleared' && dismissedClearKey !== clearResultKey;
|
||||
currentLevel.status === 'cleared' &&
|
||||
dismissedClearKey !== clearResultKey &&
|
||||
isClearResultReady;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
@@ -478,26 +589,41 @@ export function PuzzleRuntimeShell({
|
||||
) : null}
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(255,255,255,0.04)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.04)_1px,transparent_1px)] bg-[length:34px_34px] opacity-20" />
|
||||
|
||||
<div className="absolute left-0 top-0 z-20 flex w-full items-start justify-between gap-3 px-4 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="absolute left-0 top-0 z-20 w-full px-4 py-4">
|
||||
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="返回上一页"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="flex max-w-[70vw] flex-col items-end gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-right backdrop-blur">
|
||||
<div className="text-[0.68rem] font-semibold tracking-[0.2em] text-white/70">
|
||||
PUZZLE
|
||||
</div>
|
||||
<div className="line-clamp-1 text-sm font-bold text-white">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
<div className="text-xs text-white/74">
|
||||
{currentLevel.authorDisplayName} · 第 {currentLevel.levelIndex} 关 ·{' '}
|
||||
{statusLabel}
|
||||
<div className="flex min-w-0 flex-col items-center gap-1 rounded-[1.2rem] bg-black/26 px-4 py-3 text-center backdrop-blur">
|
||||
<div className="line-clamp-1 text-sm font-bold text-white sm:text-base">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
<div className="line-clamp-1 text-xs text-white/78">
|
||||
{currentLevel.authorDisplayName}
|
||||
</div>
|
||||
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
|
||||
{levelLabel}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsPanelOpen(true)}
|
||||
aria-label="打开拼图设置"
|
||||
title="打开拼图设置"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-200/60"
|
||||
>
|
||||
<PixelIcon
|
||||
src={CHROME_ICONS.settings}
|
||||
className="h-[1.4rem] w-[1.4rem] drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -517,10 +643,7 @@ export function PuzzleRuntimeShell({
|
||||
const isSelected = piece?.pieceId === selectedPieceId;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${cell.row}:${cell.col}`}
|
||||
className="relative p-1"
|
||||
>
|
||||
<div key={`${cell.row}:${cell.col}`} className="relative p-1">
|
||||
<div
|
||||
ref={(node) => {
|
||||
if (!piece) {
|
||||
@@ -542,7 +665,9 @@ export function PuzzleRuntimeShell({
|
||||
: '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]'
|
||||
isMerged
|
||||
? 'transition-colors'
|
||||
: 'transition-[background-color,border-color,box-shadow,opacity]'
|
||||
}`}
|
||||
onPointerDown={(event) => {
|
||||
if (!piece || isMerged) {
|
||||
@@ -591,11 +716,6 @@ 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" />
|
||||
{!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>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
''
|
||||
@@ -632,7 +752,11 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className="pointer-events-auto relative touch-none overflow-hidden bg-emerald-300/10"
|
||||
className={`pointer-events-auto relative touch-none overflow-hidden border-emerald-100/72 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
|
||||
group,
|
||||
piece,
|
||||
)}`}
|
||||
data-merged-piece-outline="true"
|
||||
style={{
|
||||
gridColumn: piece.localCol + 1,
|
||||
gridRow: piece.localRow + 1,
|
||||
@@ -676,7 +800,6 @@ export function PuzzleRuntimeShell({
|
||||
<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>
|
||||
))}
|
||||
@@ -717,6 +840,142 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isClearFlashVisible ? (
|
||||
<div
|
||||
data-testid="puzzle-clear-flash"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 z-30 overflow-hidden"
|
||||
>
|
||||
<div className="puzzle-clear-flash-overlay absolute inset-0" />
|
||||
<div className="puzzle-clear-flash-beam" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettingsPanelOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-settings-title"
|
||||
className="pixel-nine-slice pixel-modal-shell flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="relative border-b border-white/10 px-4 py-3 sm:px-5 sm:py-4">
|
||||
<div className="min-w-0 pr-10">
|
||||
<h2
|
||||
id="puzzle-settings-title"
|
||||
className="text-sm font-semibold text-white"
|
||||
>
|
||||
拼图设置
|
||||
</h2>
|
||||
<div className="mt-1 text-[11px] text-zinc-500">
|
||||
调整音乐音量,查看本局进度,或返回上一页。
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭拼图设置"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
className="absolute right-4 top-3 p-1 text-zinc-400 transition-colors hover:text-white sm:right-5 sm:top-4"
|
||||
>
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto p-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(56,189,248,0.14),transparent_65%),rgba(0,0,0,0.24)] p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[10px] tracking-[0.24em] text-sky-200/80">
|
||||
音频
|
||||
</div>
|
||||
<div className="mt-2 text-sm font-semibold text-white">
|
||||
音乐音量
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-full border border-white/10 bg-black/28 px-2 py-1 text-xs text-white/80">
|
||||
{Math.round(musicVolume * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-3">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
aria-label="拼图音乐音量"
|
||||
value={Math.round(musicVolume * 100)}
|
||||
onChange={(event) =>
|
||||
onMusicVolumeChange(
|
||||
Number(event.currentTarget.value) / 100,
|
||||
)
|
||||
}
|
||||
className="h-2 w-full cursor-pointer accent-sky-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-black/25 px-4 py-3">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-zinc-500">
|
||||
本局进度
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-sm text-white/82">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">关卡</span>
|
||||
<span className="font-semibold text-white">
|
||||
{levelLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">已完成关卡</span>
|
||||
<span className="font-semibold text-white">
|
||||
{run.clearedLevelCount}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">当前状态</span>
|
||||
<span className="font-semibold text-white">
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-white/56">当前用时</span>
|
||||
<span className="font-mono font-semibold text-white">
|
||||
{formatElapsedMs(currentLevel.elapsedMs)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-4 py-3 sm:px-5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSettingsPanelOpen(false)}
|
||||
className="rounded-full border border-white/12 bg-black/20 px-3 py-1.5 text-[11px] text-zinc-200 transition hover:text-white"
|
||||
>
|
||||
继续拼图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsSettingsPanelOpen(false);
|
||||
onBack();
|
||||
}}
|
||||
className="rounded-full bg-amber-200 px-4 py-2 text-sm font-bold text-slate-950 transition hover:bg-amber-100"
|
||||
>
|
||||
返回上一页
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{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
|
||||
@@ -748,7 +1007,7 @@ export function PuzzleRuntimeShell({
|
||||
setDismissedClearKey(clearResultKey);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<PixelIcon src={CHROME_ICONS.close} className="h-4 w-4" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -768,7 +1027,9 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 text-sm font-bold text-white">排行榜</div>
|
||||
<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>
|
||||
@@ -776,24 +1037,32 @@ export function PuzzleRuntimeShell({
|
||||
<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>
|
||||
{leaderboardEntries.length > 0 ? (
|
||||
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 className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
|
||||
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user