diff --git a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
index 3fbf02e1..66252d06 100644
--- a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
+++ b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.test.tsx
@@ -296,6 +296,25 @@ test('翻牌只在开局阶段出现,交换后的新卡不会重新翻转', as
);
});
+test('未拿到 active run 时显示本地等待层薄壳', () => {
+ render(
+ ,
+ );
+
+ const pendingOverlay = screen.getByTestId(
+ 'puzzle-clear-runtime-pending-overlay',
+ );
+ expect(pendingOverlay.getAttribute('role')).toBe('status');
+ expect(pendingOverlay.textContent).toContain('等待开局');
+});
+
test('指针拖拽到另一张卡片时提交后端交换动作', async () => {
const onSwapCards = vi.fn().mockResolvedValue(undefined);
@@ -1148,6 +1167,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => {
/>,
);
+ expect(screen.getByRole('dialog', { name: '本关完成' })).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: /下一关/u }));
expect(onNextLevel).toHaveBeenCalledTimes(1);
@@ -1162,6 +1182,7 @@ test('关卡结算弹层提供下一关和失败重试动作', () => {
/>,
);
+ expect(screen.getByRole('dialog', { name: '本关失败' })).toBeTruthy();
fireEvent.click(screen.getAllByRole('button', { name: '重试' }).at(-1)!);
expect(onRetryLevel).toHaveBeenCalledTimes(1);
});
diff --git a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
index 558a82a5..a6dab42d 100644
--- a/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
+++ b/src/components/puzzle-clear-runtime/PuzzleClearRuntimeShell.tsx
@@ -1,6 +1,7 @@
import { ArrowLeft, ChevronRight, RotateCcw } from 'lucide-react';
import {
type CSSProperties,
+ type ReactNode,
type PointerEvent as ReactPointerEvent,
useEffect,
useMemo,
@@ -122,6 +123,28 @@ type PuzzleClearMergeHighlightState = {
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;
@@ -240,6 +263,100 @@ function getCardImageStyle(card: PuzzleClearCardAsset | null) {
} 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 (
+
+ );
+}
+
+function PuzzleClearRuntimePendingOverlay() {
+ return (
+
+ 等待开局
+
+ );
+}
+
+function PuzzleClearRuntimeSettlementDialog({
+ status,
+ isBusy,
+ onRetryLevel,
+ onPrimaryAction,
+}: PuzzleClearRuntimeSettlementDialogProps) {
+ const isExitAction = status === 'finished' || status === 'level_failed';
+ const title = getPuzzleClearSettlementTitle(status);
+
+ return (
+
+
+ {title}
+
+
+
+ 重试
+
+
+ {isExitAction ? '返回' : '下一关'}
+ {!isExitAction ? : null}
+
+
+
+ );
+}
+
function readPointerClientPoint(event: ReactPointerEvent) {
return {
x: Number.isFinite(event.clientX) ? event.clientX : 0,
@@ -568,6 +685,14 @@ export function PuzzleClearRuntimeShell({
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;
@@ -1467,42 +1592,17 @@ export function PuzzleClearRuntimeShell({
) : null}
- {!activeRun ? (
-
- ) : null}
+ {!activeRun ? : null}
- {hasWonLevel || hasFinished || hasFailed ? (
-
-
-
- {hasFailed ? '本关失败' : hasFinished ? '已完成' : '本关完成'}
-
-
-
- 重试
-
-
- {hasFinished || hasFailed ? '返回' : '下一关'}
- {!hasFinished && !hasFailed ? (
-
- ) : null}
-
-
-
-
+ {settlementStatus ? (
+
) : null}
);