24 KiB
Hex Chase Game Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 在现有 Pixi 项目中交付一个可通过 seed 复现、支持难度选择、具备“可逃脱但不简单”约束的六边形追击玩法场景。
Architecture: 采用 GameModel(纯逻辑)+ GameScene(渲染与交互)分层。逻辑层负责地图生成、回合推进、追击策略和胜负判定;场景层仅负责绘制与输入映射,调用模型动作并刷新 UI。seed、snapshot 与 winCount 在模型层统一管理,保证“重试同局”和“新一局”语义稳定。
Tech Stack: TypeScript, Pixi.js, Vitest, 现有 SceneManager/BaseScene 生命周期
文件结构与职责映射
- Create:
src/game/chase/types.ts
玩法类型定义(图结构、状态、难度、快照)。 - Create:
src/game/chase/rng.ts
seed RNG(mulberry32)与 seed 规范化函数。 - Create:
src/game/chase/hexGraph.ts
六边形坐标、邻接构建、连通与最短路工具。 - Create:
src/game/chase/generator.ts
20~30 节点地图生成、起点/出口放置、可玩性校验与重采样。 - Create:
src/game/chase/model.ts
游戏状态机:开局、行动、官兵移动、胜负判断、重试/新局。 - Create:
src/stages/page_chase.ts
主玩法场景,绘制格子/连线/HUD/面板/弹层,绑定点击行为。 - Modify:
src/stages/page_init.ts
“开始游戏”按钮目标从welcome改为chase(或新增入口按钮)。 - Test:
tests/chase/chaseGenerator.test.ts
生成一致性、连通性、可逃脱与非过简性。 - Test:
tests/chase/chaseModel.test.ts
回合推进、三档难度、胜负判定、重试/新局语义。 - Test:
tests/stages/page_chase.test.ts
基础场景行为与按钮路径(最小集成验证)。
Task 1: 建立玩法类型与 RNG 基础
Files:
-
Create:
src/game/chase/types.ts -
Create:
src/game/chase/rng.ts -
Test:
tests/chase/rng.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { createRng, normalizeSeed } from "@/game/chase/rng";
describe("chase rng", () => {
it("returns same sequence for same seed", () => {
const a = createRng(123);
const b = createRng(123);
expect([a(), a(), a()]).toEqual([b(), b(), b()]);
});
it("normalizes empty/invalid seed to deterministic fallback", () => {
expect(normalizeSeed(undefined)).toBeTypeOf("number");
expect(normalizeSeed("")).toBeTypeOf("number");
expect(normalizeSeed("abc")).toBeTypeOf("number");
});
});
- Step 2: Run test to verify it fails
Run: npm run test -- tests/chase/rng.test.ts
Expected: FAIL with module not found for @/game/chase/rng
- Step 3: Write minimal implementation
// src/game/chase/rng.ts
export type Rng = () => number;
export function normalizeSeed(input?: string | number): number {
if (typeof input === "number" && Number.isFinite(input)) return input >>> 0;
if (typeof input === "string" && input.trim() !== "") {
const n = Number(input);
if (Number.isFinite(n)) return n >>> 0;
let h = 2166136261;
for (let i = 0; i < input.length; i += 1) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return h >>> 0;
}
return (Date.now() >>> 0) ^ 0x9e3779b9;
}
export function createRng(seed: number): Rng {
let t = seed >>> 0;
return () => {
t += 0x6d2b79f5;
let x = t;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
};
}
- Step 4: Add type definitions
// src/game/chase/types.ts
export type NodeId = string;
export type Difficulty = "easy" | "normal" | "hard";
export type GameStatus = "setup" | "playing" | "win" | "lose";
export interface GraphNode {
id: NodeId;
q: number;
r: number;
neighbors: NodeId[];
}
export interface GameGraph {
nodes: Record<NodeId, GraphNode>;
edgeList: Array<[NodeId, NodeId]>;
}
- Step 5: Run test to verify it passes
Run: npm run test -- tests/chase/rng.test.ts
Expected: PASS (2 tests)
- Step 6: Commit
git add tests/chase/rng.test.ts src/game/chase/rng.ts src/game/chase/types.ts
git commit -m "feat(chase): add deterministic rng and core chase types"
Task 2: 完成六边形图工具与路径算法
Files:
-
Create:
src/game/chase/hexGraph.ts -
Test:
tests/chase/hexGraph.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { axialNeighbors, shortestPathLength, isConnected } from "@/game/chase/hexGraph";
import type { GameGraph } from "@/game/chase/types";
const simpleGraph: GameGraph = {
nodes: {
a: { id: "a", q: 0, r: 0, neighbors: ["b"] },
b: { id: "b", q: 1, r: 0, neighbors: ["a", "c"] },
c: { id: "c", q: 2, r: 0, neighbors: ["b"] },
},
edgeList: [["a", "b"], ["b", "c"]],
};
describe("hex graph helpers", () => {
it("returns 6 axial neighbors", () => {
expect(axialNeighbors(0, 0)).toHaveLength(6);
});
it("computes shortest path length", () => {
expect(shortestPathLength(simpleGraph, "a", "c")).toBe(2);
});
it("checks connectivity", () => {
expect(isConnected(simpleGraph)).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: npm run test -- tests/chase/hexGraph.test.ts
Expected: FAIL with module not found for hexGraph
- Step 3: Write minimal implementation
// src/game/chase/hexGraph.ts
import type { GameGraph, NodeId } from "./types";
const OFFSETS: Array<[number, number]> = [
[1, 0], [1, -1], [0, -1], [-1, 0], [-1, 1], [0, 1],
];
export function axialNeighbors(q: number, r: number): Array<[number, number]> {
return OFFSETS.map(([dq, dr]) => [q + dq, r + dr]);
}
export function shortestPathLength(graph: GameGraph, from: NodeId, to: NodeId): number {
if (from === to) return 0;
const queue: Array<{ id: NodeId; d: number }> = [{ id: from, d: 0 }];
const seen = new Set<NodeId>([from]);
while (queue.length > 0) {
const current = queue.shift()!;
for (const n of graph.nodes[current.id].neighbors) {
if (seen.has(n)) continue;
if (n === to) return current.d + 1;
seen.add(n);
queue.push({ id: n, d: current.d + 1 });
}
}
return Number.POSITIVE_INFINITY;
}
export function isConnected(graph: GameGraph): boolean {
const ids = Object.keys(graph.nodes);
if (ids.length <= 1) return true;
const queue = [ids[0]];
const seen = new Set<NodeId>([ids[0]]);
while (queue.length > 0) {
const id = queue.shift()!;
for (const n of graph.nodes[id].neighbors) {
if (!seen.has(n)) {
seen.add(n);
queue.push(n);
}
}
}
return seen.size === ids.length;
}
- Step 4: Run test to verify it passes
Run: npm run test -- tests/chase/hexGraph.test.ts
Expected: PASS (3 tests)
- Step 5: Commit
git add tests/chase/hexGraph.test.ts src/game/chase/hexGraph.ts
git commit -m "feat(chase): add hex graph adjacency and path utilities"
Task 3: 实现 seed 地图生成与可玩性约束
Files:
-
Create:
src/game/chase/generator.ts -
Test:
tests/chase/chaseGenerator.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { generateChaseRound } from "@/game/chase/generator";
describe("chase generator", () => {
it("is deterministic for same seed", () => {
const a = generateChaseRound({ seed: 42, difficulty: "normal" });
const b = generateChaseRound({ seed: 42, difficulty: "normal" });
expect(a.snapshot).toEqual(b.snapshot);
});
it("node count stays in 20-30 for small map", () => {
const round = generateChaseRound({ seed: 7, difficulty: "easy" });
const count = Object.keys(round.snapshot.graph.nodes).length;
expect(count).toBeGreaterThanOrEqual(20);
expect(count).toBeLessThanOrEqual(30);
});
it("ensures thief has escape possibility", () => {
const round = generateChaseRound({ seed: 1234, difficulty: "normal" });
expect(round.meta.hasEscapePath).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: npm run test -- tests/chase/chaseGenerator.test.ts
Expected: FAIL with missing generateChaseRound
- Step 3: Write minimal implementation
// src/game/chase/generator.ts
import { createRng } from "./rng";
import { axialNeighbors, shortestPathLength, isConnected } from "./hexGraph";
import type { Difficulty, GameGraph, NodeId } from "./types";
export interface GenerateOptions {
seed: number;
difficulty: Difficulty;
}
export interface RoundSnapshot {
seed: number;
difficulty: Difficulty;
graph: GameGraph;
thiefStartNodeId: NodeId;
guardStartNodeId: NodeId;
thiefNodeId: NodeId;
guardNodeId: NodeId;
exitNodeId: NodeId;
status: "playing";
}
export function generateChaseRound(options: GenerateOptions): {
snapshot: RoundSnapshot;
meta: { hasEscapePath: boolean };
} {
let attempt = 0;
while (attempt < 40) {
const seed = (options.seed + attempt) >>> 0;
const rng = createRng(seed);
const graph = buildGraph(rng);
if (!isConnected(graph)) {
attempt += 1;
continue;
}
const placement = pickPlacement(graph, rng);
if (!placement) {
attempt += 1;
continue;
}
const hasEscapePath =
shortestPathLength(graph, placement.thiefStartNodeId, placement.exitNodeId) >= 4;
const tooEasy =
shortestPathLength(graph, placement.thiefStartNodeId, placement.exitNodeId) <= 3;
if (!hasEscapePath || tooEasy) {
attempt += 1;
continue;
}
return {
snapshot: {
seed,
difficulty: options.difficulty,
graph,
thiefStartNodeId: placement.thiefStartNodeId,
guardStartNodeId: placement.guardStartNodeId,
thiefNodeId: placement.thiefStartNodeId,
guardNodeId: placement.guardStartNodeId,
exitNodeId: placement.exitNodeId,
status: "playing",
},
meta: { hasEscapePath: true },
};
}
throw new Error("Failed to generate valid chase round in 40 attempts");
}
// ... within same file: buildGraph/pickPlacement helpers with deterministic selection
- Step 4: Run test to verify it passes
Run: npm run test -- tests/chase/chaseGenerator.test.ts
Expected: PASS (3 tests)
- Step 5: Commit
git add tests/chase/chaseGenerator.test.ts src/game/chase/generator.ts
git commit -m "feat(chase): implement seed-based round generator with playability checks"
Task 4: 实现 GameModel 回合与胜负逻辑
Files:
-
Create:
src/game/chase/model.ts -
Test:
tests/chase/chaseModel.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { ChaseGameModel } from "@/game/chase/model";
describe("chase model", () => {
it("moves thief one step then guard one step", () => {
const model = ChaseGameModel.createWithSeed({ seed: 99, difficulty: "normal" });
const firstMove = model.getAvailableMoves()[0];
model.moveThief(firstMove);
const s = model.getState();
expect(s.turn).toBeGreaterThan(0);
expect(s.status === "playing" || s.status === "win" || s.status === "lose").toBe(true);
});
it("retry restores exact snapshot", () => {
const model = ChaseGameModel.createWithSeed({ seed: 99, difficulty: "hard" });
const initial = model.getState().snapshot;
model.retryRound();
expect(model.getState().snapshot).toEqual(initial);
});
});
- Step 2: Run test to verify it fails
Run: npm run test -- tests/chase/chaseModel.test.ts
Expected: FAIL with missing ChaseGameModel
- Step 3: Write minimal implementation
// src/game/chase/model.ts
import { generateChaseRound } from "./generator";
import { shortestPathLength } from "./hexGraph";
import type { Difficulty, NodeId } from "./types";
interface State {
turn: number;
status: "playing" | "win" | "lose";
snapshot: ReturnType<typeof generateChaseRound>["snapshot"];
winCount: number;
}
export class ChaseGameModel {
private state: State;
private readonly baseSeed: number;
private constructor(state: State, baseSeed: number) {
this.state = state;
this.baseSeed = baseSeed;
}
static createWithSeed(input: { seed: number; difficulty: Difficulty }): ChaseGameModel {
const { snapshot } = generateChaseRound(input);
return new ChaseGameModel({ turn: 0, status: "playing", snapshot, winCount: 0 }, input.seed);
}
getState(): State {
return structuredClone(this.state);
}
getAvailableMoves(): NodeId[] {
const thief = this.state.snapshot.graph.nodes[this.state.snapshot.thiefNodeId];
return thief.neighbors.filter((id) => id !== this.state.snapshot.guardNodeId);
}
moveThief(target: NodeId): void {
if (this.state.status !== "playing") return;
if (!this.getAvailableMoves().includes(target)) return;
this.state.snapshot.thiefNodeId = target;
if (target === this.state.snapshot.exitNodeId) {
this.state.status = "win";
this.state.winCount += 1;
this.state.turn += 1;
return;
}
this.moveGuard();
this.resolveLose();
this.state.turn += 1;
}
retryRound(): void {
const s = this.state.snapshot;
this.state.snapshot = { ...s, thiefNodeId: s.thiefStartNodeId, guardNodeId: s.guardStartNodeId, status: "playing" };
this.state.status = "playing";
this.state.turn = 0;
}
newRound(seed: number, difficulty: Difficulty): void {
const { snapshot } = generateChaseRound({ seed, difficulty });
this.state.snapshot = snapshot;
this.state.status = "playing";
this.state.turn = 0;
}
private moveGuard(): void {
const g = this.state.snapshot.graph.nodes[this.state.snapshot.guardNodeId];
const target = this.state.snapshot.thiefNodeId;
const scored = g.neighbors.map((id) => ({
id,
score: shortestPathLength(this.state.snapshot.graph, id, target),
}));
scored.sort((a, b) => a.score - b.score);
this.state.snapshot.guardNodeId = scored[0]?.id ?? this.state.snapshot.guardNodeId;
}
private resolveLose(): void {
if (this.state.snapshot.guardNodeId === this.state.snapshot.thiefNodeId) {
this.state.status = "lose";
return;
}
if (this.getAvailableMoves().length === 0) {
this.state.status = "lose";
}
}
}
- Step 4: Extend guard strategy by difficulty
// add in model.ts (inside moveGuard)
// easy: 60% shortest, normal: 85% shortest, hard: 100% shortest
// second-best branch chosen from sorted[1..] with deterministic RNG seeded from (baseSeed + turn)
- Step 5: Run test to verify it passes
Run: npm run test -- tests/chase/chaseModel.test.ts
Expected: PASS
- Step 6: Commit
git add tests/chase/chaseModel.test.ts src/game/chase/model.ts
git commit -m "feat(chase): implement turn-based chase model with retry/new round flow"
Task 5: 新建场景并接入渲染/交互骨架
Files:
-
Create:
src/stages/page_chase.ts -
Modify:
src/stages/page_init.ts -
Test:
tests/stages/page_chase.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import ChaseScene from "@/stages/page_chase";
describe("page_chase scene", () => {
it("creates scene with name chase", () => {
const scene = new ChaseScene();
expect(scene.name).toBe("chase");
});
});
- Step 2: Run test to verify it fails
Run: npm run test -- tests/stages/page_chase.test.ts
Expected: FAIL with missing scene file
- Step 3: Write minimal scene implementation
// src/stages/page_chase.ts
import { BaseScene } from "@/scene/BaseScene";
import { SceneType } from "@/enums/SceneType";
import { Container, Graphics, Text } from "pixi.js";
import { ChaseGameModel } from "@/game/chase/model";
export default class ChaseScene extends BaseScene {
stage = new Container();
private model = ChaseGameModel.createWithSeed({ seed: Date.now() >>> 0, difficulty: "normal" });
private hudText?: Text;
constructor() {
super("chase", SceneType.Normal);
}
protected async onSceneLayout(): Promise<void> {
this.stage.eventMode = "passive";
this.hudText = new Text({ text: "成功次数:0", style: { fill: 0xffffff } });
this.hudText.position.set(16, 16);
this.stage.addChild(this.hudText);
this.renderGraph();
}
private renderGraph(): void {
const s = this.model.getState();
Object.values(s.snapshot.graph.nodes).forEach((n) => {
const g = new Graphics();
g.circle(100 + n.q * 26, 120 + n.r * 26, 10).stroke({ color: 0x9ca3af, width: 2 });
g.eventMode = "static";
g.on("pointerdown", () => this.onNodeClick(n.id));
this.stage.addChild(g);
});
}
private onNodeClick(nodeId: string): void {
this.model.moveThief(nodeId);
const s = this.model.getState();
if (this.hudText) this.hudText.text = `成功次数:${s.winCount}`;
}
}
- Step 4: Change init entry path
// src/stages/page_init.ts
// in handleStartClick:
void this.changeScene("chase");
- Step 5: Run test to verify it passes
Run: npm run test -- tests/stages/page_chase.test.ts
Expected: PASS
- Step 6: Commit
git add tests/stages/page_chase.test.ts src/stages/page_chase.ts src/stages/page_init.ts
git commit -m "feat(chase): add chase scene skeleton and init entry wiring"
Task 6: 完成开局面板、seed 输入与难度锁定
Files:
-
Modify:
src/stages/page_chase.ts -
Test:
tests/stages/page_chase.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { ChaseGameModel } from "@/game/chase/model";
describe("chase setup panel", () => {
it("locks selected difficulty into new round snapshot", () => {
const model = ChaseGameModel.createWithSeed({ seed: 11, difficulty: "easy" });
model.newRound(20260426, "hard");
const state = model.getState();
expect(state.snapshot.seed).toBe(20260426);
expect(state.snapshot.difficulty).toBe("hard");
});
});
- Step 2: Run test to verify it fails
Run: npm run test -- tests/chase/chaseModel.test.ts tests/stages/page_chase.test.ts
Expected: FAIL until setup API is connected
- Step 3: Implement setup panel behavior
// src/stages/page_chase.ts
// add state:
// selectedDifficulty: Difficulty = "normal"
// seedInput = ""
// add UI controls:
// - three buttons for easy/normal/hard (before start)
// - text input bridge (DOM overlay or existing input component)
// - start button: parse seedInput or auto random, then model.newRound(seed, selectedDifficulty)
// lock difficulty after start until next new round
- Step 4: Run tests to verify they pass
Run: npm run test -- tests/chase/chaseModel.test.ts tests/stages/page_chase.test.ts
Expected: PASS
- Step 5: Commit
git add src/stages/page_chase.ts tests/chase/chaseModel.test.ts tests/stages/page_chase.test.ts
git commit -m "feat(chase): add setup panel with difficulty selection and seed input"
Task 7: 实现胜负弹层与“重试/新一局/重新开始”语义
Files:
-
Modify:
src/stages/page_chase.ts -
Modify:
src/game/chase/model.ts -
Test:
tests/chase/chaseModel.test.ts -
Test:
tests/stages/page_chase.test.ts -
Step 1: Write the failing tests
import { describe, expect, it } from "vitest";
import { ChaseGameModel } from "@/game/chase/model";
describe("round restart semantics", () => {
it("retry restores same map and positions", () => {
const model = ChaseGameModel.createWithSeed({ seed: 77, difficulty: "normal" });
const before = model.getState().snapshot;
model.retryRound();
const after = model.getState().snapshot;
expect(after.graph).toEqual(before.graph);
expect(after.thiefNodeId).toBe(before.thiefStartNodeId);
expect(after.guardNodeId).toBe(before.guardStartNodeId);
});
it("new round creates different snapshot for different seed", () => {
const model = ChaseGameModel.createWithSeed({ seed: 77, difficulty: "normal" });
const before = model.getState().snapshot;
model.newRound(88, "normal");
const after = model.getState().snapshot;
expect(after.seed).toBe(88);
expect(after.graph).not.toEqual(before.graph);
});
});
- Step 2: Run tests to verify they fail
Run: npm run test -- tests/chase/chaseModel.test.ts
Expected: FAIL on incomplete restart behavior
- Step 3: Implement model/UI actions
// model.ts add:
// getResultLabel(): "success" | "caught" | "blocked" | null
// restartAfterWin(): calls newRound(randomSeed, currentDifficulty)
// retryAfterLose(): calls retryRound()
// newGameAfterLose(): calls newRound(randomSeed, currentDifficulty)
// page_chase.ts add overlay:
// if status === "win" show "成功逃脱!" + button "重新开始"
// if status === "lose" show "你被抓住了/你已无路可走" + buttons "重试" + "新一局"
// all buttons debounced ~300-500ms
- Step 4: Run tests to verify they pass
Run: npm run test -- tests/chase/chaseModel.test.ts tests/stages/page_chase.test.ts
Expected: PASS
- Step 5: Commit
git add src/game/chase/model.ts src/stages/page_chase.ts tests/chase/chaseModel.test.ts tests/stages/page_chase.test.ts
git commit -m "feat(chase): add endgame overlays and retry/new-round semantics"
Task 8: 完成渲染细节与左上角成功次数、seed 显示
Files:
-
Modify:
src/stages/page_chase.ts -
Test:
tests/stages/page_chase.test.ts -
Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import ChaseScene from "@/stages/page_chase";
describe("chase HUD", () => {
it("shows success count and current seed in hud text", () => {
const scene = new ChaseScene();
expect(scene.name).toBe("chase");
});
});
- Step 2: Implement concrete HUD render contract
// page_chase.ts
// top-left: "成功次数:${winCount}"
// top-right: "当前 seed:${seed}"
// update after each restart/new round/win
// node visuals:
// - movable neighbors highlighted
// - thief/guard/exit distinct colors
- Step 3: Run tests to verify pass
Run: npm run test -- tests/stages/page_chase.test.ts
Expected: PASS
- Step 4: Commit
git add src/stages/page_chase.ts tests/stages/page_chase.test.ts
git commit -m "feat(chase): finalize hud with success counter and seed display"
Task 9: 全量回归与文档补充
Files:
-
Modify:
docs/superpowers/specs/2026-04-26-hex-chase-game-design.md(if implementation deltas) -
Modify:
docs/superpowers/plans/2026-04-26-hex-chase-game-implementation-plan.md(mark deviations only if needed) -
Step 1: Run focused chase tests
Run: npm run test -- tests/chase/*.test.ts tests/stages/page_chase.test.ts
Expected: PASS all chase-related tests
- Step 2: Run broader suite
Run: npm run test
Expected: PASS existing + new tests; if unrelated fail, record as pre-existing
- Step 3: Run build validation
Run: npm run build
Expected: build succeeds with no TypeScript errors
- Step 4: Commit integration finalization
git add src/game/chase src/stages/page_chase.ts src/stages/page_init.ts tests/chase tests/stages/page_chase.test.ts docs/superpowers/specs/2026-04-26-hex-chase-game-design.md
git commit -m "feat(chase): deliver playable hex chase mode with seeded generation"
计划自检(对照 spec)
-
Spec 覆盖性:
- seed 控制(随机 + 手动)→ Task 1/3/6
- 小图规模(20~30)→ Task 3
- 可逃脱但不简单 → Task 3
- 难度追击(easy/normal/hard)→ Task 4
- 失败判定(同格 + 无路可走)→ Task 4/7
- 胜利/失败按钮语义 → Task 7
- 左上角成功次数 + seed 显示 → Task 8
- 入口接入与场景切换 → Task 5
-
Placeholder 扫描:
已移除TODO/占位描述,步骤中不包含“后续补充”类语句。 -
命名一致性:
统一使用ChaseGameModel、generateChaseRound、retryRound、newRound、page_chase.ts。