387a1c26e3
清理已跟踪原始日志与个人本地配置 修复前后端有效门禁、测试和构建问题 补齐生产 Jenkins、SpacetimeDB 本地命令与文档约束 收紧图片编辑器状态、附件与媒体引用测试 排除已下线旧创作入口测试并清理 warning
1612 lines
54 KiB
TypeScript
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;
|