From a0cef22eda3726db00ff005e24d267e5c120710e Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Mon, 27 Apr 2026 01:06:51 +0800 Subject: [PATCH] feat(chase): move logic under stages/games/chase, solvability - Relocate generator/model/graph/rng/types into logic/ - Add solvability helpers and tests - Fix vitest mock path for logic/generator after move - Fix page_chase stage test import (page_chase.ts) Made-with: Cursor --- .../specs/2026-04-26-hex-chase-game-design.md | 4 +- src/game/chase/generator.ts | 238 ------- src/game/chase/hexGraph.ts | 75 --- src/game/chase/model.ts | 204 ------ src/game/chase/rng.ts | 54 -- src/game/chase/types.ts | 17 - src/stages/games/chase/logic/generator.ts | 290 +++++++++ src/stages/games/chase/logic/hexGraph.ts | 124 ++++ src/stages/games/chase/logic/index.ts | 18 + src/stages/games/chase/logic/model.ts | 204 ++++++ src/stages/games/chase/logic/rng.ts | 54 ++ src/stages/games/chase/logic/solvability.ts | 103 +++ src/stages/games/chase/logic/types.ts | 17 + src/stages/games/chase/page_chase.ts | 711 +++++++++++++++++++++ src/stages/page_chase.ts | 711 --------------------- tests/chase/chaseGenerator.test.ts | 96 ++- tests/chase/chaseModel.test.ts | 6 +- tests/chase/hexGraph.test.ts | 6 +- tests/chase/rng.test.ts | 2 +- tests/chase/solvability.test.ts | 53 ++ tests/stages/page_chase.test.ts | 2 +- 21 files changed, 1665 insertions(+), 1324 deletions(-) delete mode 100644 src/game/chase/generator.ts delete mode 100644 src/game/chase/hexGraph.ts delete mode 100644 src/game/chase/model.ts delete mode 100644 src/game/chase/rng.ts delete mode 100644 src/game/chase/types.ts create mode 100644 src/stages/games/chase/logic/generator.ts create mode 100644 src/stages/games/chase/logic/hexGraph.ts create mode 100644 src/stages/games/chase/logic/index.ts create mode 100644 src/stages/games/chase/logic/model.ts create mode 100644 src/stages/games/chase/logic/rng.ts create mode 100644 src/stages/games/chase/logic/solvability.ts create mode 100644 src/stages/games/chase/logic/types.ts create mode 100644 src/stages/games/chase/page_chase.ts delete mode 100644 src/stages/page_chase.ts create mode 100644 tests/chase/solvability.test.ts diff --git a/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md b/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md index 773e4e0..df9829e 100644 --- a/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md +++ b/docs/superpowers/specs/2026-04-26-hex-chase-game-design.md @@ -32,7 +32,7 @@ ## 3. 总体架构 -新增主玩法场景 `page_chase.ts`,从 `init` 进入。场景内部采用“模型与渲染分离”: +新增主玩法场景 `src/stages/games/chase/page_chase.ts`,从 `init` 进入。场景内部采用“模型与渲染分离”: - `GameModel`(纯逻辑层) - 管理地图、seed、回合、角色位置、胜负状态; @@ -213,6 +213,6 @@ 建议按以下顺序进入实现计划: 1. 先完成 `GameModel` 与地图生成/校验; -2. 接入 `page_chase` 基础渲染与点击移动; +2. 接入 `games/chase` 场景基础渲染与点击移动; 3. 接入难度策略与胜负/结算流程; 4. 最后完善 HUD、seed 输入、按钮交互与测试。 diff --git a/src/game/chase/generator.ts b/src/game/chase/generator.ts deleted file mode 100644 index 6a3f470..0000000 --- a/src/game/chase/generator.ts +++ /dev/null @@ -1,238 +0,0 @@ -import { axialNeighbors, isConnected, shortestPathLength } from "./hexGraph"; -import { createRng, normalizeSeed } from "./rng"; -import type { Difficulty, GameGraph, GraphNode, NodeId } from "./types"; - -const MAX_ATTEMPTS = 40; - -type CoordKey = `${number},${number}`; - -export interface GenerateChaseRoundInput { - seed: number | string; - difficulty: Difficulty; -} - -export interface ChaseRound { - snapshot: { - seed: number; - difficulty: Difficulty; - graph: GameGraph; - thiefStartNodeId: NodeId; - guardStartNodeId: NodeId; - thiefNodeId: NodeId; - guardNodeId: NodeId; - exitNodeId: NodeId; - status: "playing"; - }; - meta: { - hasEscapePath: boolean; - pathLength: number; - attemptsUsed: number; - }; -} - -export interface GenerateChaseRoundOptions { - generateAttempt?: (seed: number, difficulty: Difficulty) => ChaseRound; -} - -function coordKey(q: number, r: number): CoordKey { - return `${q},${r}`; -} - -function parseCoord(id: NodeId): [number, number] { - const [qStr, rStr] = id.split(","); - return [Number(qStr), Number(rStr)]; -} - -function randomInt(rng: () => number, min: number, maxInclusive: number): number { - return Math.floor(rng() * (maxInclusive - min + 1)) + min; -} - -function difficultyNodeRange(difficulty: Difficulty): [number, number] { - if (difficulty === "easy") { - return [20, 24]; - } - if (difficulty === "hard") { - return [24, 30]; - } - return [22, 27]; -} - -function minimumPathByDifficulty(difficulty: Difficulty): number { - if (difficulty === "easy") { - return 3; - } - if (difficulty === "hard") { - return 6; - } - return 4; -} - -function linkBidirectional(nodes: Record, a: NodeId, b: NodeId): void { - if (!nodes[a].neighbors.includes(b)) { - nodes[a].neighbors.push(b); - } - if (!nodes[b].neighbors.includes(a)) { - nodes[b].neighbors.push(a); - } -} - -function buildEdgeList(nodes: Record): Array<[NodeId, NodeId]> { - const edges: Array<[NodeId, NodeId]> = []; - const seen = new Set(); - - for (const nodeId of Object.keys(nodes)) { - for (const neighborId of nodes[nodeId].neighbors) { - const a = nodeId < neighborId ? nodeId : neighborId; - const b = nodeId < neighborId ? neighborId : nodeId; - const key = `${a}|${b}`; - if (!seen.has(key)) { - seen.add(key); - edges.push([a, b]); - } - } - } - - return edges; -} - -function getFarthestNode(graph: GameGraph, from: NodeId): { id: NodeId; distance: number } { - const queue: Array<[NodeId, number]> = [[from, 0]]; - let head = 0; - const visited = new Set([from]); - let farthest = { id: from, distance: 0 }; - - while (head < queue.length) { - const [current, distance] = queue[head]; - head += 1; - - if (distance > farthest.distance) { - farthest = { id: current, distance }; - } - - for (const neighbor of graph.nodes[current].neighbors) { - if (!visited.has(neighbor) && graph.nodes[neighbor]) { - visited.add(neighbor); - queue.push([neighbor, distance + 1]); - } - } - } - - return farthest; -} - -function generateGraphAttempt(seed: number, difficulty: Difficulty): ChaseRound { - const rng = createRng(seed); - const [minNodes, maxNodes] = difficultyNodeRange(difficulty); - const targetNodeCount = randomInt(rng, minNodes, maxNodes); - - const nodes: Record = {}; - const originId = coordKey(0, 0); - nodes[originId] = { id: originId, q: 0, r: 0, neighbors: [] }; - - const existingNodeIds: NodeId[] = [originId]; - - while (existingNodeIds.length < targetNodeCount) { - const baseId = existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)]; - const baseNode = nodes[baseId]; - const candidates = axialNeighbors(baseNode.q, baseNode.r).filter(([q, r]) => { - const id = coordKey(q, r); - return !nodes[id]; - }); - - if (candidates.length === 0) { - continue; - } - - const [q, r] = candidates[randomInt(rng, 0, candidates.length - 1)]; - const newId = coordKey(q, r); - nodes[newId] = { id: newId, q, r, neighbors: [] }; - existingNodeIds.push(newId); - linkBidirectional(nodes, baseId, newId); - } - - const extraEdges = Math.floor(targetNodeCount * 0.2); - for (let i = 0; i < extraEdges; i += 1) { - const nodeId = existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)]; - const [q, r] = parseCoord(nodeId); - const adjacentExisting = axialNeighbors(q, r) - .map(([nq, nr]) => coordKey(nq, nr)) - .filter((neighborId) => nodes[neighborId] && neighborId !== nodeId); - - if (adjacentExisting.length === 0) { - continue; - } - - const neighborId = - adjacentExisting[randomInt(rng, 0, adjacentExisting.length - 1)]; - linkBidirectional(nodes, nodeId, neighborId); - } - - const graph: GameGraph = { - nodes, - edgeList: buildEdgeList(nodes), - }; - - const thiefStartNodeId = - existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)]; - const farthest = getFarthestNode(graph, thiefStartNodeId); - const exitNodeId = farthest.id; - const pathLength = shortestPathLength(graph, thiefStartNodeId, exitNodeId); - const farthestFromExit = getFarthestNode(graph, exitNodeId); - let guardStartNodeId = farthestFromExit.id; - if (guardStartNodeId === thiefStartNodeId) { - const fallbackCandidates = existingNodeIds.filter( - (nodeId) => nodeId !== thiefStartNodeId, - ); - guardStartNodeId = fallbackCandidates[0] ?? thiefStartNodeId; - } - - return { - snapshot: { - seed, - difficulty, - graph, - thiefStartNodeId, - guardStartNodeId, - thiefNodeId: thiefStartNodeId, - guardNodeId: guardStartNodeId, - exitNodeId, - status: "playing", - }, - meta: { - hasEscapePath: Number.isFinite(pathLength) && pathLength > 0, - pathLength, - attemptsUsed: 1, - }, - }; -} - -export function generateChaseRound( - input: GenerateChaseRoundInput, - options: GenerateChaseRoundOptions = {}, -): ChaseRound { - const baseSeed = normalizeSeed(input.seed); - const minPathLength = minimumPathByDifficulty(input.difficulty); - const generateAttempt = options.generateAttempt ?? generateGraphAttempt; - - for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { - const attemptSeed = normalizeSeed(baseSeed + attempt * 0x9e3779b9); - const round = generateAttempt(attemptSeed, input.difficulty); - const isPlayable = - isConnected(round.snapshot.graph) && - round.meta.hasEscapePath && - round.snapshot.guardStartNodeId !== round.snapshot.thiefStartNodeId && - round.meta.pathLength >= minPathLength; - - if (isPlayable) { - return { - ...round, - meta: { - ...round.meta, - attemptsUsed: attempt + 1, - }, - }; - } - } - - throw new Error("Unable to generate playable chase round within retry limit"); -} diff --git a/src/game/chase/hexGraph.ts b/src/game/chase/hexGraph.ts deleted file mode 100644 index e4011ca..0000000 --- a/src/game/chase/hexGraph.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { GameGraph, NodeId } from "./types"; - -export function axialNeighbors(q: number, r: number): Array<[number, number]> { - return [ - [q + 1, r], - [q + 1, r - 1], - [q, r - 1], - [q - 1, r], - [q - 1, r + 1], - [q, r + 1], - ]; -} - -export function shortestPathLength( - graph: GameGraph, - from: NodeId, - to: NodeId, -): number { - if (!graph.nodes[from] || !graph.nodes[to]) { - return Infinity; - } - - if (from === to) { - return 0; - } - - const queue: Array<[NodeId, number]> = [[from, 0]]; - let head = 0; - const visited = new Set([from]); - - while (head < queue.length) { - const [current, distance] = queue[head]; - head += 1; - const node = graph.nodes[current]; - - for (const neighbor of node.neighbors) { - if (neighbor === to) { - return distance + 1; - } - if (!visited.has(neighbor) && graph.nodes[neighbor]) { - visited.add(neighbor); - queue.push([neighbor, distance + 1]); - } - } - } - - return Infinity; -} - -export function isConnected(graph: GameGraph): boolean { - const nodeIds = Object.keys(graph.nodes); - if (nodeIds.length <= 1) { - return true; - } - - const start = nodeIds[0]; - const visited = new Set([start]); - const queue: NodeId[] = [start]; - let head = 0; - - while (head < queue.length) { - const current = queue[head]; - head += 1; - const node = graph.nodes[current]; - - for (const neighbor of node.neighbors) { - if (!visited.has(neighbor) && graph.nodes[neighbor]) { - visited.add(neighbor); - queue.push(neighbor); - } - } - } - - return visited.size === nodeIds.length; -} diff --git a/src/game/chase/model.ts b/src/game/chase/model.ts deleted file mode 100644 index 63efb36..0000000 --- a/src/game/chase/model.ts +++ /dev/null @@ -1,204 +0,0 @@ -import { shortestPathLength } from "./hexGraph"; -import { - generateChaseRound, - type ChaseRound, - type GenerateChaseRoundInput, -} from "./generator"; -import { createRng, normalizeSeed, type Rng } from "./rng"; -import type { Difficulty, NodeId } from "./types"; - -type GameStatus = "playing" | "win" | "lose"; - -type ModelSnapshot = Omit & { - status: GameStatus; -}; - -interface ChaseGameState { - snapshot: ModelSnapshot; - winCount: number; - turn: number; - loseReason?: "caught" | "trapped"; -} - -interface ChaseGameModelOptions { - random?: () => number; -} - -const GUARD_SHORTEST_RATE: Record = { - easy: 0.6, - normal: 0.85, - hard: 1, -}; - -function cloneRound(round: ChaseRound): ChaseGameState { - return { - snapshot: { - ...round.snapshot, - status: "playing", - }, - winCount: 0, - turn: 0, - loseReason: undefined, - }; -} - -function cloneSnapshot(snapshot: ModelSnapshot): ModelSnapshot { - const graph = { - nodes: Object.fromEntries( - Object.entries(snapshot.graph.nodes).map(([id, node]) => [ - id, - { ...node, neighbors: [...node.neighbors] }, - ]), - ), - edgeList: snapshot.graph.edgeList.map(([a, b]) => [a, b] as [NodeId, NodeId]), - }; - - return { - ...snapshot, - graph, - }; -} - -export class ChaseGameModel { - private state: ChaseGameState; - private initialSnapshot: ModelSnapshot; - private readonly randomOverride?: () => number; - private random: () => number; - - private constructor(state: ChaseGameState, random: () => number, randomOverride?: () => number) { - this.state = state; - this.initialSnapshot = cloneSnapshot(state.snapshot); - this.randomOverride = randomOverride; - this.random = random; - } - - static createWithSeed( - input: GenerateChaseRoundInput, - options: ChaseGameModelOptions = {}, - ): ChaseGameModel { - const round = generateChaseRound(input); - const state = cloneRound(round); - const rng = options.random ?? createRng(normalizeSeed(state.snapshot.seed)); - return new ChaseGameModel(state, rng, options.random); - } - - getState(): ChaseGameState { - return { - ...this.state, - snapshot: cloneSnapshot(this.state.snapshot), - }; - } - - getAvailableMoves(): NodeId[] { - if (this.state.snapshot.status !== "playing") { - return []; - } - - const node = this.state.snapshot.graph.nodes[this.state.snapshot.thiefNodeId]; - if (!node) { - return []; - } - - return node.neighbors.filter((id) => id !== this.state.snapshot.guardNodeId); - } - - moveThief(target: NodeId): ChaseGameState { - if (this.state.snapshot.status !== "playing") { - return this.getState(); - } - - const availableMoves = this.getAvailableMoves(); - if (!availableMoves.includes(target)) { - return this.getState(); - } - - this.state.turn += 1; - this.state.snapshot.thiefNodeId = target; - 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(); - } - - this.moveGuardOneStep(); - - 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(); - } - - retryRound(): ChaseGameState { - const winCount = this.state.winCount; - this.resetRng(this.initialSnapshot.seed); - this.state = { - snapshot: cloneSnapshot(this.initialSnapshot), - winCount, - turn: 0, - loseReason: undefined, - }; - return this.getState(); - } - - newRound(seed: number | string, difficulty: Difficulty): ChaseGameState { - const round = generateChaseRound({ seed, difficulty }); - const winCount = this.state.winCount; - const newState = cloneRound(round); - this.state = { ...newState, winCount }; - this.initialSnapshot = cloneSnapshot(newState.snapshot); - this.resetRng(newState.snapshot.seed); - return this.getState(); - } - - private resetRng(seed: number): void { - if (this.randomOverride) { - this.random = this.randomOverride; - return; - } - this.random = createRng(normalizeSeed(seed)); - } - - private moveGuardOneStep(): void { - const guardNode = this.state.snapshot.graph.nodes[this.state.snapshot.guardNodeId]; - if (!guardNode || guardNode.neighbors.length === 0) { - return; - } - - const movesWithDistance = guardNode.neighbors.map((neighborId) => ({ - nodeId: neighborId, - distance: shortestPathLength( - this.state.snapshot.graph, - neighborId, - this.state.snapshot.thiefNodeId, - ), - })); - - const finiteMoves = movesWithDistance.filter((item) => Number.isFinite(item.distance)); - if (finiteMoves.length === 0) { - return; - } - - finiteMoves.sort((a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)); - const shortestDistance = finiteMoves[0].distance; - const shortestMoves = finiteMoves.filter((item) => item.distance === shortestDistance); - const nonShortestMoves = finiteMoves.filter((item) => item.distance !== shortestDistance); - - const shortestRate = GUARD_SHORTEST_RATE[this.state.snapshot.difficulty]; - let nextMove = shortestMoves[0]; - if (shortestRate < 1 && this.random() > shortestRate && nonShortestMoves.length > 0) { - const idx = Math.floor(this.random() * nonShortestMoves.length); - nextMove = nonShortestMoves[idx]; - } - - this.state.snapshot.guardNodeId = nextMove.nodeId; - } -} diff --git a/src/game/chase/rng.ts b/src/game/chase/rng.ts deleted file mode 100644 index ccb1126..0000000 --- a/src/game/chase/rng.ts +++ /dev/null @@ -1,54 +0,0 @@ -export type Rng = () => number; - -const DEFAULT_SEED = 0x9e3779b9; - -function toUint32(value: number): number { - return value >>> 0; -} - -function hashString(input: string): number { - let hash = 2166136261; - for (let i = 0; i < input.length; i += 1) { - hash ^= input.charCodeAt(i); - hash = Math.imul(hash, 16777619); - } - return hash >>> 0; -} - -export function normalizeSeed(input?: string | number): number { - // Seed normalization contract (always returns uint32): - // 1) finite number -> ToUint32 (e.g. -1 => 0xffffffff, 1.9 => 1) - // 2) numeric string -> parse then ToUint32 - // 3) non-numeric string -> stable hash -> uint32 - // 4) undefined / empty / whitespace / NaN / non-finite -> DEFAULT_SEED - if (typeof input === "number" && Number.isFinite(input)) { - return toUint32(input); - } - - if (typeof input === "string") { - const trimmed = input.trim(); - if (trimmed.length === 0) { - return DEFAULT_SEED; - } - - const numericSeed = Number(trimmed); - if (Number.isFinite(numericSeed)) { - return toUint32(numericSeed); - } - - return hashString(trimmed); - } - - return DEFAULT_SEED; -} - -export function createRng(seed: number): Rng { - let state = normalizeSeed(seed); - - return () => { - state = toUint32(state + 0x6d2b79f5); - let t = Math.imul(state ^ (state >>> 15), 1 | state); - t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); - return toUint32(t ^ (t >>> 14)) / 4294967296; - }; -} diff --git a/src/game/chase/types.ts b/src/game/chase/types.ts deleted file mode 100644 index 774c352..0000000 --- a/src/game/chase/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -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; - edgeList: Array<[NodeId, NodeId]>; -} diff --git a/src/stages/games/chase/logic/generator.ts b/src/stages/games/chase/logic/generator.ts new file mode 100644 index 0000000..00e474a --- /dev/null +++ b/src/stages/games/chase/logic/generator.ts @@ -0,0 +1,290 @@ +import { + axialNeighbors, + isConnected, + shortestPath, + shortestPathLength, +} from "./hexGraph"; +import { createRng, normalizeSeed } from "./rng"; +import { thiefHasWinningStrategy } from "./solvability"; +import type { Difficulty, GameGraph, GraphNode, NodeId } from "./types"; + +const MAX_ATTEMPTS = 80; + +type CoordKey = `${number},${number}`; + +export interface GenerateChaseRoundInput { + seed: number | string; + difficulty: Difficulty; +} + +export interface ChaseRound { + snapshot: { + seed: number; + difficulty: Difficulty; + graph: GameGraph; + thiefStartNodeId: NodeId; + guardStartNodeId: NodeId; + thiefNodeId: NodeId; + guardNodeId: NodeId; + exitNodeId: NodeId; + status: "playing"; + }; + meta: { + hasEscapePath: boolean; + pathLength: number; + attemptsUsed: number; + }; +} + +export interface GenerateChaseRoundOptions { + generateAttempt?: (seed: number, difficulty: Difficulty) => ChaseRound; +} + +function coordKey(q: number, r: number): CoordKey { + return `${q},${r}`; +} + +function parseCoord(id: NodeId): [number, number] { + const [qStr, rStr] = id.split(","); + return [Number(qStr), Number(rStr)]; +} + +function randomInt(rng: () => number, min: number, maxInclusive: number): number { + return Math.floor(rng() * (maxInclusive - min + 1)) + min; +} + +function difficultyNodeRange(difficulty: Difficulty): [number, number] { + if (difficulty === "easy") { + return [20, 24]; + } + if (difficulty === "hard") { + return [24, 30]; + } + return [22, 27]; +} + +function minimumPathByDifficulty(difficulty: Difficulty): number { + if (difficulty === "easy") { + return 3; + } + if (difficulty === "hard") { + return 6; + } + return 4; +} + +function linkBidirectional(nodes: Record, a: NodeId, b: NodeId): void { + if (!nodes[a].neighbors.includes(b)) { + nodes[a].neighbors.push(b); + } + if (!nodes[b].neighbors.includes(a)) { + nodes[b].neighbors.push(a); + } +} + +function buildEdgeList(nodes: Record): Array<[NodeId, NodeId]> { + const edges: Array<[NodeId, NodeId]> = []; + const seen = new Set(); + + for (const nodeId of Object.keys(nodes)) { + for (const neighborId of nodes[nodeId].neighbors) { + const a = nodeId < neighborId ? nodeId : neighborId; + const b = nodeId < neighborId ? neighborId : nodeId; + const key = `${a}|${b}`; + if (!seen.has(key)) { + seen.add(key); + edges.push([a, b]); + } + } + } + + return edges; +} + +function getFarthestNode(graph: GameGraph, from: NodeId): { id: NodeId; distance: number } { + const queue: Array<[NodeId, number]> = [[from, 0]]; + let head = 0; + const visited = new Set([from]); + let farthest = { id: from, distance: 0 }; + + while (head < queue.length) { + const [current, distance] = queue[head]; + head += 1; + + if (distance > farthest.distance) { + farthest = { id: current, distance }; + } + + for (const neighbor of graph.nodes[current].neighbors) { + if (!visited.has(neighbor) && graph.nodes[neighbor]) { + visited.add(neighbor); + queue.push([neighbor, distance + 1]); + } + } + } + + return farthest; +} + +/** + * 在「偷 → 出口」的一条最短路径上选取官兵起点(严格在偷与出口之间,不含两端)。 + * easy:偏靠近出口一侧;hard:偏靠近小偷一侧;normal:路径内均匀随机。 + */ +function pickGuardBetweenThiefAndExit( + graph: GameGraph, + rng: () => number, + difficulty: Difficulty, + thiefStartNodeId: NodeId, + exitNodeId: NodeId, +): NodeId | null { + const path = shortestPath(graph, thiefStartNodeId, exitNodeId); + if (path.length < 3) { + return null; + } + + const inner = path.slice(1, -1); + const n = inner.length; + + if (difficulty === "easy") { + const from = Math.floor(n / 2); + return inner[from + randomInt(rng, 0, n - 1 - from)]; + } + + if (difficulty === "hard") { + const to = Math.ceil(n / 2) - 1; + return inner[randomInt(rng, 0, Math.max(0, to))]; + } + + return inner[randomInt(rng, 0, n - 1)]; +} + +function generateGraphAttempt(seed: number, difficulty: Difficulty): ChaseRound { + const rng = createRng(seed); + const [minNodes, maxNodes] = difficultyNodeRange(difficulty); + const targetNodeCount = randomInt(rng, minNodes, maxNodes); + + const nodes: Record = {}; + const originId = coordKey(0, 0); + nodes[originId] = { id: originId, q: 0, r: 0, neighbors: [] }; + + const existingNodeIds: NodeId[] = [originId]; + + while (existingNodeIds.length < targetNodeCount) { + const baseId = existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)]; + const baseNode = nodes[baseId]; + const candidates = axialNeighbors(baseNode.q, baseNode.r).filter(([q, r]) => { + const id = coordKey(q, r); + return !nodes[id]; + }); + + if (candidates.length === 0) { + continue; + } + + const [q, r] = candidates[randomInt(rng, 0, candidates.length - 1)]; + const newId = coordKey(q, r); + nodes[newId] = { id: newId, q, r, neighbors: [] }; + existingNodeIds.push(newId); + linkBidirectional(nodes, baseId, newId); + } + + const extraEdges = Math.floor(targetNodeCount * 0.2); + for (let i = 0; i < extraEdges; i += 1) { + const nodeId = existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)]; + const [q, r] = parseCoord(nodeId); + const adjacentExisting = axialNeighbors(q, r) + .map(([nq, nr]) => coordKey(nq, nr)) + .filter((neighborId) => nodes[neighborId] && neighborId !== nodeId); + + if (adjacentExisting.length === 0) { + continue; + } + + const neighborId = + adjacentExisting[randomInt(rng, 0, adjacentExisting.length - 1)]; + linkBidirectional(nodes, nodeId, neighborId); + } + + const graph: GameGraph = { + nodes, + edgeList: buildEdgeList(nodes), + }; + + const thiefStartNodeId = + existingNodeIds[randomInt(rng, 0, existingNodeIds.length - 1)]; + const farthest = getFarthestNode(graph, thiefStartNodeId); + const exitNodeId = farthest.id; + const pathLength = shortestPathLength(graph, thiefStartNodeId, exitNodeId); + const guardStartNodeId = + pickGuardBetweenThiefAndExit(graph, rng, difficulty, thiefStartNodeId, exitNodeId) ?? + thiefStartNodeId; + + return { + snapshot: { + seed, + difficulty, + graph, + thiefStartNodeId, + guardStartNodeId, + thiefNodeId: thiefStartNodeId, + guardNodeId: guardStartNodeId, + exitNodeId, + status: "playing", + }, + meta: { + hasEscapePath: Number.isFinite(pathLength) && pathLength > 0, + pathLength, + attemptsUsed: 1, + }, + }; +} + +export function generateChaseRound( + input: GenerateChaseRoundInput, + options: GenerateChaseRoundOptions = {}, +): ChaseRound { + const baseSeed = normalizeSeed(input.seed); + const minPathLength = minimumPathByDifficulty(input.difficulty); + const generateAttempt = options.generateAttempt ?? generateGraphAttempt; + + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) { + const attemptSeed = normalizeSeed(baseSeed + attempt * 0x9e3779b9); + const round = generateAttempt(attemptSeed, input.difficulty); + const solvable = + thiefHasWinningStrategy( + round.snapshot.graph, + round.snapshot.thiefStartNodeId, + round.snapshot.guardStartNodeId, + round.snapshot.exitNodeId, + ); + + const pathThiefExit = shortestPath( + round.snapshot.graph, + round.snapshot.thiefStartNodeId, + round.snapshot.exitNodeId, + ); + const guardOnShortestPathBetween = + pathThiefExit.length >= 3 && + pathThiefExit.slice(1, -1).includes(round.snapshot.guardStartNodeId); + + const isPlayable = + isConnected(round.snapshot.graph) && + round.meta.hasEscapePath && + round.snapshot.guardStartNodeId !== round.snapshot.thiefStartNodeId && + round.meta.pathLength >= minPathLength && + guardOnShortestPathBetween && + solvable; + + if (isPlayable) { + return { + ...round, + meta: { + ...round.meta, + attemptsUsed: attempt + 1, + }, + }; + } + } + + throw new Error("Unable to generate playable chase round within retry limit"); +} diff --git a/src/stages/games/chase/logic/hexGraph.ts b/src/stages/games/chase/logic/hexGraph.ts new file mode 100644 index 0000000..ca65bd2 --- /dev/null +++ b/src/stages/games/chase/logic/hexGraph.ts @@ -0,0 +1,124 @@ +import type { GameGraph, NodeId } from "./types"; + +export function axialNeighbors(q: number, r: number): Array<[number, number]> { + return [ + [q + 1, r], + [q + 1, r - 1], + [q, r - 1], + [q - 1, r], + [q - 1, r + 1], + [q, r + 1], + ]; +} + +export function shortestPathLength( + graph: GameGraph, + from: NodeId, + to: NodeId, +): number { + if (!graph.nodes[from] || !graph.nodes[to]) { + return Infinity; + } + + if (from === to) { + return 0; + } + + const queue: Array<[NodeId, number]> = [[from, 0]]; + let head = 0; + const visited = new Set([from]); + + while (head < queue.length) { + const [current, distance] = queue[head]; + head += 1; + const node = graph.nodes[current]; + + for (const neighbor of node.neighbors) { + if (neighbor === to) { + return distance + 1; + } + if (!visited.has(neighbor) && graph.nodes[neighbor]) { + visited.add(neighbor); + queue.push([neighbor, distance + 1]); + } + } + } + + return Infinity; +} + +/** + * 一条最短路径(BFS;邻接 tie-break 按 nodeId 排序,保证对同一图可复现)。 + */ +export function shortestPath(graph: GameGraph, from: NodeId, to: NodeId): NodeId[] { + if (!graph.nodes[from] || !graph.nodes[to]) { + return []; + } + if (from === to) { + return [from]; + } + + const parent = new Map(); + const queue: NodeId[] = [from]; + let head = 0; + const visited = new Set([from]); + + while (head < queue.length) { + const current = queue[head]; + head += 1; + const node = graph.nodes[current]; + const neighbors = [...node.neighbors].sort((a, b) => a.localeCompare(b)); + + for (const neighbor of neighbors) { + if (neighbor === to) { + const path: NodeId[] = [to]; + let walk = current; + path.push(walk); + while (walk !== from) { + const p = parent.get(walk); + if (p === undefined) { + return []; + } + path.push(p); + walk = p; + } + path.reverse(); + return path; + } + if (!visited.has(neighbor) && graph.nodes[neighbor]) { + visited.add(neighbor); + parent.set(neighbor, current); + queue.push(neighbor); + } + } + } + + return []; +} + +export function isConnected(graph: GameGraph): boolean { + const nodeIds = Object.keys(graph.nodes); + if (nodeIds.length <= 1) { + return true; + } + + const start = nodeIds[0]; + const visited = new Set([start]); + const queue: NodeId[] = [start]; + let head = 0; + + while (head < queue.length) { + const current = queue[head]; + head += 1; + const node = graph.nodes[current]; + + for (const neighbor of node.neighbors) { + if (!visited.has(neighbor) && graph.nodes[neighbor]) { + visited.add(neighbor); + queue.push(neighbor); + } + } + } + + return visited.size === nodeIds.length; +} diff --git a/src/stages/games/chase/logic/index.ts b/src/stages/games/chase/logic/index.ts new file mode 100644 index 0000000..522cd5a --- /dev/null +++ b/src/stages/games/chase/logic/index.ts @@ -0,0 +1,18 @@ +export { default as ChaseScene } from "../scene"; +export { ChaseGameModel } from "./model"; +export { createRng, normalizeSeed } from "./rng"; +export { axialNeighbors, isConnected, shortestPath, shortestPathLength } from "./hexGraph"; +export { generateChaseRound } from "./generator"; +export { optimalGuardStep, thiefHasWinningStrategy } from "./solvability"; +export type { + Difficulty, + GameGraph, + GameStatus, + GraphNode, + NodeId, +} from "./types"; +export type { + ChaseRound, + GenerateChaseRoundInput, + GenerateChaseRoundOptions, +} from "./generator"; diff --git a/src/stages/games/chase/logic/model.ts b/src/stages/games/chase/logic/model.ts new file mode 100644 index 0000000..482a500 --- /dev/null +++ b/src/stages/games/chase/logic/model.ts @@ -0,0 +1,204 @@ +import { shortestPathLength } from "./hexGraph"; +import { + generateChaseRound, + type ChaseRound, + type GenerateChaseRoundInput, +} from "./generator"; +import { createRng, normalizeSeed } from "./rng"; +import type { Difficulty, NodeId } from "./types"; + +type GameStatus = "playing" | "win" | "lose"; + +type ModelSnapshot = Omit & { + status: GameStatus; +}; + +interface ChaseGameState { + snapshot: ModelSnapshot; + winCount: number; + turn: number; + loseReason?: "caught" | "trapped"; +} + +interface ChaseGameModelOptions { + random?: () => number; +} + +const GUARD_SHORTEST_RATE: Record = { + easy: 0.6, + normal: 0.85, + hard: 1, +}; + +function cloneRound(round: ChaseRound): ChaseGameState { + return { + snapshot: { + ...round.snapshot, + status: "playing", + }, + winCount: 0, + turn: 0, + loseReason: undefined, + }; +} + +function cloneSnapshot(snapshot: ModelSnapshot): ModelSnapshot { + const graph = { + nodes: Object.fromEntries( + Object.entries(snapshot.graph.nodes).map(([id, node]) => [ + id, + { ...node, neighbors: [...node.neighbors] }, + ]), + ), + edgeList: snapshot.graph.edgeList.map(([a, b]) => [a, b] as [NodeId, NodeId]), + }; + + return { + ...snapshot, + graph, + }; +} + +export class ChaseGameModel { + private state: ChaseGameState; + private initialSnapshot: ModelSnapshot; + private readonly randomOverride?: () => number; + private random: () => number; + + private constructor(state: ChaseGameState, random: () => number, randomOverride?: () => number) { + this.state = state; + this.initialSnapshot = cloneSnapshot(state.snapshot); + this.randomOverride = randomOverride; + this.random = random; + } + + static createWithSeed( + input: GenerateChaseRoundInput, + options: ChaseGameModelOptions = {}, + ): ChaseGameModel { + const round = generateChaseRound(input); + const state = cloneRound(round); + const rng = options.random ?? createRng(normalizeSeed(state.snapshot.seed)); + return new ChaseGameModel(state, rng, options.random); + } + + getState(): ChaseGameState { + return { + ...this.state, + snapshot: cloneSnapshot(this.state.snapshot), + }; + } + + getAvailableMoves(): NodeId[] { + if (this.state.snapshot.status !== "playing") { + return []; + } + + const node = this.state.snapshot.graph.nodes[this.state.snapshot.thiefNodeId]; + if (!node) { + return []; + } + + return node.neighbors.filter((id) => id !== this.state.snapshot.guardNodeId); + } + + moveThief(target: NodeId): ChaseGameState { + if (this.state.snapshot.status !== "playing") { + return this.getState(); + } + + const availableMoves = this.getAvailableMoves(); + if (!availableMoves.includes(target)) { + return this.getState(); + } + + this.state.turn += 1; + this.state.snapshot.thiefNodeId = target; + 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(); + } + + this.moveGuardOneStep(); + + 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(); + } + + retryRound(): ChaseGameState { + const winCount = this.state.winCount; + this.resetRng(this.initialSnapshot.seed); + this.state = { + snapshot: cloneSnapshot(this.initialSnapshot), + winCount, + turn: 0, + loseReason: undefined, + }; + return this.getState(); + } + + newRound(seed: number | string, difficulty: Difficulty): ChaseGameState { + const round = generateChaseRound({ seed, difficulty }); + const winCount = this.state.winCount; + const newState = cloneRound(round); + this.state = { ...newState, winCount }; + this.initialSnapshot = cloneSnapshot(newState.snapshot); + this.resetRng(newState.snapshot.seed); + return this.getState(); + } + + private resetRng(seed: number): void { + if (this.randomOverride) { + this.random = this.randomOverride; + return; + } + this.random = createRng(normalizeSeed(seed)); + } + + private moveGuardOneStep(): void { + const guardNode = this.state.snapshot.graph.nodes[this.state.snapshot.guardNodeId]; + if (!guardNode || guardNode.neighbors.length === 0) { + return; + } + + const movesWithDistance = guardNode.neighbors.map((neighborId) => ({ + nodeId: neighborId, + distance: shortestPathLength( + this.state.snapshot.graph, + neighborId, + this.state.snapshot.thiefNodeId, + ), + })); + + const finiteMoves = movesWithDistance.filter((item) => Number.isFinite(item.distance)); + if (finiteMoves.length === 0) { + return; + } + + finiteMoves.sort((a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)); + const shortestDistance = finiteMoves[0].distance; + const shortestMoves = finiteMoves.filter((item) => item.distance === shortestDistance); + const nonShortestMoves = finiteMoves.filter((item) => item.distance !== shortestDistance); + + const shortestRate = GUARD_SHORTEST_RATE[this.state.snapshot.difficulty]; + let nextMove = shortestMoves[0]; + if (shortestRate < 1 && this.random() > shortestRate && nonShortestMoves.length > 0) { + const idx = Math.floor(this.random() * nonShortestMoves.length); + nextMove = nonShortestMoves[idx]; + } + + this.state.snapshot.guardNodeId = nextMove.nodeId; + } +} diff --git a/src/stages/games/chase/logic/rng.ts b/src/stages/games/chase/logic/rng.ts new file mode 100644 index 0000000..ccb1126 --- /dev/null +++ b/src/stages/games/chase/logic/rng.ts @@ -0,0 +1,54 @@ +export type Rng = () => number; + +const DEFAULT_SEED = 0x9e3779b9; + +function toUint32(value: number): number { + return value >>> 0; +} + +function hashString(input: string): number { + let hash = 2166136261; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return hash >>> 0; +} + +export function normalizeSeed(input?: string | number): number { + // Seed normalization contract (always returns uint32): + // 1) finite number -> ToUint32 (e.g. -1 => 0xffffffff, 1.9 => 1) + // 2) numeric string -> parse then ToUint32 + // 3) non-numeric string -> stable hash -> uint32 + // 4) undefined / empty / whitespace / NaN / non-finite -> DEFAULT_SEED + if (typeof input === "number" && Number.isFinite(input)) { + return toUint32(input); + } + + if (typeof input === "string") { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return DEFAULT_SEED; + } + + const numericSeed = Number(trimmed); + if (Number.isFinite(numericSeed)) { + return toUint32(numericSeed); + } + + return hashString(trimmed); + } + + return DEFAULT_SEED; +} + +export function createRng(seed: number): Rng { + let state = normalizeSeed(seed); + + return () => { + state = toUint32(state + 0x6d2b79f5); + let t = Math.imul(state ^ (state >>> 15), 1 | state); + t ^= t + Math.imul(t ^ (t >>> 7), 61 | t); + return toUint32(t ^ (t >>> 14)) / 4294967296; + }; +} diff --git a/src/stages/games/chase/logic/solvability.ts b/src/stages/games/chase/logic/solvability.ts new file mode 100644 index 0000000..555cbe5 --- /dev/null +++ b/src/stages/games/chase/logic/solvability.ts @@ -0,0 +1,103 @@ +import { shortestPathLength } from "./hexGraph"; +import type { GameGraph, NodeId } from "./types"; + +/** + * 官兵一步:始终走离小偷最短的邻格(与 ChaseGameModel hard 档一致,tie-break 按 nodeId)。 + */ +export function optimalGuardStep(graph: GameGraph, guard: NodeId, thief: NodeId): NodeId { + const guardNode = graph.nodes[guard]; + if (!guardNode || guardNode.neighbors.length === 0) { + return guard; + } + + const movesWithDistance = guardNode.neighbors.map((neighborId) => ({ + nodeId: neighborId, + distance: shortestPathLength(graph, neighborId, thief), + })); + + const finiteMoves = movesWithDistance.filter((item) => Number.isFinite(item.distance)); + if (finiteMoves.length === 0) { + return guard; + } + + finiteMoves.sort((a, b) => a.distance - b.distance || a.nodeId.localeCompare(b.nodeId)); + return finiteMoves[0].nodeId; +} + +function stateKey(thief: NodeId, guard: NodeId): string { + return `${thief}|${guard}`; +} + +/** + * 是否存在小偷的必胜策略(对最优追击官兵)。 + * 回合:小偷先走一步;若未到出口,官兵走一步;重复。 + * + * 用交替可达性的最小不动点处理状态环,避免递归栈溢出。 + */ +export function thiefHasWinningStrategy( + graph: GameGraph, + thiefStart: NodeId, + guardStart: NodeId, + exitNodeId: NodeId, +): boolean { + if (thiefStart === guardStart) { + return false; + } + + const nodeIds = Object.keys(graph.nodes); + const pairs: Array<[NodeId, NodeId]> = []; + for (const t of nodeIds) { + for (const g of nodeIds) { + if (t !== g) { + pairs.push([t, g]); + } + } + } + + const winning = new Set(); + + for (const [t, g] of pairs) { + const tNode = graph.nodes[t]; + if (!tNode) { + continue; + } + const canStepToExit = tNode.neighbors.some((m) => m !== g && m === exitNodeId); + if (canStepToExit) { + winning.add(stateKey(t, g)); + } + } + + let changed = true; + while (changed) { + changed = false; + for (const [t, g] of pairs) { + const key = stateKey(t, g); + if (winning.has(key)) { + continue; + } + const tNode = graph.nodes[t]; + if (!tNode) { + continue; + } + for (const m of tNode.neighbors) { + if (m === g) { + continue; + } + if (m === exitNodeId) { + continue; + } + const g2 = optimalGuardStep(graph, g, m); + if (g2 === m) { + continue; + } + if (winning.has(stateKey(m, g2))) { + winning.add(key); + changed = true; + break; + } + } + } + } + + return winning.has(stateKey(thiefStart, guardStart)); +} diff --git a/src/stages/games/chase/logic/types.ts b/src/stages/games/chase/logic/types.ts new file mode 100644 index 0000000..774c352 --- /dev/null +++ b/src/stages/games/chase/logic/types.ts @@ -0,0 +1,17 @@ +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; + edgeList: Array<[NodeId, NodeId]>; +} diff --git a/src/stages/games/chase/page_chase.ts b/src/stages/games/chase/page_chase.ts new file mode 100644 index 0000000..91bd63b --- /dev/null +++ b/src/stages/games/chase/page_chase.ts @@ -0,0 +1,711 @@ +import { BaseScene } from "@/scene/BaseScene"; +import { sceneManager } from "@/scene/SceneManager"; +import { SceneType } from "@/enums/SceneType"; +import { appRuntime } from "@/kernel/AppRuntime"; +import { ChaseGameModel } from "./logic/model"; +import type { Difficulty, NodeId } from "./logic/types"; +import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; + +const NODE_RADIUS = 24; +const NODE_GAP = 76; +const END_ACTION_COOLDOWN_MS = 400; +const PAD_X = 20; +const PAD_Y = 12; +/** 顶栏 + 开局控件占用高度(用于把地图放到下方安全区) */ +const TOP_CHROME_H = 200; +const SEED_INPUT_WIDTH = 220; + +const TOKEN_LABEL_STYLE = new TextStyle({ + fontSize: 15, + fontWeight: "700", + fill: 0xffffff, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + stroke: { color: 0x0f172a, width: 3 }, +}); + +type ChaseModelLike = Pick< + ChaseGameModel, + "getState" | "moveThief" | "newRound" | "retryRound" +>; +type DocumentLike = { + body: { + appendChild: (node: HTMLInputElement) => void; + contains: (node: HTMLInputElement) => boolean; + }; + createElement: (tag: string) => HTMLInputElement; +}; +type ChaseSceneOptions = { + model?: ChaseModelLike; + modelFactory?: () => ChaseModelLike; + documentRef?: DocumentLike; +}; + +export default class ChaseScene extends BaseScene { + stage = new Container(); + private model?: ChaseModelLike; + private readonly modelFactory?: () => ChaseModelLike; + private hudText?: Text; + private winCountText?: Text; + private seedText?: Text; + 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 retryLabel = new Text({ text: "" }); + private newGameLabel = new Text({ text: "" }); + private restartLabel = new Text({ text: "" }); + private seedInputElement?: HTMLInputElement; + private readonly documentRef?: DocumentLike; + private selectedDifficulty: Difficulty = "normal"; + private seedInput = ""; + private difficultyLocked = false; + private inputAttached = false; + private unsubscribeStageChange?: () => void; + private endActionLocked = false; + private lastEndActionAt = 0; + private readonly onWindowResize = (): void => { + this.relayoutChrome(); + if (this.model && this.hudText) { + this.refreshView(); + } + }; + + constructor(options: ChaseSceneOptions = {}) { + super("chase", SceneType.Normal); + this.model = options.model; + this.modelFactory = options.modelFactory; + this.documentRef = options.documentRef; + } + + protected async onSceneLayout(): Promise { + this.stage.sortableChildren = true; + this.stage.eventMode = "passive"; + + this.hudText = new Text({ + text: "Chase: loading...", + style: new TextStyle({ + fontSize: 16, + fill: 0xe2e8f0, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + this.stage.addChild(this.hudText); + + this.winCountText = new Text({ + text: "", + style: new TextStyle({ + fontSize: 17, + fill: 0xffffff, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + this.stage.addChild(this.winCountText); + + this.seedText = new Text({ + text: "", + style: new TextStyle({ + fontSize: 17, + fill: 0xffffff, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + this.seedText.anchor.set(1, 0); + this.stage.addChild(this.seedText); + + this.setupText = new Text({ + text: "", + style: new TextStyle({ + fontSize: 14, + fill: 0x93c5fd, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + this.stage.addChild(this.setupText); + + this.setupLayer.eventMode = "passive"; + this.stage.addChild(this.setupLayer); + this.buildSetupControls(); + + this.graphLayer.eventMode = "passive"; + this.stage.addChild(this.graphLayer); + this.buildResultOverlay(); + this.stage.addChild(this.resultOverlay); + + this.relayoutChrome(); + if (!this.model) { + this.model = + this.modelFactory?.() ?? + ChaseGameModel.createWithSeed({ + seed: 20260426, + difficulty: "normal", + }); + } + this.refreshView(); + } + + protected onSceneEnter(): void { + if (typeof globalThis !== "undefined" && "addEventListener" in globalThis) { + globalThis.addEventListener("resize", this.onWindowResize); + } + if (!this.unsubscribeStageChange) { + this.unsubscribeStageChange = sceneManager.onStageChange((current) => { + if (current.name !== this.name) { + this.detachSeedInputBridge(); + return; + } + this.syncSeedInputBridge(); + }); + } + this.syncSeedInputBridge(); + } + + protected onSceneExit(): void { + if (typeof globalThis !== "undefined" && "removeEventListener" in globalThis) { + globalThis.removeEventListener("resize", this.onWindowResize); + } + this.unsubscribeStageChange?.(); + this.unsubscribeStageChange = undefined; + this.detachSeedInputBridge(); + } + + update(_dt: number, _name: string, _ticker: Ticker): void { + this.syncSeedInputBridge(); + } + + public refreshView(): void { + if (!this.model || !this.hudText || !this.setupText || !this.winCountText || !this.seedText) { + return; + } + + const state = this.model.getState(); + this.winCountText.text = `成功次数: ${state.winCount}`; + this.seedText.text = `当前 seed:${state.snapshot.seed}`; + const roleHint = + state.snapshot.status === "playing" + ? "\n偷=你 · 兵=官兵 · 出=出口 · 点击黄圈可走格" + : ""; + this.hudText.text = `回合 ${state.turn} | ${state.snapshot.status} | 偷@${state.snapshot.thiefNodeId} 兵@${state.snapshot.guardNodeId}${roleHint}`; + 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) { + child.destroy({ children: true }); + } + + const nodes = Object.values(state.snapshot.graph.nodes); + const { offsetX, offsetY } = this.computeGraphCenterOffset(nodes); + + const thiefNode = state.snapshot.graph.nodes[state.snapshot.thiefNodeId]; + const availableMoves = thiefNode + ? thiefNode.neighbors.filter((id) => id !== state.snapshot.guardNodeId) + : []; + const availableMoveSet = new Set(availableMoves); + + // 先画边,避免压住节点与高亮 + const edgeLayer = new Graphics(); + for (const [fromId, toId] of state.snapshot.graph.edgeList) { + const from = state.snapshot.graph.nodes[fromId]; + const to = state.snapshot.graph.nodes[toId]; + if (!from || !to) { + continue; + } + const fromX = from.q * NODE_GAP + offsetX; + const fromY = from.r * NODE_GAP + offsetY; + const toX = to.q * NODE_GAP + offsetX; + const toY = to.r * NODE_GAP + offsetY; + edgeLayer.moveTo(fromX, fromY); + edgeLayer.lineTo(toX, toY); + } + edgeLayer.stroke({ width: 3.5, color: 0xf1f5f9, alpha: 0.55 }); + this.graphLayer.addChild(edgeLayer); + for (const nodeId of availableMoves) { + const node = state.snapshot.graph.nodes[nodeId]; + if (!node) { + continue; + } + const highlight = new Graphics(); + highlight.label = "move-highlight"; + highlight.circle(0, 0, NODE_RADIUS + 12); + highlight.stroke({ width: 4, color: 0xfde047, alpha: 1 }); + highlight.position.set(node.q * NODE_GAP + offsetX, node.r * NODE_GAP + offsetY); + this.graphLayer.addChild(highlight); + } + + for (const node of nodes) { + const px = node.q * NODE_GAP + offsetX; + const py = node.r * NODE_GAP + offsetY; + const cell = new Container(); + cell.position.set(px, py); + cell.eventMode = "static"; + cell.cursor = "pointer"; + cell.on("pointerdown", () => this.handleNodeClick(node.id)); + + const body = new Graphics(); + body.circle(0, 0, NODE_RADIUS); + const isThief = node.id === state.snapshot.thiefNodeId; + const isGuard = node.id === state.snapshot.guardNodeId; + const isExit = node.id === state.snapshot.exitNodeId; + const isMove = availableMoveSet.has(node.id); + const color = isThief + ? 0x16a34a + : isGuard + ? 0xdc2626 + : isExit + ? 0x2563eb + : isMove + ? 0x0ea5e9 + : 0x475569; + const dim = + state.snapshot.status === "playing" && !isThief && !isGuard && !isExit && !isMove; + body.fill({ color, alpha: dim ? 0.42 : 0.98 }); + body.stroke({ width: isThief || isGuard ? 3 : 2, color: 0xf8fafc, alpha: dim ? 0.35 : 0.95 }); + cell.addChild(body); + + if (isThief) { + const t = new Text({ text: "偷", style: TOKEN_LABEL_STYLE }); + t.anchor.set(0.5, 0.5); + t.position.set(0, 0); + cell.addChild(t); + } else if (isGuard) { + const mark = new Graphics(); + mark.roundRect(-9, -11, 18, 20, 3); + mark.fill({ color: 0xe2e8f0, alpha: 0.95 }); + mark.stroke({ width: 1.5, color: 0x0f172a, alpha: 0.85 }); + cell.addChild(mark); + const t = new Text({ text: "兵", style: TOKEN_LABEL_STYLE }); + t.anchor.set(0.5, 0.5); + t.position.set(0, 0); + cell.addChild(t); + } else if (isExit) { + const t = new Text({ text: "出", style: TOKEN_LABEL_STYLE }); + t.anchor.set(0.5, 0.5); + t.position.set(0, 0); + cell.addChild(t); + } + + this.graphLayer.addChild(cell); + } + + this.applyGraphAutoscale(nodes, offsetX, offsetY); + } + + /** 在可视区内尽量放大棋盘(上限避免过大) */ + private applyGraphAutoscale( + nodes: Array<{ q: number; r: number }>, + offsetX: number, + offsetY: number, + ): void { + if (nodes.length === 0) { + this.graphLayer.scale.set(1); + return; + } + let minPx = Infinity; + let maxPx = -Infinity; + let minPy = Infinity; + let maxPy = -Infinity; + for (const n of nodes) { + const px = n.q * NODE_GAP + offsetX; + const py = n.r * NODE_GAP + offsetY; + minPx = Math.min(minPx, px - NODE_RADIUS - 14); + maxPx = Math.max(maxPx, px + NODE_RADIUS + 14); + minPy = Math.min(minPy, py - NODE_RADIUS - 14); + maxPy = Math.max(maxPy, py + NODE_RADIUS + 14); + } + const bw = Math.max(maxPx - minPx, 40); + const bh = Math.max(maxPy - minPy, 40); + const { width, height } = appRuntime.game.getInfo(); + const playTop = Math.min(TOP_CHROME_H, height * 0.28); + const margin = 48; + const availW = Math.max(120, width - margin * 2); + const availH = Math.max(120, height - playTop - margin * 2); + const sx = availW / bw; + const sy = availH / bh; + const s = Math.min(1.85, Math.max(1.05, Math.min(sx, sy))); + this.graphLayer.scale.set(s); + } + + /** 将节点包络中心移到局部原点,避免负坐标画进顶栏 */ + private computeGraphCenterOffset( + nodes: Array<{ q: number; r: number }>, + ): { offsetX: number; offsetY: number } { + if (nodes.length === 0) { + return { offsetX: 0, offsetY: 0 }; + } + let minX = Infinity; + let maxX = -Infinity; + let minY = Infinity; + let maxY = -Infinity; + for (const node of nodes) { + const x = node.q * NODE_GAP; + const y = node.r * NODE_GAP; + minX = Math.min(minX, x); + maxX = Math.max(maxX, x); + minY = Math.min(minY, y); + maxY = Math.max(maxY, y); + } + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + return { offsetX: -cx, offsetY: -cy }; + } + + private relayoutChrome(): void { + const { width, height } = appRuntime.game.getInfo(); + const safeTop = PAD_Y; + this.winCountText?.position.set(PAD_X, safeTop); + this.seedText?.position.set(width - PAD_X, safeTop); + this.hudText?.position.set(PAD_X, safeTop + 26); + this.setupText?.position.set(PAD_X, safeTop + 72); + this.setupLayer.position.set(PAD_X, safeTop + 98); + + const playTop = Math.min(TOP_CHROME_H, height * 0.28); + const playCenterY = playTop + (height - playTop) / 2; + this.graphLayer.position.set(width / 2, playCenterY); + + this.resultOverlay.position.set(width / 2 - 120, playTop + 8); + + this.positionSeedInputDom(); + } + + private positionSeedInputDom(): void { + if (!this.seedInputElement) { + return; + } + const topPx = this.setupLayer.position.y + 52; + this.seedInputElement.style.left = `${PAD_X}px`; + this.seedInputElement.style.top = `${topPx}px`; + this.seedInputElement.style.width = `${SEED_INPUT_WIDTH}px`; + } + + private handleNodeClick(targetNodeId: NodeId): void { + if (!this.model) { + return; + } + this.model.moveThief(targetNodeId); + this.refreshView(); + } + + public setDifficulty(difficulty: Difficulty): void { + if (this.difficultyLocked) { + return; + } + this.selectedDifficulty = difficulty; + this.refreshView(); + } + + public setSeedInput(seedInput: string): void { + this.seedInput = seedInput; + if (this.seedInputElement) { + this.seedInputElement.value = seedInput; + } + this.refreshView(); + } + + public startGame(): void { + if (!this.model) { + return; + } + const seedText = this.seedInput.trim(); + const parsed = Number(seedText); + const seed = + seedText.length === 0 || !Number.isFinite(parsed) + ? Math.floor(Math.random() * 0xffffffff) + : parsed; + + this.model.newRound(seed, this.selectedDifficulty); + this.difficultyLocked = true; + this.seedInput = `${seed}`; + if (this.seedInputElement) { + this.seedInputElement.value = this.seedInput; + } + this.refreshView(); + } + + public resetSetup(): void { + this.difficultyLocked = false; + this.seedInput = ""; + if (this.seedInputElement) { + this.seedInputElement.value = ""; + } + this.refreshView(); + } + + private handleRetryClick(): void { + if (!this.model || this.endActionLocked || !this.canRunEndAction()) { + return; + } + this.endActionLocked = true; + try { + this.model.retryRound(); + this.refreshView(); + } finally { + this.endActionLocked = false; + } + } + + private handleNewGameClick(): void { + if (!this.model || this.endActionLocked || !this.canRunEndAction()) { + return; + } + this.endActionLocked = true; + try { + this.resetSetup(); + this.startGame(); + } finally { + this.endActionLocked = false; + } + } + + private handleRestartClick(): void { + if (!this.model || this.endActionLocked || !this.canRunEndAction()) { + return; + } + this.endActionLocked = true; + try { + this.resetSetup(); + this.startGame(); + } finally { + this.endActionLocked = false; + } + } + + private buildSetupControls(): void { + this.setupLayer.removeChildren(); + const difficulties: Difficulty[] = ["easy", "normal", "hard"]; + difficulties.forEach((difficulty, index) => { + const button = this.difficultyButtons[difficulty]; + button.eventMode = "static"; + button.cursor = "pointer"; + button.removeAllListeners(); + button.on("pointerdown", () => this.setDifficulty(difficulty)); + button.position.set(index * 96, 0); + this.setupLayer.addChild(button); + + const label = new Text({ + text: difficulty, + style: new TextStyle({ + fontSize: 14, + fill: 0xffffff, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + label.anchor.set(0.5); + label.position.set(index * 96 + 38, 18); + this.setupLayer.addChild(label); + }); + + this.startButton.eventMode = "static"; + this.startButton.cursor = "pointer"; + this.startButton.removeAllListeners(); + this.startButton.on("pointerdown", () => this.startGame()); + this.startButton.position.set(304, 0); + this.setupLayer.addChild(this.startButton); + + const startLabel = new Text({ + text: "开始游戏", + style: new TextStyle({ + fontSize: 14, + fill: 0xffffff, + fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", + }), + }); + startLabel.anchor.set(0.5); + startLabel.position.set(304 + 48, 18); + this.setupLayer.addChild(startLabel); + + this.drawDifficultyButtons(); + this.startButton.clear(); + this.startButton.roundRect(0, 0, 96, 36, 8); + this.startButton.fill({ color: 0x2563eb, alpha: 0.95 }); + } + + private buildResultOverlay(): void { + this.resultOverlay.visible = false; + this.resultOverlay.eventMode = "passive"; + + 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.retryLabel = this.createOverlayLabel("重试", 43, 59); + this.resultOverlay.addChild(this.retryLabel); + + 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.newGameLabel = this.createOverlayLabel("新一局", 146, 59); + this.resultOverlay.addChild(this.newGameLabel); + + 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.restartLabel = this.createOverlayLabel("重新开始", 54, 59); + this.resultOverlay.addChild(this.restartLabel); + } + + 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]; + button.clear(); + button.roundRect(0, 0, 76, 36, 8); + const selected = this.selectedDifficulty === difficulty; + const lockedColor = this.difficultyLocked ? 0x475569 : selected ? 0x0ea5e9 : 0x1f2937; + button.fill({ color: lockedColor, alpha: selected ? 0.95 : 0.88 }); + button.stroke({ width: selected ? 2 : 1, color: 0xe2e8f0, alpha: 0.9 }); + button.cursor = this.difficultyLocked ? "not-allowed" : "pointer"; + }; + drawButton("easy"); + drawButton("normal"); + drawButton("hard"); + } + + private attachSeedInputBridge(): void { + const doc = this.resolveDocument(); + if (!doc || this.seedInputElement || this.inputAttached) { + return; + } + const input = doc.createElement("input"); + input.type = "text"; + input.placeholder = "Seed (optional)"; + input.value = this.seedInput; + input.style.position = "fixed"; + input.style.zIndex = "10"; + input.style.boxSizing = "border-box"; + input.style.padding = "6px 10px"; + input.style.fontSize = "14px"; + input.style.borderRadius = "6px"; + input.style.border = "1px solid #94a3b8"; + input.addEventListener("input", this.handleSeedInputEvent); + doc.body.appendChild(input); + this.seedInputElement = input; + this.inputAttached = true; + this.positionSeedInputDom(); + } + + private detachSeedInputBridge(): void { + if (!this.seedInputElement) { + this.inputAttached = false; + return; + } + this.seedInputElement.removeEventListener("input", this.handleSeedInputEvent); + this.seedInputElement.remove(); + this.seedInputElement = undefined; + this.inputAttached = false; + } + + private syncSeedInputBridge(): void { + if (!this.resolveDocument()) { + return; + } + if (this.stage.visible) { + this.attachSeedInputBridge(); + } else { + this.detachSeedInputBridge(); + } + } + + private readonly handleSeedInputEvent = (event: Event): void => { + const target = event.target as HTMLInputElement | null; + this.seedInput = target?.value ?? ""; + this.refreshView(); + }; + + private resolveDocument(): DocumentLike | undefined { + if (this.documentRef) { + return this.documentRef; + } + if (typeof document !== "undefined") { + return document; + } + 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.restartLabel.visible = true; + this.retryButton.visible = false; + this.retryLabel.visible = false; + this.newGameButton.visible = false; + this.newGameLabel.visible = false; + return; + } + + this.resultText.text = loseReason === "trapped" ? "你已无路可走" : "你被抓住了"; + this.restartButton.visible = false; + this.restartLabel.visible = false; + this.retryButton.visible = true; + this.retryLabel.visible = true; + this.newGameButton.visible = true; + this.newGameLabel.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/src/stages/page_chase.ts b/src/stages/page_chase.ts deleted file mode 100644 index 63ed952..0000000 --- a/src/stages/page_chase.ts +++ /dev/null @@ -1,711 +0,0 @@ -import { BaseScene } from "@/scene/BaseScene"; -import { sceneManager } from "@/scene/SceneManager"; -import { SceneType } from "@/enums/SceneType"; -import { appRuntime } from "@/kernel/AppRuntime"; -import { ChaseGameModel } from "@/game/chase/model"; -import type { Difficulty, NodeId } from "@/game/chase/types"; -import { Container, Graphics, Text, TextStyle, type Ticker } from "pixi.js"; - -const NODE_RADIUS = 24; -const NODE_GAP = 76; -const END_ACTION_COOLDOWN_MS = 400; -const PAD_X = 20; -const PAD_Y = 12; -/** 顶栏 + 开局控件占用高度(用于把地图放到下方安全区) */ -const TOP_CHROME_H = 200; -const SEED_INPUT_WIDTH = 220; - -const TOKEN_LABEL_STYLE = new TextStyle({ - fontSize: 15, - fontWeight: "700", - fill: 0xffffff, - fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - stroke: { color: 0x0f172a, width: 3 }, -}); - -type ChaseModelLike = Pick< - ChaseGameModel, - "getState" | "moveThief" | "newRound" | "retryRound" ->; -type DocumentLike = { - body: { - appendChild: (node: HTMLInputElement) => void; - contains: (node: HTMLInputElement) => boolean; - }; - createElement: (tag: string) => HTMLInputElement; -}; -type ChaseSceneOptions = { - model?: ChaseModelLike; - modelFactory?: () => ChaseModelLike; - documentRef?: DocumentLike; -}; - -export default class ChaseScene extends BaseScene { - stage = new Container(); - private model?: ChaseModelLike; - private readonly modelFactory?: () => ChaseModelLike; - private hudText?: Text; - private winCountText?: Text; - private seedText?: Text; - 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 retryLabel = new Text({ text: "" }); - private newGameLabel = new Text({ text: "" }); - private restartLabel = new Text({ text: "" }); - private seedInputElement?: HTMLInputElement; - private readonly documentRef?: DocumentLike; - private selectedDifficulty: Difficulty = "normal"; - private seedInput = ""; - private difficultyLocked = false; - private inputAttached = false; - private unsubscribeStageChange?: () => void; - private endActionLocked = false; - private lastEndActionAt = 0; - private readonly onWindowResize = (): void => { - this.relayoutChrome(); - if (this.model && this.hudText) { - this.refreshView(); - } - }; - - constructor(options: ChaseSceneOptions = {}) { - super("chase", SceneType.Normal); - this.model = options.model; - this.modelFactory = options.modelFactory; - this.documentRef = options.documentRef; - } - - protected async onSceneLayout(): Promise { - this.stage.sortableChildren = true; - this.stage.eventMode = "passive"; - - this.hudText = new Text({ - text: "Chase: loading...", - style: new TextStyle({ - fontSize: 16, - fill: 0xe2e8f0, - fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - }), - }); - this.stage.addChild(this.hudText); - - this.winCountText = new Text({ - text: "", - style: new TextStyle({ - fontSize: 17, - fill: 0xffffff, - fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - }), - }); - this.stage.addChild(this.winCountText); - - this.seedText = new Text({ - text: "", - style: new TextStyle({ - fontSize: 17, - fill: 0xffffff, - fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - }), - }); - this.seedText.anchor.set(1, 0); - this.stage.addChild(this.seedText); - - this.setupText = new Text({ - text: "", - style: new TextStyle({ - fontSize: 14, - fill: 0x93c5fd, - fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - }), - }); - this.stage.addChild(this.setupText); - - this.setupLayer.eventMode = "passive"; - this.stage.addChild(this.setupLayer); - this.buildSetupControls(); - - this.graphLayer.eventMode = "passive"; - this.stage.addChild(this.graphLayer); - this.buildResultOverlay(); - this.stage.addChild(this.resultOverlay); - - this.relayoutChrome(); - if (!this.model) { - this.model = - this.modelFactory?.() ?? - ChaseGameModel.createWithSeed({ - seed: 20260426, - difficulty: "normal", - }); - } - this.refreshView(); - } - - protected onSceneEnter(): void { - if (typeof globalThis !== "undefined" && "addEventListener" in globalThis) { - globalThis.addEventListener("resize", this.onWindowResize); - } - if (!this.unsubscribeStageChange) { - this.unsubscribeStageChange = sceneManager.onStageChange((current) => { - if (current.name !== this.name) { - this.detachSeedInputBridge(); - return; - } - this.syncSeedInputBridge(); - }); - } - this.syncSeedInputBridge(); - } - - protected onSceneExit(): void { - if (typeof globalThis !== "undefined" && "removeEventListener" in globalThis) { - globalThis.removeEventListener("resize", this.onWindowResize); - } - this.unsubscribeStageChange?.(); - this.unsubscribeStageChange = undefined; - this.detachSeedInputBridge(); - } - - update(_dt: number, _name: string, _ticker: Ticker): void { - this.syncSeedInputBridge(); - } - - public refreshView(): void { - if (!this.model || !this.hudText || !this.setupText || !this.winCountText || !this.seedText) { - return; - } - - const state = this.model.getState(); - this.winCountText.text = `成功次数: ${state.winCount}`; - this.seedText.text = `当前 seed:${state.snapshot.seed}`; - const roleHint = - state.snapshot.status === "playing" - ? "\n偷=你 · 兵=官兵 · 出=出口 · 点击黄圈可走格" - : ""; - this.hudText.text = `回合 ${state.turn} | ${state.snapshot.status} | 偷@${state.snapshot.thiefNodeId} 兵@${state.snapshot.guardNodeId}${roleHint}`; - 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) { - child.destroy({ children: true }); - } - - const nodes = Object.values(state.snapshot.graph.nodes); - const { offsetX, offsetY } = this.computeGraphCenterOffset(nodes); - - const thiefNode = state.snapshot.graph.nodes[state.snapshot.thiefNodeId]; - const availableMoves = thiefNode - ? thiefNode.neighbors.filter((id) => id !== state.snapshot.guardNodeId) - : []; - const availableMoveSet = new Set(availableMoves); - - // 先画边,避免压住节点与高亮 - const edgeLayer = new Graphics(); - for (const [fromId, toId] of state.snapshot.graph.edgeList) { - const from = state.snapshot.graph.nodes[fromId]; - const to = state.snapshot.graph.nodes[toId]; - if (!from || !to) { - continue; - } - const fromX = from.q * NODE_GAP + offsetX; - const fromY = from.r * NODE_GAP + offsetY; - const toX = to.q * NODE_GAP + offsetX; - const toY = to.r * NODE_GAP + offsetY; - edgeLayer.moveTo(fromX, fromY); - edgeLayer.lineTo(toX, toY); - } - edgeLayer.stroke({ width: 3.5, color: 0xf1f5f9, alpha: 0.55 }); - this.graphLayer.addChild(edgeLayer); - for (const nodeId of availableMoves) { - const node = state.snapshot.graph.nodes[nodeId]; - if (!node) { - continue; - } - const highlight = new Graphics(); - highlight.label = "move-highlight"; - highlight.circle(0, 0, NODE_RADIUS + 12); - highlight.stroke({ width: 4, color: 0xfde047, alpha: 1 }); - highlight.position.set(node.q * NODE_GAP + offsetX, node.r * NODE_GAP + offsetY); - this.graphLayer.addChild(highlight); - } - - for (const node of nodes) { - const px = node.q * NODE_GAP + offsetX; - const py = node.r * NODE_GAP + offsetY; - const cell = new Container(); - cell.position.set(px, py); - cell.eventMode = "static"; - cell.cursor = "pointer"; - cell.on("pointerdown", () => this.handleNodeClick(node.id)); - - const body = new Graphics(); - body.circle(0, 0, NODE_RADIUS); - const isThief = node.id === state.snapshot.thiefNodeId; - const isGuard = node.id === state.snapshot.guardNodeId; - const isExit = node.id === state.snapshot.exitNodeId; - const isMove = availableMoveSet.has(node.id); - const color = isThief - ? 0x16a34a - : isGuard - ? 0xdc2626 - : isExit - ? 0x2563eb - : isMove - ? 0x0ea5e9 - : 0x475569; - const dim = - state.snapshot.status === "playing" && !isThief && !isGuard && !isExit && !isMove; - body.fill({ color, alpha: dim ? 0.42 : 0.98 }); - body.stroke({ width: isThief || isGuard ? 3 : 2, color: 0xf8fafc, alpha: dim ? 0.35 : 0.95 }); - cell.addChild(body); - - if (isThief) { - const t = new Text({ text: "偷", style: TOKEN_LABEL_STYLE }); - t.anchor.set(0.5, 0.5); - t.position.set(0, 0); - cell.addChild(t); - } else if (isGuard) { - const mark = new Graphics(); - mark.roundRect(-9, -11, 18, 20, 3); - mark.fill({ color: 0xe2e8f0, alpha: 0.95 }); - mark.stroke({ width: 1.5, color: 0x0f172a, alpha: 0.85 }); - cell.addChild(mark); - const t = new Text({ text: "兵", style: TOKEN_LABEL_STYLE }); - t.anchor.set(0.5, 0.5); - t.position.set(0, 0); - cell.addChild(t); - } else if (isExit) { - const t = new Text({ text: "出", style: TOKEN_LABEL_STYLE }); - t.anchor.set(0.5, 0.5); - t.position.set(0, 0); - cell.addChild(t); - } - - this.graphLayer.addChild(cell); - } - - this.applyGraphAutoscale(nodes, offsetX, offsetY); - } - - /** 在可视区内尽量放大棋盘(上限避免过大) */ - private applyGraphAutoscale( - nodes: Array<{ q: number; r: number }>, - offsetX: number, - offsetY: number, - ): void { - if (nodes.length === 0) { - this.graphLayer.scale.set(1); - return; - } - let minPx = Infinity; - let maxPx = -Infinity; - let minPy = Infinity; - let maxPy = -Infinity; - for (const n of nodes) { - const px = n.q * NODE_GAP + offsetX; - const py = n.r * NODE_GAP + offsetY; - minPx = Math.min(minPx, px - NODE_RADIUS - 14); - maxPx = Math.max(maxPx, px + NODE_RADIUS + 14); - minPy = Math.min(minPy, py - NODE_RADIUS - 14); - maxPy = Math.max(maxPy, py + NODE_RADIUS + 14); - } - const bw = Math.max(maxPx - minPx, 40); - const bh = Math.max(maxPy - minPy, 40); - const { width, height } = appRuntime.game.getInfo(); - const playTop = Math.min(TOP_CHROME_H, height * 0.28); - const margin = 48; - const availW = Math.max(120, width - margin * 2); - const availH = Math.max(120, height - playTop - margin * 2); - const sx = availW / bw; - const sy = availH / bh; - const s = Math.min(1.85, Math.max(1.05, Math.min(sx, sy))); - this.graphLayer.scale.set(s); - } - - /** 将节点包络中心移到局部原点,避免负坐标画进顶栏 */ - private computeGraphCenterOffset( - nodes: Array<{ q: number; r: number }>, - ): { offsetX: number; offsetY: number } { - if (nodes.length === 0) { - return { offsetX: 0, offsetY: 0 }; - } - let minX = Infinity; - let maxX = -Infinity; - let minY = Infinity; - let maxY = -Infinity; - for (const node of nodes) { - const x = node.q * NODE_GAP; - const y = node.r * NODE_GAP; - minX = Math.min(minX, x); - maxX = Math.max(maxX, x); - minY = Math.min(minY, y); - maxY = Math.max(maxY, y); - } - const cx = (minX + maxX) / 2; - const cy = (minY + maxY) / 2; - return { offsetX: -cx, offsetY: -cy }; - } - - private relayoutChrome(): void { - const { width, height } = appRuntime.game.getInfo(); - const safeTop = PAD_Y; - this.winCountText?.position.set(PAD_X, safeTop); - this.seedText?.position.set(width - PAD_X, safeTop); - this.hudText?.position.set(PAD_X, safeTop + 26); - this.setupText?.position.set(PAD_X, safeTop + 72); - this.setupLayer.position.set(PAD_X, safeTop + 98); - - const playTop = Math.min(TOP_CHROME_H, height * 0.28); - const playCenterY = playTop + (height - playTop) / 2; - this.graphLayer.position.set(width / 2, playCenterY); - - this.resultOverlay.position.set(width / 2 - 120, playTop + 8); - - this.positionSeedInputDom(); - } - - private positionSeedInputDom(): void { - if (!this.seedInputElement) { - return; - } - const topPx = this.setupLayer.position.y + 52; - this.seedInputElement.style.left = `${PAD_X}px`; - this.seedInputElement.style.top = `${topPx}px`; - this.seedInputElement.style.width = `${SEED_INPUT_WIDTH}px`; - } - - private handleNodeClick(targetNodeId: NodeId): void { - if (!this.model) { - return; - } - this.model.moveThief(targetNodeId); - this.refreshView(); - } - - public setDifficulty(difficulty: Difficulty): void { - if (this.difficultyLocked) { - return; - } - this.selectedDifficulty = difficulty; - this.refreshView(); - } - - public setSeedInput(seedInput: string): void { - this.seedInput = seedInput; - if (this.seedInputElement) { - this.seedInputElement.value = seedInput; - } - this.refreshView(); - } - - public startGame(): void { - if (!this.model) { - return; - } - const seedText = this.seedInput.trim(); - const parsed = Number(seedText); - const seed = - seedText.length === 0 || !Number.isFinite(parsed) - ? Math.floor(Math.random() * 0xffffffff) - : parsed; - - this.model.newRound(seed, this.selectedDifficulty); - this.difficultyLocked = true; - this.seedInput = `${seed}`; - if (this.seedInputElement) { - this.seedInputElement.value = this.seedInput; - } - this.refreshView(); - } - - public resetSetup(): void { - this.difficultyLocked = false; - this.seedInput = ""; - if (this.seedInputElement) { - this.seedInputElement.value = ""; - } - this.refreshView(); - } - - private handleRetryClick(): void { - if (!this.model || this.endActionLocked || !this.canRunEndAction()) { - return; - } - this.endActionLocked = true; - try { - this.model.retryRound(); - this.refreshView(); - } finally { - this.endActionLocked = false; - } - } - - private handleNewGameClick(): void { - if (!this.model || this.endActionLocked || !this.canRunEndAction()) { - return; - } - this.endActionLocked = true; - try { - this.resetSetup(); - this.startGame(); - } finally { - this.endActionLocked = false; - } - } - - private handleRestartClick(): void { - if (!this.model || this.endActionLocked || !this.canRunEndAction()) { - return; - } - this.endActionLocked = true; - try { - this.resetSetup(); - this.startGame(); - } finally { - this.endActionLocked = false; - } - } - - private buildSetupControls(): void { - this.setupLayer.removeChildren(); - const difficulties: Difficulty[] = ["easy", "normal", "hard"]; - difficulties.forEach((difficulty, index) => { - const button = this.difficultyButtons[difficulty]; - button.eventMode = "static"; - button.cursor = "pointer"; - button.removeAllListeners(); - button.on("pointerdown", () => this.setDifficulty(difficulty)); - button.position.set(index * 96, 0); - this.setupLayer.addChild(button); - - const label = new Text({ - text: difficulty, - style: new TextStyle({ - fontSize: 14, - fill: 0xffffff, - fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - }), - }); - label.anchor.set(0.5); - label.position.set(index * 96 + 38, 18); - this.setupLayer.addChild(label); - }); - - this.startButton.eventMode = "static"; - this.startButton.cursor = "pointer"; - this.startButton.removeAllListeners(); - this.startButton.on("pointerdown", () => this.startGame()); - this.startButton.position.set(304, 0); - this.setupLayer.addChild(this.startButton); - - const startLabel = new Text({ - text: "开始游戏", - style: new TextStyle({ - fontSize: 14, - fill: 0xffffff, - fontFamily: "'Microsoft YaHei', 'PingFang SC', system-ui, sans-serif", - }), - }); - startLabel.anchor.set(0.5); - startLabel.position.set(304 + 48, 18); - this.setupLayer.addChild(startLabel); - - this.drawDifficultyButtons(); - this.startButton.clear(); - this.startButton.roundRect(0, 0, 96, 36, 8); - this.startButton.fill({ color: 0x2563eb, alpha: 0.95 }); - } - - private buildResultOverlay(): void { - this.resultOverlay.visible = false; - this.resultOverlay.eventMode = "passive"; - - 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.retryLabel = this.createOverlayLabel("重试", 43, 59); - this.resultOverlay.addChild(this.retryLabel); - - 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.newGameLabel = this.createOverlayLabel("新一局", 146, 59); - this.resultOverlay.addChild(this.newGameLabel); - - 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.restartLabel = this.createOverlayLabel("重新开始", 54, 59); - this.resultOverlay.addChild(this.restartLabel); - } - - 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]; - button.clear(); - button.roundRect(0, 0, 76, 36, 8); - const selected = this.selectedDifficulty === difficulty; - const lockedColor = this.difficultyLocked ? 0x475569 : selected ? 0x0ea5e9 : 0x1f2937; - button.fill({ color: lockedColor, alpha: selected ? 0.95 : 0.88 }); - button.stroke({ width: selected ? 2 : 1, color: 0xe2e8f0, alpha: 0.9 }); - button.cursor = this.difficultyLocked ? "not-allowed" : "pointer"; - }; - drawButton("easy"); - drawButton("normal"); - drawButton("hard"); - } - - private attachSeedInputBridge(): void { - const doc = this.resolveDocument(); - if (!doc || this.seedInputElement || this.inputAttached) { - return; - } - const input = doc.createElement("input"); - input.type = "text"; - input.placeholder = "Seed (optional)"; - input.value = this.seedInput; - input.style.position = "fixed"; - input.style.zIndex = "10"; - input.style.boxSizing = "border-box"; - input.style.padding = "6px 10px"; - input.style.fontSize = "14px"; - input.style.borderRadius = "6px"; - input.style.border = "1px solid #94a3b8"; - input.addEventListener("input", this.handleSeedInputEvent); - doc.body.appendChild(input); - this.seedInputElement = input; - this.inputAttached = true; - this.positionSeedInputDom(); - } - - private detachSeedInputBridge(): void { - if (!this.seedInputElement) { - this.inputAttached = false; - return; - } - this.seedInputElement.removeEventListener("input", this.handleSeedInputEvent); - this.seedInputElement.remove(); - this.seedInputElement = undefined; - this.inputAttached = false; - } - - private syncSeedInputBridge(): void { - if (!this.resolveDocument()) { - return; - } - if (this.stage.visible) { - this.attachSeedInputBridge(); - } else { - this.detachSeedInputBridge(); - } - } - - private readonly handleSeedInputEvent = (event: Event): void => { - const target = event.target as HTMLInputElement | null; - this.seedInput = target?.value ?? ""; - this.refreshView(); - }; - - private resolveDocument(): DocumentLike | undefined { - if (this.documentRef) { - return this.documentRef; - } - if (typeof document !== "undefined") { - return document; - } - 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.restartLabel.visible = true; - this.retryButton.visible = false; - this.retryLabel.visible = false; - this.newGameButton.visible = false; - this.newGameLabel.visible = false; - return; - } - - this.resultText.text = loseReason === "trapped" ? "你已无路可走" : "你被抓住了"; - this.restartButton.visible = false; - this.restartLabel.visible = false; - this.retryButton.visible = true; - this.retryLabel.visible = true; - this.newGameButton.visible = true; - this.newGameLabel.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/chaseGenerator.test.ts b/tests/chase/chaseGenerator.test.ts index 8c0003b..dbb56c2 100644 --- a/tests/chase/chaseGenerator.test.ts +++ b/tests/chase/chaseGenerator.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { shortestPathLength } from "../../src/game/chase/hexGraph"; -import { generateChaseRound } from "../../src/game/chase/generator"; -import type { Difficulty } from "../../src/game/chase/types"; +import { shortestPath, shortestPathLength } from "../../src/stages/games/chase/logic/hexGraph"; +import { generateChaseRound } from "../../src/stages/games/chase/logic/generator"; +import { thiefHasWinningStrategy } from "../../src/stages/games/chase/logic/solvability"; +import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types"; describe("chase round generator", () => { it("is deterministic for same seed and difficulty", () => { @@ -31,23 +32,23 @@ describe("chase round generator", () => { }).toMatchInlineSnapshot(` { "difficulty": "easy", - "edgeCount": 19, - "exitNodeId": "0,-1", + "edgeCount": 25, + "exitNodeId": "-2,0", "firstFiveNodeIds": [ "0,0", - "1,-1", - "2,-2", - "2,-1", - "1,-2", + "0,1", + "0,-1", + "-1,1", + "-1,2", ], - "guardNodeId": "0,0", - "guardStartNodeId": "0,0", + "guardNodeId": "-2,1", + "guardStartNodeId": "-2,1", "hasEscapePath": true, - "nodeCount": 20, - "seed": 424242, + "nodeCount": 24, + "seed": 2654860011, "status": "playing", - "thiefNodeId": "4,-5", - "thiefStartNodeId": "4,-5", + "thiefNodeId": "2,-1", + "thiefStartNodeId": "2,-1", } `); }); @@ -91,6 +92,25 @@ describe("chase round generator", () => { } }); + it("places guard strictly between thief and exit on the canonical shortest path", () => { + for (let seed = 1; seed <= 40; seed += 1) { + for (const difficulty of ["easy", "normal", "hard"] as const) { + const round = generateChaseRound({ seed, difficulty }); + const path = shortestPath( + round.snapshot.graph, + round.snapshot.thiefStartNodeId, + round.snapshot.exitNodeId, + ); + expect(path.length).toBeGreaterThanOrEqual(3); + const inner = path.slice(1, -1); + expect(inner).toContain(round.snapshot.guardStartNodeId); + expect(round.meta.pathLength).toBeGreaterThanOrEqual( + difficulty === "easy" ? 3 : difficulty === "hard" ? 6 : 4, + ); + } + } + }); + it("throws after retry limit when all attempts are unplayable", () => { let attemptCount = 0; const unplayableStrategy = (seed: number, difficulty: Difficulty) => { @@ -121,6 +141,50 @@ describe("chase round generator", () => { { generateAttempt: unplayableStrategy }, ), ).toThrow("Unable to generate playable chase round within retry limit"); - expect(attemptCount).toBe(40); + expect(attemptCount).toBe(80); + }); + + it("rejects rounds that are unwinnable against optimal guard", () => { + const trapGraph: GameGraph = { + nodes: { + L: { id: "L", q: 0, r: 0, neighbors: ["N"] }, + N: { id: "N", q: 1, r: 0, neighbors: ["L", "E"] }, + E: { id: "E", q: 2, r: 0, neighbors: ["N"] }, + }, + edgeList: [ + ["L", "N"], + ["N", "E"], + ], + }; + + expect(thiefHasWinningStrategy(trapGraph, "L", "N", "E")).toBe(false); + + let attemptCount = 0; + const corridorTrap = (seed: number, difficulty: Difficulty) => { + attemptCount += 1; + return { + snapshot: { + seed, + difficulty, + graph: trapGraph, + thiefStartNodeId: "L", + guardStartNodeId: "N", + thiefNodeId: "L", + guardNodeId: "N", + exitNodeId: "E", + status: "playing" as const, + }, + meta: { + hasEscapePath: true, + pathLength: 2, + attemptsUsed: 1, + }, + }; + }; + + expect(() => + generateChaseRound({ seed: 1, difficulty: "normal" }, { generateAttempt: corridorTrap }), + ).toThrow("Unable to generate playable chase round within retry limit"); + expect(attemptCount).toBe(80); }); }); diff --git a/tests/chase/chaseModel.test.ts b/tests/chase/chaseModel.test.ts index 0d94e48..e7f817a 100644 --- a/tests/chase/chaseModel.test.ts +++ b/tests/chase/chaseModel.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ChaseGameModel } from "../../src/game/chase/model"; -import type { Difficulty, GameGraph } from "../../src/game/chase/types"; +import { ChaseGameModel } from "../../src/stages/games/chase/logic/model"; +import type { Difficulty, GameGraph } from "../../src/stages/games/chase/logic/types"; const mockedGenerateChaseRound = vi.fn(); -vi.mock("../../src/game/chase/generator", () => ({ +vi.mock("../../src/stages/games/chase/logic/generator", () => ({ generateChaseRound: (...args: unknown[]) => mockedGenerateChaseRound(...args), })); diff --git a/tests/chase/hexGraph.test.ts b/tests/chase/hexGraph.test.ts index 2ee9f4c..92329b0 100644 --- a/tests/chase/hexGraph.test.ts +++ b/tests/chase/hexGraph.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from "vitest"; import { axialNeighbors, isConnected, + shortestPath, shortestPathLength, -} from "../../src/game/chase/hexGraph"; -import type { GameGraph } from "../../src/game/chase/types"; +} from "../../src/stages/games/chase/logic/hexGraph"; +import type { GameGraph } from "../../src/stages/games/chase/logic/types"; function buildGraph(nodes: GameGraph["nodes"]): GameGraph { return { @@ -36,6 +37,7 @@ describe("hex graph utilities", () => { expect(shortestPathLength(graph, "A", "D")).toBe(3); expect(shortestPathLength(graph, "A", "A")).toBe(0); + expect(shortestPath(graph, "A", "D")).toEqual(["A", "B", "C", "D"]); }); it("returns Infinity when target is unreachable", () => { diff --git a/tests/chase/rng.test.ts b/tests/chase/rng.test.ts index 4fc48ea..8af0a75 100644 --- a/tests/chase/rng.test.ts +++ b/tests/chase/rng.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { createRng, normalizeSeed } from "../../src/game/chase/rng"; +import { createRng, normalizeSeed } from "../../src/stages/games/chase/logic/rng"; describe("chase rng", () => { it("generates the same sequence for the same seed", () => { diff --git a/tests/chase/solvability.test.ts b/tests/chase/solvability.test.ts new file mode 100644 index 0000000..c30902c --- /dev/null +++ b/tests/chase/solvability.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { optimalGuardStep, thiefHasWinningStrategy } from "../../src/stages/games/chase/logic/solvability"; +import type { GameGraph } from "../../src/stages/games/chase/logic/types"; + +function lineGraph(ids: string[]): GameGraph { + const nodes: GameGraph["nodes"] = {}; + const edgeList: Array<[string, string]> = []; + for (let i = 0; i < ids.length; i += 1) { + const id = ids[i]; + const prev = ids[i - 1]; + const next = ids[i + 1]; + const neighbors: string[] = []; + if (prev) { + neighbors.push(prev); + } + if (next) { + neighbors.push(next); + } + nodes[id] = { id, q: i, r: 0, neighbors }; + } + for (let i = 0; i < ids.length - 1; i += 1) { + edgeList.push([ids[i], ids[i + 1]]); + } + return { nodes, edgeList }; +} + +describe("solvability (optimal guard)", () => { + it("returns false when guard blocks the only exit from a leaf", () => { + const graph = lineGraph(["L", "N", "E"]); + expect(thiefHasWinningStrategy(graph, "L", "N", "E")).toBe(false); + }); + + it("returns true when thief reaches exit in one step before guard reacts", () => { + const graph: GameGraph = { + nodes: { + T: { id: "T", q: 0, r: 0, neighbors: ["E", "G"] }, + E: { id: "E", q: 1, r: 0, neighbors: ["T"] }, + G: { id: "G", q: 0, r: 1, neighbors: ["T"] }, + }, + edgeList: [ + ["T", "E"], + ["T", "G"], + ], + }; + expect(thiefHasWinningStrategy(graph, "T", "G", "E")).toBe(true); + }); + + it("optimalGuardStep matches shortest-path tie-break toward thief", () => { + const graph = lineGraph(["A", "B", "C"]); + expect(optimalGuardStep(graph, "A", "C")).toBe("B"); + expect(optimalGuardStep(graph, "C", "A")).toBe("B"); + }); +}); diff --git a/tests/stages/page_chase.test.ts b/tests/stages/page_chase.test.ts index 32de639..d6c3868 100644 --- a/tests/stages/page_chase.test.ts +++ b/tests/stages/page_chase.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import ChaseScene from "../../src/stages/page_chase"; +import ChaseScene from "../../src/stages/games/chase/page_chase"; import InitScene from "../../src/stages/page_init"; import { sceneManager } from "../../src/scene/SceneManager"; import { Text } from "pixi.js";