diff --git a/src/game/chase/model.ts b/src/game/chase/model.ts index 47ce72f..63efb36 100644 --- a/src/game/chase/model.ts +++ b/src/game/chase/model.ts @@ -17,6 +17,7 @@ interface ChaseGameState { snapshot: ModelSnapshot; winCount: number; turn: number; + loseReason?: "caught" | "trapped"; } interface ChaseGameModelOptions { @@ -37,6 +38,7 @@ function cloneRound(round: ChaseRound): ChaseGameState { }, winCount: 0, turn: 0, + loseReason: undefined, }; } @@ -115,6 +117,7 @@ export class ChaseGameModel { if (this.state.snapshot.thiefNodeId === this.state.snapshot.exitNodeId) { this.state.snapshot.status = "win"; this.state.winCount += 1; + this.state.loseReason = undefined; return this.getState(); } @@ -122,11 +125,13 @@ export class ChaseGameModel { if (this.state.snapshot.guardNodeId === this.state.snapshot.thiefNodeId) { this.state.snapshot.status = "lose"; + this.state.loseReason = "caught"; return this.getState(); } if (this.getAvailableMoves().length === 0) { this.state.snapshot.status = "lose"; + this.state.loseReason = "trapped"; } return this.getState(); @@ -139,6 +144,7 @@ export class ChaseGameModel { snapshot: cloneSnapshot(this.initialSnapshot), winCount, turn: 0, + loseReason: undefined, }; return this.getState(); } diff --git a/src/stages/page_chase.ts b/src/stages/page_chase.ts index 2420b87..5ecb3b9 100644 --- a/src/stages/page_chase.ts +++ b/src/stages/page_chase.ts @@ -8,7 +8,10 @@ import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; const NODE_RADIUS = 18; const NODE_GAP = 58; -type ChaseModelLike = Pick; +type ChaseModelLike = Pick< + ChaseGameModel, + "getState" | "moveThief" | "newRound" | "retryRound" +>; type DocumentLike = { body: { appendChild: (node: HTMLInputElement) => void; @@ -30,12 +33,17 @@ export default class ChaseScene extends BaseScene { private setupText?: Text; private setupLayer = new Container(); private graphLayer = new Container(); + private resultOverlay = new Container(); + private resultText = new Text({ text: "" }); private difficultyButtons: Record = { easy: new Graphics(), normal: new Graphics(), hard: new Graphics(), }; private startButton = new Graphics(); + private retryButton = new Graphics(); + private newGameButton = new Graphics(); + private restartButton = new Graphics(); private seedInputElement?: HTMLInputElement; private readonly documentRef?: DocumentLike; private selectedDifficulty: Difficulty = "normal"; @@ -43,6 +51,7 @@ export default class ChaseScene extends BaseScene { private difficultyLocked = false; private inputAttached = false; private unsubscribeStageChange?: () => void; + private endActionLocked = false; constructor(options: ChaseSceneOptions = {}) { super("chase", SceneType.Normal); @@ -85,6 +94,8 @@ export default class ChaseScene extends BaseScene { this.graphLayer.eventMode = "passive"; this.graphLayer.position.set(140, 140); this.stage.addChild(this.graphLayer); + this.buildResultOverlay(); + this.stage.addChild(this.resultOverlay); if (!this.model) { this.model = @@ -129,6 +140,7 @@ export default class ChaseScene extends BaseScene { this.hudText.text = `Turn ${state.turn} | ${state.snapshot.status} | thief=${state.snapshot.thiefNodeId} guard=${state.snapshot.guardNodeId}`; this.setupText.text = `difficulty=${this.selectedDifficulty} | seed=${this.seedInput.trim() || "(auto)"} | locked=${this.difficultyLocked ? "yes" : "no"}`; this.drawDifficultyButtons(); + this.renderResultOverlay(state.snapshot.status, state.loseReason); const oldChildren = this.graphLayer.removeChildren(); for (const child of oldChildren) { @@ -210,6 +222,33 @@ export default class ChaseScene extends BaseScene { this.refreshView(); } + private handleRetryClick(): void { + if (!this.model || this.endActionLocked) { + return; + } + this.endActionLocked = true; + this.model.retryRound(); + this.refreshView(); + } + + private handleNewGameClick(): void { + if (!this.model || this.endActionLocked) { + return; + } + this.endActionLocked = true; + this.resetSetup(); + this.startGame(); + } + + private handleRestartClick(): void { + if (!this.model || this.endActionLocked) { + return; + } + this.endActionLocked = true; + this.resetSetup(); + this.startGame(); + } + private buildSetupControls(): void { this.setupLayer.removeChildren(); const difficulties: Difficulty[] = ["easy", "normal", "hard"]; @@ -260,6 +299,66 @@ export default class ChaseScene extends BaseScene { this.startButton.fill({ color: 0x2563eb, alpha: 0.95 }); } + private buildResultOverlay(): void { + this.resultOverlay.visible = false; + this.resultOverlay.eventMode = "passive"; + this.resultOverlay.position.set(24, 170); + + this.resultText = new Text({ + text: "", + style: new TextStyle({ + fontSize: 24, + fill: 0xffffff, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + this.resultOverlay.addChild(this.resultText); + + this.retryButton.eventMode = "static"; + this.retryButton.cursor = "pointer"; + this.retryButton.removeAllListeners(); + this.retryButton.on("pointerdown", () => this.handleRetryClick()); + this.retryButton.position.set(0, 42); + this.retryButton.roundRect(0, 0, 86, 34, 8); + this.retryButton.fill({ color: 0x2563eb, alpha: 0.95 }); + this.resultOverlay.addChild(this.retryButton); + this.resultOverlay.addChild(this.createOverlayLabel("重试", 43, 59)); + + this.newGameButton.eventMode = "static"; + this.newGameButton.cursor = "pointer"; + this.newGameButton.removeAllListeners(); + this.newGameButton.on("pointerdown", () => this.handleNewGameClick()); + this.newGameButton.position.set(98, 42); + this.newGameButton.roundRect(0, 0, 96, 34, 8); + this.newGameButton.fill({ color: 0x0ea5e9, alpha: 0.95 }); + this.resultOverlay.addChild(this.newGameButton); + this.resultOverlay.addChild(this.createOverlayLabel("新一局", 146, 59)); + + this.restartButton.eventMode = "static"; + this.restartButton.cursor = "pointer"; + this.restartButton.removeAllListeners(); + this.restartButton.on("pointerdown", () => this.handleRestartClick()); + this.restartButton.position.set(0, 42); + this.restartButton.roundRect(0, 0, 108, 34, 8); + this.restartButton.fill({ color: 0x22c55e, alpha: 0.95 }); + this.resultOverlay.addChild(this.restartButton); + this.resultOverlay.addChild(this.createOverlayLabel("重新开始", 54, 59)); + } + + private createOverlayLabel(text: string, x: number, y: number): Text { + const label = new Text({ + text, + style: new TextStyle({ + fontSize: 14, + fill: 0xffffff, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + label.anchor.set(0.5); + label.position.set(x, y); + return label; + } + private drawDifficultyButtons(): void { const drawButton = (difficulty: Difficulty): void => { const button = this.difficultyButtons[difficulty]; @@ -333,4 +432,29 @@ export default class ChaseScene extends BaseScene { } return undefined; } + + private renderResultOverlay( + status: "playing" | "win" | "lose", + loseReason?: "caught" | "trapped", + ): void { + if (status === "playing") { + this.resultOverlay.visible = false; + this.endActionLocked = false; + return; + } + + this.resultOverlay.visible = true; + if (status === "win") { + this.resultText.text = "成功逃脱!"; + this.restartButton.visible = true; + this.retryButton.visible = false; + this.newGameButton.visible = false; + return; + } + + this.resultText.text = loseReason === "trapped" ? "无路可走" : "被抓住"; + this.restartButton.visible = false; + this.retryButton.visible = true; + this.newGameButton.visible = true; + } } diff --git a/tests/stages/page_chase.test.ts b/tests/stages/page_chase.test.ts index fbb0db5..0122084 100644 --- a/tests/stages/page_chase.test.ts +++ b/tests/stages/page_chase.test.ts @@ -281,4 +281,66 @@ describe("chase stage skeleton", () => { expect(unsubscribed).toBe(true); onStageChangeSpy.mockRestore(); }); + + it("shows win overlay and restart button triggers new seeded round once", () => { + const newRound = vi.fn(); + const getState = vi.fn().mockReturnValue({ + ...createSceneState(), + snapshot: { + ...createSceneState().snapshot, + status: "win", + }, + }); + const scene = new ChaseScene({ + model: { getState, moveThief: vi.fn(), newRound, retryRound: vi.fn() } as any, + }); + void (scene as any).onSceneLayout(); + + expect((scene as any).resultOverlay.visible).toBe(true); + expect((scene as any).resultText.text).toContain("成功逃脱"); + + (scene as any).restartButton.emit("pointerdown"); + (scene as any).restartButton.emit("pointerdown"); + expect(newRound).toHaveBeenCalledTimes(1); + }); + + it("shows lose overlay and retry/new buttons call correct model methods", () => { + const loseState = { + ...createSceneState(), + snapshot: { + ...createSceneState().snapshot, + status: "lose", + }, + loseReason: "trapped", + }; + + const retryRound = vi.fn(); + const retryScene = new ChaseScene({ + model: { + getState: vi.fn().mockReturnValue(loseState), + moveThief: vi.fn(), + newRound: vi.fn(), + retryRound, + } as any, + }); + void (retryScene as any).onSceneLayout(); + expect((retryScene as any).resultText.text).toContain("无路可走"); + (retryScene as any).retryButton.emit("pointerdown"); + (retryScene as any).retryButton.emit("pointerdown"); + expect(retryRound).toHaveBeenCalledTimes(1); + + const newRound = vi.fn(); + const newGameScene = new ChaseScene({ + model: { + getState: vi.fn().mockReturnValue(loseState), + moveThief: vi.fn(), + newRound, + retryRound: vi.fn(), + } as any, + }); + void (newGameScene as any).onSceneLayout(); + (newGameScene as any).newGameButton.emit("pointerdown"); + (newGameScene as any).newGameButton.emit("pointerdown"); + expect(newRound).toHaveBeenCalledTimes(1); + }); });