Files
Genarrative/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx
kdletters 52c6f4282f 等待推荐页运行态全部资源
推荐页 ready 持续观察运行态图片、背景、音视频和资源 pending 标记
资源换签与玩法图集解析中通过隐藏标记阻止遮罩提前消失
补齐拼图、跳一跳、抓大鹅和敲木鱼运行态资源等待接入
补充推荐页资源等待回归测试和团队文档
2026-06-08 17:50:45 +08:00

2512 lines
90 KiB
TypeScript

import {
ArrowLeft,
ArrowRight,
Clock,
Loader2,
Settings,
Sparkles,
Trophy,
X,
} from 'lucide-react';
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import puzzleLevelLogo from '../../../media/logo.png';
import type {
DragPuzzlePieceRequest,
PuzzleBoardSnapshot,
PuzzleCellPosition,
PuzzleMergedGroupState,
PuzzleRecommendedNextWork,
PuzzleRunSnapshot,
PuzzleRuntimeLevelSnapshot,
PuzzleRuntimePropKind,
SwapPuzzlePiecesRequest,
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
import { useResolvedAssetReadUrl } from '../../hooks/useResolvedAssetReadUrl';
import {
createRuntimeDragInputController,
createRuntimeInputPointFromClient,
readRuntimeInputElementBounds,
resolveRuntimeInputGridCell,
type RuntimeDragInputSession,
type RuntimeInputPoint,
} from '../../services/input-devices';
import { resolvePuzzleUiBackgroundSource } from '../../services/puzzle-runtime/puzzleUiBackgroundSource';
import {
buildPuzzleUiSpriteBackgroundStyle,
buildPuzzleUiSpriteHitZoneStyle,
loadPuzzleUiSpritesheetLayout,
type PuzzleUiSpriteKind,
type PuzzleUiSpritesheetLayout,
} from '../../services/puzzle-runtime/puzzleUiSpritesheetParser';
import {
DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG,
playRuntimeClickSound,
playRuntimeCountdownSound,
playRuntimeLevelClearSound,
resolveRuntimeCountdownSecondBucket,
} from '../../services/runtimeAudioFeedback';
import { useAuthUi } from '../auth/AuthUiContext';
import { RuntimeResourcePendingMarker } from '../common/RuntimeResourcePendingMarker';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
import {
buildMergedGroupOutlinePath,
buildRoundedGridCellClipPath,
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 PuzzleUiSprite({
src,
kind,
layout,
className = '',
withHitZone = false,
}: {
src: string | null;
kind: PuzzleUiSpriteKind;
layout: PuzzleUiSpritesheetLayout | null;
className?: string;
withHitZone?: boolean;
}) {
if (!src) {
return null;
}
return (
<span
aria-hidden="true"
data-puzzle-ui-sprite={kind}
className={`relative inline-block shrink-0 bg-no-repeat ${className}`}
style={buildPuzzleUiSpriteBackgroundStyle({ src, kind, layout })}
>
{withHitZone ? (
<span
aria-hidden="true"
data-puzzle-ui-sprite-hit-zone={kind}
className="puzzle-runtime-ui-sprite-hit-zone absolute"
style={buildPuzzleUiSpriteHitZoneStyle({ kind, layout })}
/>
) : null}
</span>
);
}
function resolvePuzzleUiSpriteAspectRatio(
kind: PuzzleUiSpriteKind,
layout: PuzzleUiSpritesheetLayout | null,
fallback: string,
) {
const region = layout?.regions[kind];
return region ? `${region.width} / ${region.height}` : fallback;
}
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);
}
function resolveRuntimeElapsedMs(
level: PuzzleRuntimeLevelSnapshot,
nowMs: number,
uiPauseStartedAtMs: number | null,
) {
// 进行中关卡的 elapsedMs 只在通关结算后写入,设置面板需要实时派生。
if (level.status !== 'playing') {
return (
level.elapsedMs ?? Math.max(0, level.timeLimitMs - level.remainingMs)
);
}
const timeLimitMs = level.timeLimitMs || level.remainingMs;
const remainingMs = resolveRuntimeRemainingMs(
level,
nowMs,
uiPauseStartedAtMs,
);
return Math.max(0, timeLimitMs - remainingMs);
}
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 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 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]);
}
function triggerPuzzlePiecePressFeedback(volume: number) {
triggerPuzzlePiecePressHapticFeedback();
playRuntimeClickSound(undefined, volume);
}
/**
* 拼图运行时壳层。
* 前端维护运行时即时交互:交换、拖动、合并、拆分与本关通关在前端裁决。
* 后端继续负责开始关卡、下一关候选、道具扣费、排行榜等服务侧能力。
*/
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 runtimeSvgClipId = 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 [isOriginalImageViewerVisible, setIsOriginalImageViewerVisible] =
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 clearSoundKeyRef = useRef<string | null>(null);
const countdownSoundKeyRef = 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 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 [uiSpritesheetLayout, setUiSpritesheetLayout] =
useState<PuzzleUiSpritesheetLayout | null>(null);
const [isUiSpritesheetLayoutResolving, setIsUiSpritesheetLayoutResolving] =
useState(false);
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 backgroundAudioRef = useRef<HTMLAudioElement | 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 displayElapsedMs = currentLevel
? resolveRuntimeElapsedMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
: 0;
const runtimeStatus = currentLevel
? currentLevel.status === 'playing' && displayRemainingMs <= 0
? 'failed'
: currentLevel.status
: 'playing';
const platformThemeClass =
authUi?.platformTheme === 'dark'
? 'platform-theme--dark'
: 'platform-theme--light';
const isInteractionLocked =
isBusy ||
runtimeStatus !== 'playing' ||
Boolean(propDialog) ||
isOriginalImageViewerVisible;
const clearResultKey = currentLevel
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
: null;
const runtimeRunId = run?.runId ?? null;
const currentLevelIndex = currentLevel?.levelIndex ?? null;
const currentLevelStartedAtMs = currentLevel?.startedAtMs ?? null;
const currentLevelStatus = currentLevel?.status ?? null;
const musicVolume = authUi?.musicVolume ?? DEFAULT_PUZZLE_MUSIC_VOLUME;
const backgroundMusicSrc =
currentLevel?.backgroundMusic?.audioSrc?.trim() || null;
const levelAudioConfig = DEFAULT_RUNTIME_LEVEL_AUDIO_CONFIG;
const onMusicVolumeChange = authUi?.setMusicVolume ?? (() => {});
const {
resolvedUrl: resolvedBackgroundMusicSrc,
isResolving: isBackgroundMusicResolving,
} = useResolvedAssetReadUrl(backgroundMusicSrc);
const { resolvedUrl: resolvedCoverImage, isResolving: isCoverImageResolving } =
useResolvedAssetReadUrl(
currentLevel?.coverImageSrc ?? null,
);
const rawUiBackgroundImage = resolvePuzzleUiBackgroundSource(currentLevel);
const {
resolvedUrl: resolvedUiBackgroundImage,
isResolving: isUiBackgroundResolving,
} = useResolvedAssetReadUrl(rawUiBackgroundImage ?? null);
const rawUiSpritesheetImage =
currentLevel?.uiSpritesheetImageSrc?.trim() ||
(currentLevel?.uiSpritesheetImageObjectKey?.trim()
? `/${currentLevel.uiSpritesheetImageObjectKey.trim().replace(/^\/+/u, '')}`
: null);
const {
resolvedUrl: resolvedUiSpritesheetImage,
isResolving: isUiSpritesheetResolving,
} = useResolvedAssetReadUrl(rawUiSpritesheetImage);
const hasUiSpritesheet = Boolean(resolvedUiSpritesheetImage);
const tryPlayBackgroundMusic = useCallback(() => {
const audio = backgroundAudioRef.current;
if (!audio || !resolvedBackgroundMusicSrc || runtimeStatus !== 'playing') {
if (audio) {
audio.pause();
}
return;
}
audio.volume = Math.max(0, Math.min(1, musicVolume));
void audio.play().catch(() => {});
}, [musicVolume, resolvedBackgroundMusicSrc, runtimeStatus]);
useEffect(() => {
if (!rawUiSpritesheetImage) {
setUiSpritesheetLayout(null);
setIsUiSpritesheetLayoutResolving(false);
return;
}
const controller = new AbortController();
setUiSpritesheetLayout(null);
setIsUiSpritesheetLayoutResolving(true);
void loadPuzzleUiSpritesheetLayout(rawUiSpritesheetImage, {
signal: controller.signal,
})
.then((layout) => {
if (!controller.signal.aborted) {
setUiSpritesheetLayout(layout);
setIsUiSpritesheetLayoutResolving(false);
}
})
.catch(() => {
if (!controller.signal.aborted) {
// 中文注释:私有图读取或 canvas 解析失败时回退旧固定六宫格,避免运行态按钮空白。
setUiSpritesheetLayout(null);
setIsUiSpritesheetLayoutResolving(false);
}
});
return () => {
controller.abort();
};
}, [rawUiSpritesheetImage]);
useEffect(() => {
currentLevelRef.current = currentLevel;
}, [currentLevel]);
useEffect(() => {
tryPlayBackgroundMusic();
}, [tryPlayBackgroundMusic]);
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],
);
const singlePieceClipId = sanitizeSvgId(
`puzzle-single-piece-${runtimeSvgClipId}`,
);
const singlePieceClipUrl = `url(#${singlePieceClipId})`;
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 = useCallback((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 = useCallback(() => {
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 = '';
pieceElement.style.transition = '';
}
if (dragVisualTarget.groupId) {
const groupElement = groupElementRefMap.current.get(
dragVisualTarget.groupId,
);
if (groupElement) {
groupElement.style.transform = '';
groupElement.style.willChange = '';
groupElement.style.zIndex = '';
groupElement.style.opacity = '';
groupElement.style.transition = '';
}
}
dragVisualTargetRef.current = null;
}, [resolvePieceCellElement]);
const resetDragInteractionState = () => {
dragSessionRef.current = null;
draggingTargetRef.current = null;
resetDragVisualTarget();
};
const resetDragInteraction = () => {
runtimeDragInputControllerRef.current.cancel();
};
const flushDragVisual = () => {
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,
};
dragVisualTargetRef.current = nextTarget;
const offsetX = dragSession.currentX - dragSession.startX;
const offsetY = dragSession.currentY - dragSession.startY;
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';
groupElement.style.transition = 'none';
}
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 = '';
pieceElement.style.transition = '';
}
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';
pieceElement.style.transition = 'none';
}
};
useEffect(
() => () => {
resetDragVisualTarget();
},
[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) ||
isOriginalImageViewerVisible;
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 (currentLevelStatus !== 'playing') {
return;
}
const timerId = window.setInterval(() => {
setTimerNowMs(Date.now());
}, 250);
return () => window.clearInterval(timerId);
}, [currentLevelIndex, currentLevelStatus, runtimeRunId]);
useEffect(() => {
if (
!runtimeRunId ||
currentLevelIndex === null ||
currentLevelStartedAtMs === null ||
currentLevelStatus === 'cleared'
) {
return;
}
if (displayRemainingMs > 0) {
return;
}
const syncKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}`;
if (timeExpiredSyncKeyRef.current === syncKey) {
return;
}
timeExpiredSyncKeyRef.current = syncKey;
void onTimeExpiredRef.current?.();
}, [
currentLevelIndex,
currentLevelStartedAtMs,
currentLevelStatus,
displayRemainingMs,
runtimeRunId,
]);
useEffect(() => {
if (
!runtimeRunId ||
currentLevelStatus !== 'playing' ||
currentLevelIndex === null ||
currentLevelStartedAtMs === null
) {
countdownSoundKeyRef.current = null;
return;
}
const secondBucket =
displayRemainingMs <= levelAudioConfig.countdownWarningThresholdMs
? resolveRuntimeCountdownSecondBucket(displayRemainingMs)
: null;
if (secondBucket === null) {
countdownSoundKeyRef.current = null;
return;
}
const soundKey = `${runtimeRunId}:${currentLevelIndex}:${currentLevelStartedAtMs}:${secondBucket}`;
if (countdownSoundKeyRef.current === soundKey) {
return;
}
countdownSoundKeyRef.current = soundKey;
playRuntimeCountdownSound(musicVolume);
}, [
currentLevelIndex,
currentLevelStartedAtMs,
currentLevelStatus,
displayRemainingMs,
levelAudioConfig.countdownWarningThresholdMs,
musicVolume,
runtimeRunId,
]);
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;
if (clearSoundKeyRef.current !== clearResultKey) {
clearSoundKeyRef.current = clearResultKey;
playRuntimeLevelClearSound(musicVolume);
}
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, musicVolume]);
const handlePieceTap = (
pieceId: string,
selectedPieceIdBeforeInput: string | null,
) => {
if (isInteractionLocked) {
return;
}
tryPlayBackgroundMusic();
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 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();
}
};
runtimeDragInputControllerRef.current.setOptions({
dragThresholdPx: 8,
onPress: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
selectedPieceBeforeInputRef.current = selectedPieceIdRef.current;
commitSelectedPieceId(session.targetId);
triggerPuzzlePiecePressFeedback(musicVolume);
},
onDragStart: (session) => {
draggingTargetRef.current = resolvePuzzleRuntimeDragTarget(
session.targetId,
);
syncRuntimeDragFromController(session);
setDragRenderTarget({
pieceId: session.targetId,
groupId: draggingTargetRef.current?.groupId ?? null,
});
flushDragVisual();
},
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();
},
});
if (!run || !currentLevel || !board) {
return (
<div
className={`platform-ui-shell platform-theme ${platformThemeClass} 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;
}
tryPlayBackgroundMusic();
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 shouldDisplaySelectedState = !dragRenderTarget;
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 clearResultOverlayClassName = embedded
? `platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell puzzle-runtime-modal-overlay puzzle-runtime-modal-overlay--fixed flex items-center justify-center px-4 py-6 backdrop-blur-sm`
: 'puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-6 backdrop-blur-sm';
const nextSpriteButtonClassName =
'inline-flex h-12 appearance-none items-center justify-center border-0 bg-transparent p-0 leading-none shadow-none transition hover:brightness-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--platform-button-primary-border)] disabled:cursor-not-allowed disabled:opacity-45';
const nextSpriteButtonStyle = hasUiSpritesheet
? {
...buildPuzzleUiSpriteBackgroundStyle({
src: resolvedUiSpritesheetImage,
kind: 'next',
layout: uiSpritesheetLayout,
}),
aspectRatio: resolvePuzzleUiSpriteAspectRatio(
'next',
uiSpritesheetLayout,
'2 / 1',
),
}
: undefined;
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') {
setIsOriginalImageViewerVisible(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());
}
};
const clearResultDialog = isClearResultOpen ? (
<div className={clearResultOverlayClassName}>
<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);
}}
>
<X 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"
aria-label="下一关"
data-puzzle-ui-sprite={hasUiSpritesheet ? 'next' : undefined}
disabled={isBusy}
onClick={() => {
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
style={nextSpriteButtonStyle}
className={
hasUiSpritesheet
? nextSpriteButtonClassName
: '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'
}
>
{hasUiSpritesheet ? null : (
<>
{isBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
下一关
</>
)}
</button>
</footer>
) : null}
</section>
</div>
) : null;
const clearResultLayer =
embedded && clearResultDialog && typeof document !== 'undefined'
? createPortal(clearResultDialog, document.body)
: clearResultDialog;
return (
<div
className={`platform-ui-shell platform-theme ${platformThemeClass} puzzle-runtime-shell ${embedded ? 'relative h-full min-h-0 w-full' : 'fixed inset-0 z-[100]'} flex justify-center`}
>
<RuntimeResourcePendingMarker
source={backgroundMusicSrc}
kind="audio"
isPending={isBackgroundMusicResolving}
/>
<RuntimeResourcePendingMarker
source={currentLevel.coverImageSrc}
kind="image"
isPending={isCoverImageResolving}
/>
<RuntimeResourcePendingMarker
source={rawUiBackgroundImage}
kind="image"
isPending={isUiBackgroundResolving}
/>
<RuntimeResourcePendingMarker
source={rawUiSpritesheetImage}
kind="image"
isPending={isUiSpritesheetResolving || isUiSpritesheetLayoutResolving}
/>
{resolvedBackgroundMusicSrc ? (
<audio
ref={backgroundAudioRef}
src={resolvedBackgroundMusicSrc}
loop
preload="auto"
/>
) : null}
<div className="puzzle-runtime-stage relative h-full w-full overflow-hidden">
{resolvedUiBackgroundImage ? (
<ResolvedAssetImage
src={resolvedUiBackgroundImage}
alt=""
aria-hidden="true"
className="absolute inset-0 h-full w-full object-cover"
/>
) : null}
{currentLevel.coverImageSrc ? (
<ResolvedAssetImage
src={currentLevel.coverImageSrc}
alt=""
aria-hidden="true"
className={`absolute inset-0 h-full w-full object-cover blur-2xl ${
resolvedUiBackgroundImage ? 'opacity-[0.06]' : 'opacity-[0.16]'
}`}
/>
) : null}
<div
className={`puzzle-runtime-stage__grid ${
resolvedUiBackgroundImage ? 'opacity-20' : ''
}`}
/>
<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 sm:h-11 sm:w-11 ${
hasUiSpritesheet
? 'puzzle-runtime-icon-button--sprite'
: 'rounded-full'
} ${
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
} ${shouldHideBackButton ? 'invisible pointer-events-none' : 'inline-flex'}`}
>
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="back"
layout={uiSpritesheetLayout}
className={`${
hasUiSpritesheet
? 'puzzle-runtime-top-ui-sprite'
: 'h-7 w-7 rounded-full'
}`}
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<ArrowLeft className="h-4 w-4" />
)}
</button>
<div className="puzzle-runtime-header-card mx-auto flex max-w-[min(18.5rem,calc(100vw_-_6.5rem))] min-w-0 flex-col items-center text-center sm:max-w-[22rem]">
<div className="puzzle-runtime-level-title-card flex max-w-full items-center justify-center gap-2 px-3.5 py-1.5 pr-4 sm:px-4 sm:pr-5">
<span aria-hidden="true" className="puzzle-runtime-level-logo">
<img
src={puzzleLevelLogo}
alt=""
data-testid="puzzle-runtime-level-logo"
className="puzzle-runtime-level-logo__image"
draggable={false}
/>
</span>
<span className="puzzle-runtime-level-badge shrink-0 text-[0.92rem] font-black sm:text-base">
{levelLabel}
</span>
<span className="min-w-0 truncate text-[0.92rem] font-black sm:text-base">
{currentLevel.levelName}
</span>
</div>
<div
className={`puzzle-runtime-timer-card -mt-px inline-flex items-center gap-1.5 px-3.5 py-1.5 font-mono text-lg font-black leading-none 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 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 ${
hasUiSpritesheet
? 'puzzle-runtime-icon-button--sprite'
: 'rounded-full'
} ${
hasUiSpritesheet ? 'puzzle-runtime-icon-button--precise-hit' : ''
}`}
>
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="settings"
layout={uiSpritesheetLayout}
className={`${
hasUiSpritesheet
? 'puzzle-runtime-top-ui-sprite'
: 'h-7 w-7 rounded-full'
}`}
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<Settings className="h-4 w-4" />
)}
</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))`,
}}
>
<svg
aria-hidden="true"
className="pointer-events-none absolute h-0 w-0 overflow-hidden"
focusable="false"
>
<defs>
<clipPath
id={singlePieceClipId}
clipPathUnits="objectBoundingBox"
>
<path d={buildRoundedGridCellClipPath()} />
</clipPath>
</defs>
</svg>
{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 =
shouldDisplaySelectedState &&
!isMerged &&
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 border-0 text-sm font-black ${
occupied
? isSelected
? 'puzzle-runtime-piece--selected'
: isMerged
? 'puzzle-runtime-piece--merged'
: 'puzzle-runtime-piece--filled'
: 'puzzle-runtime-piece--empty'
} ${
isMerged
? 'transition-opacity'
: 'transition-[opacity,transform]'
}`}
style={{
clipPath: isMerged ? undefined : singlePieceClipUrl,
WebkitClipPath: isMerged ? undefined : singlePieceClipUrl,
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 mergedGroupClipId = sanitizeSvgId(
`${runtimeSvgClipId}-${group.groupId}`,
);
const mergedGroupClipPath = buildMergedGroupOutlinePath(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}%`,
}}
>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full overflow-visible"
data-merged-group-clip="true"
viewBox={`0 0 ${group.colSpan} ${group.rowSpan}`}
preserveAspectRatio="none"
>
<defs>
<clipPath
id={mergedGroupClipId}
clipPathUnits="userSpaceOnUse"
>
<path d={mergedGroupClipPath} />
</clipPath>
</defs>
<g clipPath={`url(#${mergedGroupClipId})`}>
{group.pieces.map((piece) => (
<g key={piece.pieceId} data-merged-piece-visual="true">
<clipPath
id={sanitizeSvgId(
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
)}
clipPathUnits="userSpaceOnUse"
>
<rect
x={piece.localCol}
y={piece.localRow}
width={1}
height={1}
/>
</clipPath>
<g
clipPath={`url(#${sanitizeSvgId(
`${runtimeSvgClipId}-${group.groupId}-${piece.pieceId}`,
)})`}
>
{resolvedCoverImage ? (
<image
href={resolvedCoverImage}
xlinkHref={resolvedCoverImage}
x={piece.localCol - piece.correctCol}
y={piece.localRow - piece.correctRow}
width={board.cols}
height={board.rows}
preserveAspectRatio="none"
/>
) : (
<rect
x={piece.localCol}
y={piece.localRow}
width={1}
height={1}
fill="rgba(16,185,129,0.42)"
/>
)}
</g>
</g>
))}
</g>
</svg>
<div
className="pointer-events-none relative z-10 grid h-full w-full touch-none 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"
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();
}}
/>
))}
</div>
</div>
);
})}
{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 &&
shouldDisplaySelectedState &&
runtimeStatus === 'playing' ? (
<div className="puzzle-runtime-status-chip rounded-full px-3 py-1 text-xs">
已选择
</div>
) : null}
{canShowNextAction ? (
<button
type="button"
disabled={isBusy}
data-puzzle-ui-sprite={hasUiSpritesheet ? 'next' : undefined}
aria-label={hasSimilarWorkChoices ? '换个作品' : '下一关'}
onClick={() => {
if (hasSimilarWorkChoices) {
setDismissedClearKey(null);
setIsClearResultReady(true);
return;
}
onAdvanceNextLevel({
profileId: run.nextLevelProfileId ?? undefined,
levelId: run.nextLevelId ?? null,
});
}}
style={nextSpriteButtonStyle}
className={
hasUiSpritesheet
? nextSpriteButtonClassName
: 'puzzle-runtime-primary-button inline-flex min-h-11 items-center justify-center rounded-full px-5 py-2.5 text-sm font-bold transition hover:brightness-105 disabled:opacity-45'
}
>
{hasUiSpritesheet ? null : (
<>
<ArrowRight className="h-4 w-4" />
{hasSimilarWorkChoices ? '换个作品' : '下一关'}
</>
)}
</button>
) : null}
<div className="grid w-full max-w-[23rem] grid-cols-3 items-center justify-items-center gap-3 px-1 sm:max-w-[26rem] sm:gap-4">
<button
type="button"
disabled={isInteractionLocked}
aria-label="提示"
onClick={() => openPropDialog('hint', '使用提示')}
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
resolvedUiSpritesheetImage
? 'puzzle-runtime-sprite-tool-button--precise-hit'
: ''
}`}
>
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="hint"
layout={uiSpritesheetLayout}
className="puzzle-runtime-bottom-ui-sprite"
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<span className="puzzle-runtime-tool-button__warm text-lg font-black">
?
</span>
)}
</button>
<button
type="button"
disabled={runtimeStatus !== 'playing' || !resolvedCoverImage}
aria-label="原图"
aria-pressed={isOriginalImageViewerVisible}
onClick={() => {
if (isOriginalImageViewerVisible) {
setIsOriginalImageViewerVisible(false);
return;
}
openPropDialog('reference', '查看原图');
}}
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
isOriginalImageViewerVisible
? 'puzzle-runtime-tool-button--active'
: 'puzzle-runtime-tool-button'
} ${
resolvedUiSpritesheetImage
? 'puzzle-runtime-sprite-tool-button--precise-hit'
: ''
}`}
>
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="reference"
layout={uiSpritesheetLayout}
className="puzzle-runtime-bottom-ui-sprite"
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<span className="text-lg font-black">□</span>
)}
</button>
<button
type="button"
disabled={isInteractionLocked}
aria-label="冻结"
onClick={() => openPropDialog('freezeTime', '冻结时间')}
className={`puzzle-runtime-sprite-tool-button inline-flex h-16 w-full items-center justify-center transition disabled:opacity-45 sm:h-[4.5rem] ${
resolvedUiSpritesheetImage
? 'puzzle-runtime-sprite-tool-button--precise-hit'
: ''
}`}
>
<PuzzleUiSprite
src={resolvedUiSpritesheetImage}
kind="freezeTime"
layout={uiSpritesheetLayout}
className="puzzle-runtime-bottom-ui-sprite"
withHitZone
/>
{resolvedUiSpritesheetImage ? null : (
<span className="puzzle-runtime-tool-button__cool text-lg font-black">
*
</span>
)}
</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}
{isOriginalImageViewerVisible && resolvedCoverImage ? (
<div
data-testid="puzzle-original-viewer"
className="puzzle-runtime-modal-overlay absolute inset-0 z-40 flex items-center justify-center px-4 py-4 backdrop-blur-sm"
style={{ background: 'rgba(2, 6, 23, 0.94)' }}
onClick={() => {
setIsOriginalImageViewerVisible(false);
}}
>
<button
type="button"
aria-label="关闭原图"
className="puzzle-runtime-secondary-button absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center rounded-full transition hover:brightness-105"
onClick={(event) => {
event.stopPropagation();
setIsOriginalImageViewerVisible(false);
}}
>
<X className="h-4 w-4" />
</button>
<div
className="flex h-full w-full items-center justify-center"
onClick={(event) => event.stopPropagation()}
>
<img
src={resolvedCoverImage}
alt={`${currentLevel.levelName} `}
className="max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] object-contain"
/>
</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 w-full max-w-[22rem] overflow-hidden rounded-[1.35rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
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 flex max-h-[min(88vh,36rem)] w-full max-w-md flex-col overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.24)]"
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"
>
<X 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(displayElapsedMs)}
</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}
{clearResultLayer}
</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;