5 Commits
Author SHA1 Message Date
devlez-gitea 75814528f0 Merge pull request 'release ghosts from the pen via door target and staged exit' (#2) from spec-02-ghost-pen-release into main
Reviewed-on: #2
2026-10-18 07:14:30 +00:00
luriasb 66100a7b74 release ghosts from the pen via door target and staged exit
SPEC 01's four archetypes never left the ghost pen. With Pac-Man starting
below the pen, greedy Manhattan pursuit scores `up` as the worst option
from every cell inside, so the door is never chosen and all four ghosts
walk a closed loop forever. The bug predates SPEC 01: the original
`hunter` ghost was trapped too, and only the removed `random` ghost
escaped by luck, masking it.

Fixes it by aiming ghosts at the door while they are inside a PEN box,
then gating the release one ghost per GHOST_RELEASE_FRAMES.

The box deliberately includes row 12, the two door cells. Stopping at
row 13 would hand control back to the archetype while the ghost is
still in the doorway, and its target below would pull it back inside.

Measured on the first life with Pac-Man idle: the four ghosts clear PEN
at frames 51, 90, 160 and 240, all before the 300-frame criterion.
The release replays after a death because resetPositions zeroes
game.frame.

Implements SPEC 02 and closes the unmet acceptance criterion it leaves
in SPEC 01. The game stays deterministic; no randomness is introduced.
2026-10-18 01:12:38 -06:00
devlez-gitea 90bc68449a Merge pull request 'Spec 01 four distinct ghosts' (#1) from spec-01-four-distinct-ghosts into main
Reviewed-on: #1
2026-10-18 04:20:49 +00:00
luriasb d150e4bebc implement four distinct ghost archetypes
Replace the generic hunter/random split with the classic four: Blinky
chases directly, Pinky ambushes 4 tiles ahead of Pac-Man, Inky flanks
using Blinky reflected over a pivot 2 tiles ahead, and Clyde chases
when far and retreats to its corner when within 8 tiles.
2026-10-17 00:53:13 -06:00
luriasb a08eb8af32 add spec 2026-10-16 12:17:18 -06:00
5 changed files with 389 additions and 22 deletions
+6
View File
@@ -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
+139
View File
@@ -0,0 +1,139 @@
# SPEC 01 — Four distinct ghosts in the pen
> **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.
## 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.
> **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.
- [ ] 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.
+146
View File
@@ -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.
+83 -20
View File
@@ -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,
@@ -110,9 +116,61 @@ function movePacman( game ) {
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 );
// 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 };
// 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 ) {
const grid = game.grid;
const p = game.pacman;
const options = Object.keys( DIRS ).filter(
( dir ) => dir !== OPPOSITE[ g.dir ] && canMove( grid, g.x, g.y, dir, 'ghost' )
@@ -120,31 +178,33 @@ function decideGhost( game, g ) {
// Sin salida (callejon): permitir el giro de 180.
const choices = options.length ? options : [ '' + OPPOSITE[ g.dir ] ];
if ( g.kind === 'hunter' ) {
const px = Math.round( p.x );
const py = Math.round( p.y );
let best = choices[ 0 ];
let bestDist = Infinity;
for ( const dir of choices ) {
const d = DIRS[ dir ];
const nx = g.x + d.x;
const ny = g.y + d.y;
const dist = Math.abs( nx - px ) + Math.abs( ny - py );
if ( dist < bestDist ) {
bestDist = dist;
best = dir;
}
const target = ghostTarget( game, g );
let best = choices[ 0 ];
let bestDist = Infinity;
for ( const dir of choices ) {
const d = DIRS[ dir ];
const nx = g.x + d.x;
const ny = g.y + d.y;
const dist = Math.abs( nx - target.x ) + Math.abs( ny - target.y );
if ( dist < bestDist ) {
bestDist = dist;
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, 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 );
@@ -164,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;
@@ -176,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 ) ) {
+15 -2
View File
@@ -51,12 +51,25 @@ const MAZE = MAZE_STR.map( ( row ) => row.split( '' ).map( parseTile ) );
const TUNNEL_ROW = 14;
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 = [
{ x: 13, y: 14, kind: 'hunter' }, // dentro de la pen
{ x: 14, y: 14, kind: 'random' }, // dentro de la pen
{ x: 11, y: 14, kind: 'blinky' },
{ x: 13, y: 14, kind: 'pinky' },
{ x: 14, y: 14, kind: 'inky' },
{ 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;