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:
1 parent
be53908471
commit
c035650063
4 files changed
+490
-156
No files matched your search
+64
-19
@@ -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>;
|
||||
Reference in new issue
Block a user