Compare commits
3
Commits
3d7ab0046f
...
90bc68449a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90bc68449a | ||
|
|
d150e4bebc | ||
|
|
a08eb8af32 |
@@ -0,0 +1,6 @@
|
|||||||
|
# spec workflow configuration
|
||||||
|
#
|
||||||
|
# AutoCreateBranch — controls whether /spec-impl creates the git branch automatically.
|
||||||
|
# true (default) → /spec-impl creates and switches to spec-NN-slug without asking
|
||||||
|
# false → /spec-impl asks for [y/N] confirmation before creating the branch
|
||||||
|
AutoCreateBranch: true
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# SPEC 01 — Four distinct ghosts in the pen
|
||||||
|
|
||||||
|
> **Status:** Approved
|
||||||
|
> **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.
|
||||||
|
|
||||||
|
## Why this spec exists
|
||||||
|
|
||||||
|
The current MVP ships only two ghosts in `GHOST_STARTS` (`'hunter'` and `'random'`) and a single branch in `decideGhost` that swaps between a Manhattan-distance chase and a uniform random walk. The user wants four ghosts with visibly different behaviours, anchored on the classic Pac-Man archetypes, while keeping the rest of the game (movement, collisions, render, overlay) untouched.
|
||||||
|
|
||||||
|
The render layer is already prepared for four ghosts — `render.js` declares exactly four colours in `GHOST_COLORS`. This spec leverages that and changes only the data and the decision function.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- Four entries in `GHOST_STARTS` (`src/js/maze.js`), placed inside the pen, symmetric with respect to the maze's vertical axis.
|
||||||
|
- Four `kind` values: `'blinky'`, `'pinky'`, `'inky'`, `'clyde'`.
|
||||||
|
- Four behaviour branches in `decideGhost` (`src/js/game.js`), one per `kind`.
|
||||||
|
- Same speed for all four ghosts (the existing `GHOST_SPEED`).
|
||||||
|
- Colour per archetype (the existing `GHOST_COLORS` order already maps 1:1 to the four kinds in the order Blinky, Pinky, Inky, Clyde).
|
||||||
|
|
||||||
|
**Out of scope (for future specs):**
|
||||||
|
|
||||||
|
- Power-pellets, frightened mode, blue ghosts.
|
||||||
|
- Pathfinding beyond the next intersection (no A*, no BFS).
|
||||||
|
- Ghost house release animation or staged exits (all four spawn inside the pen and walk out through the door like today).
|
||||||
|
- Per-ghost speed, per-ghost scatter tiles, per-ghost animation state.
|
||||||
|
- Sounds, levels beyond the first, high-scores, settings.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/js/maze.js
|
||||||
|
const GHOST_STARTS = [
|
||||||
|
{ x: 11, y: 14, kind: 'blinky' },
|
||||||
|
{ x: 13, y: 14, kind: 'pinky' },
|
||||||
|
{ x: 14, y: 14, kind: 'inky' },
|
||||||
|
{ x: 16, y: 14, kind: 'clyde' },
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
Conventions:
|
||||||
|
|
||||||
|
- Coordinates: cell `(x, y)`, origin top-left, `x ∈ [0, 27]`, `y ∈ [0, 30]`.
|
||||||
|
- The four start cells are symmetric: columns 11↔16 and 13↔14 mirror across the maze's vertical centre line.
|
||||||
|
- The pen door at columns 13–14, row 12 stays clear (no ghost spawns on `(13,12)` or `(14,12)`).
|
||||||
|
|
||||||
|
`game.ghosts[]` keeps the existing shape `{ x, y, dir, speed, kind }`. `resetPositions` already iterates by index, so it scales to four entries with no change.
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/js/game.js — relevant globals stay the same
|
||||||
|
const GHOST_SPEED = 0.1;
|
||||||
|
const DIRS = { left: { x: -1, y: 0 }, right: { x: 1, y: 0 }, up: { x: 0, y: -1 }, down: { x: 0, y: 1 } };
|
||||||
|
const OPPOSITE = { left: 'right', right: 'left', up: 'down', down: 'up' };
|
||||||
|
```
|
||||||
|
|
||||||
|
No new structures are introduced. Render colours are reused:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// src/js/render.js — already declares 4 colours, no change
|
||||||
|
const GHOST_COLORS = [ '#ff0000', '#00ffff', '#ffb8ff', '#ffb852' ];
|
||||||
|
```
|
||||||
|
|
||||||
|
## Implementation plan
|
||||||
|
|
||||||
|
1. **Edit `src/js/maze.js`** — replace the current two-entry `GHOST_STARTS` with the four-entry array shown in the data model. No other change in this file.
|
||||||
|
2. **Edit `src/js/game.js` `decideGhost`** — replace the existing two-branch `if (g.kind === 'hunter') … else …` with the four behaviours described below. Keep the `options`/`choices` filtering against the opposite direction intact, so every ghost still avoids 180° reversals at intersections.
|
||||||
|
3. **Verify `resetPositions`** — read the file and confirm the `forEach( ( g, i ) => GHOST_STARTS[ i ] )` loop still works with four entries. No code change expected; document the confirmation.
|
||||||
|
4. **Verify `isWall` and `wrapTunnel`** — read the file and confirm the pen door is still passable for `actor === 'ghost'` and that the tunnel wrap still triggers on row 14. No code change expected.
|
||||||
|
5. **Verify `render.js`** — read the file and confirm `GHOST_COLORS` already has four entries and `forEach( ( g, i ) => GHOST_COLORS[ i ] || '#ff0000' )` paints all four. No code change expected.
|
||||||
|
6. **Manual playtest** — open `src/index.html` in a browser, click Start, and observe the four ghosts. The pen must not trap any ghost permanently (all four walk out through the door on their own).
|
||||||
|
|
||||||
|
### Behaviour definitions for `decideGhost`
|
||||||
|
|
||||||
|
For each ghost, the candidates are `choices` (the existing filter that excludes the opposite of `g.dir` and any wall; if empty, the opposite is allowed as a fallback). The branch picks one:
|
||||||
|
|
||||||
|
- **Blinky** (`'blinky'`) — pursue. For each candidate, compute `|nx − round(pacman.x)| + |ny − round(pacman.y)|` and pick the minimum. This is the current `'hunter'` branch, renamed.
|
||||||
|
- **Pinky** (`'pinky'`) — ambush. Target cell is four tiles ahead of Pac-Man in `pacman.dir` (wrapping to `(0,14)` or `(27,14)` when the offset leaves the maze horizontally — i.e. when the target is past the tunnel, anchor it to Pac-Man's actual column). Pick the candidate that minimises Manhattan distance to that target.
|
||||||
|
- **Inky** (`'inky'`) — flank. Compute the cell two tiles ahead of Pac-Man in `pacman.dir` (same wrap rule as Pinky), then the Inky target is the reflection of Blinky's cell through that pivot: `target = 2 * pivot − blinkyCell`. Pick the candidate that minimises Manhattan distance to that target. If Blinky is missing (it never is in this spec, but defensively), fall back to Pinky's target.
|
||||||
|
- **Clyde** (`'clyde'`) — shy. If `|gx − round(pacman.x)| + |gy − round(pacman.y)| > 8`, behave like Blinky (pursue). Otherwise, target Clyde's home corner `(0, 30)` and pick the candidate that minimises Manhattan distance to that corner.
|
||||||
|
|
||||||
|
All four branches use the same `choices` filter and the same tie-break order (first best in the iteration order). They differ only in **which target cell** they compare against.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [ ] `GHOST_STARTS` in `src/js/maze.js` has exactly four entries, all with `y === 14` and `kind` in `{ 'blinky', 'pinky', 'inky', 'clyde' }`.
|
||||||
|
- [ ] `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.
|
||||||
|
- [ ] 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.
|
||||||
|
- [ ] The browser console shows no errors or warnings during a full play (start, eat some dots, die, restart).
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- **Yes:** four ghosts inside the pen, symmetric. Keeps the existing maze untouched, respects the vertical symmetry already enforced in `MAZE_STR`, and leaves the pen door clear.
|
||||||
|
- **No:** two inside and two outside. Would require either a second spawn mechanism or a maze edit, both outside the scope of this spec.
|
||||||
|
- **Yes:** rename `'hunter'` → `'blinky'` and drop `'random'`. The four classic archetypes subsume the old `'random'` behaviour (Clyde's "shy" mode and Pinky's ambush produce enough variety without a pure-random ghost).
|
||||||
|
- **No:** keep names descriptive (`'ambush'`, `'flank'`, `'shy'`, `'aggressive'`). The classic names are short, recognisable, and match what the player will read online.
|
||||||
|
- **Yes:** same `GHOST_SPEED = 0.1` for all four. Simplifies balance and keeps the existing speed constant untouched.
|
||||||
|
- **No:** per-ghost speed. Belongs to a future balancing spec.
|
||||||
|
- **Yes:** Pinky and Inky clamp their target to within maze bounds when the four-tile offset would overshoot horizontally (anchor to Pac-Man's column when the target row is the tunnel row). Prevents targeting a non-existent cell.
|
||||||
|
- **No:** A* or BFS pathfinding. The intersection-decision model is enough for four distinct behaviours and matches the existing code style.
|
||||||
|
- **Yes:** Inky's fallback when Blinky is missing is Pinky's target. Defensive — never triggers today, costs three lines.
|
||||||
|
- **No:** power-pellets, frightened mode, blue ghosts, ghost house release animation. All deferred to later specs.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
| ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| A ghost gets trapped inside the pen because its target is also inside. | `isWall( actor = 'ghost' )` already ignores the pen door, so every ghost can leave through the door at any intersection. The `choices` filter never includes walls, so a stuck ghost still picks a valid exit. |
|
||||||
|
| Asymmetric spawn cells look off. | The four cells `(11,14)`, `(13,14)`, `(14,14)`, `(16,14)` are symmetric across the maze's vertical axis (cols 11↔16, 13↔14), matching the symmetry already present in `MAZE_STR`. |
|
||||||
|
| Renaming `'hunter'` breaks any external consumer. | `kind` is only read inside `decideGhost`; `ghosts` is created from `GHOST_STARTS` and never serialized. No consumer exists outside this repo. |
|
||||||
|
| Inky or Pinky target a cell outside the maze on wrap. | The implementation plan explicitly bounds the target to maze columns using `Math.max(0, Math.min(W − 1, …))`, and the tunnel-row special case anchors to Pac-Man's column. |
|
||||||
|
|
||||||
|
## What is **not** in this spec
|
||||||
|
|
||||||
|
- Power-pellets and frightened mode.
|
||||||
|
- Pathfinding beyond the next intersection.
|
||||||
|
- Ghost house release animation or staged exits.
|
||||||
|
- Per-ghost speed, per-ghost scatter tiles, per-ghost animation state.
|
||||||
|
- Sounds, multiple levels, high-scores, settings.
|
||||||
|
- Any new file, dependency, bundler, or build step.
|
||||||
|
|
||||||
|
Each one of those, if it lands, goes in its own spec.
|
||||||
+57
-18
@@ -110,9 +110,52 @@ function movePacman( game ) {
|
|||||||
wrapTunnel( p, width );
|
wrapTunnel( p, width );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Celda situada `n` casillas por delante de Pac-Man segun su direccion.
|
||||||
|
// Las columnas se mantienen dentro del laberinto con clamp; si el offset
|
||||||
|
// se sale lateralmente por el tunel, la columna se ancla a la de Pac-Man.
|
||||||
|
function aheadOfPacman( game, n ) {
|
||||||
|
const p = game.pacman;
|
||||||
|
const W = game.grid[ 0 ].length;
|
||||||
|
const d = DIRS[ p.dir ] || { x: 0, y: 0 };
|
||||||
|
const px = Math.round( p.x );
|
||||||
|
const py = Math.round( p.y );
|
||||||
|
const ty = py + d.y * n;
|
||||||
|
if ( ty === TUNNEL_ROW ) return { x: px, y: ty };
|
||||||
|
return { x: Math.max( 0, Math.min( W - 1, px + d.x * n ) ), y: ty };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Objetivo de cada arquetipo. Las cuatro ramas comparten el filtro de
|
||||||
|
// candidatos y el desempate; solo cambia la celda contra la que se compara.
|
||||||
|
function ghostTarget( game, g ) {
|
||||||
|
const p = game.pacman;
|
||||||
|
const px = Math.round( p.x );
|
||||||
|
const py = Math.round( p.y );
|
||||||
|
|
||||||
|
// Blinky: perseguir sin mas.
|
||||||
|
if ( g.kind === 'blinky' ) return { x: px, y: py };
|
||||||
|
|
||||||
|
// Pinky: emboscarse 4 casillas por delante.
|
||||||
|
if ( g.kind === 'pinky' ) return aheadOfPacman( game, 4 );
|
||||||
|
|
||||||
|
// Inky: flanquear, reflejando a Blinky sobre el pivot a 2 casillas delante.
|
||||||
|
if ( g.kind === 'inky' ) {
|
||||||
|
const pivot = aheadOfPacman( game, 2 );
|
||||||
|
const blinky = game.ghosts.find( ( o ) => o.kind === 'blinky' );
|
||||||
|
// Sin Blinky (defensivo, no ocurre hoy) Pinky hace de suplente.
|
||||||
|
if ( !blinky ) return aheadOfPacman( game, 4 );
|
||||||
|
return {
|
||||||
|
x: 2 * pivot.x - Math.round( blinky.x ),
|
||||||
|
y: 2 * pivot.y - Math.round( blinky.y ),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clyde: persigue mientras este lejos y se retira a su esquina si se acerca.
|
||||||
|
if ( Math.abs( g.x - px ) + Math.abs( g.y - py ) > 8 ) return { x: px, y: py };
|
||||||
|
return { x: 0, y: 30 };
|
||||||
|
}
|
||||||
|
|
||||||
function decideGhost( game, g ) {
|
function decideGhost( game, g ) {
|
||||||
const grid = game.grid;
|
const grid = game.grid;
|
||||||
const p = game.pacman;
|
|
||||||
|
|
||||||
const options = Object.keys( DIRS ).filter(
|
const options = Object.keys( DIRS ).filter(
|
||||||
( dir ) => dir !== OPPOSITE[ g.dir ] && canMove( grid, g.x, g.y, dir, 'ghost' )
|
( dir ) => dir !== OPPOSITE[ g.dir ] && canMove( grid, g.x, g.y, dir, 'ghost' )
|
||||||
@@ -120,25 +163,21 @@ function decideGhost( game, g ) {
|
|||||||
// Sin salida (callejon): permitir el giro de 180.
|
// Sin salida (callejon): permitir el giro de 180.
|
||||||
const choices = options.length ? options : [ '' + OPPOSITE[ g.dir ] ];
|
const choices = options.length ? options : [ '' + OPPOSITE[ g.dir ] ];
|
||||||
|
|
||||||
if ( g.kind === 'hunter' ) {
|
const target = ghostTarget( game, g );
|
||||||
const px = Math.round( p.x );
|
|
||||||
const py = Math.round( p.y );
|
let best = choices[ 0 ];
|
||||||
let best = choices[ 0 ];
|
let bestDist = Infinity;
|
||||||
let bestDist = Infinity;
|
for ( const dir of choices ) {
|
||||||
for ( const dir of choices ) {
|
const d = DIRS[ dir ];
|
||||||
const d = DIRS[ dir ];
|
const nx = g.x + d.x;
|
||||||
const nx = g.x + d.x;
|
const ny = g.y + d.y;
|
||||||
const ny = g.y + d.y;
|
const dist = Math.abs( nx - target.x ) + Math.abs( ny - target.y );
|
||||||
const dist = Math.abs( nx - px ) + Math.abs( ny - py );
|
if ( dist < bestDist ) {
|
||||||
if ( dist < bestDist ) {
|
bestDist = dist;
|
||||||
bestDist = dist;
|
best = dir;
|
||||||
best = dir;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
g.dir = best;
|
|
||||||
} else {
|
|
||||||
g.dir = choices[ Math.floor( Math.random() * choices.length ) ];
|
|
||||||
}
|
}
|
||||||
|
g.dir = best;
|
||||||
}
|
}
|
||||||
|
|
||||||
function moveGhost( game, g ) {
|
function moveGhost( game, g ) {
|
||||||
|
|||||||
+7
-2
@@ -51,9 +51,14 @@ const MAZE = MAZE_STR.map( ( row ) => row.split( '' ).map( parseTile ) );
|
|||||||
|
|
||||||
const TUNNEL_ROW = 14;
|
const TUNNEL_ROW = 14;
|
||||||
const PACMAN_START = { x: 13, y: 23 };
|
const PACMAN_START = { x: 13, y: 23 };
|
||||||
|
// Los cuatro arquetipos clasicos, dentro de la pen. Simetricos respecto al
|
||||||
|
// eje vertical (cols 11<->16, 13<->14) y en el orden de GHOST_COLORS de
|
||||||
|
// render.js (blinky rojo, pinky cyan, inky rosa, clyde naranja).
|
||||||
const GHOST_STARTS = [
|
const GHOST_STARTS = [
|
||||||
{ x: 13, y: 14, kind: 'hunter' }, // dentro de la pen
|
{ x: 11, y: 14, kind: 'blinky' },
|
||||||
{ x: 14, y: 14, kind: 'random' }, // dentro de la pen
|
{ x: 13, y: 14, kind: 'pinky' },
|
||||||
|
{ x: 14, y: 14, kind: 'inky' },
|
||||||
|
{ x: 16, y: 14, kind: 'clyde' },
|
||||||
];
|
];
|
||||||
|
|
||||||
window.MAZE = MAZE;
|
window.MAZE = MAZE;
|
||||||
|
|||||||
Reference in New Issue
Block a user