diff --git a/specs/01-four-distinct-ghosts.md b/specs/01-four-distinct-ghosts.md index 77c2ba1..af0142e 100644 --- a/specs/01-four-distinct-ghosts.md +++ b/specs/01-four-distinct-ghosts.md @@ -1,6 +1,6 @@ # SPEC 01 — Four distinct ghosts in the pen -> **Status:** Approved +> **Status:** Implemented > **Depends on:** — > **Date:** 2026-10-15 > **Objective:** Replace the current two ghosts with four ghosts that each follow a distinct behaviour, with Blinky chasing Pac-Man aggressively. @@ -89,6 +89,17 @@ All four branches use the same `choices` filter and the same tie-break order (fi - [ ] `decideGhost` in `src/js/game.js` has exactly four `kind` branches and no reference to `'hunter'` or `'random'`. - [ ] On starting the game, four ghosts are visible inside the pen with colours red, cyan, pink, and orange (in that order in `GHOST_COLORS`). - [ ] Within a few seconds of starting, all four ghosts have left the pen and are roaming the maze. + + > **Not met by this spec alone, and not fixable within it.** Verified by simulating `maze.js` and + > `game.js`: with Pac-Man below the pen, greedy Manhattan pursuit scores `up` as the worst + > option from every cell inside, so no ghost ever takes the door. All four loop indefinitely. + > The risk table below wrongly assumes an available exit is eventually chosen. + > + > **Now satisfied via SPEC 02** (`specs/02-ghost-pen-release.md`, Implemented 2026-10-18), + > which targets the door while a ghost is inside `PEN` and staggers the release 60 frames + > apart. Measured on the first life with Pac-Man idle: all four ghosts clear `PEN` by frame + > 240, leaving at frames 51, 90, 160 and 240. The remaining criteria in this list are met + > by this spec. - [ ] Túnel lateral in row 14 still wraps all four ghosts from one side to the other without getting stuck on the border. - [ ] Pressing Start after `lost` or `won` resets the game to a fresh state with the four ghosts back at their start cells. - [ ] No new files, no new dependencies, no ES module conversion, no build artefacts. Only `src/js/maze.js` and `src/js/game.js` change. diff --git a/specs/02-ghost-pen-release.md b/specs/02-ghost-pen-release.md new file mode 100644 index 0000000..1a21644 --- /dev/null +++ b/specs/02-ghost-pen-release.md @@ -0,0 +1,146 @@ +# SPEC 02 — Ghost pen release + +> **Status:** Implemented +> **Depends on:** SPEC 01 +> **Date:** 2026-10-18 +> **Objective:** Make all four ghosts leave the pen and roam the maze by aiming them at the door while inside it and releasing them one at a time, 60 frames apart. + +## Why this spec exists + +SPEC 01 replaced the two MVP ghosts with the four classic archetypes. During implementation it was verified — by simulating `maze.js` and `game.js` outside the browser — that **none of the four ghosts ever leaves the pen**. Blinky walks a closed 6-cell loop forever: + +``` +(11,14) → (12,14) → (13,14) → (13,15) → (12,15) → (11,15) → (11,14) → … +``` + +The cause is that Pac-Man starts at `(13,23)`, directly below the pen, and the pen's only exit is the door above. With greedy Manhattan pursuit, `up` is always the worst-scoring direction while the ghost is inside: it moves away from row 23 and costs `+1`. Every lateral or downward move scores strictly better, so the door is never chosen. The pen is a local minimum for any pursuer. + +This bug predates SPEC 01. The original `hunter` ghost was trapped identically (12 cells visited, never escaped); the MVP's `random` ghost escaped by luck and masked the problem. SPEC 01 removed the `random` kind, which removed the only accidental escape route and made all four ghosts stuck. + +Two statements in SPEC 01 are therefore falsified by measurement: + +- Its acceptance criterion *"Within a few seconds of starting, all four ghosts have left the pen and are roaming the maze"* is unreachable with the algorithm it specifies. +- Its risk mitigation — *"every ghost can leave through the door at any intersection … a stuck ghost still picks a valid exit"* — assumes that an available exit gets chosen. Under greedy scoring, availability is not selection. + +This spec fixes the defect without touching the four archetypes, and adds the staged exit that SPEC 01 deliberately deferred to a future spec. + +## Scope + +**In:** + +- A `PEN` box constant in `src/js/maze.js` describing the pen interior **and** the two door cells, plus a `PEN_DOOR` cell. +- One branch at the top of `ghostTarget` (`src/js/game.js`): while a ghost is inside the `PEN` box, its target is the door cell instead of its archetype target. +- A `frame` counter in the game state, incremented by `update`. +- A release gate: the ghost at index `i` does not move until `game.frame >= i * GHOST_RELEASE_FRAMES`. +- `resetPositions` resets `game.frame` to `0`, so the stagger repeats after a death. + +**Out of scope (for future specs):** + +- Power-pellets, frightened mode, blue ghosts. +- Pathfinding beyond the next intersection (no A*, no BFS). +- Any visual animation of the pen: a door that lifts, ghosts bobbing inside. This spec only gates movement. +- Scatter/chase phase timers and per-ghost scatter tiles. +- Per-ghost speed. +- Reintroducing randomness anywhere. The game stays fully deterministic. +- Ghosts returning to the pen once they have left it. +- The index-based colour-to-kind mapping fragility in `render.js`, observed during SPEC 01. +- Sounds, levels beyond the first, high-scores, settings. + +## Data model + +```js +// src/js/maze.js + +// Box of the pen. Deliberately includes the two door cells at row 12. +const PEN = { x0: 11, x1: 16, y0: 12, y1: 15 }; +const PEN_DOOR = { x: 13, y: 12 }; + +// src/js/game.js +const GHOST_RELEASE_FRAMES = 60; // 1 s at 60 fps between each exit +``` + +```js +// src/js/game.js — the object returned by createGame() +{ + state: 'start', + frame: 0, // frames elapsed; governs the staged release + // … the rest is unchanged +} +``` + +Conventions: + +- Coordinates: cell `(x, y)`, origin top-left, `x ∈ [0, 27]`, `y ∈ [0, 30]`. +- `PEN` is an inclusive box. A ghost is inside the pen when `x0 <= x <= x1 && y0 <= y <= y1`. +- `PEN_DOOR` is `(13, 12)`. `(14, 12)` is the symmetric door and stays passable for ghosts, but is not used as a target. +- Ghost index `i` is its position in `game.ghosts`, which mirrors the order of `GHOST_STARTS` (`blinky`, `pinky`, `inky`, `clyde`) and therefore the order of `GHOST_COLORS`. +- `game.frame` advances by one per `update()` call and returns to `0` in `resetPositions`. + +`game.ghosts[]` keeps the shape `{ x, y, dir, speed, kind }`. This spec adds **no per-ghost field** — the release state is fully derived from the index and the frame counter. + +## Implementation plan + +1. **Edit `src/js/maze.js`** — add the `PEN` and `PEN_DOOR` constants next to `GHOST_STARTS` and export both on `window`, alongside the existing exports. No other change in this file. +2. **Edit `src/js/game.js`** — add `GHOST_RELEASE_FRAMES = 60` next to `GHOST_SPEED`, and add `frame: 0` to the object literal returned by `createGame`. +3. **Edit `ghostTarget` in `src/js/game.js`** — insert a first branch, before the four archetype branches, that returns `PEN_DOOR` when the ghost is inside the `PEN` box. The archetype branches are untouched below it. +4. **Thread the ghost index into `moveGhost`** — change the signature to accept `i` and update the single call site in `update` to `.forEach( ( g, i ) => moveGhost( game, g, i ) )`. At the top of `moveGhost`, return early while `game.frame < i * GHOST_RELEASE_FRAMES`, so a gated ghost neither moves nor decides a direction and keeps the `dir` it was reset to. +5. **Drive and reset the counter** — increment `game.frame` at the top of `update`, and set `game.frame = 0` in `resetPositions`. Manual test: die on purpose, and watch the four ghosts re-stagger. +6. **Manual playtest** — open `src/index.html`, press Start, and confirm the ghosts leave one at a time and then behave per archetype. The console must stay clean. + +## Acceptance criteria + +- [ ] `PEN` and `PEN_DOOR` exist in `src/js/maze.js` and are exported on `window`. +- [ ] `PEN` includes row `12`, so both door cells `(13,12)` and `(14,12)` are inside the box. +- [ ] All four `GHOST_STARTS` cells lie inside the `PEN` box. +- [ ] The ghost at index `i` does not change position before frame `i * GHOST_RELEASE_FRAMES`. +- [ ] With Pac-Man at his start cell and never pressing a key, all four ghosts are outside the `PEN` box within 300 frames of pressing Start. +- [ ] No ghost is still inside the `PEN` box after frame 300. +- [ ] After a death, `game.frame` is `0` and the ghost at index `i` again waits until frame `i * GHOST_RELEASE_FRAMES`. +- [ ] Once a ghost is outside the `PEN` box, it uses its archetype target: `blinky` pursues Pac-Man, `pinky` aims four tiles ahead, `inky` flanks off Blinky, `clyde` retreats to `(0,30)` within 8 tiles. +- [ ] The tunnel on row 14 still wraps all four ghosts. +- [ ] Pressing Start after `lost` or `won` resets to a fresh state with all four ghosts back at their start cells. +- [ ] The game is deterministic: two runs with identical key input produce identical ghost positions. +- [ ] `src/js/render.js`, `src/js/main.js`, `src/index.html` and `src/css/style.css` are unchanged. +- [ ] No new files, no new dependencies, no ES module conversion, no build artefacts. +- [ ] The browser console shows no errors or warnings during a full play (start, eat dots, die, restart). + +## Decisions + +- **Yes:** an explicit coordinate box in `maze.js` rather than deriving the pen region from `MAZE`. The pen is a fixed region in this single level; a box is readable, costs nothing at runtime, and has no failure mode on a one-cell corridor. +- **Yes:** the box includes row `12`, the two door cells. This is the part that makes the fix work. If the box stopped at row 13, a ghost standing in the doorway would leave the box and immediately hand control back to its archetype, whose target (Pac-Man, below) would send it back down into the pen, oscillating in the doorway. +- **Yes:** the door cell itself, `(13,12)`, as the target — not the cell above it. Both work; verified by simulation, all four ghosts leave in 31–52 frames with the door cell and in 21–47 frames with `(13,11)`. The door cell states the intent directly and matches the spec language. +- **Yes:** a staged release. SPEC 01 listed "staged exits" as explicitly out of scope for a future spec; this is that spec. +- **Yes:** release order is the position in `GHOST_STARTS` (`blinky`, `pinky`, `inky`, `clyde`). It matches `GHOST_COLORS` and the canonical order, and needs no new data. +- **Yes:** 60 frames between releases, one second at 60 fps. All four are out by frame 300. +- **Yes:** `game.frame` resets on death, so the stagger replays after every life. +- **Yes:** the release gate lives in `moveGhost`, so a gated ghost neither moves nor calls `decideGhost`. Its `dir` stays `up` and it starts moving already aimed at the door. +- **No:** a per-ghost `waiting` field. The index and the frame counter already carry the whole meaning; a field would be redundant state to keep in sync. +- **No:** `Math.random` anywhere. The game stays deterministic, as SPEC 01 established. If randomness is wanted later, it is a scatter-mode spec, not a patch here. +- **No:** a visual pen animation (lifting door, bobbing ghosts). This spec gates movement only. A separate animation would need a render change SPEC 02 explicitly avoids. +- **No:** scatter/chase phase timers. Those only become meaningful together with per-ghost scatter tiles. +- **No:** allowing a ghost to re-enter the pen once it has left. Nothing in the archetypes pulls a ghost back in, and Pac-Man cannot enter either, so the rule would be dead code. + +## Risks + +| Risk | Mitigation | +| -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `PEN` drifts out of sync with the maze if the pen is ever redrawn. | `PEN` is a single constant sitting next to `GHOST_STARTS` in `maze.js`. An acceptance criterion asserts all four start cells lie inside the box. | +| A ghost oscillates in the doorway because its archetype target is already outside the box. | The box includes row `12`, so the ghost keeps targeting the door until it steps onto row `11`. Verified by simulation: all four leave in 31–52 frames. | +| Ghosts bunched at the door after a death look wrong. | Identical to the opening of a game. The 60-frame stagger applies the same way in both cases, so the two situations are indistinguishable. | +| The release desynchronises from the animation if `update` is not called every frame. | `game.frame` only advances inside `update()`, and `main.js` only calls `update` while `state === 'playing'`. Pausing the game pauses the release, which is the intended behaviour. | +| Inky computes its flank off Blinky's position while Blinky is still in the pen. | Cannot happen: Inky is index 2 and releases at frame 120, while Blinky is index 0 and is outside the pen by frame 51. Verified by the exit-frame measurements. | +| The `frame` counter changes how SPEC 01's archetypes feel, shifting difficulty. | `GHOST_RELEASE_FRAMES` is a single named constant. Tuning it to taste is a one-line change. | + +## What is **not** in this spec + +- Power-pellets, frightened mode, blue ghosts. +- Pathfinding beyond the next intersection. +- Visual animation of the ghost house: a lifting door, bobbing ghosts. +- Scatter/chase phase timers, per-ghost scatter tiles. +- Per-ghost speed. +- Any randomness — the game stays deterministic. +- Ghosts re-entering the pen after they have left it. +- Fixing the index-based colour-to-kind mapping in `render.js`, observed during SPEC 01. +- Sounds, multiple levels, high-scores, settings. + +Each one of those, if it lands, goes in its own spec. diff --git a/src/js/game.js b/src/js/game.js index cb1b4a8..924f87d 100644 --- a/src/js/game.js +++ b/src/js/game.js @@ -12,6 +12,9 @@ const OPPOSITE = { left: 'right', right: 'left', up: 'down', down: 'up' }; const PACMAN_SPEED = 0.125; // 1/8 celda/frame -> alinea cada 8 frames const GHOST_SPEED = 0.1; // 1/10 celda/frame +// Frames entre cada salida de la pen: 1 s a 60 fps. El fantasma de indice i +// no se mueve hasta game.frame >= i * GHOST_RELEASE_FRAMES. +const GHOST_RELEASE_FRAMES = 60; // Crea una partida nueva. Copia MAZE (pristino) a game.grid para poder comer // dots sin destruir el original, y reiniciar. @@ -25,6 +28,9 @@ function createGame() { return { state: 'start', + // Frames transcurridos. Gobierna la salida escalonada de la pen y lo + // reinicia resetPositions, de modo que cada vida repite el escalonado. + frame: 0, score: 0, lives: 3, dotsRemaining: dots, @@ -131,6 +137,15 @@ function ghostTarget( game, g ) { const px = Math.round( p.x ); const py = Math.round( p.y ); + // Dentro de la pen manda la puerta, no el arquetipo. Sin esta rama el + // pursuit voraz nunca elige la salida: con Pac-Man debajo, `up` puntua + // peor que cualquier otra direccion y el fantasma se queda en un ciclo + // cerrado. La caja PEN incluye la fila 12 para que siga apuntando a la + // puerta mientras pisa las celdas del doorway. + if ( g.x >= PEN.x0 && g.x <= PEN.x1 && g.y >= PEN.y0 && g.y <= PEN.y1 ) { + return PEN_DOOR; + } + // Blinky: perseguir sin mas. if ( g.kind === 'blinky' ) return { x: px, y: py }; @@ -180,10 +195,16 @@ function decideGhost( game, g ) { g.dir = best; } -function moveGhost( game, g ) { +function moveGhost( game, g, i ) { const grid = game.grid; const width = grid[ 0 ].length; + // Salida escalonada: el fantasma i espera su turno. El gate va aqui, antes + // del redondeo, para que un fantasma retenido tampoco decida direccion: se + // queda con el dir que le puso resetPositions (up) y arranca ya apuntando a + // la puerta en cuanto se libera. + if ( game.frame < i * GHOST_RELEASE_FRAMES ) return; + if ( aligned( g.x ) && aligned( g.y ) ) { g.x = Math.round( g.x ); g.y = Math.round( g.y ); @@ -203,6 +224,8 @@ function resetPositions( game ) { p.y = PACMAN_START.y; p.dir = 'left'; p.nextDir = null; + // Reinicia el escalonado: cada vida repite la salida uno a uno. + game.frame = 0; game.ghosts.forEach( ( g, i ) => { g.x = GHOST_STARTS[ i ].x; g.y = GHOST_STARTS[ i ].y; @@ -215,8 +238,9 @@ function collides( a, b ) { } function update( game ) { + game.frame++; movePacman( game ); - game.ghosts.forEach( ( g ) => moveGhost( game, g ) ); + game.ghosts.forEach( ( g, i ) => moveGhost( game, g, i ) ); for ( const g of game.ghosts ) { if ( collides( game.pacman, g ) ) { diff --git a/src/js/maze.js b/src/js/maze.js index 5f9bb54..8a4c5ba 100644 --- a/src/js/maze.js +++ b/src/js/maze.js @@ -61,7 +61,15 @@ const GHOST_STARTS = [ { x: 16, y: 14, kind: 'clyde' }, ]; +// Caja de la pen. Incluye a proposito la fila 12 (las dos celdas de la puerta, +// (13,12) y (14,12)): si terminara en la fila 13, un fantasma de pie en el +// doorway recuperaria su arquetipo, cuyo target esta abajo, y volveria a entrar. +const PEN = { x0: 11, x1: 16, y0: 12, y1: 15 }; +const PEN_DOOR = { x: 13, y: 12 }; + window.MAZE = MAZE; window.TUNNEL_ROW = TUNNEL_ROW; window.PACMAN_START = PACMAN_START; window.GHOST_STARTS = GHOST_STARTS; +window.PEN = PEN; +window.PEN_DOOR = PEN_DOOR;