This commit is contained in:
2026-04-29 20:56:59 +08:00
parent fb6f455530
commit 730f485f48
200 changed files with 9881 additions and 2221 deletions

View File

@@ -1,4 +1,14 @@
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
import {
ArrowLeft,
ArrowRight,
Clock,
Eye,
Lightbulb,
Loader2,
Snowflake,
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
@@ -6,6 +16,8 @@ import type {
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRuntimeLevelSnapshot,
PuzzleRuntimePropKind,
PuzzleRunSnapshot,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
@@ -23,6 +35,11 @@ type PuzzleRuntimeShellProps = {
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: () => void;
onPauseChange?: (paused: boolean) => void | Promise<void>;
onUseProp?: (
propKind: PuzzleRuntimePropKind,
) => Promise<PuzzleRunSnapshot | null | void>;
onTimeExpired?: () => void | Promise<void>;
};
type PuzzleBoardPieceViewModel = {
@@ -103,6 +120,13 @@ function resolveMergedPieceOutlineClass(
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
),
);
const hasCell = (row: number, col: number) =>
groupCellKeys.has(buildLocalCellKey(row, col));
const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col);
const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1);
const hasBottomBoundary = (row: number, col: number) =>
!hasCell(row + 1, col);
const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1);
const hasTopEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow - 1, piece.localCol),
);
@@ -115,15 +139,63 @@ function resolveMergedPieceOutlineClass(
const hasLeftEdge = !groupCellKeys.has(
buildLocalCellKey(piece.localRow, piece.localCol - 1),
);
const topLeftRadius =
hasTopEdge && hasLeftEdge
? 'rounded-tl-[0.85rem]'
: (!hasTopEdge && !hasLeftEdge) ||
(hasTopEdge &&
!hasLeftEdge &&
!hasTopBoundary(piece.localRow, piece.localCol - 1)) ||
(hasLeftEdge &&
!hasTopEdge &&
!hasLeftBoundary(piece.localRow - 1, piece.localCol))
? 'rounded-tl-[0.35rem]'
: 'rounded-tl-none';
const topRightRadius =
hasTopEdge && hasRightEdge
? 'rounded-tr-[0.85rem]'
: (!hasTopEdge && !hasRightEdge) ||
(hasTopEdge &&
!hasRightEdge &&
!hasTopBoundary(piece.localRow, piece.localCol + 1)) ||
(hasRightEdge &&
!hasTopEdge &&
!hasRightBoundary(piece.localRow - 1, piece.localCol))
? 'rounded-tr-[0.35rem]'
: 'rounded-tr-none';
const bottomRightRadius =
hasBottomEdge && hasRightEdge
? 'rounded-br-[0.85rem]'
: (!hasBottomEdge && !hasRightEdge) ||
(hasBottomEdge &&
!hasRightEdge &&
!hasBottomBoundary(piece.localRow, piece.localCol + 1)) ||
(hasRightEdge &&
!hasBottomEdge &&
!hasRightBoundary(piece.localRow + 1, piece.localCol))
? 'rounded-br-[0.35rem]'
: 'rounded-br-none';
const bottomLeftRadius =
hasBottomEdge && hasLeftEdge
? 'rounded-bl-[0.85rem]'
: (!hasBottomEdge && !hasLeftEdge) ||
(hasBottomEdge &&
!hasLeftEdge &&
!hasBottomBoundary(piece.localRow, piece.localCol - 1)) ||
(hasLeftEdge &&
!hasBottomEdge &&
!hasLeftBoundary(piece.localRow + 1, piece.localCol))
? 'rounded-bl-[0.35rem]'
: 'rounded-bl-none';
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',
topLeftRadius,
topRightRadius,
bottomRightRadius,
bottomLeftRadius,
].join(' ');
}
@@ -180,9 +252,82 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
.padStart(2, '0')}`;
}
function formatTimerMs(value: number | null | undefined) {
const normalizedMs = Math.max(0, Math.ceil((value ?? 0) / 1000) * 1000);
const totalSeconds = Math.floor(normalizedMs / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function resolveActiveFreezeElapsedMs(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
) {
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
return 0;
}
return Math.max(
0,
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
);
}
function resolveRuntimeRemainingMs(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
uiPauseStartedAtMs: number | null,
) {
if (level.status !== 'playing') {
return level.remainingMs;
}
const timeLimitMs = level.timeLimitMs || level.remainingMs;
const snapshotPauseElapsedMs = level.pauseStartedAtMs
? Math.max(0, nowMs - level.pauseStartedAtMs)
: 0;
const optimisticPauseElapsedMs =
!level.pauseStartedAtMs && uiPauseStartedAtMs
? Math.max(0, nowMs - uiPauseStartedAtMs)
: 0;
const effectiveElapsedMs = Math.max(
0,
nowMs -
level.startedAtMs -
level.pausedAccumulatedMs -
snapshotPauseElapsedMs -
optimisticPauseElapsedMs -
level.freezeAccumulatedMs -
resolveActiveFreezeElapsedMs(level, nowMs),
);
return Math.max(0, timeLimitMs - effectiveElapsedMs);
}
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
type PuzzlePropDialogState = {
propKind: PuzzleRuntimePropKind;
title: string;
};
type PuzzleMergeFlashState = {
key: string;
groupId: string;
leftPercent: number;
topPercent: number;
};
type PuzzleHintDemoState = {
key: string;
pieceIds: string[];
offsetXPercent: number;
offsetYPercent: number;
};
/**
* 拼图运行时壳层。
@@ -196,10 +341,34 @@ export function PuzzleRuntimeShell({
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
onPauseChange,
onUseProp,
onTimeExpired,
}: PuzzleRuntimeShellProps) {
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
null,
);
const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] =
useState(false);
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
const [isPropConfirming, setIsPropConfirming] = useState(false);
const [propConfirmError, setPropConfirmError] = useState<string | null>(null);
const [hintDemo, setHintDemo] = useState<PuzzleHintDemoState | null>(null);
const [mergeFlash, setMergeFlash] = useState<PuzzleMergeFlashState | null>(
null,
);
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
const [uiPauseStartedAtMs, setUiPauseStartedAtMs] = useState<number | null>(
null,
);
const onPauseChangeRef = useRef(onPauseChange);
const onTimeExpiredRef = useRef(onTimeExpired);
const previousUiPauseActiveRef = useRef(false);
const pauseChangePromiseRef = useRef<Promise<void>>(Promise.resolve());
const timeExpiredSyncKeyRef = useRef<string | null>(null);
const dragSessionRef = useRef<{
pieceId: string;
pointerId: number;
@@ -229,9 +398,20 @@ export function PuzzleRuntimeShell({
const [isClearResultReady, setIsClearResultReady] = useState(false);
const clearPresentationKeyRef = useRef<string | null>(null);
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
const mergeGroupSignatureRef = useRef<string | null>(null);
const hintDemoTimeoutRef = useRef<number | null>(null);
const mergeFlashTimeoutRef = useRef<number | null>(null);
const boardRef = useRef<HTMLDivElement | null>(null);
const currentLevel = run?.currentLevel ?? null;
const board = currentLevel?.board ?? null;
const displayRemainingMs = currentLevel
? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
: 0;
const runtimeStatus = currentLevel
? currentLevel.status === 'playing' && displayRemainingMs <= 0
? 'failed'
: currentLevel.status
: 'playing';
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
@@ -262,6 +442,23 @@ export function PuzzleRuntimeShell({
return buildMergedGroupViewModels(board.mergedGroups, pieces);
}, [board, pieces]);
const largestMovableGroup = useMemo(() => {
const groups = mergedGroups.filter((group) =>
group.pieces.some(
(piece) =>
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
),
);
return (
groups.sort(
(left, right) =>
right.pieceIds.length - left.pieceIds.length ||
left.minRow - right.minRow ||
left.minCol - right.minCol,
)[0] ?? null
);
}, [mergedGroups]);
const mergedCellKeys = useMemo(
() =>
new Set(
@@ -284,6 +481,54 @@ export function PuzzleRuntimeShell({
[pieces],
);
useEffect(() => {
const signature =
board?.mergedGroups
.map(
(group) =>
`${group.groupId}:${group.pieceIds.slice().sort().join(',')}`,
)
.sort()
.join('|') ?? '';
const previousSignature = mergeGroupSignatureRef.current;
mergeGroupSignatureRef.current = signature;
if (!previousSignature || !board || currentLevel?.status !== 'playing') {
return;
}
const previousGroupSizes = new Map(
previousSignature
.split('|')
.filter(Boolean)
.map((entry) => {
const [groupId, pieceIds = ''] = entry.split(':');
return [groupId, pieceIds.split(',').filter(Boolean).length] as const;
}),
);
const newGroup = mergedGroups.find(
(group) =>
group.pieceIds.length > 1 &&
group.pieceIds.length > (previousGroupSizes.get(group.groupId) ?? 0),
);
if (!newGroup) {
return;
}
if (mergeFlashTimeoutRef.current !== null) {
window.clearTimeout(mergeFlashTimeoutRef.current);
}
setMergeFlash({
key: `${newGroup.groupId}:${Date.now()}`,
groupId: newGroup.groupId,
leftPercent:
((newGroup.minCol + newGroup.colSpan / 2) / board.cols) * 100,
topPercent: ((newGroup.minRow + newGroup.rowSpan / 2) / board.rows) * 100,
});
mergeFlashTimeoutRef.current = window.setTimeout(() => {
setMergeFlash(null);
}, PUZZLE_MERGE_FLASH_DURATION_MS);
}, [board, currentLevel?.status, mergedGroups]);
const resolvePieceCellElement = (pieceId: string) => {
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
const pieceCellElement =
@@ -447,6 +692,76 @@ export function PuzzleRuntimeShell({
[],
);
useEffect(() => {
onPauseChangeRef.current = onPauseChange;
}, [onPauseChange]);
useEffect(() => {
onTimeExpiredRef.current = onTimeExpired;
}, [onTimeExpired]);
const isUiPauseActive =
isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible;
useEffect(() => {
if (previousUiPauseActiveRef.current === isUiPauseActive) {
return;
}
previousUiPauseActiveRef.current = isUiPauseActive;
setUiPauseStartedAtMs((currentValue) =>
isUiPauseActive ? (currentValue ?? Date.now()) : null,
);
pauseChangePromiseRef.current = Promise.resolve(
onPauseChangeRef.current?.(isUiPauseActive),
).catch(() => undefined);
}, [isUiPauseActive]);
useEffect(() => {
if (!currentLevel || currentLevel.status !== 'playing') {
return;
}
const timerId = window.setInterval(() => {
setTimerNowMs(Date.now());
}, 250);
return () => window.clearInterval(timerId);
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
useEffect(() => {
if (!run || !currentLevel || currentLevel.status !== 'playing') {
return;
}
if (displayRemainingMs > 0) {
return;
}
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
if (timeExpiredSyncKeyRef.current === syncKey) {
return;
}
timeExpiredSyncKeyRef.current = syncKey;
void onTimeExpiredRef.current?.();
}, [
currentLevel?.levelIndex,
currentLevel?.startedAtMs,
currentLevel?.status,
displayRemainingMs,
run?.runId,
]);
useEffect(
() => () => {
if (hintDemoTimeoutRef.current !== null) {
window.clearTimeout(hintDemoTimeoutRef.current);
}
if (mergeFlashTimeoutRef.current !== null) {
window.clearTimeout(mergeFlashTimeoutRef.current);
}
},
[],
);
useEffect(() => {
if (!currentLevel || !clearResultKey) {
clearPresentationKeyRef.current = null;
@@ -498,7 +813,7 @@ export function PuzzleRuntimeShell({
}
const handlePieceClick = (pieceId: string) => {
if (isBusy) {
if (isInteractionLocked) {
return;
}
@@ -585,7 +900,7 @@ export function PuzzleRuntimeShell({
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (isBusy) {
if (isInteractionLocked) {
return;
}
event.preventDefault();
@@ -631,7 +946,18 @@ export function PuzzleRuntimeShell({
scheduleDragVisual();
};
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
const draggingGroupId = dragRenderTarget?.groupId ?? null;
const freezeRemainingMs =
currentLevel.freezeUntilMs && currentLevel.status === 'playing'
? Math.max(0, currentLevel.freezeUntilMs - timerNowMs)
: 0;
const statusLabel =
runtimeStatus === 'cleared'
? '已通关'
: runtimeStatus === 'failed'
? '失败'
: '进行中';
const nextAvailable =
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
const levelLabel = `${currentLevel.levelIndex}`;
@@ -643,8 +969,85 @@ export function PuzzleRuntimeShell({
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
const draggingGroupId = dragRenderTarget?.groupId ?? null;
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
if (runtimeStatus !== 'playing') {
return;
}
setPropConfirmError(null);
setPropDialog({ propKind, title });
};
const playHintDemo = () => {
const targetGroup = largestMovableGroup;
const targetPieces = targetGroup?.pieces ?? [];
const fallbackPiece = pieces.find(
(piece) =>
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
);
const anchorPiece = targetPieces[0] ?? fallbackPiece ?? null;
if (!anchorPiece) {
return;
}
const pieceIds =
targetPieces.length > 0
? targetPieces.map((piece) => piece.pieceId)
: [anchorPiece.pieceId];
const offsetXPercent =
((anchorPiece.correctCol - anchorPiece.col) / board.cols) * 100;
const offsetYPercent =
((anchorPiece.correctRow - anchorPiece.row) / board.rows) * 100;
setHintDemo({
key: `${anchorPiece.pieceId}:${Date.now()}`,
pieceIds,
offsetXPercent,
offsetYPercent,
});
if (hintDemoTimeoutRef.current !== null) {
window.clearTimeout(hintDemoTimeoutRef.current);
}
hintDemoTimeoutRef.current = window.setTimeout(() => {
setHintDemo(null);
}, PUZZLE_HINT_DEMO_DURATION_MS);
};
const handleConfirmProp = async () => {
if (!propDialog) {
return;
}
const propKind = propDialog.propKind;
setIsPropConfirming(true);
setPropConfirmError(null);
try {
await pauseChangePromiseRef.current;
const useResult = await onUseProp?.(propKind);
if (useResult === null) {
return;
}
setPropDialog(null);
} catch (error) {
setPropConfirmError(
error instanceof Error ? error.message : '使用拼图道具失败',
);
return;
} finally {
setIsPropConfirming(false);
}
if (propKind === 'hint') {
playHintDemo();
}
if (propKind === 'reference') {
setIsOriginalOverlayVisible(true);
}
if (propKind === 'freezeTime') {
setIsFreezeEffectVisible(true);
window.setTimeout(() => {
setIsFreezeEffectVisible(false);
}, 900);
}
};
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
@@ -680,6 +1083,16 @@ export function PuzzleRuntimeShell({
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
{levelLabel}
</div>
<div
className={`mt-1 inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-mono text-xs font-black ${
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
? 'bg-red-500/22 text-red-100'
: 'bg-white/10 text-white/86'
}`}
>
<Clock className="h-3.5 w-3.5" />
{formatTimerMs(displayRemainingMs)}
</div>
</div>
<button
@@ -697,13 +1110,14 @@ export function PuzzleRuntimeShell({
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
<div className="absolute inset-0 flex items-center justify-center p-3 pt-28 pb-32 sm:p-4">
<div
ref={boardRef}
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"
className="relative grid aspect-[9/16] w-full max-w-[min(96vw,calc(56.25vh_-_8.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:rounded-[1.45rem]"
style={{
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
}}
>
{buildBoardCells(board).map((cell) => {
@@ -726,13 +1140,22 @@ export function PuzzleRuntimeShell({
pieceCellElementRefMap.current.delete(piece.pieceId);
}}
data-piece-cell-id={piece?.pieceId ?? undefined}
className="relative p-1"
className="relative"
style={{
zIndex: resolveDraggedPieceCellLayer(
piece?.pieceId,
draggingPieceId,
isMerged,
),
transform:
piece && hintDemo?.pieceIds.includes(piece.pieceId)
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.03)`
: undefined,
transition: hintDemo?.pieceIds.includes(
piece?.pieceId ?? '',
)
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
: undefined,
}}
>
<div
@@ -747,13 +1170,13 @@ export function PuzzleRuntimeShell({
pieceElementRefMap.current.delete(piece.pieceId);
}}
data-piece-id={piece?.pieceId ?? undefined}
className={`relative flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
className={`relative flex h-full items-center justify-center border-2 border-white/22 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-transparent bg-transparent text-white'
: 'border-white/18 bg-white/12 text-white'
: 'bg-white/12 text-white'
: 'border-white/8 bg-black/18 text-white/20'
} ${
isMerged
@@ -792,7 +1215,7 @@ export function PuzzleRuntimeShell({
}}
>
{piece ? (
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
<div className="relative h-full w-full overflow-hidden">
{isMerged ? null : resolvedCoverImage ? (
<div
className="absolute inset-0"
@@ -833,12 +1256,22 @@ export function PuzzleRuntimeShell({
groupElementRefMap.current.delete(group.groupId);
}}
data-merged-group-id={group.groupId}
className="pointer-events-none absolute z-10 p-1"
className="pointer-events-none absolute z-10"
style={{
zIndex: resolveDraggedMergedGroupLayer(
group.groupId,
draggingGroupId,
),
transform: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
: undefined,
transition: hintDemo?.pieceIds.some((pieceId) =>
group.pieceIds.includes(pieceId),
)
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
: undefined,
left: `${(group.minCol / board.cols) * 100}%`,
top: `${(group.minRow / board.rows) * 100}%`,
width: `${(group.colSpan / board.cols) * 100}%`,
@@ -855,7 +1288,7 @@ export function PuzzleRuntimeShell({
{group.pieces.map((piece) => (
<div
key={piece.pieceId}
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(
className={`pointer-events-auto relative touch-none overflow-hidden border-white/22 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
group,
piece,
)}`}
@@ -906,17 +1339,86 @@ export function PuzzleRuntimeShell({
</div>
</div>
))}
{isOriginalOverlayVisible && resolvedCoverImage ? (
<div
data-testid="puzzle-original-overlay"
className="pointer-events-none absolute inset-0 z-40 bg-black/10"
>
<div
className="absolute inset-0 opacity-70"
style={{
backgroundImage: `url("${resolvedCoverImage}")`,
backgroundSize: '100% 100%',
backgroundPosition: 'center',
}}
/>
</div>
) : null}
{mergeFlash ? (
<div
key={mergeFlash.key}
data-testid="puzzle-merge-flash"
className="pointer-events-none absolute z-50"
style={{
left: `${mergeFlash.leftPercent}%`,
top: `${mergeFlash.topPercent}%`,
}}
>
<div className="puzzle-merge-center-flash" />
</div>
) : null}
</div>
</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="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-3 py-3 sm:px-4 sm:py-4">
<div className="flex items-center gap-2 rounded-full bg-black/32 p-1.5 backdrop-blur">
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('hint', '使用提示')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
>
<Lightbulb className="h-4 w-4 text-amber-100" />
</button>
<button
type="button"
disabled={runtimeStatus !== 'playing'}
aria-pressed={isOriginalOverlayVisible}
onClick={() => {
if (isOriginalOverlayVisible) {
setIsOriginalOverlayVisible(false);
return;
}
openPropDialog('reference', '查看原图');
}}
className={`inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold transition hover:bg-white/10 disabled:opacity-45 ${
isOriginalOverlayVisible
? 'bg-sky-200 text-slate-950'
: 'text-white/86'
}`}
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
>
<Snowflake className="h-4 w-4 text-cyan-100" />
</button>
</div>
<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' ? (
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
@@ -935,9 +1437,11 @@ export function PuzzleRuntimeShell({
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
{isBusy
? '同步中...'
: currentLevel.status === 'cleared'
: runtimeStatus === 'cleared'
? '等待下一关候选'
: '完成整张图即可通关'}
: runtimeStatus === 'failed'
? '本关失败'
: '完成整张图即可通关'}
</div>
)}
</div>
@@ -954,6 +1458,81 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{freezeRemainingMs > 0 || isFreezeEffectVisible ? (
<div
data-testid="puzzle-freeze-effect"
className="pointer-events-none absolute inset-0 z-30"
>
<div className="puzzle-freeze-effect-layer absolute inset-0 backdrop-saturate-150" />
<div className="absolute left-1/2 top-28 -translate-x-1/2 rounded-full border border-cyan-100/30 bg-cyan-950/50 px-3 py-1.5 font-mono text-xs font-black text-cyan-50 backdrop-blur">
{formatTimerMs(freezeRemainingMs)}
</div>
</div>
) : null}
{propDialog ? (
<div
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
onClick={() => {
if (!isPropConfirming) {
setPropDialog(null);
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-prop-confirm-title"
className="pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<header className="flex items-center gap-3 border-b border-white/10 px-5 py-4">
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
<Sparkles className="h-4 w-4" />
</span>
<h2
id="puzzle-prop-confirm-title"
className="text-sm font-black text-white"
>
{propDialog.title}
</h2>
</header>
<div className="px-5 py-4 text-sm text-white/72">
1
{propConfirmError ? (
<div className="mt-3 rounded-[0.9rem] border border-red-300/20 bg-red-500/12 px-3 py-2 text-xs leading-5 text-red-100">
{propConfirmError}
</div>
) : null}
</div>
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={() => setPropDialog(null)}
disabled={isPropConfirming}
className="rounded-full border border-white/12 bg-black/20 px-4 py-2 text-xs font-bold text-zinc-200 transition hover:text-white"
>
</button>
<button
type="button"
disabled={isPropConfirming}
onClick={() => {
void handleConfirmProp();
}}
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-60"
>
{isPropConfirming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
</button>
</footer>
</section>
</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"
@@ -1079,6 +1658,38 @@ export function PuzzleRuntimeShell({
</div>
) : null}
{runtimeStatus === 'failed' ? (
<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-failed-title"
className="flex w-full max-w-[24rem] 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="border-b border-white/10 px-5 py-4">
<h2
id="puzzle-failed-title"
className="text-lg font-black text-white"
>
</h2>
<div className="mt-1 text-xs text-white/62">
{currentLevel.levelName}
</div>
</header>
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
onClick={onBack}
className="rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black 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
@@ -1163,7 +1774,9 @@ export function PuzzleRuntimeShell({
))
) : (
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
{isBusy
? '正在同步真实排行榜…'
: '暂无真实排行榜成绩'}
</div>
)}
</div>