Compare commits
3
Commits
d150e4bebc
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75814528f0 | ||
|
|
66100a7b74 | ||
|
|
90bc68449a |
@@ -1,6 +1,6 @@
|
|||||||
# SPEC 01 — Four distinct ghosts in the pen
|
# SPEC 01 — Four distinct ghosts in the pen
|
||||||
|
|
||||||
> **Status:** Approved
|
> **Status:** Implemented
|
||||||
> **Depends on:** —
|
> **Depends on:** —
|
||||||
> **Date:** 2026-10-15
|
> **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.
|
> **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'`.
|
- [ ] `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`).
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] 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.
|
- [ ] No new files, no new dependencies, no ES module conversion, no build artefacts. Only `src/js/maze.js` and `src/js/game.js` change.
|
||||||
|
|||||||
@@ -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.
|
||||||
+26
-2
@@ -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 PACMAN_SPEED = 0.125; // 1/8 celda/frame -> alinea cada 8 frames
|
||||||
const GHOST_SPEED = 0.1; // 1/10 celda/frame
|
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
|
// Crea una partida nueva. Copia MAZE (pristino) a game.grid para poder comer
|
||||||
// dots sin destruir el original, y reiniciar.
|
// dots sin destruir el original, y reiniciar.
|
||||||
@@ -25,6 +28,9 @@ function createGame() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
state: 'start',
|
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,
|
score: 0,
|
||||||
lives: 3,
|
lives: 3,
|
||||||
dotsRemaining: dots,
|
dotsRemaining: dots,
|
||||||
@@ -131,6 +137,15 @@ function ghostTarget( game, g ) {
|
|||||||
const px = Math.round( p.x );
|
const px = Math.round( p.x );
|
||||||
const py = Math.round( p.y );
|
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.
|
// Blinky: perseguir sin mas.
|
||||||
if ( g.kind === 'blinky' ) return { x: px, y: py };
|
if ( g.kind === 'blinky' ) return { x: px, y: py };
|
||||||
|
|
||||||
@@ -180,10 +195,16 @@ function decideGhost( game, g ) {
|
|||||||
g.dir = best;
|
g.dir = best;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveGhost( game, g ) {
|
function moveGhost( game, g, i ) {
|
||||||
const grid = game.grid;
|
const grid = game.grid;
|
||||||
const width = grid[ 0 ].length;
|
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 ) ) {
|
if ( aligned( g.x ) && aligned( g.y ) ) {
|
||||||
g.x = Math.round( g.x );
|
g.x = Math.round( g.x );
|
||||||
g.y = Math.round( g.y );
|
g.y = Math.round( g.y );
|
||||||
@@ -203,6 +224,8 @@ function resetPositions( game ) {
|
|||||||
p.y = PACMAN_START.y;
|
p.y = PACMAN_START.y;
|
||||||
p.dir = 'left';
|
p.dir = 'left';
|
||||||
p.nextDir = null;
|
p.nextDir = null;
|
||||||
|
// Reinicia el escalonado: cada vida repite la salida uno a uno.
|
||||||
|
game.frame = 0;
|
||||||
game.ghosts.forEach( ( g, i ) => {
|
game.ghosts.forEach( ( g, i ) => {
|
||||||
g.x = GHOST_STARTS[ i ].x;
|
g.x = GHOST_STARTS[ i ].x;
|
||||||
g.y = GHOST_STARTS[ i ].y;
|
g.y = GHOST_STARTS[ i ].y;
|
||||||
@@ -215,8 +238,9 @@ function collides( a, b ) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function update( game ) {
|
function update( game ) {
|
||||||
|
game.frame++;
|
||||||
movePacman( game );
|
movePacman( game );
|
||||||
game.ghosts.forEach( ( g ) => moveGhost( game, g ) );
|
game.ghosts.forEach( ( g, i ) => moveGhost( game, g, i ) );
|
||||||
|
|
||||||
for ( const g of game.ghosts ) {
|
for ( const g of game.ghosts ) {
|
||||||
if ( collides( game.pacman, g ) ) {
|
if ( collides( game.pacman, g ) ) {
|
||||||
|
|||||||
@@ -61,7 +61,15 @@ const GHOST_STARTS = [
|
|||||||
{ x: 16, y: 14, kind: 'clyde' },
|
{ 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.MAZE = MAZE;
|
||||||
window.TUNNEL_ROW = TUNNEL_ROW;
|
window.TUNNEL_ROW = TUNNEL_ROW;
|
||||||
window.PACMAN_START = PACMAN_START;
|
window.PACMAN_START = PACMAN_START;
|
||||||
window.GHOST_STARTS = GHOST_STARTS;
|
window.GHOST_STARTS = GHOST_STARTS;
|
||||||
|
window.PEN = PEN;
|
||||||
|
window.PEN_DOOR = PEN_DOOR;
|
||||||
|
|||||||
Reference in New Issue
Block a user