This commit is contained in:
2026-06-05 22:10:30 +08:00
parent 2a271876ac
commit c98c3de96d
8 changed files with 600 additions and 71 deletions

View File

@@ -1015,6 +1015,67 @@ test('消除成功时会播放消除和掉落过渡动画', async () => {
'.puzzle-clear-transition-piece--drop',
).length,
).toBeGreaterThan(0);
expect(
screen
.getByTestId('puzzle-clear-board')
.querySelector('.puzzle-clear-transition-piece--drop')
?.getAttribute('style') ?? '',
).toContain('--puzzle-clear-drop-delay: 520ms');
});
test('正确局部拼合但未消除时会高光提醒拼合组', async () => {
const previousRun = cloneRunWithBoard(createRun(), {
rows: 3,
cols: 3,
cells: [
{ row: 0, col: 0, card: createCard(40, 0, 0), lockedGroupId: null },
{ row: 0, col: 1, card: createCard(40, 1, 0), lockedGroupId: null },
{ row: 0, col: 2, card: createCard(42, 0, 0), lockedGroupId: null },
{ row: 1, col: 0, card: createCard(43, 0, 0), lockedGroupId: null },
{ row: 1, col: 1, card: createCard(44, 0, 0), lockedGroupId: null },
{ row: 1, col: 2, card: createCard(45, 0, 0), lockedGroupId: null },
{ row: 2, col: 0, card: createCard(46, 0, 0), lockedGroupId: null },
{ row: 2, col: 1, card: createCard(47, 0, 0), lockedGroupId: null },
{ row: 2, col: 2, card: createCard(48, 0, 0), lockedGroupId: null },
],
});
const nextRun = cloneRunWithBoard(previousRun, {
rows: 3,
cols: 3,
cells: previousRun.board.cells.map((cell) =>
cell.row === 0 && (cell.col === 0 || cell.col === 1)
? { ...cell, lockedGroupId: 'group-40' }
: cell,
),
});
const { rerender } = render(
<PuzzleClearRuntimeShell
profile={createProfile()}
run={previousRun}
onSwapCards={vi.fn()}
onRetryLevel={vi.fn()}
onNextLevel={vi.fn()}
onTimeUp={vi.fn()}
/>,
);
rerender(
<PuzzleClearRuntimeShell
profile={createProfile()}
run={nextRun}
onSwapCards={vi.fn()}
onRetryLevel={vi.fn()}
onNextLevel={vi.fn()}
onTimeUp={vi.fn()}
/>,
);
await waitFor(() =>
expect(
screen.getByTestId('puzzle-clear-locked-group-visual').className,
).toContain('puzzle-clear-locked-group-visual--highlight'),
);
});
test('完成拼接的局部会以连续组面板呈现,而不是单个绿框', () => {
@@ -1162,7 +1223,7 @@ test('棋盘继承拼图模板的正方形触控面约束', () => {
expect(board.className).toContain('aspect-square');
expect(board.className).toContain('touch-none');
expect(board.className).toContain('select-none');
expect(board.className).toContain('gap-[3px]');
expect(board.className).toContain('gap-[1.5px]');
expect(board.className).not.toContain('gap-1.5');
expect(board.className).not.toContain('h-full');
expect(board.className).toContain('relative');

View File

@@ -113,8 +113,18 @@ type PuzzleClearClearTransitionState = {
}>;
};
type PuzzleClearMergeHighlightState = {
highlightKey: number;
groupIds: string[];
};
type PuzzleClearLockedGroupViewModel = PuzzleClearDragGroupState;
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,
@@ -272,6 +282,60 @@ function cloneDragGroupState(
};
}
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,
@@ -327,7 +391,12 @@ function buildPuzzleClearClearTransition(
col: cell.col,
card: cell.card!,
distance: movedDistance,
delayMs: Math.min(220, Math.max(0, (movedDistance - 1) * 60 + cell.row * 16)),
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,
};
})
@@ -383,6 +452,7 @@ export function PuzzleClearRuntimeShell({
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,
@@ -395,6 +465,8 @@ export function PuzzleClearRuntimeShell({
);
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,
@@ -460,6 +532,10 @@ export function PuzzleClearRuntimeShell({
.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(() => {
@@ -580,24 +656,41 @@ export function PuzzleClearRuntimeShell({
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) {
setClearTransition(null);
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;
}
setDragState(null);
setSwapFeedback(null);
setSwapFlight(null);
setSelectedCell(null);
setClearTransition(transition);
clearTransitionTimerRef.current = window.setTimeout(() => {
clearTransitionTimerRef.current = null;
setClearTransition(null);
}, 640);
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,
@@ -615,7 +708,16 @@ export function PuzzleClearRuntimeShell({
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(
@@ -629,6 +731,12 @@ export function PuzzleClearRuntimeShell({
if (swapFlightTimerRef.current !== null) {
window.clearTimeout(swapFlightTimerRef.current);
}
if (clearTransitionTimerRef.current !== null) {
window.clearTimeout(clearTransitionTimerRef.current);
}
if (mergeHighlightTimerRef.current !== null) {
window.clearTimeout(mergeHighlightTimerRef.current);
}
},
[],
);
@@ -964,7 +1072,7 @@ export function PuzzleClearRuntimeShell({
<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-[3px] overflow-hidden rounded-[0.9rem]"
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))`,
@@ -1114,7 +1222,7 @@ export function PuzzleClearRuntimeShell({
className="pointer-events-none absolute inset-0 z-20"
style={{
display: 'grid',
gap: '3px',
gap: '1.5px',
gridTemplateColumns: `repeat(${board?.cols ?? 3}, minmax(0, 1fr))`,
gridTemplateRows: `repeat(${board?.rows ?? 3}, minmax(0, 1fr))`,
}}
@@ -1123,9 +1231,17 @@ export function PuzzleClearRuntimeShell({
.filter((group) => group.groupId !== draggedGroupId)
.map((group) => (
<div
key={group.groupId}
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)]"
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}`,
@@ -1160,7 +1276,7 @@ export function PuzzleClearRuntimeShell({
{clearTransition ? (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 z-30 grid gap-[3px]"
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))`,