Maze: Pac-Man-style dots, ghosts, and continuous movement

Full redesign of the Maze template (same templateType: "maze" — plain
text column, no migration needed): dots (*) to eat, power pellets (O)
that frighten ghosts, ghost spawns (G, 0-4), and continuous glide
movement with buffered turns instead of one-cell-per-keypress. Win
condition confirmed with the user explicitly: collect every dot and
pellet, then reach the exit — kept the exit tile rather than switching
to classic Pac-Man's "no exit, board-clear wins," since this app's
maze identity is built around it.

Ghosts chase via BFS pathfinding (reusing the same BFS shape the
maze's own reachability check already used, just retargeted at the
live player position each tick) and flee via simple greedy
distance-maximizing when frightened. The reachability check itself
generalized from "is the exit reachable" to "is every dot, pellet, and
the exit reachable" — same guarantee, now covering the actual win
condition instead of just the exit. Verified precisely: a maze with a
reachable exit but one walled-off dot is correctly rejected.

Continuous movement is tick-based, not real physics: a repeating timer
steps the player in the buffered direction (or continues straight) if
open, tweening the sprite's pixel position between cells for a glide
feel rather than snapping. Same 3-lives-on-capture, coarse-grained
resume (position + collected keys + lives, ghosts reset to spawn on
reload) as the other templates' resume behavior.

The one existing maze game on production validated cleanly against
the new schema unchanged (lives defaults to 3, its dots were already
reachable) — no data migration needed this time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QcXH24ky6zjk2UyK5oZUPH
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-01 03:35:31 +00:00
1 parent be53908471
commit c035650063
4 files changed
+490 -156

No files matched your search

+64 -19
View File
@@ -1,24 +1,26 @@
import { z } from "zod";
const MAZE_CHARS = new Set(["#", ".", "S", "E", "*"]);
const MAZE_CHARS = new Set(["#", ".", "S", "E", "*", "O", "G"]);
const MAX_GHOSTS = 4;
/** BFS from S to any E, treating '#' as the only impassable tile. */
function isExitReachable(grid: string[]): boolean {
// Pac-Man-flavored timing/scoring — fixed, not hand-authored (same
// "curve is fixed" precedent as the quiz money ladder / clicker HP curve).
export const PLAYER_STEP_MS = 180;
export const GHOST_STEP_MS = 220; // slightly slower than the player — keeps it beatable.
export const FRIGHTEN_DURATION_MS = 7000;
export const GHOST_RESPAWN_DELAY_MS = 3000;
export const DOT_SCORE = 10;
export const PELLET_SCORE = 50;
export const GHOST_EAT_SCORE = 200;
/** BFS from S — used both to find the exit and to prove every collectible tile is reachable. '#' is the only impassable tile. */
function reachableFrom(grid: string[], start: [number, number]): Set<string> {
const rows = grid.length;
const cols = grid[0]?.length ?? 0;
let start: [number, number] | null = null;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === "S") start = [r, c];
}
}
if (!start) return false;
const visited = new Set<string>([`${start[0]},${start[1]}`]);
const queue: [number, number][] = [start];
while (queue.length > 0) {
const [r, c] = queue.shift()!;
if (grid[r][c] === "E") return true;
for (const [dr, dc] of [
[-1, 0],
[1, 0],
@@ -34,22 +36,47 @@ function isExitReachable(grid: string[]): boolean {
queue.push([nr, nc]);
}
}
return false;
return visited;
}
function findStart(grid: string[]): [number, number] | null {
for (let r = 0; r < grid.length; r++) {
const c = grid[r].indexOf("S");
if (c !== -1) return [r, c];
}
return null;
}
/** Every dot, power pellet, and the exit must be reachable — win condition requires collecting them all. */
function allCollectiblesReachable(grid: string[]): boolean {
const start = findStart(grid);
if (!start) return false;
const reachable = reachableFrom(grid, start);
for (let r = 0; r < grid.length; r++) {
for (let c = 0; c < grid[r].length; c++) {
const ch = grid[r][c];
if ((ch === "*" || ch === "O" || ch === "E") && !reachable.has(`${r},${c}`)) return false;
}
}
return true;
}
export const mazeDefinitionSchema = z
.object({
// Each string is one row. '#' wall, '.' floor, 'S' start, 'E' exit, '*' item.
// Each string is one row. '#' wall, '.' floor, 'S' start, 'E' exit,
// '*' dot, 'O' power pellet, 'G' ghost spawn (0-4 of them).
grid: z.array(z.string().min(1)).min(3).max(30),
// 0 = no limit.
timeLimitSec: z.number().int().min(0).max(600).default(0),
lives: z.number().int().min(1).max(9).default(3),
})
.refine((def) => def.grid.every((row) => row.length === def.grid[0].length), {
message: "All grid rows must be the same length",
path: ["grid"],
})
.refine((def) => def.grid.every((row) => [...row].every((c) => MAZE_CHARS.has(c))), {
message: "Grid may only contain the characters # . S E *",
message: "Grid may only contain the characters # . S E * O G",
path: ["grid"],
})
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "S").length === 1, {
@@ -60,23 +87,41 @@ export const mazeDefinitionSchema = z
message: "Grid must contain at least one E (exit)",
path: ["grid"],
})
.refine((def) => isExitReachable(def.grid), {
message: "Exit (E) must be reachable from the start (S)",
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "G").length <= MAX_GHOSTS, {
message: `Grid may contain at most ${MAX_GHOSTS} G (ghost spawns)`,
path: ["grid"],
})
.refine((def) => allCollectiblesReachable(def.grid), {
message: "Every dot (*), power pellet (O), and the exit (E) must be reachable from the start (S)",
path: ["grid"],
});
export type MazeDefinition = z.infer<typeof mazeDefinitionSchema>;
export const DEFAULT_MAZE_DEFINITION: MazeDefinition = {
grid: ["#######", "#S.*..#", "#.###.#", "#...*.#", "#.###.#", "#....E#", "#######"],
grid: [
"#############",
"#O****#****O#",
"#*###*#*###*#",
"#*#*******#*#",
"#*#*#####*#*#",
"#****GGG****#",
"#*#*#####*#*#",
"#*#*******#*#",
"#*###*#*###*#",
"#S****#****E#",
"#############",
],
timeLimitSec: 0,
lives: 3,
};
export const mazeProgressSchema = z.object({
playerRow: z.number().int().min(0),
playerCol: z.number().int().min(0),
collectedItemKeys: z.array(z.string()).default([]),
collectedKeys: z.array(z.string()).default([]),
remainingMs: z.number().int().min(0),
lives: z.number().int().min(0),
});
export type MazeProgress = z.infer<typeof mazeProgressSchema>;