Files
tis/src/lib/games/maze.ts
T
ogrechkoandClaude Sonnet 5 c035650063 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
2026-08-01 03:35:31 +00:00

128 lines
4.2 KiB
TypeScript

import { z } from "zod";
const MAZE_CHARS = new Set(["#", ".", "S", "E", "*", "O", "G"]);
const MAX_GHOSTS = 4;
// 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;
const visited = new Set<string>([`${start[0]},${start[1]}`]);
const queue: [number, number][] = [start];
while (queue.length > 0) {
const [r, c] = queue.shift()!;
for (const [dr, dc] of [
[-1, 0],
[1, 0],
[0, -1],
[0, 1],
]) {
const nr = r + dr;
const nc = c + dc;
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue;
const key = `${nr},${nc}`;
if (visited.has(key) || grid[nr][nc] === "#") continue;
visited.add(key);
queue.push([nr, nc]);
}
}
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,
// '*' 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 * O G",
path: ["grid"],
})
.refine((def) => def.grid.flatMap((row) => [...row]).filter((c) => c === "S").length === 1, {
message: "Grid must contain exactly one S (start)",
path: ["grid"],
})
.refine((def) => def.grid.some((row) => row.includes("E")), {
message: "Grid must contain at least one E (exit)",
path: ["grid"],
})
.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: [
"#############",
"#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),
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>;