export type Direction = "up" | "right" | "down" | "left"; export type Status = "playing" | "won" | "lost"; export type GhostName = "Blinky" | "Pinky" | "Inky" | "Clyde"; export type Point = { x: number; y: number }; export type Actor = Point & { direction: Direction }; export type GhostState = Point & { name: GhostName; home: Point }; export type GameState = { level: number; pacman: Actor; ghosts: GhostState[]; pellets: Point[]; powers: Point[]; score: number; lives: number; fright: number; status: Status; ticks: number; }; export type Level = { rows: string[]; pacman: Point; ghosts: GhostState[]; pellets: Point[]; powers: Point[]; scatter: Record; width: number; height: number; }; const RAW_LEVELS = [ [ "###################", "#P...............B#", "#.###.###.###.###.#", "#o....K...I....o..#", "#.###.#.###.#.###.#", "#.....#..C..#.....#", "###.#.#####.#.###.#", "#.................#", "#.###.#.###.#.###.#", "#o...............o#", "###################", ], [ "###################", "#P..o....#....o..B#", "#.###.##.#.##.###.#", "#.....K.....I.....#", "###.#.###.###.#.###", "#...#.....C.....#.#", "#.#.#.#######.#.#.#", "#.................#", "#.###.#.###.#.###.#", "#o...............o#", "###################", ], [ "###################", "#P...o.........o.B#", "#.###.###.###.###.#", "#.....K..#..I.....#", "#.###.#.###.#.###.#", "#o....#..C..#....o#", "###.#.##.#.##.#.###", "#.................#", "#.###.#.###.#.###.#", "#o...............o#", "###################", ], ] as const; const GHOSTS = { B: "Blinky", K: "Pinky", I: "Inky", C: "Clyde" } as const; const GHOST_CHARS: Record = { Blinky: "B", Pinky: "K", Inky: "I", Clyde: "C" }; const STEPS: Record = { up: { x: 0, y: -1 }, right: { x: 1, y: 0 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, }; export const directions: Direction[] = ["up", "right", "down", "left"]; export const LEVELS: Level[] = RAW_LEVELS.map(parseLevel); export class PacManGame { level: number; pacman: Actor; ghosts: GhostState[]; pellets: Point[]; powers: Point[]; score: number; lives: number; fright: number; status: Status; ticks: number; constructor(state: GameState = newLevelState(0, 0, 3)) { const parsed = parseGameState(state); this.level = parsed.level; this.pacman = copyActor(parsed.pacman); this.ghosts = parsed.ghosts.map(copyGhost); this.pellets = parsed.pellets.map(copyPoint); this.powers = parsed.powers.map(copyPoint); this.score = parsed.score; this.lives = parsed.lives; this.fright = parsed.fright; this.status = parsed.status; this.ticks = parsed.ticks; } get width(): number { return this.map.width; } get height(): number { return this.map.height; } move(direction: Direction, options: { moveGhosts?: boolean } = {}): boolean { if (this.status !== "playing") return false; const next = movePoint(this.pacman, direction); if (!this.isOpen(next)) return false; this.pacman = { ...next, direction }; this.ticks++; this.eat(); if (this.hitGhost()) return true; if (this.clearIfDone()) return true; if (options.moveGhosts !== false) this.moveGhosts(); return true; } moveGhosts(): void { if (this.status !== "playing") return; const targets = this.ghostTargets(); this.ghosts = this.ghosts.map((ghost, i) => ({ ...ghost, ...bestStep(ghost, targets[i], this.fright > 0, this), })); this.hitGhost(); if (this.status === "playing" && this.fright > 0) this.fright--; } ghostTargets(): Point[] { return this.ghosts.map((ghost) => { if (this.fright > 0) return this.map.scatter[ghost.name]; if (ghost.name === "Blinky") return this.pacman; if (ghost.name === "Pinky") return ahead(this.pacman, 2); if (ghost.name === "Inky") return this.ticks % 6 < 3 ? this.map.scatter.Inky : this.pacman; return distance(ghost, this.pacman) <= 4 ? this.map.scatter.Clyde : this.pacman; }); } legalDirections(point: Point = this.pacman): Direction[] { return directions.filter((direction) => this.isOpen(movePoint(point, direction))); } statusText(): string { const base = `L${this.level + 1} Score ${this.score} Lives ${this.lives}`; const fright = this.fright ? ` Power ${this.fright}` : ""; return this.status === "playing" ? base + fright : `${this.status.toUpperCase()} ${base}`; } toState(): GameState { return { level: this.level, pacman: copyActor(this.pacman), ghosts: this.ghosts.map(copyGhost), pellets: this.pellets.map(copyPoint), powers: this.powers.map(copyPoint), score: this.score, lives: this.lives, fright: this.fright, status: this.status, ticks: this.ticks, }; } static fromState(state: unknown): PacManGame { return new PacManGame(parseGameState(state)); } private get map(): Level { return LEVELS[this.level]; } private isOpen(point: Point): boolean { return this.map.rows[point.y]?.[point.x] === " "; } private eat(): void { const beforePellets = this.pellets.length; const beforePowers = this.powers.length; this.pellets = this.pellets.filter((pellet) => !samePoint(pellet, this.pacman)); this.powers = this.powers.filter((power) => !samePoint(power, this.pacman)); if (this.pellets.length < beforePellets) this.score += 10; if (this.powers.length < beforePowers) { this.score += 50; this.fright = 8; } } private hitGhost(): boolean { const hits = this.ghosts.filter((ghost) => samePoint(ghost, this.pacman)); if (!hits.length) return false; if (this.fright > 0) { for (const hit of hits) { this.score += 200; this.ghosts = this.ghosts.map((ghost) => ghost === hit ? { ...ghost, ...copyPoint(ghost.home) } : ghost); } return false; } this.lives--; this.fright = 0; if (this.lives <= 0) this.status = "lost"; else this.resetActors(); return true; } private clearIfDone(): boolean { if (this.pellets.length || this.powers.length) return false; if (this.level === LEVELS.length - 1) this.status = "won"; else this.loadLevel(this.level + 1); return true; } private resetActors(): void { this.pacman = { ...copyPoint(this.map.pacman), direction: "right" }; this.ghosts = this.ghosts.map((ghost) => ({ ...ghost, ...copyPoint(ghost.home) })); } private loadLevel(level: number): void { const next = newLevelState(level, this.score, this.lives); this.level = next.level; this.pacman = next.pacman; this.ghosts = next.ghosts; this.pellets = next.pellets; this.powers = next.powers; this.fright = 0; this.ticks = 0; } } export function boardPrompt(game: PacManGame): string { const level = LEVELS[game.level]; return level.rows.map((row, y) => [...row].map((cell, x) => renderCell(game, cell, { x, y })).join(""), ).join("\n"); } export function parseGameState(value: unknown): GameState { if (!isRecord(value) || typeof value.level !== "number" || !Number.isInteger(value.level)) throw new Error("Invalid state"); const level = value.level; if (level < 0 || level >= LEVELS.length) throw new Error("Invalid state"); const map = LEVELS[level]; const state = { level, pacman: parseActor(value.pacman, map), ghosts: parseGhosts(value.ghosts, map), pellets: parsePoints(value.pellets, map, "pellet"), powers: parsePoints(value.powers, map, "power"), score: parseNonNegativeInt(value.score, "score"), lives: parseNonNegativeInt(value.lives, "lives"), fright: parseNonNegativeInt(value.fright, "fright"), status: parseStatus(value.status), ticks: parseNonNegativeInt(value.ticks, "ticks"), }; if (!state.ghosts.length) throw new Error("Invalid ghost"); return state; } function parseLevel(rows: readonly string[]): Level { const width = rows[0].length; const level: Level = { rows: rows.map((row) => row.replace(/[PBIKC.o]/g, " ")), pacman: { x: 1, y: 1 }, ghosts: [], pellets: [], powers: [], scatter: { Blinky: { x: 1, y: rows.length - 2 }, Pinky: { x: width - 2, y: rows.length - 2 }, Inky: { x: width - 2, y: 1 }, Clyde: { x: 1, y: rows.length - 2 }, }, width, height: rows.length, }; for (const [y, row] of rows.entries()) { if (row.length !== width) throw new Error("Invalid level"); for (const [x, cell] of [...row].entries()) { if (cell === ".") level.pellets.push({ x, y }); else if (cell === "o") level.powers.push({ x, y }); else if (cell === "P") level.pacman = { x, y }; else if (cell in GHOSTS) { const name = GHOSTS[cell as keyof typeof GHOSTS]; level.ghosts.push({ name, x, y, home: { x, y } }); } } } return level; } function newLevelState(level: number, score: number, lives: number): GameState { const map = LEVELS[level]; return { level, pacman: { ...copyPoint(map.pacman), direction: "right" }, ghosts: map.ghosts.map(copyGhost), pellets: map.pellets.map(copyPoint), powers: map.powers.map(copyPoint), score, lives, fright: 0, status: "playing", ticks: 0, }; } function renderCell(game: PacManGame, cell: string, point: Point): string { const ghosts = game.ghosts.filter((ghost) => samePoint(ghost, point)); if (samePoint(game.pacman, point) && ghosts.length) return "X"; if (samePoint(game.pacman, point)) return "P"; if (ghosts[0]) return game.fright ? "g" : GHOST_CHARS[ghosts[0].name]; if (game.powers.some((power) => samePoint(power, point))) return "o"; if (game.pellets.some((pellet) => samePoint(pellet, point))) return "."; return cell; } function bestStep(ghost: GhostState, target: Point, away: boolean, game: PacManGame): Point { const steps = game.legalDirections(ghost).map((direction) => movePoint(ghost, direction)); return steps.sort((a, b) => (away ? distance(b, target) - distance(a, target) : distance(a, target) - distance(b, target)))[0] ?? ghost; } function ahead(actor: Actor, cells: number): Point { let point: Point = actor; for (let i = 0; i < cells; i++) point = movePoint(point, actor.direction); return point; } function movePoint(point: Point, direction: Direction): Point { const step = STEPS[direction]; return { x: point.x + step.x, y: point.y + step.y }; } function parseActor(value: unknown, map: Level): Actor { if (!isRecord(value)) throw new Error("Invalid pacman"); const point = parsePoint(value, map, "pacman"); if (!isDirection(value.direction)) throw new Error("Invalid pacman"); return { ...point, direction: value.direction }; } function parseGhosts(value: unknown, map: Level): GhostState[] { if (!Array.isArray(value)) throw new Error("Invalid ghost"); return value.map((ghost) => { if (!isRecord(ghost) || !isGhostName(ghost.name)) throw new Error("Invalid ghost"); return { ...parsePoint(ghost, map, "ghost"), name: ghost.name, home: parsePoint(ghost.home, map, "ghost") }; }); } function parsePoints(value: unknown, map: Level, name: string): Point[] { if (!Array.isArray(value)) throw new Error(`Invalid ${name}`); return value.map((point) => parsePoint(point, map, name)); } function parsePoint(value: unknown, map: Level, name: string): Point { if (!isRecord(value)) throw new Error(`Invalid ${name}`); const { x, y } = value; if (typeof x !== "number" || typeof y !== "number" || !Number.isInteger(x) || !Number.isInteger(y)) throw new Error(`Invalid ${name}`); const point = { x, y }; if (map.rows[y]?.[x] !== " ") throw new Error(`Invalid ${name}`); return point; } function parseNonNegativeInt(value: unknown, name: string): number { if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`Invalid ${name}`); return value; } function parseStatus(value: unknown): Status { if (value === "playing" || value === "won" || value === "lost") return value; throw new Error("Invalid status"); } function isDirection(value: unknown): value is Direction { return value === "up" || value === "right" || value === "down" || value === "left"; } function isGhostName(value: unknown): value is GhostName { return value === "Blinky" || value === "Pinky" || value === "Inky" || value === "Clyde"; } function samePoint(a: Point, b: Point): boolean { return a.x === b.x && a.y === b.y; } function copyPoint(point: Point): Point { return { x: point.x, y: point.y }; } function copyActor(actor: Actor): Actor { return { ...copyPoint(actor), direction: actor.direction }; } function copyGhost(ghost: GhostState): GhostState { return { ...copyPoint(ghost), name: ghost.name, home: copyPoint(ghost.home) }; } function distance(a: Point, b: Point): number { return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; }