Files
Genarrative/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx

2373 lines
86 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,
ChevronDown,
ChevronUp,
Clock,
Eye,
Lightbulb,
Loader2,
Snowflake,
Sparkles,
Trophy,
} from 'lucide-react';
import { useEffect, useId, useMemo, useRef, useState } from 'react';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRecommendedNextWork,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
PuzzleRuntimePropKind,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { isDebugMode } from '../../config/debugMode';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
createRuntimeDragInputController,
createRuntimeInputPointFromClient,
createRuntimeInputPointFromNormalized,
readRuntimeInputElementBounds,
resolveRuntimeInputGridCell,
type RuntimeDragInputSession,
type RuntimeInputPoint,
} from '../../services/input-devices';
import { useMocapInput } from '../../services/useMocapInput';
import { CHROME_ICONS, getNineSliceStyle, UI_CHROME } from '../../uiAssets';
import { useAuthUi } from '../auth/AuthUiContext';
import { PixelIcon } from '../PixelIcon';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildMergedGroupClipPath,
buildMergedGroupOutlinePath,
resolveDraggedMergedGroupLayer,
resolveDraggedPieceCellLayer,
resolveDraggedPieceLayer,
sanitizeSvgId,
} from './puzzleRuntimeShape';
type PuzzleRuntimeShellProps = {
run: PuzzleRunSnapshot | null;
isBusy?: boolean;
error?: string | null;
hideBackButton?: boolean;
hideExitControls?: boolean;
embedded?: boolean;
onBack: () => void;
onRemodelWork?: (profileId: string) => void | Promise<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 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 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;
const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX =
'genarrative.puzzle-runtime.exit-remodel-prompt.v1';
const PUZZLE_MOCAP_DRAG_INPUT_ID = 'mocap:primary-hand';
const PUZZLE_MOCAP_CURSOR_FRAME_MS = 1000 / 60;
const shownExitRemodelPromptProfileIds = new Set<string>();
function buildExitRemodelPromptStorageKey(profileId: string) {
return `${PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX}:${encodeURIComponent(
profileId,
)}`;
}
function hasSeenExitRemodelPrompt(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return true;
}
if (shownExitRemodelPromptProfileIds.has(normalizedProfileId)) {
if (typeof window === 'undefined') {
return true;
}
}
try {
const seen =
window.localStorage.getItem(
buildExitRemodelPromptStorageKey(normalizedProfileId),
) === '1';
if (seen) {
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
}
return seen;
} catch {
return shownExitRemodelPromptProfileIds.has(normalizedProfileId);
}
}
function markExitRemodelPromptSeen(profileId: string) {
const normalizedProfileId = profileId.trim();
if (!normalizedProfileId) {
return;
}
shownExitRemodelPromptProfileIds.add(normalizedProfileId);
if (typeof window === 'undefined') {
return;
}
try {
window.localStorage.setItem(
buildExitRemodelPromptStorageKey(normalizedProfileId),
'1',
);
} catch {
// 中文注释:隐私模式下 localStorage 可能不可写,内存集合足够兜底本次挂载周期。
}
}
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;
};
type PuzzleMocapCursorState = {
x: number;
y: number;
state: string;
};
type PuzzleMocapCursorSample = PuzzleMocapCursorState & {
receivedAtMs: number;
};
type PuzzleRuntimeDragTargetState = {
pieceId: string;
groupId: string | null;
};
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,
hideBackButton = false,
hideExitControls = false,
embedded = false,
onBack,
onRemodelWork,
onSwapPieces,
onDragPiece,
onAdvanceNextLevel,
onRestartLevel,
onPauseChange,
onUseProp,
onTimeExpired,
}: PuzzleRuntimeShellProps) {
const mergedGroupSvgIdPrefix = sanitizeSvgId(useId());
const authUi = useAuthUi();
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
const selectedPieceIdRef = useRef<string | null>(null);
const selectedPieceBeforeInputRef = useRef<string | null>(null);
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
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 [isMocapDebugExpanded, setIsMocapDebugExpanded] = useState(false);
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;
inputId: string;
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 [mocapCursor, setMocapCursor] = useState<PuzzleMocapCursorState | null>(
null,
);
const mocapCursorPreviousSampleRef = useRef<PuzzleMocapCursorSample | null>(
null,
);
const mocapCursorTargetSampleRef = useRef<PuzzleMocapCursorSample | null>(null);
const mocapCursorIntervalRef = useRef<number | null>(null);
const updateMocapCursorSampleRef = useRef<(
nextSample: PuzzleMocapCursorSample,
) => void>(() => {});
const runtimeDragInputControllerRef = useRef(
createRuntimeDragInputController<string>(),
);
const draggingTargetRef = useRef<PuzzleRuntimeDragTargetState | 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 currentLevelRef = useRef(currentLevel);
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 isInteractionLocked =
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
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 mocapInput = useMocapInput({enabled: runtimeStatus === 'playing'});
const primaryMocapHand = mocapInput.latestCommand?.primaryHand;
const primaryMocapHandState = primaryMocapHand?.state;
const primaryMocapHandX = primaryMocapHand?.x;
const primaryMocapHandY = primaryMocapHand?.y;
const mocapActionsLabel =
mocapInput.latestCommand?.actions.length
? mocapInput.latestCommand.actions.join(', ')
: '无';
const mocapHandLabel =
primaryMocapHandState &&
typeof primaryMocapHandX === 'number' &&
typeof primaryMocapHandY === 'number'
? `${primaryMocapHandState} @ ${primaryMocapHandX.toFixed(2)}, ${primaryMocapHandY.toFixed(2)}`
: '无';
const mocapParseWarningLabel = mocapInput.latestCommand?.parseWarnings?.length
? mocapInput.latestCommand.parseWarnings.join('')
: '无';
const mocapRawPacketLabel = mocapInput.rawPacketPreview?.text ?? '未收到';
const shouldShowMocapDebugPanel = isDebugMode();
useEffect(() => {
currentLevelRef.current = currentLevel;
}, [currentLevel]);
const commitSelectedPieceId = (pieceId: string | null) => {
selectedPieceIdRef.current = pieceId;
setSelectedPieceId(pieceId);
};
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 resetDragInteractionState = () => {
cancelDragVisualFrame();
dragOffsetRef.current = null;
dragSessionRef.current = null;
draggingTargetRef.current = null;
resetDragVisualTarget();
};
const resetDragInteraction = () => {
runtimeDragInputControllerRef.current.cancel();
};
const flushDragVisual = () => {
dragVisualFrameRef.current = null;
const dragSession = dragSessionRef.current;
if (!dragSession || !dragSession.dragging) {
resetDragVisualTarget();
return;
}
const piece = pieceById.get(dragSession.pieceId) ?? null;
const groupId =
draggingTargetRef.current?.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 ||
isExitRemodelPromptOpen ||
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 === 'cleared') {
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]);
const handlePieceTap = (
pieceId: string,
selectedPieceIdBeforeInput: string | null,
) => {
if (isInteractionLocked) {
return;
}
if (!selectedPieceIdBeforeInput) {
commitSelectedPieceId(pieceId);
return;
}
if (selectedPieceIdBeforeInput === pieceId) {
commitSelectedPieceId(null);
return;
}
onSwapPieces({
firstPieceId: selectedPieceIdBeforeInput,
secondPieceId: pieceId,
});
commitSelectedPieceId(null);
};
const resolvePuzzleRuntimeDragTarget = (
pieceId: string,
): PuzzleRuntimeDragTargetState | null => {
const sourcePiece = pieceById.get(pieceId) ?? null;
if (!sourcePiece) {
return null;
}
return {
pieceId: sourcePiece.pieceId,
groupId: sourcePiece.mergedGroupId ?? null,
};
};
const commitPuzzleRuntimeDrag = (
target: PuzzleRuntimeDragTargetState | null,
point: RuntimeInputPoint,
) => {
const dragSession = dragSessionRef.current;
if (!target || !dragSession) {
return;
}
const targetCell = board
? resolveRuntimeInputGridCell(point, board)
: null;
if (!targetCell) {
return;
}
onDragPiece({
pieceId: target.pieceId,
targetRow: targetCell.row,
targetCol: targetCell.col,
});
};
const resolveBoardInputPointFromClient = (
clientX: number,
clientY: number,
) =>
createRuntimeInputPointFromClient(
clientX,
clientY,
readRuntimeInputElementBounds(boardRef.current),
);
const resolveBoardInputPointFromNormalized = (
normalizedX: number,
normalizedY: number,
) =>
createRuntimeInputPointFromNormalized(
normalizedX,
normalizedY,
readRuntimeInputElementBounds(boardRef.current),
);
const resetMocapCursorInterpolation = () => {
mocapCursorPreviousSampleRef.current = null;
mocapCursorTargetSampleRef.current = null;
setMocapCursor(null);
};
updateMocapCursorSampleRef.current = (nextSample: PuzzleMocapCursorSample) => {
const previousTarget = mocapCursorTargetSampleRef.current;
mocapCursorPreviousSampleRef.current = previousTarget ?? nextSample;
mocapCursorTargetSampleRef.current = nextSample;
if (!previousTarget) {
setMocapCursor(nextSample);
}
};
const syncRuntimeDragFromController = (
session: RuntimeDragInputSession<string> | null,
) => {
if (!session) {
return;
}
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
dragSessionRef.current = {
pieceId: session.targetId,
inputId: session.inputId,
dragging: session.dragging,
startX: session.startPoint.clientX,
startY: session.startPoint.clientY,
currentX: session.currentPoint.clientX,
currentY: session.currentPoint.clientY,
};
if (session.dragging) {
flushDragVisual();
scheduleDragVisual();
}
};
runtimeDragInputControllerRef.current.setOptions({
dragThresholdPx: 8,
onPress: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
syncRuntimeDragFromController(session);
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
commitSelectedPieceId(session.targetId);
triggerPuzzlePiecePressHapticFeedback();
},
onDragStart: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
syncRuntimeDragFromController(session);
},
onDragMove: (session) => {
syncRuntimeDragFromController(session);
},
onDrop: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(session.targetId);
syncRuntimeDragFromController(session);
commitPuzzleRuntimeDrag(draggingTargetRef.current, session.currentPoint);
commitSelectedPieceId(null);
selectedPieceBeforeInputRef.current = null;
resetDragInteractionState();
},
onTap: (session) => {
handlePieceTap(session.targetId, selectedPieceBeforeInputRef.current);
selectedPieceBeforeInputRef.current = null;
resetDragInteractionState();
},
onCancel: () => {
commitSelectedPieceId(selectedPieceBeforeInputRef.current);
selectedPieceBeforeInputRef.current = null;
resetDragInteractionState();
},
});
useEffect(() => {
const activeSession = runtimeDragInputControllerRef.current.getSession();
if (!board || runtimeStatus !== 'playing' || isInteractionLocked) {
runtimeDragInputControllerRef.current.cancel();
resetMocapCursorInterpolation();
return;
}
if (
!primaryMocapHandState ||
typeof primaryMocapHandX !== 'number' ||
typeof primaryMocapHandY !== 'number'
) {
runtimeDragInputControllerRef.current.cancel(PUZZLE_MOCAP_DRAG_INPUT_ID);
resetMocapCursorInterpolation();
return;
}
const nextSample = {
x: primaryMocapHandX,
y: primaryMocapHandY,
state: primaryMocapHandState,
receivedAtMs: performance.now(),
};
updateMocapCursorSampleRef.current(nextSample);
const handPoint = resolveBoardInputPointFromNormalized(nextSample.x, nextSample.y);
if (primaryMocapHandState === 'grab') {
if (activeSession?.inputId !== PUZZLE_MOCAP_DRAG_INPUT_ID) {
const sourceCell = resolveRuntimeInputGridCell(handPoint, board);
const sourcePiece = sourceCell
? pieceByCell.get(`${sourceCell.row}:${sourceCell.col}`) ?? null
: null;
if (!sourcePiece) {
runtimeDragInputControllerRef.current.cancel(
PUZZLE_MOCAP_DRAG_INPUT_ID,
);
return;
}
runtimeDragInputControllerRef.current.press({
targetId: sourcePiece.pieceId,
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
deviceKind: 'mocap',
point: handPoint,
});
return;
}
runtimeDragInputControllerRef.current.move({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: handPoint,
forceDragging: true,
});
return;
}
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
runtimeDragInputControllerRef.current.release({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: handPoint,
forceDrop: activeSession.deviceKind === 'mocap',
});
}
}, [
board,
isInteractionLocked,
pieceByCell,
primaryMocapHandState,
primaryMocapHandX,
primaryMocapHandY,
runtimeStatus,
]);
useEffect(() => {
if (!board || runtimeStatus !== 'playing') {
if (mocapCursorIntervalRef.current !== null) {
window.clearInterval(mocapCursorIntervalRef.current);
mocapCursorIntervalRef.current = null;
}
return;
}
const tickMocapCursor = () => {
const targetSample = mocapCursorTargetSampleRef.current;
if (!targetSample) {
return;
}
const previousSample = mocapCursorPreviousSampleRef.current ?? targetSample;
const durationMs = Math.max(
PUZZLE_MOCAP_CURSOR_FRAME_MS,
targetSample.receivedAtMs - previousSample.receivedAtMs,
);
const progress = targetSample.receivedAtMs === previousSample.receivedAtMs
? 1
: Math.min(
1,
Math.max(0, (performance.now() - targetSample.receivedAtMs) / durationMs),
);
const nextCursor = {
x: previousSample.x + (targetSample.x - previousSample.x) * progress,
y: previousSample.y + (targetSample.y - previousSample.y) * progress,
state: targetSample.state,
};
const nextPoint = resolveBoardInputPointFromNormalized(
nextCursor.x,
nextCursor.y,
);
setMocapCursor(nextCursor);
const activeSession = runtimeDragInputControllerRef.current.getSession();
if (activeSession?.inputId === PUZZLE_MOCAP_DRAG_INPUT_ID) {
runtimeDragInputControllerRef.current.move({
inputId: PUZZLE_MOCAP_DRAG_INPUT_ID,
point: nextPoint,
forceDragging: true,
});
}
};
tickMocapCursor();
mocapCursorIntervalRef.current = window.setInterval(
tickMocapCursor,
PUZZLE_MOCAP_CURSOR_FRAME_MS,
);
return () => {
if (mocapCursorIntervalRef.current !== null) {
window.clearInterval(mocapCursorIntervalRef.current);
mocapCursorIntervalRef.current = null;
}
};
}, [board, runtimeStatus]);
if (!run || !currentLevel || !board) {
return (
<div
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex items-center justify-center`}
>
<div className="puzzle-runtime-pill flex items-center gap-2 rounded-full px-5 py-3 text-sm">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
</div>
);
}
const handlePiecePointerUp = (event: React.PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId);
runtimeDragInputControllerRef.current.release({
inputId: `pointer:${event.pointerId}`,
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
});
};
const handlePiecePointerDown = (
pieceId: string,
event: React.PointerEvent<HTMLDivElement>,
) => {
if (isInteractionLocked) {
return;
}
event.preventDefault();
resetDragInteraction();
event.currentTarget.setPointerCapture?.(event.pointerId);
runtimeDragInputControllerRef.current.press({
targetId: pieceId,
inputId: `pointer:${event.pointerId}`,
deviceKind: 'pointer',
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
});
};
const handlePiecePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
runtimeDragInputControllerRef.current.move({
inputId: `pointer:${event.pointerId}`,
point: resolveBoardInputPointFromClient(event.clientX, event.clientY),
});
};
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 exitPromptProfileId = currentLevel.profileId.trim();
const shouldHideBackButton = hideBackButton || hideExitControls;
const leaderboardEntries =
(currentLevel.leaderboardEntries ?? []).length > 0
? currentLevel.leaderboardEntries
: (run.leaderboardEntries ?? []);
const isClearResultOpen =
currentLevel.status === 'cleared' &&
dismissedClearKey !== clearResultKey &&
isClearResultReady;
const handleBackRequest = () => {
if (hideExitControls) {
return;
}
if (
onRemodelWork &&
exitPromptProfileId &&
!hasSeenExitRemodelPrompt(exitPromptProfileId)
) {
markExitRemodelPromptSeen(exitPromptProfileId);
setIsExitRemodelPromptOpen(true);
return;
}
onBack();
};
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);
let useResult: PuzzleRunSnapshot | null | void = null;
try {
await pauseChangePromiseRef.current;
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') {
// 中文注释:正式 run 可能在冻结确认期间已被服务端结算为失败态;
// 这种边界同步只关闭确认窗,不再播放冻结成功反馈。
const resultLevel =
useResult && typeof useResult === 'object'
? useResult.currentLevel
: currentLevelRef.current;
if (resultLevel?.status === 'playing') {
setIsFreezeEffectVisible(true);
window.setTimeout(() => {
setIsFreezeEffectVisible(false);
}, 900);
}
}
if (propKind === 'extendTime') {
setTimerNowMs(Date.now());
}
};
return (
<div
className={`puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
>
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
{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="puzzle-runtime-stage__grid" />
<div className="absolute left-0 top-0 z-20 w-full px-3 py-3 sm:px-4">
<div className="grid grid-cols-[2.5rem_minmax(0,1fr)_2.5rem] items-start gap-2 sm:grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] sm:gap-3">
<button
type="button"
onClick={handleBackRequest}
aria-label="返回上一页"
disabled={shouldHideBackButton}
className={`puzzle-runtime-icon-button h-10 w-10 items-center justify-center rounded-full sm:h-11 sm:w-11 ${
shouldHideBackButton
? 'invisible pointer-events-none'
: 'inline-flex'
}`}
>
<ArrowLeft className="h-4 w-4" />
</button>
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(15rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center gap-1.5 rounded-[1.1rem] px-3 py-2 text-center sm:max-w-[18rem] sm:px-4">
<div className="flex max-w-full items-center justify-center gap-1.5">
<span className="puzzle-runtime-level-badge shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold sm:text-[11px]">
{levelLabel}
</span>
<span className="min-w-0 truncate text-sm font-black sm:text-base">
{currentLevel.levelName}
</span>
</div>
<div
className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1.5 font-mono text-lg font-black leading-none shadow-[0_10px_28px_rgba(0,0,0,0.2)] sm:text-xl ${
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
? 'puzzle-runtime-timer--urgent'
: 'puzzle-runtime-timer'
}`}
>
<Clock className="h-4 w-4 sm:h-5 sm:w-5" />
{formatTimerMs(displayRemainingMs)}
</div>
</div>
<button
type="button"
onClick={() => setIsSettingsPanelOpen(true)}
aria-label="打开拼图设置"
title="打开拼图设置"
className="puzzle-runtime-icon-button inline-flex h-10 w-10 items-center justify-center rounded-full transition duration-150 hover:-translate-y-px hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] sm:h-11 sm:w-11"
>
<PixelIcon
src={CHROME_ICONS.settings}
className="h-5 w-5 drop-shadow-[0_4px_10px_rgba(0,0,0,0.45)] sm:h-[1.4rem] sm:w-[1.4rem]"
/>
</button>
</div>
</div>
<div className="absolute inset-0 flex items-center justify-center px-1 py-3 pt-24 pb-32 sm:px-4 sm:py-4 sm:pt-24 sm:pb-28">
<div
ref={boardRef}
data-testid="puzzle-board"
className="puzzle-runtime-board relative grid aspect-square w-full max-w-[min(99vw,calc(100vh_-_14rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border backdrop-blur-sm sm:max-w-[min(92vw,calc(100vh_-_13rem))] 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={`puzzle-runtime-piece relative flex h-full items-center justify-center overflow-hidden rounded-[0.85rem] border-2 text-sm font-black transition ${
occupied
? isSelected
? 'puzzle-runtime-piece--selected'
: isMerged
? 'puzzle-runtime-piece--merged'
: 'puzzle-runtime-piece--filled'
: 'puzzle-runtime-piece--empty'
} ${
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(event);
}}
onPointerUp={(event) => {
if (piece && !isMerged) {
handlePiecePointerUp(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>
) : (
''
)}
</div>
</div>
);
})}
{mergedGroups.map((group) => {
const outlinePath = buildMergedGroupOutlinePath(group);
const clipPath = buildMergedGroupClipPath(group);
return (
<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}%`,
}}
>
{outlinePath ? (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-20 h-full w-full overflow-visible"
preserveAspectRatio="none"
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
>
<defs>
<clipPath
clipPathUnits="objectBoundingBox"
id={`${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
group.groupId,
)}`}
>
<path
clipRule="evenodd"
d={clipPath}
fillRule="evenodd"
/>
</clipPath>
</defs>
<path
d={outlinePath}
data-merged-group-outline="true"
fill="transparent"
fillRule="evenodd"
/>
<path
d={outlinePath}
data-merged-group-outline-stroke="true"
fill="none"
stroke="rgba(255, 255, 255, 0.22)"
strokeLinejoin="round"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
/>
</svg>
) : null}
<div
className="pointer-events-none relative z-10 grid h-full w-full touch-none overflow-hidden active:scale-[0.992]"
style={{
WebkitClipPath: outlinePath
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
group.groupId,
)})`
: undefined,
clipPath: outlinePath
? `url(#${mergedGroupSvgIdPrefix}-${sanitizeSvgId(
group.groupId,
)})`
: undefined,
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 shadow-[0_12px_30px_rgba(15,23,42,0.16)]"
data-merged-piece-outline="true"
style={{
gridColumn: piece.localCol + 1,
gridRow: piece.localRow + 1,
}}
onPointerDown={(event) => {
handlePiecePointerDown(piece.pieceId, event);
}}
onPointerMove={(event) => {
handlePiecePointerMove(event);
}}
onPointerUp={(event) => {
handlePiecePointerUp(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>
))}
</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}
{mocapCursor ? (
<div
data-testid="puzzle-mocap-cursor"
className={`pointer-events-none absolute z-[70] flex h-8 w-8 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border-2 ${
mocapCursor.state === 'grab'
? 'border-amber-200 bg-amber-400/90 text-amber-950'
: 'border-cyan-200 bg-cyan-300/90 text-cyan-950'
} shadow-[0_10px_24px_rgba(15,23,42,0.25)]`}
style={{left: `${mocapCursor.x * 100}%`, top: `${mocapCursor.y * 100}%`}}
>
<span className="text-[10px] font-black leading-none">
{mocapCursor.state === 'grab' ? '抓' : '手'}
</span>
</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="puzzle-runtime-error-chip rounded-full px-3 py-1 text-xs">
{error}
</div>
) : null}
{selectedPieceId && runtimeStatus === 'playing' ? (
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
</div>
) : null}
{shouldShowMocapDebugPanel ? (
<section
data-testid="puzzle-mocap-debug"
className="w-[min(92vw,34rem)] overflow-hidden rounded-[0.9rem] border border-white/20 bg-slate-950/70 font-mono text-[10px] leading-4 text-white shadow-[0_12px_32px_rgba(15,23,42,0.25)] backdrop-blur"
>
<button
type="button"
aria-expanded={isMocapDebugExpanded}
aria-controls="puzzle-mocap-debug-content"
onClick={() => {
setIsMocapDebugExpanded((current) => !current);
}}
className="flex min-h-9 w-full items-center justify-between gap-3 px-3 py-2 text-left transition hover:bg-white/10"
>
<span className="min-w-0 truncate">
mocap: {mocapInput.status}
</span>
{isMocapDebugExpanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronUp className="h-3.5 w-3.5 shrink-0" />
)}
</button>
{isMocapDebugExpanded ? (
<div
id="puzzle-mocap-debug-content"
className="border-t border-white/10 px-3 pb-2 pt-2"
>
<div>: {mocapActionsLabel}</div>
<div>: {mocapHandLabel}</div>
<div>: {mocapParseWarningLabel}</div>
<div className="max-h-20 overflow-auto break-all text-white/75">
: {mocapRawPacketLabel}
</div>
{mocapInput.error ? <div>: {mocapInput.error}</div> : null}
</div>
) : null}
</section>
) : 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="puzzle-runtime-primary-button inline-flex min-h-11 items-center gap-2 rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45"
>
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
<ArrowRight className="h-4 w-4" />
</button>
) : null}
<div className="puzzle-runtime-toolbar flex items-center justify-center gap-2 rounded-full p-2 sm:gap-3">
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('hint', '使用提示')}
className="puzzle-runtime-tool-button 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 disabled:opacity-45"
>
<Lightbulb className="puzzle-runtime-tool-button__warm h-6 w-6" />
</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 disabled:opacity-45 ${
isOriginalOverlayVisible
? 'puzzle-runtime-tool-button--active'
: 'puzzle-runtime-tool-button'
}`}
>
<Eye className="h-6 w-6" />
</button>
<button
type="button"
disabled={isInteractionLocked}
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className="puzzle-runtime-tool-button 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 disabled:opacity-45"
>
<Snowflake className="puzzle-runtime-tool-button__cool h-6 w-6" />
</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="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
onClick={() => {
if (!isPropConfirming) {
setPropDialog(null);
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-prop-confirm-title"
className="puzzle-runtime-dialog pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<header className="puzzle-runtime-dialog__line flex items-center gap-3 border-b px-5 py-4">
<span className="puzzle-runtime-primary-button inline-flex h-9 w-9 items-center justify-center rounded-full">
<Sparkles className="h-4 w-4" />
</span>
<h2
id="puzzle-prop-confirm-title"
className="text-sm font-black"
>
{propDialog.title}
</h2>
</header>
<div className="puzzle-runtime-dialog__body px-5 py-4 text-sm">
1
{propConfirmError ? (
<div className="puzzle-runtime-error-chip mt-3 rounded-[0.9rem] border px-3 py-2 text-xs leading-5">
{propConfirmError}
</div>
) : null}
</div>
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-5 py-4">
<button
type="button"
onClick={() => setPropDialog(null)}
disabled={isPropConfirming}
className="puzzle-runtime-secondary-button rounded-full px-4 py-2 text-xs font-bold transition hover:brightness-105"
>
</button>
<button
type="button"
disabled={isPropConfirming}
onClick={() => {
void handleConfirmProp();
}}
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-black transition hover:brightness-105 disabled:opacity-60"
>
{isPropConfirming ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : null}
</button>
</footer>
</section>
</div>
) : null}
{isSettingsPanelOpen ? (
<div
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center p-3 backdrop-blur-sm sm:p-4"
onClick={() => setIsSettingsPanelOpen(false)}
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-settings-title"
className="puzzle-runtime-dialog 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.24)]"
style={getNineSliceStyle(UI_CHROME.modalPanel)}
onClick={(event) => event.stopPropagation()}
>
<header className="puzzle-runtime-dialog__line relative border-b 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"
>
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 text-[11px]">
{hideExitControls
? '调整音乐音量,查看本局进度。'
: '调整音乐音量,查看本局进度,或返回上一页。'}
</div>
</div>
<button
type="button"
aria-label="关闭拼图设置"
onClick={() => setIsSettingsPanelOpen(false)}
className="puzzle-runtime-dialog__soft absolute right-4 top-3 p-1 transition-colors hover:brightness-75 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="puzzle-runtime-settings-card rounded-2xl p-4">
<div className="flex items-start justify-between gap-3">
<div>
<div className="puzzle-runtime-tool-button__cool text-[10px] tracking-[0.24em]">
</div>
<div className="mt-2 text-sm font-semibold">
</div>
</div>
<div className="puzzle-runtime-pill rounded-full px-2 py-1 text-xs">
{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-[var(--puzzle-runtime-accent-text)]"
/>
</div>
</div>
<div className="puzzle-runtime-settings-card rounded-2xl px-4 py-3">
<div className="puzzle-runtime-dialog__soft text-[10px] uppercase tracking-[0.18em]">
</div>
<div className="puzzle-runtime-dialog__body mt-3 space-y-2 text-sm">
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft"></span>
<span className="font-semibold">
{levelLabel}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft"></span>
<span className="font-semibold">
{run.clearedLevelCount}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft"></span>
<span className="font-semibold">
{statusLabel}
</span>
</div>
<div className="flex items-center justify-between gap-3">
<span className="puzzle-runtime-dialog__soft"></span>
<span className="font-mono font-semibold">
{formatElapsedMs(currentLevel.elapsedMs)}
</span>
</div>
</div>
</div>
</div>
<footer className="puzzle-runtime-dialog__line flex items-center justify-end gap-3 border-t px-4 py-3 sm:px-5">
<button
type="button"
onClick={() => setIsSettingsPanelOpen(false)}
className="puzzle-runtime-secondary-button rounded-full px-3 py-1.5 text-[11px] transition hover:brightness-105"
>
</button>
{!hideExitControls ? (
<button
type="button"
onClick={() => {
setIsSettingsPanelOpen(false);
onBack();
}}
className={`puzzle-runtime-primary-button rounded-full px-4 py-2 text-sm font-bold transition hover:brightness-105 ${
shouldHideBackButton ? 'hidden' : ''
}`}
>
</button>
) : null}
</footer>
</section>
</div>
) : null}
{isExitRemodelPromptOpen && !hideExitControls ? (
<div
className="puzzle-runtime-modal-overlay absolute inset-0 z-50 flex items-center justify-center px-4 py-6 backdrop-blur-md"
>
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-exit-remodel-title"
className="puzzle-runtime-dialog relative flex w-full max-w-[21rem] flex-col overflow-hidden rounded-[1.35rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
onClick={(event) => event.stopPropagation()}
>
<div className="pointer-events-none absolute inset-x-8 top-0 h-px bg-gradient-to-r from-transparent via-[var(--puzzle-runtime-accent-text)] to-transparent" />
<header className="flex flex-col items-center px-6 pt-7 text-center">
<div className="puzzle-runtime-stat-card mb-4 grid h-14 w-14 place-items-center rounded-2xl">
<Sparkles className="puzzle-runtime-tool-button__warm h-7 w-7" />
</div>
<h2
id="puzzle-exit-remodel-title"
className="text-[1.75rem] font-black leading-[1.08]"
>
<br />
!
</h2>
</header>
<footer className="grid gap-3 px-5 pb-5 pt-6">
<button
type="button"
disabled={isBusy}
onClick={() => {
setIsExitRemodelPromptOpen(false);
void onRemodelWork?.(exitPromptProfileId);
}}
className="puzzle-runtime-primary-button min-h-[3.25rem] rounded-2xl px-5 text-sm font-black transition hover:brightness-105 active:translate-y-px disabled:cursor-not-allowed disabled:opacity-50"
>
</button>
<button
type="button"
onClick={() => {
setIsExitRemodelPromptOpen(false);
onBack();
}}
className="puzzle-runtime-secondary-button min-h-[3rem] rounded-2xl px-5 text-sm font-bold transition hover:brightness-105 active:translate-y-px"
>
退
</button>
</footer>
</section>
</div>
) : null}
{runtimeStatus === 'failed' ? (
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-failed-title"
className="puzzle-runtime-dialog flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
>
<header className="puzzle-runtime-dialog__line border-b px-5 py-4">
<h2
id="puzzle-failed-title"
className="text-lg font-black"
>
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 text-xs">
{currentLevel.levelName}
</div>
</header>
<footer className="puzzle-runtime-dialog__line grid grid-cols-2 gap-3 border-t px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
void onRestartLevel?.();
}}
className="puzzle-runtime-secondary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
>
</button>
<button
type="button"
disabled={isBusy}
onClick={() => openPropDialog('extendTime', '继续1分钟')}
className="puzzle-runtime-primary-button rounded-full px-4 py-2.5 text-sm font-black transition hover:brightness-105 disabled:opacity-50"
>
1
</button>
</footer>
</section>
</div>
) : null}
{isClearResultOpen ? (
<div className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm">
<section
role="dialog"
aria-modal="true"
aria-labelledby="puzzle-clear-result-title"
className="puzzle-runtime-dialog flex max-h-[min(92vh,42rem)] w-full max-w-[28rem] flex-col overflow-hidden rounded-[1.5rem] shadow-[0_28px_90px_rgba(0,0,0,0.28)]"
>
<header className="puzzle-runtime-dialog__line flex items-start justify-between gap-3 border-b px-5 py-4">
<div className="min-w-0">
<div className="puzzle-runtime-primary-button mb-2 inline-flex h-9 w-9 items-center justify-center rounded-full">
<Trophy className="h-4 w-4" />
</div>
<h2
id="puzzle-clear-result-title"
className="truncate text-lg font-black"
>
</h2>
<div className="puzzle-runtime-dialog__soft mt-1 line-clamp-1 text-xs">
{currentLevel.levelName}
</div>
</div>
<button
type="button"
aria-label="关闭通关弹窗"
className="puzzle-runtime-secondary-button inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full transition hover:brightness-105"
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="puzzle-runtime-stat-card flex items-center justify-between gap-4 rounded-[1rem] px-4 py-3">
<div className="flex items-center gap-3">
<span className="puzzle-runtime-pill inline-flex h-9 w-9 items-center justify-center rounded-full">
<Clock className="h-4 w-4" />
</span>
<span className="puzzle-runtime-dialog__soft text-sm font-semibold">
</span>
</div>
<span className="puzzle-runtime-tool-button__warm font-mono text-xl font-black">
{formatElapsedMs(currentLevel.elapsedMs)}
</span>
</div>
<div className="mt-4">
<div className="mb-2 text-sm font-bold">
</div>
<div className="puzzle-runtime-dialog__line overflow-hidden rounded-[1rem] border">
<div className="puzzle-runtime-leaderboard-head grid grid-cols-[3rem_minmax(0,1fr)_5.75rem] px-3 py-2 text-[11px] font-bold">
<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 min-h-[3.25rem] grid-cols-[3rem_minmax(0,1fr)_5.75rem] items-center gap-x-2 px-3 py-2.5 text-sm ${
entry.isCurrentPlayer
? 'puzzle-runtime-leaderboard-row--active'
: 'puzzle-runtime-leaderboard-row border-t'
}`}
>
<span className="font-mono font-black">
#{entry.rank}
</span>
<span className="min-w-0">
<span className="block truncate font-semibold leading-tight">
{entry.nickname}
</span>
{entry.visibleTags?.length ? (
<span className="puzzle-runtime-leaderboard-tags">
{entry.visibleTags.map((tag) => (
<span
className="puzzle-runtime-leaderboard-tag"
key={tag}
>
{tag}
</span>
))}
</span>
) : null}
</span>
<span className="text-right font-mono text-xs font-bold">
{formatElapsedMs(entry.elapsedMs)}
</span>
</div>
))
) : (
<div className="puzzle-runtime-dialog__soft flex min-h-24 items-center justify-center px-4 py-5 text-sm">
{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="puzzle-runtime-dialog__line flex items-center justify-end border-t px-5 py-4">
<button
type="button"
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
className="puzzle-runtime-primary-button inline-flex items-center gap-2 rounded-full px-5 py-2.5 text-sm font-black transition hover:brightness-105 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="puzzle-runtime-next-card group grid min-h-[5.75rem] grid-cols-[4.5rem_minmax(0,1fr)] overflow-hidden rounded-[1rem] text-left transition disabled:cursor-not-allowed disabled:opacity-45 sm:grid-cols-1"
>
<div className="puzzle-runtime-next-card__media relative min-h-full 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="puzzle-runtime-next-card-overlay absolute inset-0 transition group-hover:opacity-0" />
</div>
<div className="min-w-0 px-3 py-2.5">
<div className="truncate text-sm font-black">
{item.levelName}
</div>
<div className="puzzle-runtime-dialog__soft mt-1 truncate text-xs font-semibold">
{item.authorDisplayName}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{item.themeTags.slice(0, 2).map((tag) => (
<span
key={tag}
className="puzzle-runtime-next-card__tag max-w-full truncate rounded-full px-2 py-0.5 text-[10px] font-bold"
>
{tag}
</span>
))}
</div>
</div>
</button>
);
}
export default PuzzleRuntimeShell;