diff --git a/src/stages/page_chase.ts b/src/stages/page_chase.ts index e87b977..c91db6f 100644 --- a/src/stages/page_chase.ts +++ b/src/stages/page_chase.ts @@ -7,6 +7,7 @@ import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; const NODE_RADIUS = 18; const NODE_GAP = 58; +const END_ACTION_COOLDOWN_MS = 250; type ChaseModelLike = Pick< ChaseGameModel, @@ -52,6 +53,7 @@ export default class ChaseScene extends BaseScene { private inputAttached = false; private unsubscribeStageChange?: () => void; private endActionLocked = false; + private lastEndActionAt = 0; constructor(options: ChaseSceneOptions = {}) { super("chase", SceneType.Normal); @@ -223,30 +225,42 @@ export default class ChaseScene extends BaseScene { } private handleRetryClick(): void { - if (!this.model || this.endActionLocked) { + if (!this.model || this.endActionLocked || !this.canRunEndAction()) { return; } this.endActionLocked = true; - this.model.retryRound(); - this.refreshView(); + try { + this.model.retryRound(); + this.refreshView(); + } finally { + this.endActionLocked = false; + } } private handleNewGameClick(): void { - if (!this.model || this.endActionLocked) { + if (!this.model || this.endActionLocked || !this.canRunEndAction()) { return; } this.endActionLocked = true; - this.resetSetup(); - this.startGame(); + try { + this.resetSetup(); + this.startGame(); + } finally { + this.endActionLocked = false; + } } private handleRestartClick(): void { - if (!this.model || this.endActionLocked) { + if (!this.model || this.endActionLocked || !this.canRunEndAction()) { return; } this.endActionLocked = true; - this.resetSetup(); - this.startGame(); + try { + this.resetSetup(); + this.startGame(); + } finally { + this.endActionLocked = false; + } } private buildSetupControls(): void { @@ -457,4 +471,13 @@ export default class ChaseScene extends BaseScene { this.retryButton.visible = true; this.newGameButton.visible = true; } + + private canRunEndAction(): boolean { + const now = Date.now(); + if (now - this.lastEndActionAt < END_ACTION_COOLDOWN_MS) { + return false; + } + this.lastEndActionAt = now; + return true; + } } diff --git a/tests/chase/chaseModel.test.ts b/tests/chase/chaseModel.test.ts index a1aa38c..0d94e48 100644 --- a/tests/chase/chaseModel.test.ts +++ b/tests/chase/chaseModel.test.ts @@ -190,6 +190,26 @@ describe("chase game model", () => { expect(retried.guardNodeId).toBe(initial.guardNodeId); }); + it("clears loseReason after retryRound", () => { + mockedGenerateChaseRound.mockReturnValueOnce({ + ...makeRound(99, "normal"), + snapshot: { + ...makeRound(99, "normal").snapshot, + thiefStartNodeId: "A", + thiefNodeId: "A", + guardStartNodeId: "C", + guardNodeId: "C", + exitNodeId: "D", + }, + }); + const model = ChaseGameModel.createWithSeed({ seed: 99, difficulty: "normal" }); + model.moveThief("B"); + expect(model.getState().loseReason).toBe("caught"); + + model.retryRound(); + expect(model.getState().loseReason).toBeUndefined(); + }); + it("newRound switches seed/difficulty and keeps winCount", () => { mockedGenerateChaseRound .mockReturnValueOnce(makeRound(55, "easy")) @@ -244,6 +264,29 @@ describe("chase game model", () => { expect(after.thiefStartNodeId).toBe("A"); }); + it("clears loseReason after newRound", () => { + mockedGenerateChaseRound + .mockReturnValueOnce({ + ...makeRound(120, "hard"), + snapshot: { + ...makeRound(120, "hard").snapshot, + thiefStartNodeId: "A", + thiefNodeId: "A", + guardStartNodeId: "C", + guardNodeId: "C", + exitNodeId: "D", + }, + }) + .mockReturnValueOnce(makeRound(121, "normal")); + + const model = ChaseGameModel.createWithSeed({ seed: 120, difficulty: "hard" }); + model.moveThief("B"); + expect(model.getState().loseReason).toBe("caught"); + + model.newRound(121, "normal"); + expect(model.getState().loseReason).toBeUndefined(); + }); + it("retryRound resets rng so same move sequence is reproducible", () => { const easyRound = { snapshot: { diff --git a/tests/stages/page_chase.test.ts b/tests/stages/page_chase.test.ts index 9e03996..f1b01e8 100644 --- a/tests/stages/page_chase.test.ts +++ b/tests/stages/page_chase.test.ts @@ -347,4 +347,24 @@ describe("chase stage skeleton", () => { expect(newRound).toHaveBeenCalledTimes(1); expect(newRound.mock.calls[0][1]).toBe("easy"); }); + + it("shows caught lose message text", () => { + const scene = new ChaseScene({ + model: { + getState: vi.fn().mockReturnValue({ + ...createSceneState(), + snapshot: { + ...createSceneState().snapshot, + status: "lose", + }, + loseReason: "caught", + }), + moveThief: vi.fn(), + newRound: vi.fn(), + retryRound: vi.fn(), + } as any, + }); + void (scene as any).onSceneLayout(); + expect((scene as any).resultText.text).toContain("你被抓住了"); + }); });