This commit is contained in:
2026-04-27 22:50:18 +08:00
parent ded6f6ee2a
commit b6c6640548
77 changed files with 5240 additions and 833 deletions

View File

@@ -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>