Browse Source

feat(chase): add endgame overlays and retry/new-round semantics

Made-with: Cursor
master
npmrun 2 weeks ago
parent
commit
262ddbdc24
  1. 6
      src/game/chase/model.ts
  2. 126
      src/stages/page_chase.ts
  3. 62
      tests/stages/page_chase.test.ts

6
src/game/chase/model.ts

@ -17,6 +17,7 @@ interface ChaseGameState {
snapshot: ModelSnapshot; snapshot: ModelSnapshot;
winCount: number; winCount: number;
turn: number; turn: number;
loseReason?: "caught" | "trapped";
} }
interface ChaseGameModelOptions { interface ChaseGameModelOptions {
@ -37,6 +38,7 @@ function cloneRound(round: ChaseRound): ChaseGameState {
}, },
winCount: 0, winCount: 0,
turn: 0, turn: 0,
loseReason: undefined,
}; };
} }
@ -115,6 +117,7 @@ export class ChaseGameModel {
if (this.state.snapshot.thiefNodeId === this.state.snapshot.exitNodeId) { if (this.state.snapshot.thiefNodeId === this.state.snapshot.exitNodeId) {
this.state.snapshot.status = "win"; this.state.snapshot.status = "win";
this.state.winCount += 1; this.state.winCount += 1;
this.state.loseReason = undefined;
return this.getState(); return this.getState();
} }
@ -122,11 +125,13 @@ export class ChaseGameModel {
if (this.state.snapshot.guardNodeId === this.state.snapshot.thiefNodeId) { if (this.state.snapshot.guardNodeId === this.state.snapshot.thiefNodeId) {
this.state.snapshot.status = "lose"; this.state.snapshot.status = "lose";
this.state.loseReason = "caught";
return this.getState(); return this.getState();
} }
if (this.getAvailableMoves().length === 0) { if (this.getAvailableMoves().length === 0) {
this.state.snapshot.status = "lose"; this.state.snapshot.status = "lose";
this.state.loseReason = "trapped";
} }
return this.getState(); return this.getState();
@ -139,6 +144,7 @@ export class ChaseGameModel {
snapshot: cloneSnapshot(this.initialSnapshot), snapshot: cloneSnapshot(this.initialSnapshot),
winCount, winCount,
turn: 0, turn: 0,
loseReason: undefined,
}; };
return this.getState(); return this.getState();
} }

126
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_RADIUS = 18;
const NODE_GAP = 58; const NODE_GAP = 58;
type ChaseModelLike = Pick<ChaseGameModel, "getState" | "moveThief" | "newRound">; type ChaseModelLike = Pick<
ChaseGameModel,
"getState" | "moveThief" | "newRound" | "retryRound"
>;
type DocumentLike = { type DocumentLike = {
body: { body: {
appendChild: (node: HTMLInputElement) => void; appendChild: (node: HTMLInputElement) => void;
@ -30,12 +33,17 @@ export default class ChaseScene extends BaseScene {
private setupText?: Text; private setupText?: Text;
private setupLayer = new Container(); private setupLayer = new Container();
private graphLayer = new Container(); private graphLayer = new Container();
private resultOverlay = new Container();
private resultText = new Text({ text: "" });
private difficultyButtons: Record<Difficulty, Graphics> = { private difficultyButtons: Record<Difficulty, Graphics> = {
easy: new Graphics(), easy: new Graphics(),
normal: new Graphics(), normal: new Graphics(),
hard: new Graphics(), hard: new Graphics(),
}; };
private startButton = new Graphics(); private startButton = new Graphics();
private retryButton = new Graphics();
private newGameButton = new Graphics();
private restartButton = new Graphics();
private seedInputElement?: HTMLInputElement; private seedInputElement?: HTMLInputElement;
private readonly documentRef?: DocumentLike; private readonly documentRef?: DocumentLike;
private selectedDifficulty: Difficulty = "normal"; private selectedDifficulty: Difficulty = "normal";
@ -43,6 +51,7 @@ export default class ChaseScene extends BaseScene {
private difficultyLocked = false; private difficultyLocked = false;
private inputAttached = false; private inputAttached = false;
private unsubscribeStageChange?: () => void; private unsubscribeStageChange?: () => void;
private endActionLocked = false;
constructor(options: ChaseSceneOptions = {}) { constructor(options: ChaseSceneOptions = {}) {
super("chase", SceneType.Normal); super("chase", SceneType.Normal);
@ -85,6 +94,8 @@ export default class ChaseScene extends BaseScene {
this.graphLayer.eventMode = "passive"; this.graphLayer.eventMode = "passive";
this.graphLayer.position.set(140, 140); this.graphLayer.position.set(140, 140);
this.stage.addChild(this.graphLayer); this.stage.addChild(this.graphLayer);
this.buildResultOverlay();
this.stage.addChild(this.resultOverlay);
if (!this.model) { if (!this.model) {
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.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.setupText.text = `difficulty=${this.selectedDifficulty} | seed=${this.seedInput.trim() || "(auto)"} | locked=${this.difficultyLocked ? "yes" : "no"}`;
this.drawDifficultyButtons(); this.drawDifficultyButtons();
this.renderResultOverlay(state.snapshot.status, state.loseReason);
const oldChildren = this.graphLayer.removeChildren(); const oldChildren = this.graphLayer.removeChildren();
for (const child of oldChildren) { for (const child of oldChildren) {
@ -210,6 +222,33 @@ export default class ChaseScene extends BaseScene {
this.refreshView(); 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 { private buildSetupControls(): void {
this.setupLayer.removeChildren(); this.setupLayer.removeChildren();
const difficulties: Difficulty[] = ["easy", "normal", "hard"]; const difficulties: Difficulty[] = ["easy", "normal", "hard"];
@ -260,6 +299,66 @@ export default class ChaseScene extends BaseScene {
this.startButton.fill({ color: 0x2563eb, alpha: 0.95 }); 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 { private drawDifficultyButtons(): void {
const drawButton = (difficulty: Difficulty): void => { const drawButton = (difficulty: Difficulty): void => {
const button = this.difficultyButtons[difficulty]; const button = this.difficultyButtons[difficulty];
@ -333,4 +432,29 @@ export default class ChaseScene extends BaseScene {
} }
return undefined; 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;
}
} }

62
tests/stages/page_chase.test.ts

@ -281,4 +281,66 @@ describe("chase stage skeleton", () => {
expect(unsubscribed).toBe(true); expect(unsubscribed).toBe(true);
onStageChangeSpy.mockRestore(); 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);
});
}); });

Loading…
Cancel
Save