Files
Genarrative/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
2026-05-01 00:33:39 +08:00

1953 lines
71 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowLeft,
ArrowRight,
Clock,
Eye,
Lightbulb,
Loader2,
Snowflake,
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRecommendedNextWork,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
PuzzleRuntimePropKind,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleRuntimeShellProps = {
run: PuzzleRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
onBack: () => void;
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void;
onRestartLevel?: () => void | Promise<void>;
onPauseChange?: (paused: boolean) => void | Promise<void>;
onUseProp?: (
propKind: PuzzleRuntimePropKind,
) => Promise<PuzzleRunSnapshot | null | void>;
onTimeExpired?: () => void | Promise<void>;
};
export type PuzzleNextLevelTarget = {
profileId?: string;
levelId?: string | null;
};
type PuzzleBoardPieceViewModel = {
pieceId: string;
row: number;
col: number;
correctRow: number;
correctCol: number;
mergedGroupId: string | null;
};
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}`;
}
function buildBoardCells(board: PuzzleBoardSnapshot) {
return Array.from({ length: board.rows * board.cols }, (_, index) => ({
row: Math.floor(index / board.cols),
col: index % board.cols,
}));
}
function buildLocalCellKey(row: number, col: number) {
return `${row}:${col}`;
}
export function resolveDraggedPieceCellLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 80;
}
export function resolveDraggedPieceLayer(
pieceId: string | null | undefined,
draggingPieceId: string | null,
isMerged: boolean,
) {
if (!pieceId || isMerged || pieceId !== draggingPieceId) {
return undefined;
}
return 81;
}
export function resolveDraggedMergedGroupLayer(
groupId: string,
draggingGroupId: string | null,
) {
return groupId === draggingGroupId ? 90 : undefined;
}
function resolveMergedPieceOutlineClass(
group: PuzzleMergedGroupViewModel,
piece: PuzzleMergedGroupViewModel['pieces'][number],
) {
const groupCellKeys = new Set(
group.pieces.map((groupPiece) =>
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),
);
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),
);
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',
topLeftRadius,
topRightRadius,
bottomRightRadius,
bottomLeftRadius,
].join(' ');
}
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')}`;
}
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 resolveAuthorAvatarLabel(authorDisplayName: string) {
return authorDisplayName.trim().slice(0, 1) || '玩';
}
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;
const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12;
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;
};
function triggerPuzzlePiecePressHapticFeedback() {
if (typeof navigator === 'undefined') {
return;
}
const vibrate = navigator.vibrate;
if (typeof vibrate !== 'function') {
return;
}
vibrate.call(navigator, [PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS]);
}
/**
* 拼图运行时壳层。
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
* 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。
*/
export function PuzzleRuntimeShell({
run,
isBusy = false,
error = null,
onBack,
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
onRestartLevel,
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;
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 pieceCellElementRefMap = useRef(new Map<string, HTMLDivElement>());
const pieceElementRefMap = useRef(new Map<string, HTMLDivElement>());
const groupElementRefMap = useRef(new Map<string, HTMLDivElement>());
const [dragRenderTarget, setDragRenderTarget] = useState<{
pieceId: string;
groupId: string | null;
} | 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 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;
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const { resolvedUrl: resolvedCoverImage } = useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
const pieces = useMemo<PuzzleBoardPieceViewModel[]>(() => {
if (!board) {
return [];
}
return board.pieces.map((piece) => ({
pieceId: piece.pieceId,
row: piece.currentRow,
col: piece.currentCol,
correctRow: piece.correctRow,
correctCol: piece.correctCol,
mergedGroupId: piece.mergedGroupId,
}));
}, [board]);
const mergedGroups = useMemo(() => {
if (!board) {
return [];
}
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(
mergedGroups.flatMap((group) =>
group.pieces.map((piece) => boardCellKey(piece)),
),
),
[mergedGroups],
);
const pieceByCell = useMemo(() => {
const map = new Map<string, PuzzleBoardPieceViewModel>();
for (const piece of pieces) {
map.set(`${piece.row}:${piece.col}`, piece);
}
return map;
}, [pieces]);
const pieceById = useMemo(
() => new Map(pieces.map((piece) => [piece.pieceId, piece])),
[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 =
(pieceElement?.parentElement as HTMLDivElement | null) ??
pieceCellElementRefMap.current.get(pieceId) ??
null;
return pieceCellElement;
};
const resetDragVisualTarget = () => {
const dragVisualTarget = dragVisualTargetRef.current;
setDragRenderTarget(null);
if (!dragVisualTarget) {
return;
}
const pieceElement = pieceElementRefMap.current.get(
dragVisualTarget.pieceId,
);
const pieceCellElement = resolvePieceCellElement(dragVisualTarget.pieceId);
if (pieceCellElement) {
pieceCellElement.style.zIndex = '';
}
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;
setDragRenderTarget((currentTarget) => {
if (
currentTarget?.pieceId === nextTarget.pieceId &&
currentTarget.groupId === nextTarget.groupId
) {
return currentTarget;
}
return 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 = '90';
groupElement.style.opacity = '0.95';
}
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
if (pieceCellElement) {
pieceCellElement.style.zIndex = '';
}
const pieceElement = pieceElementRefMap.current.get(dragSession.pieceId);
if (pieceElement) {
pieceElement.style.transform = '';
pieceElement.style.willChange = '';
pieceElement.style.zIndex = '';
pieceElement.style.opacity = '';
}
return;
}
const pieceCellElement = resolvePieceCellElement(dragSession.pieceId);
if (pieceCellElement) {
// 单块拖动时提升所属格子的堆叠层级,避免被后绘制的拼块或合并块遮住。
pieceCellElement.style.zIndex = '80';
}
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 = '81';
pieceElement.style.opacity = '0.95';
}
};
const scheduleDragVisual = () => {
if (dragVisualFrameRef.current !== null) {
return;
}
dragVisualFrameRef.current = window.requestAnimationFrame(flushDragVisual);
};
useEffect(
() => () => {
cancelDragVisualFrame();
resetDragVisualTarget();
},
[],
);
const clearPresentationTimeouts = () => {
for (const timeoutId of clearPresentationTimeoutIdsRef.current) {
window.clearTimeout(timeoutId);
}
clearPresentationTimeoutIdsRef.current = [];
};
useEffect(
() => () => {
clearPresentationTimeouts();
},
[],
);
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;
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 (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-950 text-white">
<div className="flex items-center gap-2 rounded-full bg-white/10 px-5 py-3 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
);
}
const handlePieceClick = (pieceId: string) => {
if (isInteractionLocked) {
return;
}
if (!selectedPieceId) {
setSelectedPieceId(pieceId);
return;
}
if (selectedPieceId === pieceId) {
setSelectedPieceId(null);
return;
}
onSwapPieces({
firstPieceId: selectedPieceId,
secondPieceId: pieceId,
});
setSelectedPieceId(null);
};
const resolveBoardCellFromPointer = (clientX: number, clientY: number) => {
const boardElement = boardRef.current;
if (!boardElement) {
return null;
}
const rect = boardElement.getBoundingClientRect();
if (
clientX < rect.left ||
clientX > rect.right ||
clientY < rect.top ||
clientY > rect.bottom
) {
return null;
}
const relativeX = clientX - rect.left;
const relativeY = clientY - rect.top;
const col = Math.min(
board.cols - 1,
Math.max(0, Math.floor((relativeX / rect.width) * board.cols)),
);
const row = Math.min(
board.rows - 1,
Math.max(0, Math.floor((relativeY / rect.height) * board.rows)),
);
return { row, col };
};
const handlePiecePointerUp = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
const currentDragSession = dragSessionRef.current;
if (!currentDragSession || currentDragSession.pieceId !== pieceId) {
return;
}
event.currentTarget.releasePointerCapture?.(event.pointerId);
if (currentDragSession.dragging) {
const targetCell = resolveBoardCellFromPointer(
event.clientX,
event.clientY,
);
resetDragInteraction();
if (targetCell) {
onDragPiece({
pieceId,
targetRow: targetCell.row,
targetCol: targetCell.col,
});
}
setSelectedPieceId(null);
return;
}
resetDragInteraction();
handlePieceClick(pieceId);
};
const handlePiecePointerDown = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (isInteractionLocked) {
return;
}
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);
// 按下可交互拼图片时立即给移动端短震反馈,点击选择与拖起都会有同一套手感。
triggerPuzzlePiecePressHapticFeedback();
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确保层级提升不会滞后一帧后续仍保留 raf 兜底连续刷新。
flushDragVisual();
scheduleDragVisual();
};
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 nextLevelMode =
run.nextLevelMode ?? 'none';
const recommendedNextWorks = run.recommendedNextWorks ?? [];
const hasSimilarWorkChoices =
nextLevelMode === 'similarWorks' && recommendedNextWorks.length > 0;
const canAdvanceDefaultNextLevel =
currentLevel.status === 'cleared' &&
(nextLevelMode === 'sameWork' ||
(nextLevelMode === 'similarWorks'
? Boolean(run.nextLevelProfileId ?? run.recommendedNextProfileId) &&
!hasSimilarWorkChoices
: Boolean(run.recommendedNextProfileId)));
const canShowNextAction =
canAdvanceDefaultNextLevel || hasSimilarWorkChoices;
const levelLabel = `${currentLevel.levelIndex}`;
const authorAvatarLabel = resolveAuthorAvatarLabel(
currentLevel.authorDisplayName,
);
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
: (run.leaderboardEntries ?? []);
const isClearResultOpen =
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
const canOpen =
propKind === 'extendTime'
? runtimeStatus === 'failed'
: runtimeStatus === 'playing';
if (!canOpen) {
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);
}
if (propKind === 'extendTime') {
setTimerNowMs(Date.now());
}
};
return (
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
<div className="relative h-full w-full overflow-hidden bg-[radial-gradient(circle_at_50%_20%,rgba(251,191,36,0.18),transparent_28%),radial-gradient(circle_at_20%_80%,rgba(249,115,22,0.16),transparent_26%),linear-gradient(180deg,#2d160e,#020617)]">
{currentLevel.coverImageSrc ? (
<ResolvedAssetImage
src={currentLevel.coverImageSrc}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover opacity-[0.16] blur-2xl"
/>
) : 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 w-full px-4 py-4">
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm: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 min-w-0 flex-col items-center gap-2 rounded-[1.35rem] bg-black/30 px-3 py-3 text-center backdrop-blur sm:px-5">
<div className="line-clamp-1 max-w-full text-sm font-black text-white sm:text-base">
{currentLevel.levelName}
</div>
<div
className={`inline-flex items-center gap-2 rounded-full px-4 py-2 font-mono text-2xl font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.24)] sm:text-3xl ${
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
? 'bg-red-500/24 text-red-100'
: 'bg-white/12 text-white'
}`}
>
<Clock className="h-5 w-5 sm:h-6 sm:w-6" />
{formatTimerMs(displayRemainingMs)}
</div>
<div className="flex min-w-0 max-w-full items-center justify-center gap-2 text-white/82">
<span
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border border-white/16 bg-amber-200 text-xs font-black text-slate-950 shadow-[0_8px_20px_rgba(0,0,0,0.2)]"
aria-hidden="true"
>
{authorAvatarLabel}
</span>
<span className="min-w-0 truncate text-xs font-semibold sm:text-sm">
{currentLevel.authorDisplayName}
</span>
<span className="shrink-0 rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold tracking-[0.12em] text-amber-100/90 sm:text-[11px]">
{levelLabel}
</span>
</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>
<div className="absolute inset-0 flex items-center justify-center px-1 py-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(99vw,calc(100vh_-_16.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:max-w-[min(92vw,calc(100vh_-_17rem))] sm:rounded-[1.45rem]"
style={{
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
}}
>
{buildBoardCells(board).map((cell) => {
const piece = pieceByCell.get(`${cell.row}:${cell.col}`) ?? null;
const occupied = Boolean(piece);
const isMerged = mergedCellKeys.has(boardCellKey(cell));
const isSelected = piece?.pieceId === selectedPieceId;
return (
<div
key={`${cell.row}:${cell.col}`}
ref={(node) => {
if (!piece) {
return;
}
if (node) {
pieceCellElementRefMap.current.set(piece.pieceId, node);
return;
}
pieceCellElementRefMap.current.delete(piece.pieceId);
}}
data-piece-cell-id={piece?.pieceId ?? undefined}
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
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={`relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] 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'
: '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]'
}`}
style={{
zIndex: resolveDraggedPieceLayer(
piece?.pieceId,
draggingPieceId,
isMerged,
),
}}
onPointerDown={(event) => {
if (!piece || isMerged) {
return;
}
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
if (!piece || isMerged) {
return;
}
handlePiecePointerMove(piece.pieceId, event);
}}
onPointerUp={(event) => {
if (piece && !isMerged) {
handlePiecePointerUp(piece.pieceId, event);
}
}}
onPointerCancel={() => {
resetDragInteraction();
}}
onLostPointerCapture={() => {
resetDragInteraction();
}}
>
{piece ? (
<div className="relative h-full w-full overflow-hidden">
{isMerged ? null : 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(251,191,36,0.4),rgba(76,29,19,0.72))]" />
)}
<div className="absolute inset-0 bg-black/10" />
</div>
) : (
''
)}
</div>
</div>
);
})}
{mergedGroups.map((group) => (
<div
key={group.groupId}
ref={(node) => {
if (node) {
groupElementRefMap.current.set(group.groupId, node);
return;
}
groupElementRefMap.current.delete(group.groupId);
}}
data-merged-group-id={group.groupId}
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}%`,
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 border-white/22 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,
}}
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>
</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 flex-col items-center gap-2 px-3 py-3 sm:px-4 sm:py-4">
{error ? (
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
{error}
</div>
) : null}
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
</div>
) : null}
{canShowNextAction ? (
<button
type="button"
disabled={isBusy}
onClick={() => {
if (hasSimilarWorkChoices) {
setDismissedClearKey(null);
setIsClearResultReady(true);
return;
}
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="inline-flex min-h-11 items-center gap-2 rounded-full bg-amber-200 px-5 py-2.5 text-sm font-bold text-slate-950 shadow-[0_14px_36px_rgba(251,191,36,0.26)] transition hover:bg-amber-100 disabled:opacity-45"
>
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
<ArrowRight className="h-4 w-4" />
</button>
) : null}
<div className="flex items-center justify-center gap-2 rounded-full bg-black/36 p-2 backdrop-blur sm:gap-3">
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('hint', '使用提示')}
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
>
<Lightbulb className="h-6 w-6 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-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black transition hover:bg-white/10 disabled:opacity-45 ${
isOriginalOverlayVisible
? 'bg-sky-200 text-slate-950'
: 'text-white/86'
}`}
>
<Eye className="h-6 w-6" />
</button>
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className="inline-flex h-16 min-w-[5.75rem] flex-col items-center justify-center gap-1 rounded-full px-4 text-sm font-black text-white/88 transition hover:bg-white/10 disabled:opacity-45"
>
<Snowflake className="h-6 w-6 text-cyan-100" />
</button>
</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}
{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"
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}
{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="grid grid-cols-2 gap-3 border-t border-white/10 px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
void onRestartLevel?.();
}}
className="rounded-full border border-white/14 bg-black/24 px-4 py-2.5 text-sm font-black text-white transition hover:bg-white/10 disabled:opacity-50"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => openPropDialog('extendTime', '继续1分钟')}
className="rounded-full bg-amber-200 px-4 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-50"
>
1
</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
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);
}}
>
<PixelIcon src={CHROME_ICONS.close} 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.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>
{hasSimilarWorkChoices ? (
<div className="mt-4">
<div className="grid gap-2 sm:grid-cols-3">
{recommendedNextWorks.slice(0, 3).map((item) => (
<PuzzleNextWorkCard
key={item.profileId}
item={item}
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({ profileId: item.profileId });
}}
/>
))}
</div>
</div>
) : null}
</div>
{canAdvanceDefaultNextLevel ? (
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
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>
) : null}
</section>
</div>
) : null}
</div>
</div>
);
}
function PuzzleNextWorkCard({
item,
disabled,
onClick,
}: {
item: PuzzleRecommendedNextWork;
disabled: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className="group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] border border-white/10 bg-white/6 text-left transition hover:border-amber-200/40 hover:bg-amber-200/10 disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
>
<div className="relative min-h-full bg-white/8 sm:aspect-[1.35]">
{item.coverImageSrc ? (
<ResolvedAssetImage
src={item.coverImageSrc}
alt={item.levelName}
className="h-full w-full object-cover"
/>
) : (
<div className="h-full w-full bg-[linear-gradient(145deg,rgba(20,184,166,0.34),rgba(15,23,42,0.88))]" />
)}
<div className="absolute inset-0 bg-black/10 transition group-hover:bg-black/0" />
</div>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-black text-white">
{item.levelName}
</div>
<div className="mt-1 truncate text-xs font-semibold text-white/58">
{item.authorDisplayName}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{item.themeTags.slice(0, 2).map((tag) => (
<span
key={tag}
className="max-w-full truncate rounded-full bg-white/10 px-2 py-0.5 text-[10px] font-bold text-white/64"
>
{tag}
</span>
))}
</div>
</div>
</button>
);
}
export default PuzzleRuntimeShell;