123
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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))`,
|
||||
|
||||
Reference in New Issue
Block a user