1
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { expect, test, vi } from 'vitest';
|
||||
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
} from './PuzzleRuntimeShell';
|
||||
|
||||
vi.mock('../../hooks/useResolvedAssetReadUrl', () => ({
|
||||
useResolvedAssetReadUrl: () => ({
|
||||
resolvedUrl: '',
|
||||
useResolvedAssetReadUrl: (src: string | null) => ({
|
||||
resolvedUrl: src ?? '',
|
||||
isResolving: false,
|
||||
shouldResolve: false,
|
||||
}),
|
||||
@@ -32,6 +32,7 @@ function createAuthValue() {
|
||||
requireAuth: (action: () => void) => action(),
|
||||
openSettingsModal: () => {},
|
||||
openAccountModal: () => {},
|
||||
setCurrentUser: vi.fn(),
|
||||
logout: async () => {},
|
||||
musicVolume: 0.42,
|
||||
setMusicVolume: vi.fn(),
|
||||
@@ -87,6 +88,13 @@ const clearedRun: PuzzleRunSnapshot = {
|
||||
startedAtMs: 1000,
|
||||
clearedAtMs: 13_340,
|
||||
elapsedMs: 12_340,
|
||||
timeLimitMs: 300_000,
|
||||
remainingMs: 287_660,
|
||||
pausedAccumulatedMs: 0,
|
||||
pauseStartedAtMs: null,
|
||||
freezeAccumulatedMs: 0,
|
||||
freezeStartedAtMs: null,
|
||||
freezeUntilMs: null,
|
||||
leaderboardEntries: [
|
||||
{
|
||||
rank: 1,
|
||||
@@ -198,12 +206,43 @@ test('右上角设置按钮打开拼图设置并支持音量调节', () => {
|
||||
expect(authValue.setMusicVolume).toHaveBeenCalledWith(0.77);
|
||||
});
|
||||
|
||||
test('拼图棋盘使用 9:16 竖屏舞台承载切块', () => {
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const board = screen.getByTestId('puzzle-board');
|
||||
expect(board.className).toContain('aspect-[9/16]');
|
||||
expect(board.className).not.toContain('aspect-video');
|
||||
expect(board.className).not.toContain('aspect-square');
|
||||
expect(board.getAttribute('style')).toContain('grid-template-rows');
|
||||
expect(container.querySelector('.min-h-\\[4\\.5rem\\]')).toBeNull();
|
||||
});
|
||||
|
||||
test('合并块按实际拼块外轮廓描边', () => {
|
||||
const mergedRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
@@ -245,18 +284,196 @@ test('合并块按实际拼块外轮廓描边', () => {
|
||||
expect(outlinedPieces[0]?.className).toContain('border-r-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('border-b-0');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-tl-[0.85rem]');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-tr-none');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-bl-none');
|
||||
expect(outlinedPieces[0]?.className).toContain('rounded-br-[0.35rem]');
|
||||
expect(outlinedPieces[1]?.className).toContain('border-l-0');
|
||||
expect(outlinedPieces[1]?.className).toContain('rounded-tr-[0.85rem]');
|
||||
expect(outlinedPieces[1]?.className).toContain('rounded-bl-[0.35rem]');
|
||||
expect(outlinedPieces[2]?.className).toContain('border-t-0');
|
||||
expect(outlinedPieces[2]?.className).toContain('rounded-tr-[0.35rem]');
|
||||
expect(outlinedPieces[2]?.className).toContain('rounded-bl-[0.85rem]');
|
||||
});
|
||||
|
||||
test('道具确认弹窗暂停时间,提示只演示不直接移动拼块', async () => {
|
||||
const onPauseChange = vi.fn();
|
||||
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 180_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
pieces: clearedRun.currentLevel!.board.pieces.map((piece, index) => {
|
||||
if (index === 0) {
|
||||
return { ...piece, currentRow: 2, currentCol: 2 };
|
||||
}
|
||||
if (index === 8) {
|
||||
return { ...piece, currentRow: 0, currentCol: 0 };
|
||||
}
|
||||
return piece;
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const { container } = renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
onPauseChange={onPauseChange}
|
||||
onUseProp={onUseProp}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '提示' }));
|
||||
expect(screen.getByRole('dialog', { name: '使用提示' })).toBeTruthy();
|
||||
expect(screen.getByText('消耗 1 陶泥币')).toBeTruthy();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确定' }));
|
||||
});
|
||||
|
||||
expect(onUseProp).toHaveBeenCalledWith('hint');
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(false);
|
||||
expect(
|
||||
(container.querySelector('[data-piece-cell-id="piece-0"]') as HTMLElement)
|
||||
.style.transform,
|
||||
).toBe('translate(-66.66666666666666%, -66.66666666666666%) scale(1.03)');
|
||||
});
|
||||
|
||||
test('道具使用失败时保留确认弹窗和暂停态', async () => {
|
||||
const onPauseChange = vi.fn();
|
||||
const onUseProp = vi.fn().mockRejectedValue(new Error('陶泥币余额不足'));
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 180_000,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
onPauseChange={onPauseChange}
|
||||
onUseProp={onUseProp}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '冻结' }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确定' }));
|
||||
});
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '冻结时间' })).toBeTruthy();
|
||||
expect(screen.getByText('陶泥币余额不足')).toBeTruthy();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
test('倒计时归零时通知父层同步失败态', () => {
|
||||
vi.useFakeTimers();
|
||||
const onTimeExpired = vi.fn();
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now() - 181_000,
|
||||
timeLimitMs: 180_000,
|
||||
remainingMs: 0,
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
onTimeExpired={onTimeExpired}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('dialog', { name: '关卡失败' })).toBeTruthy();
|
||||
expect(onTimeExpired).toHaveBeenCalledTimes(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('查看原图开关打开覆盖层并在关闭后恢复计时', async () => {
|
||||
const onPauseChange = vi.fn();
|
||||
const onUseProp = vi.fn().mockResolvedValue(clearedRun);
|
||||
const playingRun: PuzzleRunSnapshot = {
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
status: 'playing',
|
||||
startedAtMs: Date.now(),
|
||||
remainingMs: 180_000,
|
||||
coverImageSrc: '/puzzle.png',
|
||||
board: {
|
||||
...clearedRun.currentLevel!.board,
|
||||
allTilesResolved: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={playingRun}
|
||||
onBack={vi.fn()}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
onPauseChange={onPauseChange}
|
||||
onUseProp={onUseProp}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '原图' }));
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: '确定' }));
|
||||
});
|
||||
|
||||
expect(onUseProp).toHaveBeenCalledWith('reference');
|
||||
expect(screen.getByTestId('puzzle-original-overlay')).toBeTruthy();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(true);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '原图' }));
|
||||
|
||||
expect(screen.queryByTestId('puzzle-original-overlay')).toBeNull();
|
||||
expect(onPauseChange).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
test('拖拽层级辅助函数只提升当前被拖动对象', () => {
|
||||
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', false)).toBe(80);
|
||||
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-1', false)).toBeUndefined();
|
||||
expect(resolveDraggedPieceCellLayer('piece-0', 'piece-0', true)).toBeUndefined();
|
||||
expect(
|
||||
resolveDraggedPieceCellLayer('piece-0', 'piece-1', false),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
resolveDraggedPieceCellLayer('piece-0', 'piece-0', true),
|
||||
).toBeUndefined();
|
||||
|
||||
expect(resolveDraggedPieceLayer('piece-0', 'piece-0', false)).toBe(81);
|
||||
expect(resolveDraggedPieceLayer('piece-0', null, false)).toBeUndefined();
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { ArrowLeft, ArrowRight, Clock, Loader2, Trophy } from 'lucide-react';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Clock,
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Loader2,
|
||||
Snowflake,
|
||||
Sparkles,
|
||||
Trophy,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import type {
|
||||
@@ -6,6 +16,8 @@ import type {
|
||||
PuzzleBoardSnapshot,
|
||||
PuzzleCellPosition,
|
||||
PuzzleMergedGroupState,
|
||||
PuzzleRuntimeLevelSnapshot,
|
||||
PuzzleRuntimePropKind,
|
||||
PuzzleRunSnapshot,
|
||||
SwapPuzzlePiecesRequest,
|
||||
} from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
@@ -23,6 +35,11 @@ type PuzzleRuntimeShellProps = {
|
||||
onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void;
|
||||
onDragPiece: (payload: DragPuzzlePieceRequest) => void;
|
||||
onAdvanceNextLevel: () => void;
|
||||
onPauseChange?: (paused: boolean) => void | Promise<void>;
|
||||
onUseProp?: (
|
||||
propKind: PuzzleRuntimePropKind,
|
||||
) => Promise<PuzzleRunSnapshot | null | void>;
|
||||
onTimeExpired?: () => void | Promise<void>;
|
||||
};
|
||||
|
||||
type PuzzleBoardPieceViewModel = {
|
||||
@@ -103,6 +120,13 @@ function resolveMergedPieceOutlineClass(
|
||||
buildLocalCellKey(groupPiece.localRow, groupPiece.localCol),
|
||||
),
|
||||
);
|
||||
const hasCell = (row: number, col: number) =>
|
||||
groupCellKeys.has(buildLocalCellKey(row, col));
|
||||
const hasTopBoundary = (row: number, col: number) => !hasCell(row - 1, col);
|
||||
const hasRightBoundary = (row: number, col: number) => !hasCell(row, col + 1);
|
||||
const hasBottomBoundary = (row: number, col: number) =>
|
||||
!hasCell(row + 1, col);
|
||||
const hasLeftBoundary = (row: number, col: number) => !hasCell(row, col - 1);
|
||||
const hasTopEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow - 1, piece.localCol),
|
||||
);
|
||||
@@ -115,15 +139,63 @@ function resolveMergedPieceOutlineClass(
|
||||
const hasLeftEdge = !groupCellKeys.has(
|
||||
buildLocalCellKey(piece.localRow, piece.localCol - 1),
|
||||
);
|
||||
const topLeftRadius =
|
||||
hasTopEdge && hasLeftEdge
|
||||
? 'rounded-tl-[0.85rem]'
|
||||
: (!hasTopEdge && !hasLeftEdge) ||
|
||||
(hasTopEdge &&
|
||||
!hasLeftEdge &&
|
||||
!hasTopBoundary(piece.localRow, piece.localCol - 1)) ||
|
||||
(hasLeftEdge &&
|
||||
!hasTopEdge &&
|
||||
!hasLeftBoundary(piece.localRow - 1, piece.localCol))
|
||||
? 'rounded-tl-[0.35rem]'
|
||||
: 'rounded-tl-none';
|
||||
const topRightRadius =
|
||||
hasTopEdge && hasRightEdge
|
||||
? 'rounded-tr-[0.85rem]'
|
||||
: (!hasTopEdge && !hasRightEdge) ||
|
||||
(hasTopEdge &&
|
||||
!hasRightEdge &&
|
||||
!hasTopBoundary(piece.localRow, piece.localCol + 1)) ||
|
||||
(hasRightEdge &&
|
||||
!hasTopEdge &&
|
||||
!hasRightBoundary(piece.localRow - 1, piece.localCol))
|
||||
? 'rounded-tr-[0.35rem]'
|
||||
: 'rounded-tr-none';
|
||||
const bottomRightRadius =
|
||||
hasBottomEdge && hasRightEdge
|
||||
? 'rounded-br-[0.85rem]'
|
||||
: (!hasBottomEdge && !hasRightEdge) ||
|
||||
(hasBottomEdge &&
|
||||
!hasRightEdge &&
|
||||
!hasBottomBoundary(piece.localRow, piece.localCol + 1)) ||
|
||||
(hasRightEdge &&
|
||||
!hasBottomEdge &&
|
||||
!hasRightBoundary(piece.localRow + 1, piece.localCol))
|
||||
? 'rounded-br-[0.35rem]'
|
||||
: 'rounded-br-none';
|
||||
const bottomLeftRadius =
|
||||
hasBottomEdge && hasLeftEdge
|
||||
? 'rounded-bl-[0.85rem]'
|
||||
: (!hasBottomEdge && !hasLeftEdge) ||
|
||||
(hasBottomEdge &&
|
||||
!hasLeftEdge &&
|
||||
!hasBottomBoundary(piece.localRow, piece.localCol - 1)) ||
|
||||
(hasLeftEdge &&
|
||||
!hasBottomEdge &&
|
||||
!hasLeftBoundary(piece.localRow + 1, piece.localCol))
|
||||
? 'rounded-bl-[0.35rem]'
|
||||
: 'rounded-bl-none';
|
||||
return [
|
||||
hasTopEdge ? 'border-t-2' : 'border-t-0',
|
||||
hasRightEdge ? 'border-r-2' : 'border-r-0',
|
||||
hasBottomEdge ? 'border-b-2' : 'border-b-0',
|
||||
hasLeftEdge ? 'border-l-2' : 'border-l-0',
|
||||
hasTopEdge && hasLeftEdge ? 'rounded-tl-[0.85rem]' : 'rounded-tl-none',
|
||||
hasTopEdge && hasRightEdge ? 'rounded-tr-[0.85rem]' : 'rounded-tr-none',
|
||||
hasBottomEdge && hasRightEdge ? 'rounded-br-[0.85rem]' : 'rounded-br-none',
|
||||
hasBottomEdge && hasLeftEdge ? 'rounded-bl-[0.85rem]' : 'rounded-bl-none',
|
||||
topLeftRadius,
|
||||
topRightRadius,
|
||||
bottomRightRadius,
|
||||
bottomLeftRadius,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
@@ -180,9 +252,82 @@ function formatElapsedMs(elapsedMs: number | null | undefined) {
|
||||
.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function formatTimerMs(value: number | null | undefined) {
|
||||
const normalizedMs = Math.max(0, Math.ceil((value ?? 0) / 1000) * 1000);
|
||||
const totalSeconds = Math.floor(normalizedMs / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function resolveActiveFreezeElapsedMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
) {
|
||||
if (!level.freezeStartedAtMs || !level.freezeUntilMs) {
|
||||
return 0;
|
||||
}
|
||||
return Math.max(
|
||||
0,
|
||||
Math.min(nowMs, level.freezeUntilMs) - level.freezeStartedAtMs,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRuntimeRemainingMs(
|
||||
level: PuzzleRuntimeLevelSnapshot,
|
||||
nowMs: number,
|
||||
uiPauseStartedAtMs: number | null,
|
||||
) {
|
||||
if (level.status !== 'playing') {
|
||||
return level.remainingMs;
|
||||
}
|
||||
|
||||
const timeLimitMs = level.timeLimitMs || level.remainingMs;
|
||||
const snapshotPauseElapsedMs = level.pauseStartedAtMs
|
||||
? Math.max(0, nowMs - level.pauseStartedAtMs)
|
||||
: 0;
|
||||
const optimisticPauseElapsedMs =
|
||||
!level.pauseStartedAtMs && uiPauseStartedAtMs
|
||||
? Math.max(0, nowMs - uiPauseStartedAtMs)
|
||||
: 0;
|
||||
const effectiveElapsedMs = Math.max(
|
||||
0,
|
||||
nowMs -
|
||||
level.startedAtMs -
|
||||
level.pausedAccumulatedMs -
|
||||
snapshotPauseElapsedMs -
|
||||
optimisticPauseElapsedMs -
|
||||
level.freezeAccumulatedMs -
|
||||
resolveActiveFreezeElapsedMs(level, nowMs),
|
||||
);
|
||||
|
||||
return Math.max(0, timeLimitMs - effectiveElapsedMs);
|
||||
}
|
||||
|
||||
const DEFAULT_PUZZLE_MUSIC_VOLUME = 0.6;
|
||||
const PUZZLE_CLEAR_FLASH_DURATION_MS = 900;
|
||||
const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500;
|
||||
const PUZZLE_MERGE_FLASH_DURATION_MS = 720;
|
||||
const PUZZLE_HINT_DEMO_DURATION_MS = 1_250;
|
||||
|
||||
type PuzzlePropDialogState = {
|
||||
propKind: PuzzleRuntimePropKind;
|
||||
title: string;
|
||||
};
|
||||
|
||||
type PuzzleMergeFlashState = {
|
||||
key: string;
|
||||
groupId: string;
|
||||
leftPercent: number;
|
||||
topPercent: number;
|
||||
};
|
||||
|
||||
type PuzzleHintDemoState = {
|
||||
key: string;
|
||||
pieceIds: string[];
|
||||
offsetXPercent: number;
|
||||
offsetYPercent: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 拼图运行时壳层。
|
||||
@@ -196,10 +341,34 @@ export function PuzzleRuntimeShell({
|
||||
onSwapPieces,
|
||||
onDragPiece,
|
||||
onAdvanceNextLevel,
|
||||
onPauseChange,
|
||||
onUseProp,
|
||||
onTimeExpired,
|
||||
}: PuzzleRuntimeShellProps) {
|
||||
const authUi = useAuthUi();
|
||||
const [selectedPieceId, setSelectedPieceId] = useState<string | null>(null);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||||
null,
|
||||
);
|
||||
const [isOriginalOverlayVisible, setIsOriginalOverlayVisible] =
|
||||
useState(false);
|
||||
const [isFreezeEffectVisible, setIsFreezeEffectVisible] = useState(false);
|
||||
const [isPropConfirming, setIsPropConfirming] = useState(false);
|
||||
const [propConfirmError, setPropConfirmError] = useState<string | null>(null);
|
||||
const [hintDemo, setHintDemo] = useState<PuzzleHintDemoState | null>(null);
|
||||
const [mergeFlash, setMergeFlash] = useState<PuzzleMergeFlashState | null>(
|
||||
null,
|
||||
);
|
||||
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
|
||||
const [uiPauseStartedAtMs, setUiPauseStartedAtMs] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
const onPauseChangeRef = useRef(onPauseChange);
|
||||
const onTimeExpiredRef = useRef(onTimeExpired);
|
||||
const previousUiPauseActiveRef = useRef(false);
|
||||
const pauseChangePromiseRef = useRef<Promise<void>>(Promise.resolve());
|
||||
const timeExpiredSyncKeyRef = useRef<string | null>(null);
|
||||
const dragSessionRef = useRef<{
|
||||
pieceId: string;
|
||||
pointerId: number;
|
||||
@@ -229,9 +398,20 @@ export function PuzzleRuntimeShell({
|
||||
const [isClearResultReady, setIsClearResultReady] = useState(false);
|
||||
const clearPresentationKeyRef = useRef<string | null>(null);
|
||||
const clearPresentationTimeoutIdsRef = useRef<number[]>([]);
|
||||
const mergeGroupSignatureRef = useRef<string | null>(null);
|
||||
const hintDemoTimeoutRef = useRef<number | null>(null);
|
||||
const mergeFlashTimeoutRef = useRef<number | null>(null);
|
||||
const boardRef = useRef<HTMLDivElement | null>(null);
|
||||
const currentLevel = run?.currentLevel ?? null;
|
||||
const board = currentLevel?.board ?? null;
|
||||
const displayRemainingMs = currentLevel
|
||||
? resolveRuntimeRemainingMs(currentLevel, timerNowMs, uiPauseStartedAtMs)
|
||||
: 0;
|
||||
const runtimeStatus = currentLevel
|
||||
? currentLevel.status === 'playing' && displayRemainingMs <= 0
|
||||
? 'failed'
|
||||
: currentLevel.status
|
||||
: 'playing';
|
||||
const clearResultKey = currentLevel
|
||||
? `${run?.runId ?? 'run'}:${currentLevel.profileId}:${currentLevel.levelIndex}`
|
||||
: null;
|
||||
@@ -262,6 +442,23 @@ export function PuzzleRuntimeShell({
|
||||
return buildMergedGroupViewModels(board.mergedGroups, pieces);
|
||||
}, [board, pieces]);
|
||||
|
||||
const largestMovableGroup = useMemo(() => {
|
||||
const groups = mergedGroups.filter((group) =>
|
||||
group.pieces.some(
|
||||
(piece) =>
|
||||
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
|
||||
),
|
||||
);
|
||||
return (
|
||||
groups.sort(
|
||||
(left, right) =>
|
||||
right.pieceIds.length - left.pieceIds.length ||
|
||||
left.minRow - right.minRow ||
|
||||
left.minCol - right.minCol,
|
||||
)[0] ?? null
|
||||
);
|
||||
}, [mergedGroups]);
|
||||
|
||||
const mergedCellKeys = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
@@ -284,6 +481,54 @@ export function PuzzleRuntimeShell({
|
||||
[pieces],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const signature =
|
||||
board?.mergedGroups
|
||||
.map(
|
||||
(group) =>
|
||||
`${group.groupId}:${group.pieceIds.slice().sort().join(',')}`,
|
||||
)
|
||||
.sort()
|
||||
.join('|') ?? '';
|
||||
const previousSignature = mergeGroupSignatureRef.current;
|
||||
mergeGroupSignatureRef.current = signature;
|
||||
if (!previousSignature || !board || currentLevel?.status !== 'playing') {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousGroupSizes = new Map(
|
||||
previousSignature
|
||||
.split('|')
|
||||
.filter(Boolean)
|
||||
.map((entry) => {
|
||||
const [groupId, pieceIds = ''] = entry.split(':');
|
||||
return [groupId, pieceIds.split(',').filter(Boolean).length] as const;
|
||||
}),
|
||||
);
|
||||
const newGroup = mergedGroups.find(
|
||||
(group) =>
|
||||
group.pieceIds.length > 1 &&
|
||||
group.pieceIds.length > (previousGroupSizes.get(group.groupId) ?? 0),
|
||||
);
|
||||
if (!newGroup) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergeFlashTimeoutRef.current !== null) {
|
||||
window.clearTimeout(mergeFlashTimeoutRef.current);
|
||||
}
|
||||
setMergeFlash({
|
||||
key: `${newGroup.groupId}:${Date.now()}`,
|
||||
groupId: newGroup.groupId,
|
||||
leftPercent:
|
||||
((newGroup.minCol + newGroup.colSpan / 2) / board.cols) * 100,
|
||||
topPercent: ((newGroup.minRow + newGroup.rowSpan / 2) / board.rows) * 100,
|
||||
});
|
||||
mergeFlashTimeoutRef.current = window.setTimeout(() => {
|
||||
setMergeFlash(null);
|
||||
}, PUZZLE_MERGE_FLASH_DURATION_MS);
|
||||
}, [board, currentLevel?.status, mergedGroups]);
|
||||
|
||||
const resolvePieceCellElement = (pieceId: string) => {
|
||||
const pieceElement = pieceElementRefMap.current.get(pieceId) ?? null;
|
||||
const pieceCellElement =
|
||||
@@ -447,6 +692,76 @@ export function PuzzleRuntimeShell({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onPauseChangeRef.current = onPauseChange;
|
||||
}, [onPauseChange]);
|
||||
|
||||
useEffect(() => {
|
||||
onTimeExpiredRef.current = onTimeExpired;
|
||||
}, [onTimeExpired]);
|
||||
|
||||
const isUiPauseActive =
|
||||
isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible;
|
||||
|
||||
useEffect(() => {
|
||||
if (previousUiPauseActiveRef.current === isUiPauseActive) {
|
||||
return;
|
||||
}
|
||||
previousUiPauseActiveRef.current = isUiPauseActive;
|
||||
setUiPauseStartedAtMs((currentValue) =>
|
||||
isUiPauseActive ? (currentValue ?? Date.now()) : null,
|
||||
);
|
||||
pauseChangePromiseRef.current = Promise.resolve(
|
||||
onPauseChangeRef.current?.(isUiPauseActive),
|
||||
).catch(() => undefined);
|
||||
}, [isUiPauseActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevel || currentLevel.status !== 'playing') {
|
||||
return;
|
||||
}
|
||||
|
||||
const timerId = window.setInterval(() => {
|
||||
setTimerNowMs(Date.now());
|
||||
}, 250);
|
||||
|
||||
return () => window.clearInterval(timerId);
|
||||
}, [currentLevel?.levelIndex, currentLevel?.runId, currentLevel?.status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!run || !currentLevel || currentLevel.status !== 'playing') {
|
||||
return;
|
||||
}
|
||||
if (displayRemainingMs > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncKey = `${run.runId}:${currentLevel.levelIndex}:${currentLevel.startedAtMs}`;
|
||||
if (timeExpiredSyncKeyRef.current === syncKey) {
|
||||
return;
|
||||
}
|
||||
timeExpiredSyncKeyRef.current = syncKey;
|
||||
void onTimeExpiredRef.current?.();
|
||||
}, [
|
||||
currentLevel?.levelIndex,
|
||||
currentLevel?.startedAtMs,
|
||||
currentLevel?.status,
|
||||
displayRemainingMs,
|
||||
run?.runId,
|
||||
]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (hintDemoTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hintDemoTimeoutRef.current);
|
||||
}
|
||||
if (mergeFlashTimeoutRef.current !== null) {
|
||||
window.clearTimeout(mergeFlashTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLevel || !clearResultKey) {
|
||||
clearPresentationKeyRef.current = null;
|
||||
@@ -498,7 +813,7 @@ export function PuzzleRuntimeShell({
|
||||
}
|
||||
|
||||
const handlePieceClick = (pieceId: string) => {
|
||||
if (isBusy) {
|
||||
if (isInteractionLocked) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -585,7 +900,7 @@ export function PuzzleRuntimeShell({
|
||||
pieceId: string,
|
||||
event: React.PointerEvent<HTMLDivElement>,
|
||||
) => {
|
||||
if (isBusy) {
|
||||
if (isInteractionLocked) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
@@ -631,7 +946,18 @@ export function PuzzleRuntimeShell({
|
||||
scheduleDragVisual();
|
||||
};
|
||||
|
||||
const statusLabel = currentLevel.status === 'cleared' ? '已通关' : '进行中';
|
||||
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
||||
const draggingGroupId = dragRenderTarget?.groupId ?? null;
|
||||
const freezeRemainingMs =
|
||||
currentLevel.freezeUntilMs && currentLevel.status === 'playing'
|
||||
? Math.max(0, currentLevel.freezeUntilMs - timerNowMs)
|
||||
: 0;
|
||||
const statusLabel =
|
||||
runtimeStatus === 'cleared'
|
||||
? '已通关'
|
||||
: runtimeStatus === 'failed'
|
||||
? '失败'
|
||||
: '进行中';
|
||||
const nextAvailable =
|
||||
currentLevel.status === 'cleared' && Boolean(run.recommendedNextProfileId);
|
||||
const levelLabel = `第 ${currentLevel.levelIndex} 关`;
|
||||
@@ -643,8 +969,85 @@ export function PuzzleRuntimeShell({
|
||||
currentLevel.status === 'cleared' &&
|
||||
dismissedClearKey !== clearResultKey &&
|
||||
isClearResultReady;
|
||||
const draggingPieceId = dragRenderTarget?.pieceId ?? null;
|
||||
const draggingGroupId = dragRenderTarget?.groupId ?? null;
|
||||
const isInteractionLocked =
|
||||
isBusy || runtimeStatus !== 'playing' || Boolean(propDialog);
|
||||
|
||||
const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => {
|
||||
if (runtimeStatus !== 'playing') {
|
||||
return;
|
||||
}
|
||||
setPropConfirmError(null);
|
||||
setPropDialog({ propKind, title });
|
||||
};
|
||||
|
||||
const playHintDemo = () => {
|
||||
const targetGroup = largestMovableGroup;
|
||||
const targetPieces = targetGroup?.pieces ?? [];
|
||||
const fallbackPiece = pieces.find(
|
||||
(piece) =>
|
||||
piece.row !== piece.correctRow || piece.col !== piece.correctCol,
|
||||
);
|
||||
const anchorPiece = targetPieces[0] ?? fallbackPiece ?? null;
|
||||
if (!anchorPiece) {
|
||||
return;
|
||||
}
|
||||
const pieceIds =
|
||||
targetPieces.length > 0
|
||||
? targetPieces.map((piece) => piece.pieceId)
|
||||
: [anchorPiece.pieceId];
|
||||
const offsetXPercent =
|
||||
((anchorPiece.correctCol - anchorPiece.col) / board.cols) * 100;
|
||||
const offsetYPercent =
|
||||
((anchorPiece.correctRow - anchorPiece.row) / board.rows) * 100;
|
||||
setHintDemo({
|
||||
key: `${anchorPiece.pieceId}:${Date.now()}`,
|
||||
pieceIds,
|
||||
offsetXPercent,
|
||||
offsetYPercent,
|
||||
});
|
||||
if (hintDemoTimeoutRef.current !== null) {
|
||||
window.clearTimeout(hintDemoTimeoutRef.current);
|
||||
}
|
||||
hintDemoTimeoutRef.current = window.setTimeout(() => {
|
||||
setHintDemo(null);
|
||||
}, PUZZLE_HINT_DEMO_DURATION_MS);
|
||||
};
|
||||
|
||||
const handleConfirmProp = async () => {
|
||||
if (!propDialog) {
|
||||
return;
|
||||
}
|
||||
const propKind = propDialog.propKind;
|
||||
setIsPropConfirming(true);
|
||||
setPropConfirmError(null);
|
||||
try {
|
||||
await pauseChangePromiseRef.current;
|
||||
const useResult = await onUseProp?.(propKind);
|
||||
if (useResult === null) {
|
||||
return;
|
||||
}
|
||||
setPropDialog(null);
|
||||
} catch (error) {
|
||||
setPropConfirmError(
|
||||
error instanceof Error ? error.message : '使用拼图道具失败',
|
||||
);
|
||||
return;
|
||||
} finally {
|
||||
setIsPropConfirming(false);
|
||||
}
|
||||
if (propKind === 'hint') {
|
||||
playHintDemo();
|
||||
}
|
||||
if (propKind === 'reference') {
|
||||
setIsOriginalOverlayVisible(true);
|
||||
}
|
||||
if (propKind === 'freezeTime') {
|
||||
setIsFreezeEffectVisible(true);
|
||||
window.setTimeout(() => {
|
||||
setIsFreezeEffectVisible(false);
|
||||
}, 900);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-center bg-slate-950 text-white">
|
||||
@@ -680,6 +1083,16 @@ export function PuzzleRuntimeShell({
|
||||
<div className="text-[11px] font-semibold tracking-[0.16em] text-amber-100/84">
|
||||
{levelLabel}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 inline-flex items-center gap-1 rounded-full px-2.5 py-1 font-mono text-xs font-black ${
|
||||
displayRemainingMs <= 20_000 && runtimeStatus === 'playing'
|
||||
? 'bg-red-500/22 text-red-100'
|
||||
: 'bg-white/10 text-white/86'
|
||||
}`}
|
||||
>
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatTimerMs(displayRemainingMs)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -697,13 +1110,14 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 pt-24 pb-28">
|
||||
<div className="absolute inset-0 flex items-center justify-center p-3 pt-28 pb-32 sm:p-4">
|
||||
<div
|
||||
ref={boardRef}
|
||||
data-testid="puzzle-board"
|
||||
className="relative grid aspect-square w-full max-w-[min(92vw,92vh)] touch-none select-none rounded-[1.7rem] border border-white/12 bg-white/8 p-2 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm"
|
||||
className="relative grid aspect-[9/16] w-full max-w-[min(96vw,calc(56.25vh_-_8.5rem))] touch-none select-none overflow-hidden rounded-[1.2rem] border border-white/16 bg-white/8 shadow-[0_30px_80px_rgba(0,0,0,0.35)] backdrop-blur-sm sm:rounded-[1.45rem]"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${board.cols}, minmax(0, 1fr))`,
|
||||
gridTemplateRows: `repeat(${board.rows}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{buildBoardCells(board).map((cell) => {
|
||||
@@ -726,13 +1140,22 @@ export function PuzzleRuntimeShell({
|
||||
pieceCellElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-cell-id={piece?.pieceId ?? undefined}
|
||||
className="relative p-1"
|
||||
className="relative"
|
||||
style={{
|
||||
zIndex: resolveDraggedPieceCellLayer(
|
||||
piece?.pieceId,
|
||||
draggingPieceId,
|
||||
isMerged,
|
||||
),
|
||||
transform:
|
||||
piece && hintDemo?.pieceIds.includes(piece.pieceId)
|
||||
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.03)`
|
||||
: undefined,
|
||||
transition: hintDemo?.pieceIds.includes(
|
||||
piece?.pieceId ?? '',
|
||||
)
|
||||
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -747,13 +1170,13 @@ export function PuzzleRuntimeShell({
|
||||
pieceElementRefMap.current.delete(piece.pieceId);
|
||||
}}
|
||||
data-piece-id={piece?.pieceId ?? undefined}
|
||||
className={`relative flex h-full min-h-[4.5rem] items-center justify-center rounded-[1rem] border text-sm font-black transition ${
|
||||
className={`relative flex h-full items-center justify-center border-2 border-white/22 text-sm font-black transition ${
|
||||
occupied
|
||||
? isSelected
|
||||
? 'border-amber-200 bg-amber-400/84 text-slate-950 shadow-[0_12px_30px_rgba(251,191,36,0.22)]'
|
||||
: isMerged
|
||||
? 'border-transparent bg-transparent text-white'
|
||||
: 'border-white/18 bg-white/12 text-white'
|
||||
: 'bg-white/12 text-white'
|
||||
: 'border-white/8 bg-black/18 text-white/20'
|
||||
} ${
|
||||
isMerged
|
||||
@@ -792,7 +1215,7 @@ export function PuzzleRuntimeShell({
|
||||
}}
|
||||
>
|
||||
{piece ? (
|
||||
<div className="relative h-full w-full overflow-hidden rounded-[0.92rem]">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{isMerged ? null : resolvedCoverImage ? (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
@@ -833,12 +1256,22 @@ export function PuzzleRuntimeShell({
|
||||
groupElementRefMap.current.delete(group.groupId);
|
||||
}}
|
||||
data-merged-group-id={group.groupId}
|
||||
className="pointer-events-none absolute z-10 p-1"
|
||||
className="pointer-events-none absolute z-10"
|
||||
style={{
|
||||
zIndex: resolveDraggedMergedGroupLayer(
|
||||
group.groupId,
|
||||
draggingGroupId,
|
||||
),
|
||||
transform: hintDemo?.pieceIds.some((pieceId) =>
|
||||
group.pieceIds.includes(pieceId),
|
||||
)
|
||||
? `translate(${hintDemo.offsetXPercent}%, ${hintDemo.offsetYPercent}%) scale(1.02)`
|
||||
: undefined,
|
||||
transition: hintDemo?.pieceIds.some((pieceId) =>
|
||||
group.pieceIds.includes(pieceId),
|
||||
)
|
||||
? `transform ${PUZZLE_HINT_DEMO_DURATION_MS}ms cubic-bezier(0.2, 0.8, 0.2, 1)`
|
||||
: undefined,
|
||||
left: `${(group.minCol / board.cols) * 100}%`,
|
||||
top: `${(group.minRow / board.rows) * 100}%`,
|
||||
width: `${(group.colSpan / board.cols) * 100}%`,
|
||||
@@ -855,7 +1288,7 @@ export function PuzzleRuntimeShell({
|
||||
{group.pieces.map((piece) => (
|
||||
<div
|
||||
key={piece.pieceId}
|
||||
className={`pointer-events-auto relative touch-none overflow-hidden border-emerald-100/72 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
|
||||
className={`pointer-events-auto relative touch-none overflow-hidden border-white/22 bg-emerald-300/10 shadow-[0_12px_30px_rgba(6,78,59,0.16)] ${resolveMergedPieceOutlineClass(
|
||||
group,
|
||||
piece,
|
||||
)}`}
|
||||
@@ -906,17 +1339,86 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isOriginalOverlayVisible && resolvedCoverImage ? (
|
||||
<div
|
||||
data-testid="puzzle-original-overlay"
|
||||
className="pointer-events-none absolute inset-0 z-40 bg-black/10"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-70"
|
||||
style={{
|
||||
backgroundImage: `url("${resolvedCoverImage}")`,
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{mergeFlash ? (
|
||||
<div
|
||||
key={mergeFlash.key}
|
||||
data-testid="puzzle-merge-flash"
|
||||
className="pointer-events-none absolute z-50"
|
||||
style={{
|
||||
left: `${mergeFlash.leftPercent}%`,
|
||||
top: `${mergeFlash.topPercent}%`,
|
||||
}}
|
||||
>
|
||||
<div className="puzzle-merge-center-flash" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-end gap-3 px-4 py-4">
|
||||
<div className="absolute bottom-0 left-0 z-20 flex w-full items-end justify-between gap-3 px-3 py-3 sm:px-4 sm:py-4">
|
||||
<div className="flex items-center gap-2 rounded-full bg-black/32 p-1.5 backdrop-blur">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
onClick={() => openPropDialog('hint', '使用提示')}
|
||||
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
|
||||
>
|
||||
<Lightbulb className="h-4 w-4 text-amber-100" />
|
||||
提示
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={runtimeStatus !== 'playing'}
|
||||
aria-pressed={isOriginalOverlayVisible}
|
||||
onClick={() => {
|
||||
if (isOriginalOverlayVisible) {
|
||||
setIsOriginalOverlayVisible(false);
|
||||
return;
|
||||
}
|
||||
openPropDialog('reference', '查看原图');
|
||||
}}
|
||||
className={`inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold transition hover:bg-white/10 disabled:opacity-45 ${
|
||||
isOriginalOverlayVisible
|
||||
? 'bg-sky-200 text-slate-950'
|
||||
: 'text-white/86'
|
||||
}`}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
原图
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isInteractionLocked}
|
||||
onClick={() => openPropDialog('freezeTime', '冻结时间')}
|
||||
className="inline-flex h-12 min-w-16 flex-col items-center justify-center gap-0.5 rounded-full px-3 text-[11px] font-bold text-white/86 transition hover:bg-white/10 disabled:opacity-45"
|
||||
>
|
||||
<Snowflake className="h-4 w-4 text-cyan-100" />
|
||||
冻结
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{error ? (
|
||||
<div className="rounded-full bg-red-500/20 px-3 py-1 text-xs text-red-100">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedPieceId && currentLevel.status !== 'cleared' ? (
|
||||
{selectedPieceId && runtimeStatus === 'playing' ? (
|
||||
<div className="rounded-full bg-black/28 px-3 py-1 text-xs text-white/72 backdrop-blur">
|
||||
已选择
|
||||
</div>
|
||||
@@ -935,9 +1437,11 @@ export function PuzzleRuntimeShell({
|
||||
<div className="rounded-full bg-black/28 px-4 py-2 text-xs text-white/72 backdrop-blur">
|
||||
{isBusy
|
||||
? '同步中...'
|
||||
: currentLevel.status === 'cleared'
|
||||
: runtimeStatus === 'cleared'
|
||||
? '等待下一关候选'
|
||||
: '完成整张图即可通关'}
|
||||
: runtimeStatus === 'failed'
|
||||
? '本关失败'
|
||||
: '完成整张图即可通关'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -954,6 +1458,81 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{freezeRemainingMs > 0 || isFreezeEffectVisible ? (
|
||||
<div
|
||||
data-testid="puzzle-freeze-effect"
|
||||
className="pointer-events-none absolute inset-0 z-30"
|
||||
>
|
||||
<div className="puzzle-freeze-effect-layer absolute inset-0 backdrop-saturate-150" />
|
||||
<div className="absolute left-1/2 top-28 -translate-x-1/2 rounded-full border border-cyan-100/30 bg-cyan-950/50 px-3 py-1.5 font-mono text-xs font-black text-cyan-50 backdrop-blur">
|
||||
{formatTimerMs(freezeRemainingMs)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{propDialog ? (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-4 backdrop-blur-sm"
|
||||
onClick={() => {
|
||||
if (!isPropConfirming) {
|
||||
setPropDialog(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-prop-confirm-title"
|
||||
className="pixel-nine-slice pixel-modal-shell w-full max-w-[22rem] overflow-hidden shadow-[0_24px_80px_rgba(0,0,0,0.55)]"
|
||||
style={getNineSliceStyle(UI_CHROME.modalPanel)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="flex items-center gap-3 border-b border-white/10 px-5 py-4">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-200 text-slate-950">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<h2
|
||||
id="puzzle-prop-confirm-title"
|
||||
className="text-sm font-black text-white"
|
||||
>
|
||||
{propDialog.title}
|
||||
</h2>
|
||||
</header>
|
||||
<div className="px-5 py-4 text-sm text-white/72">
|
||||
消耗 1 陶泥币
|
||||
{propConfirmError ? (
|
||||
<div className="mt-3 rounded-[0.9rem] border border-red-300/20 bg-red-500/12 px-3 py-2 text-xs leading-5 text-red-100">
|
||||
{propConfirmError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<footer className="flex items-center justify-end gap-3 border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPropDialog(null)}
|
||||
disabled={isPropConfirming}
|
||||
className="rounded-full border border-white/12 bg-black/20 px-4 py-2 text-xs font-bold text-zinc-200 transition hover:text-white"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isPropConfirming}
|
||||
onClick={() => {
|
||||
void handleConfirmProp();
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-full bg-amber-200 px-5 py-2 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:opacity-60"
|
||||
>
|
||||
{isPropConfirming ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : null}
|
||||
确定
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isSettingsPanelOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-black/72 p-3 backdrop-blur-sm sm:p-4"
|
||||
@@ -1079,6 +1658,38 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtimeStatus === 'failed' ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-failed-title"
|
||||
className="flex w-full max-w-[24rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
||||
>
|
||||
<header className="border-b border-white/10 px-5 py-4">
|
||||
<h2
|
||||
id="puzzle-failed-title"
|
||||
className="text-lg font-black text-white"
|
||||
>
|
||||
关卡失败
|
||||
</h2>
|
||||
<div className="mt-1 text-xs text-white/62">
|
||||
{currentLevel.levelName}
|
||||
</div>
|
||||
</header>
|
||||
<footer className="flex items-center justify-end border-t border-white/10 px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="rounded-full bg-amber-200 px-5 py-2.5 text-sm font-black text-slate-950 transition hover:bg-amber-100"
|
||||
>
|
||||
返回
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isClearResultOpen ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
||||
<section
|
||||
@@ -1163,7 +1774,9 @@ export function PuzzleRuntimeShell({
|
||||
))
|
||||
) : (
|
||||
<div className="flex min-h-24 items-center justify-center px-4 py-5 text-sm text-white/56">
|
||||
{isBusy ? '正在同步真实排行榜…' : '暂无真实排行榜成绩'}
|
||||
{isBusy
|
||||
? '正在同步真实排行榜…'
|
||||
: '暂无真实排行榜成绩'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user