Files
Genarrative/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
T
kdletters 387a1c26e3 完成全量检查整改
清理已跟踪原始日志与个人本地配置
修复前后端有效门禁、测试和构建问题
补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束
收紧图片编辑器状态、附件与媒体引用测试
排除已下线旧创作入口测试并清理 warning
2026-07-17 16:56:46 +08:00

1612 lines
54 KiB
TypeScript

import { ArrowLeft, ChevronRight, RotateCcw } from 'lucide-react';
import {
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { createPortal } from 'react-dom';
import type {
PuzzleClearBoardCell,
PuzzleClearCardAsset,
PuzzleClearRuntimeSnapshotResponse,
PuzzleClearWorkProfileResponse,
} from '../../../packages/shared/src/contracts/puzzleClear';
import { PlatformActionButton } from '../common/PlatformActionButton';
import { PlatformStatusMessage } from '../common/PlatformStatusMessage';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleClearRuntimeShellProps = {
profile?: PuzzleClearWorkProfileResponse | null;
run?: PuzzleClearRuntimeSnapshotResponse | null;
snapshot?: PuzzleClearRuntimeSnapshotResponse | null;
isBusy?: boolean;
error?: string | null;
onSwapCards: (payload: {
fromRow: number;
fromCol: number;
toRow: number;
toCol: number;
}) => Promise<unknown>;
onRetryLevel: () => void;
onNextLevel: () => void;
onTimeUp: () => void;
onBack?: () => void;
};
type PuzzleClearGridPosition = {
row: number;
col: number;
};
type PuzzleClearRect = {
left: number;
top: number;
width: number;
height: number;
};
type PuzzleClearDragPhase = 'dragging' | 'dropping' | 'returning';
type PuzzleClearDragGroupState = {
groupId: string;
minRow: number;
minCol: number;
rowSpan: number;
colSpan: number;
cells: Array<{
row: number;
col: number;
card: PuzzleClearCardAsset;
}>;
};
type PuzzleClearDragState = {
card: PuzzleClearCardAsset;
from: PuzzleClearGridPosition;
group: PuzzleClearDragGroupState | null;
sourceRect: PuzzleClearRect;
pointerOffsetX: number;
pointerOffsetY: number;
currentX: number;
currentY: number;
phase: PuzzleClearDragPhase;
targetRect: PuzzleClearRect | null;
target: PuzzleClearGridPosition | null;
};
type PuzzleClearBoardPreviewState = {
dragOrigin: PuzzleClearGridPosition | null;
dragTarget: PuzzleClearGridPosition | null;
selectedCell: PuzzleClearGridPosition | null;
swapFeedback: PuzzleClearSwapFeedbackState | null;
};
type PuzzleClearSwapFeedbackState = {
from: PuzzleClearGridPosition;
to: PuzzleClearGridPosition;
feedbackKey: number;
};
type PuzzleClearSwapFlightState = {
card: PuzzleClearCardAsset;
fromRect: PuzzleClearRect;
toRect: PuzzleClearRect;
flightKey: number;
};
type PuzzleClearClearTransitionState = {
transitionKey: number;
clearedCells: Array<{
row: number;
col: number;
card: PuzzleClearCardAsset;
}>;
dropCells: Array<{
row: number;
col: number;
card: PuzzleClearCardAsset;
distance: number;
delayMs: number;
hideBoardCell: boolean;
}>;
};
type PuzzleClearMergeHighlightState = {
highlightKey: number;
groupIds: string[];
};
type PuzzleClearLockedGroupViewModel = PuzzleClearDragGroupState;
type PuzzleClearRuntimeOverlayShellProps = {
backdropClassName: string;
panelClassName: string;
children: ReactNode;
panelRole?: 'dialog' | 'status';
panelLabelledBy?: string;
panelLiveMode?: 'polite' | 'assertive';
panelTestId?: string;
};
type PuzzleClearRuntimeSettlementStatus = Extract<
PuzzleClearRuntimeSnapshotResponse['status'],
'level_cleared' | 'finished' | 'level_failed'
>;
type PuzzleClearRuntimeSettlementDialogProps = {
status: PuzzleClearRuntimeSettlementStatus;
isBusy: boolean;
onRetryLevel: () => void;
onPrimaryAction?: () => void;
};
const PUZZLE_CLEAR_CLEAR_TRANSITION_MS = 1120;
const PUZZLE_CLEAR_REFILL_DROP_DELAY_MS = 520;
const PUZZLE_CLEAR_DROP_STAGGER_MAX_MS = 180;
const PUZZLE_CLEAR_MERGE_HIGHLIGHT_MS = 760;
function getRun(
run: PuzzleClearRuntimeSnapshotResponse | null | undefined,
snapshot: PuzzleClearRuntimeSnapshotResponse | null | undefined,
) {
return run ?? snapshot ?? null;
}
function getCellKey(row: number, col: number) {
return `${row}:${col}`;
}
function buildCellMap(cells: PuzzleClearBoardCell[]) {
const map = new Map<string, PuzzleClearBoardCell>();
for (const cell of cells) {
map.set(getCellKey(cell.row, cell.col), cell);
}
return map;
}
function formatTimer(secondsLeft: number) {
const safeSeconds = Math.max(0, Math.floor(secondsLeft));
const minutes = Math.floor(safeSeconds / 60);
const seconds = safeSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function getCardTone(card: PuzzleClearCardAsset | null) {
if (!card) {
return '#e5e7eb';
}
const tones = ['#a7f3d0', '#bfdbfe', '#fde68a', '#fecdd3', '#ddd6fe'];
let hash = 0;
for (const character of card.groupId) {
hash = (hash + character.charCodeAt(0)) % tones.length;
}
return tones[hash] ?? tones[0];
}
function readCellPositionFromElement(element: Element | null) {
const cell = element?.closest<HTMLElement>('[data-puzzle-clear-cell="true"]');
if (!cell) {
return null;
}
const row = Number(cell.dataset.puzzleClearRow);
const col = Number(cell.dataset.puzzleClearCol);
if (!Number.isInteger(row) || !Number.isInteger(col)) {
return null;
}
return { row, col };
}
function resolvePointerReleaseCell(
event: ReactPointerEvent<HTMLButtonElement>,
) {
if (typeof document.elementFromPoint !== 'function') {
return null;
}
return readCellPositionFromElement(
document.elementFromPoint(event.clientX, event.clientY),
);
}
function resolveCardBackSrc(cardBackSrc: string, fallbackSrc: string) {
const normalized = cardBackSrc.trim();
if (
!normalized ||
normalized === '/creation-type-references/puzzle-clear-card-back.webp'
) {
return fallbackSrc;
}
return normalized;
}
function readElementRect(element: Element | null | undefined) {
if (!element) {
return null;
}
const rect = element.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
} satisfies PuzzleClearRect;
}
function mergeRects(rects: PuzzleClearRect[]) {
const visibleRects = rects.filter(
(rect) => rect.width > 0 && rect.height > 0,
);
if (visibleRects.length === 0) {
return null;
}
const left = Math.min(...visibleRects.map((rect) => rect.left));
const top = Math.min(...visibleRects.map((rect) => rect.top));
const right = Math.max(...visibleRects.map((rect) => rect.left + rect.width));
const bottom = Math.max(
...visibleRects.map((rect) => rect.top + rect.height),
);
return {
left,
top,
width: right - left,
height: bottom - top,
} satisfies PuzzleClearRect;
}
function getCardImageStyle(card: PuzzleClearCardAsset | null) {
return {
'--puzzle-clear-card-tone': getCardTone(card),
} as CSSProperties;
}
function getPuzzleClearSettlementTitle(
status: PuzzleClearRuntimeSettlementStatus,
) {
if (status === 'level_failed') {
return '本关失败';
}
if (status === 'finished') {
return '已完成';
}
return '本关完成';
}
// 运行态 overlay 先在玩法目录内做薄壳收口,保留拼消消自己的视觉和交互语义。
function PuzzleClearRuntimeOverlayShell({
backdropClassName,
panelClassName,
children,
panelRole,
panelLabelledBy,
panelLiveMode,
panelTestId,
}: PuzzleClearRuntimeOverlayShellProps) {
return (
<div className={backdropClassName}>
<div
role={panelRole}
aria-modal={panelRole === 'dialog' ? true : undefined}
aria-labelledby={panelLabelledBy}
aria-live={panelLiveMode}
data-testid={panelTestId}
className={panelClassName}
>
{children}
</div>
</div>
);
}
function PuzzleClearRuntimePendingOverlay() {
return (
<PuzzleClearRuntimeOverlayShell
backdropClassName="absolute inset-0 z-40 grid place-items-center bg-white/42 px-6 backdrop-blur-sm"
panelClassName="rounded-[1.25rem] border border-white/80 bg-white/90 px-5 py-4 text-sm font-black shadow-lg"
panelRole="status"
panelLiveMode="polite"
panelTestId="puzzle-clear-runtime-pending-overlay"
>
等待开局
</PuzzleClearRuntimeOverlayShell>
);
}
function PuzzleClearRuntimeSettlementDialog({
status,
isBusy,
onRetryLevel,
onPrimaryAction,
}: PuzzleClearRuntimeSettlementDialogProps) {
const isExitAction = status === 'finished' || status === 'level_failed';
const title = getPuzzleClearSettlementTitle(status);
return (
<PuzzleClearRuntimeOverlayShell
backdropClassName="absolute inset-0 z-50 grid place-items-center bg-slate-950/28 px-6 backdrop-blur-[2px]"
panelClassName="w-full max-w-[19rem] rounded-[1.25rem] border border-white/70 bg-white/92 p-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.22)]"
panelRole="dialog"
panelLabelledBy="puzzle-clear-runtime-settlement-title"
panelTestId="puzzle-clear-runtime-settlement-dialog"
>
<div className="text-2xl font-black">
<span id="puzzle-clear-runtime-settlement-title">{title}</span>
</div>
<div className="mt-4 grid grid-cols-2 gap-2">
<PlatformActionButton
onClick={onRetryLevel}
disabled={isBusy}
tone="ghost"
className="min-h-11 justify-center bg-white px-3 py-2 text-sm"
>
重试
</PlatformActionButton>
<PlatformActionButton
onClick={onPrimaryAction}
disabled={isBusy}
className="min-h-11 justify-center gap-2 px-3 py-2 text-sm"
>
{isExitAction ? '返回' : '下一关'}
{!isExitAction ? <ChevronRight className="h-4 w-4" /> : null}
</PlatformActionButton>
</div>
</PuzzleClearRuntimeOverlayShell>
);
}
function readPointerClientPoint(event: ReactPointerEvent<HTMLButtonElement>) {
return {
x: Number.isFinite(event.clientX) ? event.clientX : 0,
y: Number.isFinite(event.clientY) ? event.clientY : 0,
};
}
function isSameGridPosition(
left: PuzzleClearGridPosition | null | undefined,
right: PuzzleClearGridPosition | null | undefined,
) {
return Boolean(
left && right && left.row === right.row && left.col === right.col,
);
}
function resolveBoardPreviewState(
dragState: PuzzleClearDragState | null,
swapFeedback: PuzzleClearSwapFeedbackState | null,
selectedCell: PuzzleClearGridPosition | null,
): PuzzleClearBoardPreviewState {
return {
dragOrigin: dragState?.from ?? null,
dragTarget: dragState?.target ?? null,
selectedCell,
swapFeedback,
};
}
function cloneDragGroupState(
group: PuzzleClearLockedGroupViewModel,
): PuzzleClearDragGroupState {
return {
groupId: group.groupId,
minRow: group.minRow,
minCol: group.minCol,
rowSpan: group.rowSpan,
colSpan: group.colSpan,
cells: group.cells.map((entry) => ({
row: entry.row,
col: entry.col,
card: { ...entry.card },
})),
};
}
function buildPuzzleClearLockedGroupSignatures(
run: PuzzleClearRuntimeSnapshotResponse,
) {
// 只比较锁定组包含的卡片,避免同一组整体移动时误触发拼合高光。
const groupCards = new Map<string, string[]>();
for (const cell of run.board.cells) {
if (!cell.card || !cell.lockedGroupId) {
continue;
}
const cardIds = groupCards.get(cell.lockedGroupId) ?? [];
cardIds.push(cell.card.cardId);
groupCards.set(cell.lockedGroupId, cardIds);
}
const signatures = new Map<string, string>();
for (const [groupId, cardIds] of groupCards.entries()) {
if (cardIds.length <= 1) {
continue;
}
signatures.set(groupId, cardIds.sort().join('|'));
}
return signatures;
}
function buildPuzzleClearMergeHighlight(
previousRun: PuzzleClearRuntimeSnapshotResponse,
nextRun: PuzzleClearRuntimeSnapshotResponse,
): PuzzleClearMergeHighlightState | null {
if (
previousRun.runId !== nextRun.runId ||
previousRun.clearsDone !== nextRun.clearsDone
) {
return null;
}
const previousGroups = buildPuzzleClearLockedGroupSignatures(previousRun);
const nextGroups = buildPuzzleClearLockedGroupSignatures(nextRun);
const groupIds = [...nextGroups.entries()]
.filter(([groupId, nextSignature]) => {
const previousSignature = previousGroups.get(groupId);
return !previousSignature || previousSignature !== nextSignature;
})
.map(([groupId]) => groupId);
if (groupIds.length === 0) {
return null;
}
return {
highlightKey: Date.now(),
groupIds,
};
}
function buildPuzzleClearClearTransition(
previousRun: PuzzleClearRuntimeSnapshotResponse,
nextRun: PuzzleClearRuntimeSnapshotResponse,
): PuzzleClearClearTransitionState | null {
if (
previousRun.runId !== nextRun.runId ||
previousRun.clearsDone >= nextRun.clearsDone
) {
return null;
}
const previousCardPositions = new Map<string, PuzzleClearGridPosition>();
for (const cell of previousRun.board.cells) {
if (cell.card) {
previousCardPositions.set(cell.card.cardId, {
row: cell.row,
col: cell.col,
});
}
}
const nextCardIds = new Set(
nextRun.board.cells.flatMap((cell) =>
cell.card ? [cell.card.cardId] : [],
),
);
const clearedCells = previousRun.board.cells
.filter(
(cell): cell is PuzzleClearBoardCell & { card: PuzzleClearCardAsset } =>
Boolean(cell.card),
)
.filter((cell) => !nextCardIds.has(cell.card.cardId))
.map((cell) => ({
row: cell.row,
col: cell.col,
card: cell.card,
}));
if (clearedCells.length === 0) {
return null;
}
const dropCells = nextRun.board.cells
.filter((cell) => Boolean(cell.card))
.map((cell) => {
const previousPosition = cell.card
? previousCardPositions.get(cell.card.cardId)
: null;
const movedDistance = previousPosition
? cell.row - previousPosition.row
: cell.row + 1;
if (movedDistance <= 0) {
return null;
}
return {
row: cell.row,
col: cell.col,
card: cell.card!,
distance: movedDistance,
delayMs:
PUZZLE_CLEAR_REFILL_DROP_DELAY_MS +
Math.min(
PUZZLE_CLEAR_DROP_STAGGER_MAX_MS,
Math.max(0, (movedDistance - 1) * 52 + cell.row * 14),
),
hideBoardCell: !previousPosition,
};
})
.filter(
(
cell,
): cell is {
row: number;
col: number;
card: PuzzleClearCardAsset;
distance: number;
delayMs: number;
hideBoardCell: boolean;
} => cell !== null,
)
.filter((cell) => {
if (!previousCardPositions.has(cell.card.cardId)) {
return true;
}
const previousPosition = previousCardPositions.get(cell.card.cardId);
if (!previousPosition) {
return false;
}
return (
previousPosition.row !== cell.row || previousPosition.col !== cell.col
);
});
return {
transitionKey: Date.now(),
clearedCells,
dropCells,
};
}
export function PuzzleClearRuntimeShell({
profile = null,
run,
snapshot,
isBusy = false,
error = null,
onSwapCards,
onRetryLevel,
onNextLevel,
onTimeUp,
onBack,
}: PuzzleClearRuntimeShellProps) {
const activeRun = getRun(run, snapshot);
const dragOriginRef = useRef<PuzzleClearGridPosition | null>(null);
const dragPointerIdRef = useRef<number | null>(null);
const dragReleasePointerIdRef = useRef<number | null>(null);
const suppressNextClickRef = useRef(false);
const dragDropTimerRef = useRef<number | null>(null);
const swapFeedbackTimerRef = useRef<number | null>(null);
const swapFlightTimerRef = useRef<number | null>(null);
const previousRunRef = useRef<PuzzleClearRuntimeSnapshotResponse | null>(
null,
);
const clearTransitionTimerRef = useRef<number | null>(null);
const mergeHighlightTimerRef = useRef<number | null>(null);
const cellElementRefMap = useRef(new Map<string, HTMLButtonElement>());
const [selectedCell, setSelectedCell] =
useState<PuzzleClearGridPosition | null>(null);
const [dragState, setDragState] = useState<PuzzleClearDragState | null>(null);
const [swapFeedback, setSwapFeedback] =
useState<PuzzleClearSwapFeedbackState | null>(null);
const [swapFlight, setSwapFlight] =
useState<PuzzleClearSwapFlightState | null>(null);
const [clearTransition, setClearTransition] =
useState<PuzzleClearClearTransitionState | null>(null);
const [mergeHighlight, setMergeHighlight] =
useState<PuzzleClearMergeHighlightState | null>(null);
const [showOpeningPhase, setShowOpeningPhase] = useState(Boolean(activeRun));
const [secondsLeft, setSecondsLeft] = useState(
activeRun?.levelDurationSeconds ?? 600,
);
const board = activeRun?.board ?? null;
const isTransitioning = clearTransition !== null;
const activeRunRunId = activeRun?.runId ?? null;
const activeRunLevelIndex = activeRun?.levelIndex ?? null;
const activeRunLevelDurationSeconds = activeRun?.levelDurationSeconds ?? null;
const activeRunLevelStartedAtMs = activeRun?.levelStartedAtMs ?? null;
const activeRunStatus = activeRun?.status ?? null;
const cells = useMemo(() => buildCellMap(board?.cells ?? []), [board?.cells]);
const clearTransitionCellKeys = useMemo(() => {
const dropping = new Set<string>();
if (!clearTransition) {
return { dropping };
}
for (const cell of clearTransition.dropCells) {
if (cell.hideBoardCell) {
dropping.add(getCellKey(cell.row, cell.col));
}
}
return { dropping };
}, [clearTransition]);
const boardPreview = useMemo(
() => resolveBoardPreviewState(dragState, swapFeedback, selectedCell),
[dragState, selectedCell, swapFeedback],
);
const lockedGroups = useMemo<PuzzleClearLockedGroupViewModel[]>(() => {
if (!board) {
return [];
}
const groups = new Map<string, PuzzleClearLockedGroupViewModel['cells']>();
for (const cell of board.cells) {
if (!cell.card || !cell.lockedGroupId) {
continue;
}
const entries = groups.get(cell.lockedGroupId) ?? [];
entries.push({
row: cell.row,
col: cell.col,
card: cell.card,
});
groups.set(cell.lockedGroupId, entries);
}
return [...groups.entries()]
.map(([groupId, entries]) => {
const rows = entries.map((entry) => entry.row);
const cols = entries.map((entry) => entry.col);
const minRow = Math.min(...rows);
const minCol = Math.min(...cols);
return {
groupId,
minRow,
minCol,
rowSpan: Math.max(...rows) - minRow + 1,
colSpan: Math.max(...cols) - minCol + 1,
cells: entries,
};
})
.filter((group) => group.cells.length > 1)
.sort((left, right) => left.groupId.localeCompare(right.groupId));
}, [board]);
const mergeHighlightGroupIds = useMemo(
() => new Set(mergeHighlight?.groupIds ?? []),
[mergeHighlight],
);
const draggedGroup = dragState?.group ?? null;
const draggedGroupId = draggedGroup?.groupId ?? null;
const draggedGroupCellKeys = useMemo(() => {
const keys = new Set<string>();
for (const entry of draggedGroup?.cells ?? []) {
keys.add(getCellKey(entry.row, entry.col));
}
return keys;
}, [draggedGroup]);
const boardBackgroundSrc =
profile?.boardBackgroundAsset?.imageSrc?.trim() ||
profile?.draft.boardBackgroundAsset?.imageSrc?.trim() ||
'';
const cardBackSrc = profile?.draft.cardBackImageSrc?.trim() || '';
const fallbackCardBackSrc = '/creation-type-references/puzzle.webp';
const effectiveCardBackSrc = resolveCardBackSrc(
cardBackSrc,
fallbackCardBackSrc,
);
const isPlaying = activeRun?.status === 'playing';
const hasWonLevel = activeRun?.status === 'level_cleared';
const hasFinished = activeRun?.status === 'finished';
const hasFailed = activeRun?.status === 'level_failed';
const settlementStatus: PuzzleClearRuntimeSettlementStatus | null =
hasFailed
? 'level_failed'
: hasFinished
? 'finished'
: hasWonLevel
? 'level_cleared'
: null;
const clearRatio = activeRun
? Math.min(1, activeRun.clearsDone / Math.max(1, activeRun.targetClears))
: 0;
const dragGhostStyle = dragState
? {
position: 'fixed' as const,
left: dragState.currentX - dragState.pointerOffsetX,
top: dragState.currentY - dragState.pointerOffsetY,
width: dragState.sourceRect.width,
height: dragState.sourceRect.height,
zIndex: 120,
transform:
dragState.phase === 'dropping'
? `translate(${dragState.targetRect ? dragState.targetRect.left - (dragState.currentX - dragState.pointerOffsetX) : 0}px, ${
dragState.targetRect
? dragState.targetRect.top -
(dragState.currentY - dragState.pointerOffsetY)
: 0
}px) scale(0.94)`
: dragState.phase === 'returning'
? `translate(${dragState.sourceRect.left - (dragState.currentX - dragState.pointerOffsetX)}px, ${
dragState.sourceRect.top -
(dragState.currentY - dragState.pointerOffsetY)
}px) scale(1)`
: 'translate3d(0,0,0) scale(1.04)',
opacity: dragState.phase === 'returning' ? 0.86 : 0.98,
boxShadow:
dragState.phase === 'dragging'
? '0 20px 50px rgba(15, 23, 42, 0.26)'
: dragState.phase === 'dropping'
? '0 16px 42px rgba(15, 23, 42, 0.2)'
: '0 12px 28px rgba(15, 23, 42, 0.16)',
transition:
dragState.phase === 'dropping'
? 'transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 180ms ease'
: dragState.phase === 'returning'
? 'transform 140ms ease, opacity 140ms ease'
: 'none',
pointerEvents: 'none' as const,
}
: null;
useEffect(() => {
if (!activeRunRunId) {
setShowOpeningPhase(false);
return undefined;
}
setShowOpeningPhase(true);
const timer = window.setTimeout(() => {
setShowOpeningPhase(false);
}, 560);
return () => {
window.clearTimeout(timer);
};
}, [activeRunLevelIndex, activeRunRunId]);
useEffect(() => {
if (
activeRunLevelDurationSeconds === null ||
activeRunLevelStartedAtMs === null
) {
setSecondsLeft(600);
dragReleasePointerIdRef.current = null;
return undefined;
}
const updateTimer = () => {
const elapsedMs = Date.now() - activeRunLevelStartedAtMs;
const nextSeconds =
activeRunLevelDurationSeconds - Math.floor(elapsedMs / 1000);
setSecondsLeft(nextSeconds);
if (nextSeconds <= 0 && activeRunStatus === 'playing') {
onTimeUp();
}
};
updateTimer();
const timer = window.setInterval(updateTimer, 500);
return () => window.clearInterval(timer);
}, [
activeRunLevelDurationSeconds,
activeRunLevelStartedAtMs,
activeRunRunId,
activeRunStatus,
onTimeUp,
]);
useEffect(() => {
setSelectedCell(null);
dragOriginRef.current = null;
dragReleasePointerIdRef.current = null;
setSwapFlight(null);
}, [activeRun?.board]);
useEffect(() => {
const previousRun = previousRunRef.current;
previousRunRef.current = activeRun ?? null;
if (clearTransitionTimerRef.current !== null) {
window.clearTimeout(clearTransitionTimerRef.current);
clearTransitionTimerRef.current = null;
}
if (mergeHighlightTimerRef.current !== null) {
window.clearTimeout(mergeHighlightTimerRef.current);
mergeHighlightTimerRef.current = null;
}
if (!activeRun || !previousRun) {
setClearTransition(null);
setMergeHighlight(null);
return;
}
const transition = buildPuzzleClearClearTransition(previousRun, activeRun);
if (transition) {
setDragState(null);
setSwapFeedback(null);
setSwapFlight(null);
setSelectedCell(null);
setMergeHighlight(null);
setClearTransition(transition);
clearTransitionTimerRef.current = window.setTimeout(() => {
clearTransitionTimerRef.current = null;
setClearTransition(null);
}, PUZZLE_CLEAR_CLEAR_TRANSITION_MS);
return;
}
const highlight = buildPuzzleClearMergeHighlight(previousRun, activeRun);
setClearTransition(null);
if (!highlight) {
setMergeHighlight(null);
return;
}
setMergeHighlight(highlight);
mergeHighlightTimerRef.current = window.setTimeout(() => {
mergeHighlightTimerRef.current = null;
setMergeHighlight(null);
}, PUZZLE_CLEAR_MERGE_HIGHLIGHT_MS);
}, [
activeRun,
activeRun?.board,
activeRun?.clearsDone,
activeRun?.levelIndex,
activeRun?.runId,
activeRun?.status,
]);
useEffect(() => {
setSelectedCell(null);
dragOriginRef.current = null;
dragPointerIdRef.current = null;
dragReleasePointerIdRef.current = null;
setDragState(null);
setSwapFeedback(null);
setSwapFlight(null);
if (clearTransitionTimerRef.current !== null) {
window.clearTimeout(clearTransitionTimerRef.current);
clearTransitionTimerRef.current = null;
}
if (mergeHighlightTimerRef.current !== null) {
window.clearTimeout(mergeHighlightTimerRef.current);
mergeHighlightTimerRef.current = null;
}
setClearTransition(null);
setMergeHighlight(null);
}, [activeRun?.levelIndex, activeRun?.status]);
useEffect(
() => () => {
if (dragDropTimerRef.current !== null) {
window.clearTimeout(dragDropTimerRef.current);
}
if (swapFeedbackTimerRef.current !== null) {
window.clearTimeout(swapFeedbackTimerRef.current);
}
if (swapFlightTimerRef.current !== null) {
window.clearTimeout(swapFlightTimerRef.current);
}
if (clearTransitionTimerRef.current !== null) {
window.clearTimeout(clearTransitionTimerRef.current);
}
if (mergeHighlightTimerRef.current !== null) {
window.clearTimeout(mergeHighlightTimerRef.current);
}
},
[],
);
const submitSwap = (
from: PuzzleClearGridPosition,
to: PuzzleClearGridPosition,
) => {
const payload = {
fromRow: from.row,
fromCol: from.col,
toRow: to.row,
toCol: to.col,
};
setSelectedCell(null);
return onSwapCards(payload);
};
const scheduleDragFeedbackReset = (dropDelayMs: number) => {
if (dragDropTimerRef.current !== null) {
window.clearTimeout(dragDropTimerRef.current);
}
dragDropTimerRef.current = window.setTimeout(() => {
dragDropTimerRef.current = null;
dragReleasePointerIdRef.current = null;
setDragState(null);
}, dropDelayMs);
};
const scheduleSwapFeedbackReset = () => {
if (swapFeedbackTimerRef.current !== null) {
window.clearTimeout(swapFeedbackTimerRef.current);
}
swapFeedbackTimerRef.current = window.setTimeout(() => {
swapFeedbackTimerRef.current = null;
setSwapFeedback(null);
}, 260);
};
const scheduleSwapFlightReset = () => {
if (swapFlightTimerRef.current !== null) {
window.clearTimeout(swapFlightTimerRef.current);
}
swapFlightTimerRef.current = window.setTimeout(() => {
swapFlightTimerRef.current = null;
setSwapFlight(null);
}, 240);
};
const handleCellClick = (row: number, col: number) => {
if (suppressNextClickRef.current) {
suppressNextClickRef.current = false;
return;
}
if (!isPlaying || isBusy || isTransitioning) {
return;
}
const target = cells.get(getCellKey(row, col));
if (!target?.card) {
if (selectedCell && target) {
void submitSwap(selectedCell, { row, col });
return;
}
setSelectedCell(null);
return;
}
if (!selectedCell) {
setSelectedCell({ row, col });
return;
}
if (selectedCell.row === row && selectedCell.col === col) {
setSelectedCell(null);
return;
}
void submitSwap(selectedCell, { row, col });
};
const handleCellPointerDown = (
event: ReactPointerEvent<HTMLButtonElement>,
row: number,
col: number,
) => {
if (!isPlaying || isBusy || isTransitioning) {
return;
}
const point = readPointerClientPoint(event);
if (dragDropTimerRef.current !== null) {
window.clearTimeout(dragDropTimerRef.current);
dragDropTimerRef.current = null;
}
dragReleasePointerIdRef.current = null;
const target = cells.get(getCellKey(row, col));
if (!target?.card) {
dragOriginRef.current = null;
return;
}
event.preventDefault();
event.currentTarget.setPointerCapture?.(event.pointerId);
const origin = { row, col };
dragOriginRef.current = origin;
dragPointerIdRef.current = event.pointerId;
const sourceGroupId = target.lockedGroupId;
const sourceGroup =
sourceGroupId !== null
? (lockedGroups.find((group) => group.groupId === sourceGroupId) ??
null)
: null;
const sourceRect = (sourceGroup
? mergeRects(
sourceGroup.cells
.map((entry) =>
readElementRect(
cellElementRefMap.current.get(getCellKey(entry.row, entry.col)),
),
)
.filter((rect): rect is PuzzleClearRect => rect !== null),
)
: readElementRect(event.currentTarget)) ?? {
left: 0,
top: 0,
width: 0,
height: 0,
};
setDragState({
card: target.card,
from: origin,
group: sourceGroup ? cloneDragGroupState(sourceGroup) : null,
sourceRect,
pointerOffsetX: Math.max(0, point.x - sourceRect.left),
pointerOffsetY: Math.max(0, point.y - sourceRect.top),
currentX: point.x,
currentY: point.y,
phase: 'dragging',
targetRect: null,
target: null,
});
};
const handleCellPointerMove = (
event: ReactPointerEvent<HTMLButtonElement>,
) => {
if (dragPointerIdRef.current !== event.pointerId) {
return;
}
event.preventDefault();
const point = readPointerClientPoint(event);
setDragState((current) => {
if (!current || current.phase !== 'dragging') {
return current;
}
return {
...current,
currentX: point.x,
currentY: point.y,
};
});
};
const handleCellPointerUp = (
event: ReactPointerEvent<HTMLButtonElement>,
row: number,
col: number,
) => {
dragReleasePointerIdRef.current = event.pointerId;
event.currentTarget.releasePointerCapture?.(event.pointerId);
if (dragPointerIdRef.current !== event.pointerId) {
return;
}
const origin = dragOriginRef.current;
dragOriginRef.current = null;
dragPointerIdRef.current = null;
const point = readPointerClientPoint(event);
if (!origin || !isPlaying || isBusy) {
return;
}
const releaseTarget = resolvePointerReleaseCell(event) ?? { row, col };
if (origin.row === releaseTarget.row && origin.col === releaseTarget.col) {
setDragState((current) =>
current
? {
...current,
phase: 'returning',
currentX: point.x,
currentY: point.y,
targetRect: null,
target: null,
}
: current,
);
scheduleDragFeedbackReset(140);
return;
}
const target = cells.get(getCellKey(releaseTarget.row, releaseTarget.col));
if (!target) {
setSelectedCell(null);
setDragState((current) =>
current
? {
...current,
phase: 'returning',
currentX: point.x,
currentY: point.y,
targetRect: null,
target: null,
}
: current,
);
scheduleDragFeedbackReset(140);
return;
}
suppressNextClickRef.current = true;
window.setTimeout(() => {
suppressNextClickRef.current = false;
}, 0);
const targetRect = readElementRect(
cellElementRefMap.current.get(
getCellKey(releaseTarget.row, releaseTarget.col),
),
);
const sourceRect =
dragState?.sourceRect ??
readElementRect(
cellElementRefMap.current.get(getCellKey(origin.row, origin.col)),
);
const sourceCard =
dragState?.card ??
cells.get(getCellKey(origin.row, origin.col))?.card ??
null;
setDragState((current) =>
current
? {
...current,
phase: 'dropping',
currentX: point.x,
currentY: point.y,
targetRect,
target: releaseTarget,
}
: current,
);
if (targetRect && sourceRect && sourceCard) {
setSwapFlight({
card: sourceCard,
fromRect: sourceRect,
toRect: targetRect,
flightKey: Date.now(),
});
}
setSwapFeedback({
from: origin,
to: releaseTarget,
feedbackKey: Date.now(),
});
scheduleSwapFeedbackReset();
scheduleDragFeedbackReset(180);
scheduleSwapFlightReset();
void submitSwap(origin, releaseTarget).finally(() => undefined);
};
return (
<div className="platform-remap-surface relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#f8fbff] text-slate-950">
<div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(248,251,255,0.92),rgba(236,253,245,0.9))]" />
<header className="relative z-20 flex items-center justify-between gap-2 px-3 pb-2 pt-[max(0.75rem,env(safe-area-inset-top))] sm:px-4">
<PlatformActionButton
onClick={onBack}
tone="ghost"
shape="pill"
className="min-h-0 bg-white/82 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<ArrowLeft className="h-4 w-4" />
返回
</PlatformActionButton>
<div className="rounded-full border border-white/70 bg-white/86 px-3 py-2 text-sm font-black shadow-sm backdrop-blur">
{formatTimer(secondsLeft)}
</div>
<PlatformActionButton
onClick={onRetryLevel}
disabled={isBusy}
tone="ghost"
shape="pill"
className="min-h-0 bg-white/82 px-3 py-2 text-sm shadow-sm backdrop-blur"
>
<RotateCcw className="h-4 w-4" />
重试
</PlatformActionButton>
</header>
<main className="relative z-10 mx-auto flex w-full max-w-[34rem] flex-1 flex-col px-3 pb-[max(0.75rem,env(safe-area-inset-bottom))] sm:px-4">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="rounded-full border border-white/70 bg-white/78 px-3 py-1.5 text-xs font-black shadow-sm">
L{activeRun?.levelIndex ?? 1}
</div>
<div className="h-2 min-w-0 flex-1 overflow-hidden rounded-full bg-white/70 shadow-inner">
<div
className="h-full rounded-full bg-emerald-400 transition-[width]"
style={{ width: `${clearRatio * 100}%` }}
/>
</div>
<div className="rounded-full border border-white/70 bg-white/78 px-3 py-1.5 text-xs font-black shadow-sm">
{activeRun?.clearsDone ?? 0}/{activeRun?.targetClears ?? 0}
</div>
</div>
<div
className="grid gap-1.5 px-1 py-1"
style={{
gridTemplateColumns: `repeat(${board?.cols ?? 3}, minmax(0, 1fr))`,
}}
>
{Array.from({ length: board?.cols ?? 3 }, (_, col) => {
const topCard = activeRun?.readyColumns[col]?.at(-1) ?? null;
return (
<div
key={col}
className="grid aspect-square place-items-center rounded-[0.55rem] border border-white/70 bg-white/62 shadow-sm"
>
<div className="h-[72%] w-[72%] rounded-[0.45rem] bg-slate-900/12">
{topCard?.imageSrc ? (
<ResolvedAssetImage
src={effectiveCardBackSrc || topCard.imageSrc}
fallbackSrc={fallbackCardBackSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="puzzle-clear-ready-card h-full w-full rounded-[0.45rem] object-cover opacity-80"
data-testid="puzzle-clear-ready-card"
/>
) : null}
</div>
</div>
);
})}
</div>
<section className="relative mt-2 min-h-0 flex-1 rounded-[1.35rem] border border-white/76 bg-white/50 p-2 shadow-[0_22px_60px_rgba(15,118,110,0.16)] backdrop-blur">
<div
data-testid="puzzle-clear-board"
className="relative mx-auto grid aspect-square w-full max-w-[min(100%,calc(100vh_-_15rem))] touch-none select-none gap-[1.5px] overflow-hidden rounded-[0.9rem]"
style={{
gridTemplateColumns: `repeat(${board?.cols ?? 3}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board?.rows ?? 3}, minmax(0, 1fr))`,
}}
>
{boardBackgroundSrc ? (
<ResolvedAssetImage
src={boardBackgroundSrc}
alt=""
aria-hidden="true"
data-testid="puzzle-clear-board-background"
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="pointer-events-none absolute inset-0 h-full w-full select-none object-cover"
/>
) : null}
{Array.from(
{ length: (board?.rows ?? 3) * (board?.cols ?? 3) },
(_, index) => {
const cols = board?.cols ?? 3;
const row = Math.floor(index / cols);
const col = index % cols;
const cell = cells.get(getCellKey(row, col));
const card = cell?.card ?? null;
const isDragOrigin = isSameGridPosition(
boardPreview.dragOrigin,
{ row, col },
);
const isDraggedGroupCell = draggedGroupCellKeys.has(
getCellKey(row, col),
);
const selected =
selectedCell?.row === row && selectedCell?.col === col;
const isSwapFeedbackCell =
boardPreview.swapFeedback &&
((boardPreview.swapFeedback.from.row === row &&
boardPreview.swapFeedback.from.col === col) ||
(boardPreview.swapFeedback.to.row === row &&
boardPreview.swapFeedback.to.col === col));
const style = {
...getCardImageStyle(card),
'--puzzle-clear-card-index': index,
} as CSSProperties;
return (
<button
key={getCellKey(row, col)}
ref={(node) => {
const key = getCellKey(row, col);
if (node) {
cellElementRefMap.current.set(key, node);
return;
}
cellElementRefMap.current.delete(key);
}}
type="button"
disabled={!isPlaying || isBusy || isTransitioning}
onPointerDown={(event) =>
handleCellPointerDown(event, row, col)
}
onPointerMove={handleCellPointerMove}
onPointerCancel={() => {
dragOriginRef.current = null;
dragPointerIdRef.current = null;
setDragState(null);
setSelectedCell(null);
}}
onPointerUp={(event) =>
handleCellPointerUp(event, row, col)
}
onLostPointerCapture={(event) => {
if (dragPointerIdRef.current !== event.pointerId) {
return;
}
if (dragReleasePointerIdRef.current === event.pointerId) {
return;
}
dragOriginRef.current = null;
dragPointerIdRef.current = null;
setSelectedCell(null);
setDragState(null);
}}
onClick={() => handleCellClick(row, col)}
data-puzzle-clear-cell="true"
data-puzzle-clear-row={row}
data-puzzle-clear-col={col}
draggable={false}
onDragStart={(event) => event.preventDefault()}
className={`puzzle-clear-card relative z-10 grid min-h-0 touch-none select-none place-items-center overflow-hidden rounded-[0.6rem] border transition ${
card
? 'bg-white/78 p-0.5 shadow-sm'
: 'bg-transparent p-0 shadow-none'
} ${
selected
? 'border-emerald-500 ring-2 ring-emerald-300'
: card
? 'border-white/80'
: 'border-white/25'
} ${
cell?.lockedGroupId && !isDraggedGroupCell
? 'puzzle-clear-card--locked-group-cell'
: ''
} ${
card && showOpeningPhase
? 'puzzle-clear-card--opening'
: ''
} ${
hasWonLevel || hasFinished
? 'puzzle-clear-card--cleared'
: ''
} ${
isDragOrigin
? 'puzzle-clear-card--ghost-origin puzzle-clear-card--drag-origin-empty'
: ''
} ${
isDraggedGroupCell && !isDragOrigin
? 'puzzle-clear-card--drag-group-empty'
: ''
} ${
isSwapFeedbackCell
? 'puzzle-clear-card--swap-feedback'
: ''
} ${
cell?.lockedGroupId && !isDraggedGroupCell
? 'puzzle-clear-card--merge-feedback'
: ''
} ${
clearTransitionCellKeys.dropping.has(getCellKey(row, col))
? 'puzzle-clear-card--transition-hidden'
: ''
}`}
style={style}
aria-label={`卡片 ${row + 1}-${col + 1}`}
>
{card && !isDraggedGroupCell ? (
<span className="absolute inset-1 rounded-[0.48rem] bg-[var(--puzzle-clear-card-tone)] opacity-45" />
) : null}
{card?.imageSrc &&
!isDragOrigin &&
!isDraggedGroupCell &&
!cell?.lockedGroupId ? (
<ResolvedAssetImage
src={card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="pointer-events-none relative z-10 h-full w-full select-none rounded-[0.48rem] object-cover"
/>
) : null}
{isDragOrigin ? (
<span className="relative z-10 h-full w-full rounded-[0.48rem] border border-dashed border-white/28 bg-white/8" />
) : null}
</button>
);
},
)}
{lockedGroups.length > 0 ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-20"
style={{
display: 'grid',
gap: '1.5px',
gridTemplateColumns: `repeat(${board?.cols ?? 3}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board?.rows ?? 3}, minmax(0, 1fr))`,
}}
>
{lockedGroups
.filter((group) => group.groupId !== draggedGroupId)
.map((group) => (
<div
key={`${group.groupId}:${
mergeHighlightGroupIds.has(group.groupId)
? (mergeHighlight?.highlightKey ?? 'active')
: 'idle'
}`}
data-testid="puzzle-clear-locked-group-visual"
className={`puzzle-clear-locked-group-visual overflow-hidden rounded-[0.58rem] border border-white/78 bg-white/56 shadow-[0_10px_24px_rgba(15,23,42,0.08)] ${
mergeHighlightGroupIds.has(group.groupId)
? 'puzzle-clear-locked-group-visual--highlight'
: ''
}`}
style={{
gridColumn: `${group.minCol + 1} / ${group.minCol + group.colSpan + 1}`,
gridRow: `${group.minRow + 1} / ${group.minRow + group.rowSpan + 1}`,
display: 'grid',
gridTemplateColumns: `repeat(${group.colSpan}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${group.rowSpan}, minmax(0, 1fr))`,
}}
>
{group.cells.map((entry) => (
<div
key={`${group.groupId}:${entry.row}:${entry.col}`}
data-testid="puzzle-clear-locked-group-piece"
className="relative min-h-0 min-w-0 overflow-hidden"
style={{
gridColumn: entry.col - group.minCol + 1,
gridRow: entry.row - group.minRow + 1,
}}
>
<ResolvedAssetImage
src={entry.card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="h-full w-full rounded-[0.32rem] object-cover"
/>
</div>
))}
</div>
))}
</div>
) : null}
{clearTransition ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-30 grid gap-[1.5px]"
style={{
gridTemplateColumns: `repeat(${board?.cols ?? 3}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board?.rows ?? 3}, minmax(0, 1fr))`,
}}
>
{clearTransition.clearedCells.map((cell) => (
<div
key={`clear:${clearTransition.transitionKey}:${cell.row}:${cell.col}`}
className="puzzle-clear-transition-piece puzzle-clear-transition-piece--clear overflow-hidden rounded-[0.6rem] border border-white/70 bg-white/72"
style={{
gridColumn: cell.col + 1,
gridRow: cell.row + 1,
}}
>
<ResolvedAssetImage
src={cell.card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="h-full w-full rounded-[0.48rem] object-cover"
/>
</div>
))}
{clearTransition.dropCells.map((cell) => (
<div
key={`drop:${clearTransition.transitionKey}:${cell.row}:${cell.col}:${cell.card.cardId}`}
className="puzzle-clear-transition-piece puzzle-clear-transition-piece--drop overflow-hidden rounded-[0.6rem] border border-white/72 bg-white/70"
style={
{
gridColumn: cell.col + 1,
gridRow: cell.row + 1,
'--puzzle-clear-drop-delay': `${cell.delayMs}ms`,
'--puzzle-clear-drop-start-y': `calc(-120% - ${
Math.max(0, cell.distance - 1) * 25
}%)`,
} as CSSProperties
}
>
<ResolvedAssetImage
src={cell.card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="h-full w-full rounded-[0.48rem] object-cover"
/>
</div>
))}
</div>
) : null}
{dragState && dragGhostStyle
? createPortal(
<button
type="button"
data-testid="puzzle-clear-drag-ghost"
className={`puzzle-clear-drag-ghost ${
dragState.phase === 'dragging'
? 'puzzle-clear-drag-ghost--dragging'
: dragState.phase === 'dropping'
? 'puzzle-clear-drag-ghost--dropping'
: 'puzzle-clear-drag-ghost--returning'
}`}
style={dragGhostStyle}
aria-hidden="true"
tabIndex={-1}
draggable={false}
onDragStart={(event) => event.preventDefault()}
>
{draggedGroup ? (
<div
data-testid="puzzle-clear-drag-group"
className="puzzle-clear-drag-group grid h-full w-full gap-[1px] rounded-[0.56rem] p-[1px]"
style={{
gridTemplateColumns: `repeat(${draggedGroup.colSpan}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${draggedGroup.rowSpan}, minmax(0, 1fr))`,
}}
>
{draggedGroup.cells.map((entry) => (
<div
key={`${draggedGroup.groupId}:${entry.row}:${entry.col}`}
className="overflow-hidden rounded-[0.28rem]"
style={{
gridColumn: entry.col - draggedGroup.minCol + 1,
gridRow: entry.row - draggedGroup.minRow + 1,
}}
>
<ResolvedAssetImage
src={entry.card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
) : (
<ResolvedAssetImage
src={dragState.card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="h-full w-full rounded-[0.48rem] object-cover"
/>
)}
</button>,
document.body,
)
: null}
{swapFlight && swapFlight.fromRect && swapFlight.toRect
? createPortal(
<button
type="button"
data-testid="puzzle-clear-swap-flight"
className="puzzle-clear-swap-flight puzzle-clear-swap-flight--incoming"
style={
{
position: 'fixed',
left: swapFlight.fromRect.left,
top: swapFlight.fromRect.top,
width: swapFlight.fromRect.width,
height: swapFlight.fromRect.height,
zIndex: 125,
pointerEvents: 'none',
'--puzzle-clear-flight-delta-x': `${
swapFlight.toRect.left - swapFlight.fromRect.left
}px`,
'--puzzle-clear-flight-delta-y': `${
swapFlight.toRect.top - swapFlight.fromRect.top
}px`,
} as CSSProperties
}
aria-hidden="true"
tabIndex={-1}
>
<ResolvedAssetImage
src={swapFlight.card.imageSrc}
alt=""
draggable={false}
onDragStart={(event) => event.preventDefault()}
className="h-full w-full rounded-[0.48rem] object-cover"
/>
</button>,
document.body,
)
: null}
</div>
</section>
{error ? (
<PlatformStatusMessage
tone="error"
surface="platform"
className="mt-3 rounded-2xl text-sm leading-6"
>
{error}
</PlatformStatusMessage>
) : null}
</main>
{!activeRun ? <PuzzleClearRuntimePendingOverlay /> : null}
{settlementStatus ? (
<PuzzleClearRuntimeSettlementDialog
status={settlementStatus}
isBusy={isBusy}
onRetryLevel={onRetryLevel}
onPrimaryAction={
settlementStatus === 'level_cleared' ? onNextLevel : onBack
}
/>
) : null}
</div>
);
}
export default PuzzleClearRuntimeShell;