feat: add door/keys system, patrol paths, and data-driven pickups

- Implement sliding door mechanics (open/close with E key, auto-close, locked doors)
- Add key system (gold/silver keys) with data-driven pickup definitions in pickups.json
- Add enemy patrol path editor in editor.html (click to select, add points, move/remove)
- Implement enemy patrol AI that follows authored paths in a loop
- Migrate pickups from hardcoded to data-driven config (effect, amount, key grants)
- Update raycaster to handle door openness for rendering and collision
- Add HUD key chips display and "door locked" messages
- Add Gold Door and Silver Door wall types with matching key pickups
- Update level1 with patrol paths and gold key pickup placement
This commit is contained in:
Brian Fertig 2026-07-01 23:08:48 -06:00
parent 724d4fd1e8
commit 1c3bed3beb
16 changed files with 604 additions and 74 deletions

View File

@ -37,6 +37,7 @@ Then open:
| Move / strafe | `W` `A` `S` `D` (arrows also move) | | Move / strafe | `W` `A` `S` `D` (arrows also move) |
| Turn | `←` `→`, or **mouse** (click to lock) | | Turn | `←` `→`, or **mouse** (click to lock) |
| Shoot | **Left-click** (mouse locked) / `Space` | | Shoot | **Left-click** (mouse locked) / `Space` |
| Open / close door | `E` |
The game opens on a **main menu** listing the campaigns from The game opens on a **main menu** listing the campaigns from
`src/config/campaigns.json`. Pick one to play its levels in order; finishing `src/config/campaigns.json`. Pick one to play its levels in order; finishing
@ -53,10 +54,11 @@ placeholder art until you provide a spritesheet):
| File | Defines | Key fields | | File | Defines | Key fields |
| ---------------- | ---------------------- | ---------- | | ---------------- | ---------------------- | ---------- |
| `walls.json` | Wall tiles (ids 1+) | `id` (int, used in level `tiles`), `name`, `solid`, `isDoor`, `color` (`#rrggbb`, placeholder/editor tint), `texture` (sheet path), `frame` | | `walls.json` | Wall tiles (ids 1+) | `id` (int, used in level `tiles`), `name`, `solid`, `isDoor`, `key` (key id a locked door needs, e.g. `"gold"`), `color` (`#rrggbb`, placeholder/editor tint), `texture` (sheet path), `frame` |
| `overlays.json` | Wall overlays | `id` (string), `name`, `color`, `texture`, `frame` — transparent PNGs composited over the wall texture | | `overlays.json` | Wall overlays | `id` (string), `name`, `color`, `texture`, `frame` — transparent PNGs composited over the wall texture |
| `items.json` | Decorative items | `id`, `name`, `color`, `blocking` (true = its cell is impassable), `scale` (height as a fraction of wall height), `texture`, `frame` | | `items.json` | Decorative items | `id`, `name`, `color`, `blocking` (true = its cell is impassable), `scale` (height as a fraction of wall height), `texture`, `frame` |
| `enemies.json` | Enemy types | `type`, `name`, `color`, `health`, `speed`, `points`, `texture`, `frame`; plus top-level `defaultType` (what v1 levels' generic "enemy" migrates to) | | `enemies.json` | Enemy types | `type`, `name`, `color`, `health`, `speed`, `points`, `texture`, `frame`; plus top-level `defaultType` (what v1 levels' generic "enemy" migrates to) |
| `pickups.json` | Pickups & keys | `id` (used as the entity type), `name`, `color`, `effect` (`ammo` \| `health` \| `score` \| `key`), `amount`, `key` (key id granted when `effect` is `key`), `texture`, `frame` |
| `campaigns.json` | Campaigns for the menu | `id`, `name`, `description`, `levels` (ordered filenames in `src/levels/`) | | `campaigns.json` | Campaigns for the menu | `id`, `name`, `description`, `levels` (ordered filenames in `src/levels/`) |
Notes: Notes:
@ -66,8 +68,16 @@ Notes:
paintings, banners, vines. paintings, banners, vines.
- Items sit on **floor cells**. Blocking items stop the player like a wall but - Items sit on **floor cells**. Blocking items stop the player like a wall but
are not walls: shots and sight pass over them. are not walls: shots and sight pass over them.
- Pickups (`ammo`, `health`, `treasure`) and the `exit` are built-in because - **Doors** (`isDoor: true` walls) slide open Wolf3D-style when the player
they have hardcoded game effects. presses `E` facing them, stay open a few seconds, then close (never on top
of the player). Give a door a `key` (e.g. `"gold"`) to lock it — the player
must first collect a pickup whose `effect` is `"key"` with the matching
`key` id. Collected keys show as colored chips in the HUD and reset each
level. The stock set ships an unlocked Door (id 5), a Gold Door (id 6), and
a Silver Door (id 7).
- Pickup effects (`ammo`/`health`/`score`/`key`) and amounts live in
`pickups.json`; only the `exit` is built-in because ending the level is a
game mechanic.
- Each config file has an embedded fallback copy in `src/engine/registry.js`, - Each config file has an embedded fallback copy in `src/engine/registry.js`,
so a missing/broken file can't stop the game booting. Keep them in sync if so a missing/broken file can't stop the game booting. Keep them in sync if
you change defaults you care about offline. you change defaults you care about offline.
@ -85,8 +95,15 @@ and frame maps.** Overlay/item/enemy sheets should be transparent PNGs.
## Level editor (`/editor.html`) ## Level editor (`/editor.html`)
- **Palette** (top): dropdowns for **Wall / Overlay / Item / Enemy** (populated - **Palette** (top): dropdowns for **Wall / Overlay / Item / Enemy** (populated
from the config JSONs) plus **Start**, pickup, and **Erase** tool buttons. from the config JSONs) plus **Start**, pickup, **Path**, and **Erase** tool
Focusing a dropdown or clicking a button makes it the active brush. buttons. Focusing a dropdown or clicking a button makes it the active brush.
- **Path tool**: click an enemy to select it (yellow ring), then click floor
cells to append patrol points. Click an existing point for a **Move /
Remove** menu (Move = the next click relocates it). Right-click deselects.
Paths draw as looped lines — faint for all enemies, highlighted for the
selection. In game the enemy walks spawn → points → spawn in a loop at its
`speed` from `enemies.json` (cells/sec), pausing briefly when shot. Paths
are author-trusted: one drawn through a wall will walk through it.
- **Left-click / drag**: paint the selected brush. Overlays only apply to wall - **Left-click / drag**: paint the selected brush. Overlays only apply to wall
cells; items only to floor cells. Painting a wall clears anything inside it; cells; items only to floor cells. Painting a wall clears anything inside it;
painting floor clears the cell's overlay. painting floor clears the cell's overlay.
@ -189,7 +206,7 @@ phaser`) and replace the file — no other changes needed.
(see `assets/README.md`) — zero code changes. (see `assets/README.md`) — zero code changes.
- **Enemy AI**: flesh out `Entity.update()` (patrol → chase → attack); each - **Enemy AI**: flesh out `Entity.update()` (patrol → chase → attack); each
enemy def already carries a data-driven `speed`. enemy def already carries a data-driven `speed`.
- **Doors / keys**: door tiles are flagged `isDoor` in `walls.json`; - **More door types**: doors + keys are data-driven — add a wall entry with
wire sliding + locked-door logic into `GameMap`/`Raycaster`. `isDoor: true` and a `key`, plus a matching key pickup in `pickups.json`.
- **Sound, more campaigns**: add campaign entries to `campaigns.json`; hook - **Sound, more campaigns**: add campaign entries to `campaigns.json`; hook
audio into the scenes in `src/game`. audio into the scenes in `src/game`.

View File

@ -23,8 +23,9 @@ there if you prefer a different arrangement):
| `assets/textures/overlays.png`| `overlays.json` | **Transparent PNG** — composited over wall textures | | `assets/textures/overlays.png`| `overlays.json` | **Transparent PNG** — composited over wall textures |
| `assets/sprites/items.png` | `items.json` | **Transparent PNG** — billboard props (plant, lamp, …) | | `assets/sprites/items.png` | `items.json` | **Transparent PNG** — billboard props (plant, lamp, …) |
| `assets/sprites/enemies.png` | `enemies.json` | **Transparent PNG** — one frame per enemy type for now | | `assets/sprites/enemies.png` | `enemies.json` | **Transparent PNG** — one frame per enemy type for now |
| `assets/sprites/pickups.png` | `pickups.json` | **Transparent PNG** — ammo/health/treasure + keys |
A 5-frame walls sheet is therefore 960×192; a 3-frame enemies sheet 576×192. A 7-frame walls sheet is therefore 1344×192; a 3-frame enemies sheet 576×192.
You can also point different entries at different files — each unique You can also point different entries at different files — each unique
`texture` path is loaded once and shared. `texture` path is loaded once and shared.

View File

@ -124,6 +124,7 @@
<span class="sep"></span> <span class="sep"></span>
<button id="btn-tool-start">Start</button> <button id="btn-tool-start">Start</button>
<span id="pickup-tools" style="display: contents"></span> <span id="pickup-tools" style="display: contents"></span>
<button id="btn-tool-path">Path</button>
<button id="btn-tool-erase">Erase</button> <button id="btn-tool-erase">Erase</button>
</div> </div>
<div id="editor"></div> <div id="editor"></div>

View File

@ -23,21 +23,27 @@ No code changes needed — the paths and frame numbers below come straight from
## 1. `assets/textures/walls.png` — wall textures ## 1. `assets/textures/walls.png` — wall textures
**5 frames → 960×192 px. Opaque** (no transparency; these fill the whole wall). **7 frames → 1344×192 px. Opaque** (no transparency; these fill the whole wall).
| Frame | Pixels | Wall (`walls.json`) | Placeholder it replaces | | Frame | Pixels | Wall (`walls.json`) | Placeholder it replaces |
| ----- | --------- | ------------------- | ------------------------------ | | ----- | --------- | ------------------------ | ------------------------------ |
| 0 | 0–191 | id 1 — Gray Brick | gray `#9a9a9a` brick courses | | 0 | 0–191 | id 1 — Gray Brick | gray `#9a9a9a` brick courses |
| 1 | 192–383 | id 2 — Blue Stone | blue `#4a6fa5` pattern | | 1 | 192–383 | id 2 — Blue Stone | blue `#4a6fa5` pattern |
| 2 | 384–575 | id 3 — Red Brick | red `#a5503f` pattern | | 2 | 384–575 | id 3 — Red Brick | red `#a5503f` pattern |
| 3 | 576–767 | id 4 — Wood | brown `#8a6a3a` planks | | 3 | 576–767 | id 4 — Wood | brown `#8a6a3a` planks |
| 4 | 768–959 | id 5 — Door | gold `#c8a838` pattern | | 4 | 768–959 | id 5 — Door | gold `#c8a838` pattern |
| 5 | 960–1151 | id 6 — Gold Door (locked)| gold `#d4af37` pattern |
| 6 | 1152–1343 | id 7 — Silver Door (locked)| silver `#a8a8c0` pattern |
Notes: Notes:
- One texture covers one full grid cell (all four faces use the same image; - One texture covers one full grid cell (all four faces use the same image;
the engine un-mirrors back faces so text/asymmetric details stay readable). the engine un-mirrors back faces so text/asymmetric details stay readable).
- Make the texture **tileable left-to-right** if you want long runs of the - Make the texture **tileable left-to-right** if you want long runs of the
same wall type to look continuous. same wall type to look continuous.
- Door frames (4–6) are drawn on the sliding panel itself — it slides sideways
as the door opens, so avoid critical detail hugging the left edge (that's
the edge that disappears first). Locked doors should visually hint at their
key color.
## 2. `assets/textures/overlays.png` — wall overlays ## 2. `assets/textures/overlays.png` — wall overlays
@ -93,22 +99,38 @@ Notes:
- The hurt flash is generated automatically (white-tinted copy) — you don't - The hurt flash is generated automatically (white-tinted copy) — you don't
need to draw one. need to draw one.
## 5. `assets/sprites/pickups.png` — pickups & keys
**5 frames → 960×192 px. Transparent background required** — small
floor-anchored billboards (drawn at 0.5 × wall height by default; add a
`scale` field in `pickups.json` to change per pickup).
| Frame | Pixels | Pickup (`pickups.json`) | Placeholder it replaces |
| ----- | ------- | ------------------------ | ---------------------------- |
| 0 | 0–191 | `ammo` — Ammo | yellow diamond |
| 1 | 192–383 | `health` — Health | green diamond |
| 2 | 384–575 | `treasure` — Treasure | gold diamond |
| 3 | 576–767 | `key_gold` — Gold Key | gold key glyph |
| 4 | 768–959 | `key_silver` — Silver Key| silver key glyph |
Notes:
- Key colors should match their door and their HUD chip (the chip uses the
pickup's `color` from `pickups.json`).
## Not needed (yet) ## Not needed (yet)
- **Pickups** (ammo, health, treasure) and the **exit** currently always use - The **exit** uses a built-in generated marker (it's a level mechanic, not a
their built-in generated diamond markers — there's no `texture` field for configurable pickup).
them in a config file yet. If you want art for those too, say so and the
same pattern can be extended to them.
- **Floors/ceilings** are flat colors (`FLOOR_COLOR` / `CEILING_COLOR` in - **Floors/ceilings** are flat colors (`FLOOR_COLOR` / `CEILING_COLOR` in
`src/engine/constants.js`); textured floors are a separate engine feature. `src/engine/constants.js`); textured floors are a separate engine feature.
- **Weapon/hands** on the HUD is a gray rectangle stub (`HudScene`). - **Weapon/hands** on the HUD is a gray rectangle stub (`HudScene`).
## Adding more content later ## Adding more content later
New wall/overlay/item/enemy = one new 192×192 frame appended to the relevant New wall/overlay/item/enemy/pickup = one new 192×192 frame appended to the
strip + one JSON entry in `src/config/` pointing at its `frame` index. Nothing relevant strip + one JSON entry in `src/config/` pointing at its `frame`
else. You can also start a fresh PNG per entry if you prefer — the `texture` index. Nothing else. You can also start a fresh PNG per entry if you prefer —
path is per-entry, not global. the `texture` path is per-entry, not global.
## Changing resolution later ## Changing resolution later

View File

@ -45,6 +45,26 @@
"color": "#c8a838", "color": "#c8a838",
"texture": "assets/textures/walls.png", "texture": "assets/textures/walls.png",
"frame": 4 "frame": 4
},
{
"id": 6,
"name": "Gold Door",
"solid": true,
"isDoor": true,
"key": "gold",
"color": "#d4af37",
"texture": "assets/textures/walls.png",
"frame": 5
},
{
"id": 7,
"name": "Silver Door",
"solid": true,
"isDoor": true,
"key": "silver",
"color": "#a8a8c0",
"texture": "assets/textures/walls.png",
"frame": 6
} }
] ]
} }

View File

@ -49,11 +49,17 @@ export class EditorScene extends Phaser.Scene {
// { kind: 'enemy', type } place an enemy // { kind: 'enemy', type } place an enemy
// { kind: 'pickup', type } place a pickup / the exit // { kind: 'pickup', type } place a pickup / the exit
// { kind: 'start' } move the player start // { kind: 'start' } move the player start
// { kind: 'path' } edit enemy patrol paths (select + points)
// { kind: 'erase' } clear everything in the cell // { kind: 'erase' } clear everything in the cell
this.brush = { kind: 'wall', id: 1 }; this.brush = { kind: 'wall', id: 1 };
this.cellSize = 32; this.cellSize = 32;
this.offsetX = 40; this.offsetX = 40;
this.offsetY = TOP_MARGIN + 20; this.offsetY = TOP_MARGIN + 20;
// Path tool state.
this.selectedEnemy = null; // entity being path-edited
this.movingPoint = null; // path point index awaiting relocation
this.pointMenu = null; // the Move/Remove DOM popup
} }
create() { create() {
@ -75,6 +81,12 @@ export class EditorScene extends Phaser.Scene {
const controls = []; const controls = [];
const setActive = (el, brush) => { const setActive = (el, brush) => {
this.brush = brush; this.brush = brush;
if (brush.kind !== 'path') {
// Leaving the path tool drops its selection state.
this.selectedEnemy = null;
this.movingPoint = null;
this.closePointMenu();
}
for (const c of controls) c.classList.remove('active-tool'); for (const c of controls) c.classList.remove('active-tool');
el.classList.add('active-tool'); el.classList.add('active-tool');
}; };
@ -145,6 +157,7 @@ export class EditorScene extends Phaser.Scene {
wireButton(btn, { kind: 'pickup', type: def.type }); wireButton(btn, { kind: 'pickup', type: def.type });
} }
wireButton(document.getElementById('btn-tool-path'), { kind: 'path' });
wireButton(document.getElementById('btn-tool-erase'), { kind: 'erase' }); wireButton(document.getElementById('btn-tool-erase'), { kind: 'erase' });
// Default brush: the first wall in the dropdown (skip Empty if possible). // Default brush: the first wall in the dropdown (skip Empty if possible).
@ -214,12 +227,18 @@ export class EditorScene extends Phaser.Scene {
this.cursors = this.input.keyboard.createCursorKeys(); this.cursors = this.input.keyboard.createCursorKeys();
this.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT'); this.input.keyboard.addCapture('UP,DOWN,LEFT,RIGHT');
this.input.on('pointerdown', (p) => this.paintAt(p)); this.input.on('pointerdown', (p) => {
if (this.brush.kind === 'path') this.handlePathClick(p);
else this.paintAt(p);
});
this.input.on('pointermove', (p) => { this.input.on('pointermove', (p) => {
if (p.middleButtonDown()) { if (p.middleButtonDown()) {
this.offsetX += p.x - p.prevPosition.x; this.offsetX += p.x - p.prevPosition.x;
this.offsetY += p.y - p.prevPosition.y; this.offsetY += p.y - p.prevPosition.y;
} else if (p.leftButtonDown() || p.rightButtonDown()) { } else if (
(p.leftButtonDown() || p.rightButtonDown()) &&
this.brush.kind !== 'path' // path edits are click, not drag
) {
this.paintAt(p); this.paintAt(p);
} }
this.updateHoverStatus(p); this.updateHoverStatus(p);
@ -309,6 +328,113 @@ export class EditorScene extends Phaser.Scene {
this.removeItemAt(cell.x, cell.y); this.removeItemAt(cell.x, cell.y);
} }
// --- Path tool --------------------------------------------------------------
/** Path tool click routing: relocate a moving point, open the Move/Remove
* menu on a point, select an enemy, or append a point to the selection. */
handlePathClick(p) {
this.closePointMenu();
const cell = this.cellUnder(p);
if (p.rightButtonDown()) {
this.selectedEnemy = null;
this.movingPoint = null;
this.setStatus('Path selection cleared.');
return;
}
// A point is being moved: this click places it.
if (this.movingPoint !== null && this.selectedEnemy && cell) {
this.selectedEnemy.path[this.movingPoint] = {
x: cell.x + 0.5,
y: cell.y + 0.5,
};
this.movingPoint = null;
return;
}
// Clicked an existing path point → Move/Remove menu.
const pointIndex = this.pathPointUnder(p);
if (pointIndex !== null) {
this.showPointMenu(pointIndex, p.x, p.y);
return;
}
// Clicked an enemy → select it for path editing.
const enemy = cell ? this.enemyAt(cell.x, cell.y) : null;
if (enemy) {
this.selectedEnemy = enemy;
enemy.path ??= [];
this.movingPoint = null;
return;
}
// Clicked open floor with a selection → append a path point.
if (this.selectedEnemy && cell && !this.map.isWall(cell.x, cell.y)) {
this.selectedEnemy.path.push({ x: cell.x + 0.5, y: cell.y + 0.5 });
}
}
enemyAt(cx, cy) {
return (
this.level.entities.find(
(e) =>
getEntityDef(e.type)?.isEnemy &&
Math.floor(e.x) === cx &&
Math.floor(e.y) === cy,
) ?? null
);
}
/** Index of the selected enemy's path point under the pointer, or null. */
pathPointUnder(p) {
const path = this.selectedEnemy?.path;
if (!path) return null;
const hitRadius = Math.max(8, this.cellSize * 0.25);
for (let i = 0; i < path.length; i++) {
const sx = this.offsetX + path[i].x * this.cellSize;
const sy = this.offsetY + path[i].y * this.cellSize;
if (Math.hypot(p.x - sx, p.y - sy) <= hitRadius) return i;
}
return null;
}
/** Small DOM popup on a path point: Move (relocate on next click) or
* Remove (delete the point). */
showPointMenu(index, px, py) {
this.closePointMenu();
const menu = document.createElement('div');
menu.style.cssText =
`position:absolute;left:${Math.round(px)}px;top:${Math.round(py)}px;` +
'z-index:20;background:#2d2d36;border:1px solid #45454f;' +
'border-radius:4px;padding:4px;display:flex;gap:4px;';
const addButton = (label, onClick) => {
const btn = document.createElement('button');
btn.textContent = label;
btn.style.cssText =
'background:#3a3a46;color:#e6e6e6;border:1px solid #45454f;' +
'border-radius:3px;padding:3px 8px;cursor:pointer;font-size:12px;';
btn.onclick = onClick;
menu.appendChild(btn);
};
addButton('Move', () => {
this.movingPoint = index;
this.setStatus('Click a cell to place the path point.');
this.closePointMenu();
});
addButton('Remove', () => {
this.selectedEnemy?.path.splice(index, 1);
this.closePointMenu();
});
document.body.appendChild(menu);
this.pointMenu = menu;
}
closePointMenu() {
this.pointMenu?.remove();
this.pointMenu = null;
}
removeEntityAt(cx, cy) { removeEntityAt(cx, cy) {
this.level.entities = this.level.entities.filter( this.level.entities = this.level.entities.filter(
(e) => Math.floor(e.x) !== cx || Math.floor(e.y) !== cy, (e) => Math.floor(e.x) !== cx || Math.floor(e.y) !== cy,
@ -335,12 +461,24 @@ export class EditorScene extends Phaser.Scene {
return `${b.kind}:${getEntityDef(b.type)?.name ?? b.type}`; return `${b.kind}:${getEntityDef(b.type)?.name ?? b.type}`;
case 'start': case 'start':
return 'player start'; return 'player start';
case 'path':
return 'path editor';
default: default:
return 'erase'; return 'erase';
} }
} }
updateHoverStatus(p) { updateHoverStatus(p) {
if (this.brush.kind === 'path') {
const e = this.selectedEnemy;
this.setStatus(
e
? `Path: ${getEntityDef(e.type)?.name ?? e.type} — ${e.path?.length ?? 0} point(s). ` +
'Click floor to add · click a point to move/remove · right-click to deselect.'
: 'Path: click an enemy to edit its patrol route.',
);
return;
}
const cell = this.cellUnder(p); const cell = this.cellUnder(p);
if (!cell) { if (!cell) {
this.setStatus(`Brush: ${this.brushLabel()}`); this.setStatus(`Brush: ${this.brushLabel()}`);
@ -448,6 +586,20 @@ export class EditorScene extends Phaser.Scene {
g.strokeRect(ox + it.x * cs - s / 2, oy + it.y * cs - s / 2, s, s); g.strokeRect(ox + it.x * cs - s / 2, oy + it.y * cs - s / 2, s, s);
} }
// Drop a stale selection (e.g. the enemy was erased).
if (this.selectedEnemy && !this.level.entities.includes(this.selectedEnemy)) {
this.selectedEnemy = null;
this.movingPoint = null;
this.closePointMenu();
}
// Patrol paths: all faintly, the selected enemy's highlighted.
for (const e of this.level.entities) {
if (getEntityDef(e.type)?.isEnemy && e.path?.length) {
this.drawPath(g, e, e === this.selectedEnemy, ox, oy, cs);
}
}
// Entities: enemies as large circles, pickups as small ones. // Entities: enemies as large circles, pickups as small ones.
for (const e of this.level.entities) { for (const e of this.level.entities) {
if (!onScreen(e)) continue; if (!onScreen(e)) continue;
@ -455,7 +607,14 @@ export class EditorScene extends Phaser.Scene {
const r = def?.isEnemy ? Math.max(3, cs * 0.28) : Math.max(2, cs * 0.18); const r = def?.isEnemy ? Math.max(3, cs * 0.28) : Math.max(2, cs * 0.18);
g.fillStyle(def?.color ?? 0xff00ff, 1); g.fillStyle(def?.color ?? 0xff00ff, 1);
g.fillCircle(ox + e.x * cs, oy + e.y * cs, r); g.fillCircle(ox + e.x * cs, oy + e.y * cs, r);
// Selection ring for the path tool.
if (e === this.selectedEnemy) {
g.lineStyle(2, 0xffe066, 1);
g.strokeCircle(ox + e.x * cs, oy + e.y * cs, r + 3);
} }
}
this.drawMovingPointHint(g, ox, oy, cs);
// Player start (blue arrow in facing direction). // Player start (blue arrow in facing direction).
const ps = this.level.playerStart; const ps = this.level.playerStart;
@ -475,4 +634,37 @@ export class EditorScene extends Phaser.Scene {
); );
} }
} }
/** Patrol route: looped line through spawn + points, points as diamonds. */
drawPath(g, e, selected, ox, oy, cs) {
const pts = [{ x: e.x, y: e.y }, ...e.path];
g.lineStyle(selected ? 2 : 1, selected ? 0xffe066 : 0xffffff, selected ? 0.9 : 0.3);
for (let i = 0; i < pts.length; i++) {
const a = pts[i];
const b = pts[(i + 1) % pts.length]; // close the loop back to spawn
g.lineBetween(ox + a.x * cs, oy + a.y * cs, ox + b.x * cs, oy + b.y * cs);
}
const s = Math.max(5, cs * 0.2);
for (let i = 1; i < pts.length; i++) {
const px = ox + pts[i].x * cs;
const py = oy + pts[i].y * cs;
g.fillStyle(selected ? 0xffe066 : 0xcccccc, selected ? 1 : 0.6);
g.fillTriangle(px, py - s, px + s, py, px, py + s);
g.fillTriangle(px, py - s, px - s, py, px, py + s);
}
}
/** While relocating a point, mark it with a pulsing ring so it's obvious
* which one the next click will move. */
drawMovingPointHint(g, ox, oy, cs) {
if (this.movingPoint === null || !this.selectedEnemy) return;
const pt = this.selectedEnemy.path[this.movingPoint];
if (!pt) {
this.movingPoint = null;
return;
}
const pulse = 1 + 0.25 * Math.sin(this.time.now / 120);
g.lineStyle(2, 0xff5050, 1);
g.strokeCircle(ox + pt.x * cs, oy + pt.y * cs, Math.max(8, cs * 0.3) * pulse);
}
} }

View File

@ -17,6 +17,15 @@ export class Entity {
this.state = 'idle'; // 'idle' | 'hurt' | 'dead' | 'collected' this.state = 'idle'; // 'idle' | 'hurt' | 'dead' | 'collected'
/** Ms remaining in a transient state (e.g. the hurt flinch flash). */ /** Ms remaining in a transient state (e.g. the hurt flinch flash). */
this.stateTimer = 0; this.stateTimer = 0;
// Patrol route (enemies only): the spawn point plus the level-authored
// path points, walked in a loop at the def's speed (cells/sec).
const path = Array.isArray(spawn.path) ? spawn.path : [];
this.waypoints =
this.def.isEnemy && path.length > 0
? [{ x: spawn.x, y: spawn.y }, ...path.map((p) => ({ x: p.x, y: p.y }))]
: null;
this.wpIndex = 1; // current target (0 is the spawn point)
} }
get isEnemy() { get isEnemy() {
@ -52,6 +61,27 @@ export class Entity {
this.state = 'idle'; this.state = 'idle';
} }
} }
// TODO: enemy AI (patrol/chase/attack/pathfinding) hooks in here. this.updatePatrol(deltaMs);
// TODO: further enemy AI (chase/attack/pathfinding) hooks in here.
}
/** Walk the authored patrol loop. Getting shot (hurt flinch) pauses it.
* Paths are author-trusted: no collision checks, so a path drawn through
* a wall will walk through it. */
updatePatrol(deltaMs) {
if (!this.waypoints || !this.active || this.state === 'hurt') return;
const target = this.waypoints[this.wpIndex];
const step = (this.def.speed ?? 1.5) * (deltaMs / 1000);
const dx = target.x - this.x;
const dy = target.y - this.y;
const dist = Math.hypot(dx, dy);
if (dist <= step) {
this.x = target.x;
this.y = target.y;
this.wpIndex = (this.wpIndex + 1) % this.waypoints.length;
} else {
this.x += (dx / dist) * step;
this.y += (dy / dist) * step;
}
} }
} }

View File

@ -1,4 +1,5 @@
import { getWall, isSolid, getOverlayDef, getItemDef } from './registry.js'; import { getWall, isSolid, getOverlayDef, getItemDef } from './registry.js';
import { DOOR_SLIDE_MS, DOOR_OPEN_MS } from './constants.js';
/** Runtime wrapper around a LevelData grid. Shared by the game (collision + /** Runtime wrapper around a LevelData grid. Shared by the game (collision +
* raycasting) and the editor (paint/erase). Keeps all grid-index math in one * raycasting) and the editor (paint/erase). Keeps all grid-index math in one
@ -26,6 +27,26 @@ export class GameMap {
this.blocked.add(Math.floor(it.y) * this.width + Math.floor(it.x)); this.blocked.add(Math.floor(it.y) * this.width + Math.floor(it.x));
} }
} }
// Sliding doors: one state record per door tile, keyed y*width+x.
// state: 'closed' | 'opening' | 'open' | 'closing'
// openness: 0 (fully closed) → 1 (fully open); the raycaster reads this to
// draw the door partially slid aside, and isWall() treats only fully-open
// doors as passable.
this.doors = new Map();
for (let i = 0; i < this.tiles.length; i++) {
const def = getWall(this.tiles[i]);
if (def.isDoor) {
this.doors.set(i, {
cx: i % this.width,
cy: Math.floor(i / this.width),
def,
state: 'closed',
openness: 0,
timer: 0,
});
}
}
} }
inBounds(x, y) { inBounds(x, y) {
@ -48,9 +69,12 @@ export class GameMap {
this.tiles[cy * this.width + cx] = id; this.tiles[cy * this.width + cx] = id;
} }
/** Whether a wall blocks sight/shots/rays at this cell. */ /** Whether a wall blocks sight/shots/rays at this cell. A door counts as a
* wall until it has slid fully open. */
isWall(x, y) { isWall(x, y) {
return isSolid(this.tileAt(x, y)); if (!isSolid(this.tileAt(x, y))) return false;
const door = this.doorAt(x, y);
return door ? door.state !== 'open' : true;
} }
/** Whether movement is blocked here — by a wall or by a blocking item. */ /** Whether movement is blocked here — by a wall or by a blocking item. */
@ -65,6 +89,68 @@ export class GameMap {
return getWall(this.tileAt(x, y)).isDoor; return getWall(this.tileAt(x, y)).isDoor;
} }
/** The door state record at (x, y), or null. */
doorAt(x, y) {
return this.doors.get(Math.floor(y) * this.width + Math.floor(x)) ?? null;
}
/** How far open the door at (x, y) is (0 closed … 1 open); 0 if no door. */
doorOpenness(x, y) {
return this.doorAt(x, y)?.openness ?? 0;
}
/** Player pressed "use" on this cell. Toggles the door if there is one and
* it isn't locked. Returns:
* { used: false } — no door here
* { used: false, locked: keyId } — locked; the player lacks the key
* { used: true } — door is now opening/closing */
useDoor(x, y, keys = new Set()) {
const door = this.doorAt(x, y);
if (!door) return { used: false };
if (door.state === 'closed' || door.state === 'closing') {
const needed = door.def.key;
if (needed && !keys.has(needed)) return { used: false, locked: needed };
door.state = 'opening';
} else if (door.state === 'open') {
door.state = 'closing';
}
return { used: true }; // an already-opening door swallows the press
}
/** Advance door animations. `occupied` is the player's cell — a door won't
* auto-close on top of them. */
updateDoors(deltaMs, occupied) {
for (const door of this.doors.values()) {
switch (door.state) {
case 'opening':
door.openness += deltaMs / DOOR_SLIDE_MS;
if (door.openness >= 1) {
door.openness = 1;
door.state = 'open';
door.timer = DOOR_OPEN_MS;
}
break;
case 'open':
door.timer -= deltaMs;
if (door.timer <= 0) {
if (occupied && occupied.x === door.cx && occupied.y === door.cy) {
door.timer = DOOR_SLIDE_MS; // stand in the doorway → stay open
} else {
door.state = 'closing';
}
}
break;
case 'closing':
door.openness -= deltaMs / DOOR_SLIDE_MS;
if (door.openness <= 0) {
door.openness = 0;
door.state = 'closed';
}
break;
}
}
}
/** Overlay def on the wall cell at (x, y), or null. */ /** Overlay def on the wall cell at (x, y), or null. */
overlayAt(x, y) { overlayAt(x, y) {
const id = this.overlays.get(Math.floor(y) * this.width + Math.floor(x)); const id = this.overlays.get(Math.floor(y) * this.width + Math.floor(x));

View File

@ -18,6 +18,9 @@ export class Player {
this.health = START_HEALTH; this.health = START_HEALTH;
this.ammo = START_AMMO; this.ammo = START_AMMO;
this.score = 0; this.score = 0;
/** Key ids collected this level (e.g. 'gold') — opens matching doors.
* Classic raycaster rules: keys reset on every level. */
this.keys = new Set();
const dirX = Math.cos(start.dir); const dirX = Math.cos(start.dir);
const dirY = Math.sin(start.dir); const dirY = Math.sin(start.dir);

View File

@ -82,9 +82,15 @@ export class Raycaster {
} }
// March until we hit a solid cell. `side` records which axis we crossed. // March until we hit a solid cell. `side` records which axis we crossed.
// Doors are special: their face sits recessed half a cell into the tile
// and slides sideways as it opens, so a door hit is checked against the
// mid-cell plane and the door's current openness.
let hit = false; let hit = false;
let side = 0; // 0 = x-side (NS wall), 1 = y-side (EW wall) let side = 0; // 0 = x-side (NS wall), 1 = y-side (EW wall)
let tileId = 1; let tileId = 1;
let isDoorHit = false;
let doorDist = 0;
let doorTexX = 0;
let guard = 0; let guard = 0;
const maxSteps = (map.width + map.height) * 2; const maxSteps = (map.width + map.height) * 2;
while (!hit && guard++ < maxSteps) { while (!hit && guard++ < maxSteps) {
@ -102,13 +108,49 @@ export class Raycaster {
lastDef = getWall(tileId); lastDef = getWall(tileId);
lastId = tileId; lastId = tileId;
} }
if (lastDef.solid) hit = true; if (!lastDef.solid) continue;
if (lastDef.isDoor) {
// Where does the ray cross the recessed (mid-cell) door plane?
// sideDist values are ray parameters; the cell boundary was crossed
// at sideDist - deltaDist, the mid plane half a cell later.
let planeDist;
let coord; // 0..1 along the door face
if (side === 0) {
planeDist = sideDistX - deltaDistX / 2;
coord = cam.posY + planeDist * rayDirY - mapY;
} else {
planeDist = sideDistY - deltaDistY / 2;
coord = cam.posX + planeDist * rayDirX - mapX;
}
const openness = map.doorOpenness(mapX, mapY);
// The ray leaves the cell before reaching the recessed plane
// (grazing a corner), or passes through the open gap → keep going.
if (coord < openness || coord >= 1) continue;
hit = true;
isDoorHit = true;
doorDist = planeDist;
// Anchor the texture to the sliding panel so it moves with it, and
// un-mirror the back faces the same way as walls below.
doorTexX = Math.min(
TEX_SIZE - 1,
Math.floor((coord - openness) * TEX_SIZE),
);
if ((side === 0 && rayDirX < 0) || (side === 1 && rayDirY > 0)) {
doorTexX = TEX_SIZE - 1 - doorTexX;
}
} else {
hit = true;
}
} }
const def = lastDef; const def = lastDef;
// Perpendicular distance (avoids fisheye) = how far along the ray we are. // Perpendicular distance (avoids fisheye) = how far along the ray we are.
const perpDist = const perpDist = isDoorHit
side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY; ? doorDist
: side === 0
? sideDistX - deltaDistX
: sideDistY - deltaDistY;
this.zBuffer[x] = perpDist; this.zBuffer[x] = perpDist;
const lineHeight = Math.min( const lineHeight = Math.min(
@ -120,16 +162,25 @@ export class Raycaster {
let drawEnd = Math.min(height - 1, top + lineHeight - 1); let drawEnd = Math.min(height - 1, top + lineHeight - 1);
// Texture x-coordinate: where along the wall face the ray hit (0..1). // Texture x-coordinate: where along the wall face the ray hit (0..1).
// Door hits already computed theirs, anchored to the sliding panel.
let texX;
if (isDoorHit) {
texX = doorTexX;
} else {
let wallX = let wallX =
side === 0 side === 0
? cam.posY + perpDist * rayDirY ? cam.posY + perpDist * rayDirY
: cam.posX + perpDist * rayDirX; : cam.posX + perpDist * rayDirX;
wallX -= Math.floor(wallX); wallX -= Math.floor(wallX);
let texX = Math.floor(wallX * TEX_SIZE); texX = Math.floor(wallX * TEX_SIZE);
// Un-mirror the two faces that would otherwise render flipped. // Un-mirror the two faces that would otherwise render flipped. Note:
if ((side === 0 && rayDirX > 0) || (side === 1 && rayDirY < 0)) { // our camera plane is (-dirY, dirX) — the opposite perpendicular from
// the classic Lodev raycaster — so these conditions are the inverse
// of the ones usually quoted.
if ((side === 0 && rayDirX < 0) || (side === 1 && rayDirY > 0)) {
texX = TEX_SIZE - 1 - texX; texX = TEX_SIZE - 1 - texX;
} }
}
// Wall strip: 1px-wide source column scaled to the strip height. The // Wall strip: 1px-wide source column scaled to the strip height. The
// canvas clips overdraw when the wall is taller than the screen. // canvas clips overdraw when the wall is taller than the screen.
@ -143,8 +194,9 @@ export class Raycaster {
} }
// Overlay (painting, banner, …) alpha-composited over the wall texture, // Overlay (painting, banner, …) alpha-composited over the wall texture,
// sharing the same texture coordinate so it sticks to the wall. // sharing the same texture coordinate so it sticks to the wall. Doors
const overlay = map.overlayAt(mapX, mapY); // are skipped — an overlay on a sliding panel would smear.
const overlay = isDoorHit ? null : map.overlayAt(mapX, mapY);
if (overlay?.tex) { if (overlay?.tex) {
ctx.drawImage(overlay.tex, texX, 0, 1, TEX_SIZE, x, top, 1, lineHeight); ctx.drawImage(overlay.tex, texX, 0, 1, TEX_SIZE, x, top, 1, lineHeight);
} }

View File

@ -27,6 +27,15 @@ export function migrateLevel(data) {
data.version = LEVEL_FORMAT_VERSION; data.version = LEVEL_FORMAT_VERSION;
data.entities ??= []; data.entities ??= [];
data.name ??= 'Untitled'; data.name ??= 'Untitled';
// Optional per-enemy patrol paths: keep only well-formed points.
for (const e of data.entities) {
if (Array.isArray(e?.path)) {
e.path = e.path.filter(
(pt) => typeof pt?.x === 'number' && typeof pt?.y === 'number',
);
if (e.path.length === 0) delete e.path;
}
}
// Keep only well-formed overlay/item entries so a hand-edited file can't // Keep only well-formed overlay/item entries so a hand-edited file can't
// crash the renderer; ids are validated against the registry at use sites. // crash the renderer; ids are validated against the registry at use sites.
data.overlays = (data.overlays ?? []).filter( data.overlays = (data.overlays ?? []).filter(

View File

@ -37,6 +37,8 @@ const DEFAULT_WALLS = {
{ id: 3, name: 'Red Brick', solid: true, isDoor: false, color: '#a5503f', texture: 'assets/textures/walls.png', frame: 2 }, { id: 3, name: 'Red Brick', solid: true, isDoor: false, color: '#a5503f', texture: 'assets/textures/walls.png', frame: 2 },
{ id: 4, name: 'Wood', solid: true, isDoor: false, color: '#8a6a3a', texture: 'assets/textures/walls.png', frame: 3 }, { id: 4, name: 'Wood', solid: true, isDoor: false, color: '#8a6a3a', texture: 'assets/textures/walls.png', frame: 3 },
{ id: 5, name: 'Door', solid: true, isDoor: true, color: '#c8a838', texture: 'assets/textures/walls.png', frame: 4 }, { id: 5, name: 'Door', solid: true, isDoor: true, color: '#c8a838', texture: 'assets/textures/walls.png', frame: 4 },
{ id: 6, name: 'Gold Door', solid: true, isDoor: true, key: 'gold', color: '#d4af37', texture: 'assets/textures/walls.png', frame: 5 },
{ id: 7, name: 'Silver Door', solid: true, isDoor: true, key: 'silver', color: '#a8a8c0', texture: 'assets/textures/walls.png', frame: 6 },
], ],
}; };
@ -73,15 +75,19 @@ const DEFAULT_CAMPAIGNS = {
], ],
}; };
/** Pickups (and the exit) have hardcoded game-logic effects (GameScene), so const DEFAULT_PICKUPS = {
* they stay a built-in table rather than a config file. They resolve through pickups: [
* the same getEntityDef() as enemies. */ { id: 'ammo', name: 'Ammo', color: '#d0d040', effect: 'ammo', amount: 10, texture: 'assets/sprites/pickups.png', frame: 0 },
const PICKUPS = [ { id: 'health', name: 'Health', color: '#40c040', effect: 'health', amount: 25, texture: 'assets/sprites/pickups.png', frame: 1 },
{ type: 'ammo', name: 'Ammo', color: '#d0d040' }, { id: 'treasure', name: 'Treasure', color: '#c0a020', effect: 'score', amount: 500, texture: 'assets/sprites/pickups.png', frame: 2 },
{ type: 'health', name: 'Health', color: '#40c040' }, { id: 'key_gold', name: 'Gold Key', color: '#ffd700', effect: 'key', key: 'gold', texture: 'assets/sprites/pickups.png', frame: 3 },
{ type: 'treasure', name: 'Treasure', color: '#c0a020' }, { id: 'key_silver', name: 'Silver Key', color: '#c8c8d8', effect: 'key', key: 'silver', texture: 'assets/sprites/pickups.png', frame: 4 },
{ type: 'exit', name: 'Exit', color: '#00ff88' }, ],
]; };
/** The exit is a level mechanic (GameScene ends the level on touch), not a
* configurable pickup, so it's always present regardless of pickups.json. */
const EXIT_DEF = { type: 'exit', name: 'Exit', color: 0x00ff88, effect: null, isEnemy: false, health: 0 };
/** The built-in floor tile; wall id 0 is always Empty and not configurable. */ /** The built-in floor tile; wall id 0 is always Empty and not configurable. */
const EMPTY_TILE = { id: 0, name: 'Empty', solid: false, isDoor: false, color: 0x000000 }; const EMPTY_TILE = { id: 0, name: 'Empty', solid: false, isDoor: false, color: 0x000000 };
@ -121,6 +127,9 @@ function applyWalls(data) {
name: w.name ?? `Wall ${w.id}`, name: w.name ?? `Wall ${w.id}`,
solid: w.solid !== false, solid: w.solid !== false,
isDoor: w.isDoor === true, isDoor: w.isDoor === true,
// Key id (matches a pickups.json `key` field) needed to open this door;
// null = unlocked. Only meaningful when isDoor.
key: typeof w.key === 'string' ? w.key : null,
color: parseColor(w.color, 0x9a9a9a), color: parseColor(w.color, 0x9a9a9a),
texture: w.texture ?? null, texture: w.texture ?? null,
frame: w.frame ?? 0, frame: w.frame ?? 0,
@ -193,13 +202,21 @@ function applyCampaigns(data) {
})); }));
} }
function applyPickups() { function applyPickups(data) {
reg.pickups.clear(); reg.pickups.clear();
for (const p of PICKUPS) { reg.pickups.set(EXIT_DEF.type, { ...EXIT_DEF });
reg.pickups.set(p.type, { for (const p of data.pickups ?? []) {
type: p.type, if (typeof p.id !== 'string' || !p.id) continue;
name: p.name, reg.pickups.set(p.id, {
type: p.id,
name: p.name ?? p.id,
color: parseColor(p.color), color: parseColor(p.color),
effect: p.effect ?? null, // 'ammo' | 'health' | 'score' | 'key'
amount: typeof p.amount === 'number' ? p.amount : 0,
key: typeof p.key === 'string' ? p.key : null,
scale: typeof p.scale === 'number' ? p.scale : 0.5,
texture: p.texture ?? null,
frame: p.frame ?? 0,
isEnemy: false, isEnemy: false,
health: 0, health: 0,
}); });
@ -213,7 +230,7 @@ applyOverlays(DEFAULT_OVERLAYS);
applyItems(DEFAULT_ITEMS); applyItems(DEFAULT_ITEMS);
applyEnemies(DEFAULT_ENEMIES); applyEnemies(DEFAULT_ENEMIES);
applyCampaigns(DEFAULT_CAMPAIGNS); applyCampaigns(DEFAULT_CAMPAIGNS);
applyPickups(); applyPickups(DEFAULT_PICKUPS);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Loading // Loading
@ -250,19 +267,20 @@ export function initRegistry() {
} }
async function doInit() { async function doInit() {
const [walls, overlays, items, enemies, campaigns] = await Promise.all([ const [walls, overlays, items, enemies, campaigns, pickups] = await Promise.all([
fetchConfig('walls.json', DEFAULT_WALLS), fetchConfig('walls.json', DEFAULT_WALLS),
fetchConfig('overlays.json', DEFAULT_OVERLAYS), fetchConfig('overlays.json', DEFAULT_OVERLAYS),
fetchConfig('items.json', DEFAULT_ITEMS), fetchConfig('items.json', DEFAULT_ITEMS),
fetchConfig('enemies.json', DEFAULT_ENEMIES), fetchConfig('enemies.json', DEFAULT_ENEMIES),
fetchConfig('campaigns.json', DEFAULT_CAMPAIGNS), fetchConfig('campaigns.json', DEFAULT_CAMPAIGNS),
fetchConfig('pickups.json', DEFAULT_PICKUPS),
]); ]);
applyWalls(walls); applyWalls(walls);
applyOverlays(overlays); applyOverlays(overlays);
applyItems(items); applyItems(items);
applyEnemies(enemies); applyEnemies(enemies);
applyCampaigns(campaigns); applyCampaigns(campaigns);
applyPickups(); applyPickups(pickups);
// Load every referenced spritesheet once. // Load every referenced spritesheet once.
const defs = [ const defs = [
@ -270,6 +288,7 @@ async function doInit() {
...reg.overlays.values(), ...reg.overlays.values(),
...reg.items.values(), ...reg.items.values(),
...reg.enemies.values(), ...reg.enemies.values(),
...reg.pickups.values(),
]; ];
const paths = [...new Set(defs.map((d) => d.texture).filter(Boolean))]; const paths = [...new Set(defs.map((d) => d.texture).filter(Boolean))];
const images = new Map( const images = new Map(
@ -299,7 +318,8 @@ async function doInit() {
def.texFlash = makeFlashVariant(def.tex); def.texFlash = makeFlashVariant(def.tex);
} }
for (const def of reg.pickups.values()) { for (const def of reg.pickups.values()) {
def.tex = makeSpritePlaceholder(def, 'pickup'); def.tex = bakeFrame(images.get(def.texture), def.frame, TEX_SIZE)
?? makeSpritePlaceholder(def, def.effect === 'key' ? 'key' : 'pickup');
} }
} }
@ -426,6 +446,16 @@ function makeSpritePlaceholder(def, kind) {
ctx.fillStyle = shade(def.color, 0.65); ctx.fillStyle = shade(def.color, 0.65);
ctx.fillRect(S * 0.3, S * 0.7, S * 0.4, S * 0.28); ctx.fillRect(S * 0.3, S * 0.7, S * 0.4, S * 0.28);
ctx.strokeRect(S * 0.3, S * 0.7, S * 0.4, S * 0.28); ctx.strokeRect(S * 0.3, S * 0.7, S * 0.4, S * 0.28);
} else if (kind === 'key') {
// Key: ring at the top, shaft down the middle, two teeth.
ctx.lineWidth = Math.max(4, S / 12);
ctx.strokeStyle = shade(def.color, 1);
ctx.beginPath();
ctx.arc(S / 2, S * 0.28, S * 0.14, 0, Math.PI * 2);
ctx.stroke();
ctx.fillRect(S * 0.46, S * 0.42, S * 0.08, S * 0.42);
ctx.fillRect(S * 0.54, S * 0.62, S * 0.12, S * 0.07);
ctx.fillRect(S * 0.54, S * 0.74, S * 0.16, S * 0.07);
} else { } else {
// Pickup: floating diamond. // Pickup: floating diamond.
ctx.beginPath(); ctx.beginPath();
@ -497,6 +527,15 @@ export function getDefaultEnemyType() {
return reg.defaultEnemyType; return reg.defaultEnemyType;
} }
/** The pickup def that grants key `keyId` (e.g. 'gold'), or null. Used for
* the "you need the …" message and the HUD key chips. */
export function getKeyDef(keyId) {
for (const p of reg.pickups.values()) {
if (p.effect === 'key' && p.key === keyId) return p;
}
return null;
}
/** All wall defs (including Empty, id 0), sorted by id — editor dropdown order. */ /** All wall defs (including Empty, id 0), sorted by id — editor dropdown order. */
export function listWalls() { export function listWalls() {
return [...reg.walls.values()].sort((a, b) => a.id - b.id); return [...reg.walls.values()].sort((a, b) => a.id - b.id);

View File

@ -5,7 +5,7 @@ import { Entity } from '../../engine/Entity.js';
import { Raycaster } from '../../engine/Raycaster.js'; import { Raycaster } from '../../engine/Raycaster.js';
import { SpriteRenderer } from '../../engine/SpriteRenderer.js'; import { SpriteRenderer } from '../../engine/SpriteRenderer.js';
import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js'; import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js';
import { getItemDef } from '../../engine/registry.js'; import { getItemDef, getKeyDef } from '../../engine/registry.js';
import { import {
RENDER_WIDTH, RENDER_WIDTH,
RENDER_HEIGHT, RENDER_HEIGHT,
@ -86,7 +86,7 @@ export class GameScene extends Phaser.Scene {
setupInput() { setupInput() {
const kb = this.input.keyboard; const kb = this.input.keyboard;
this.keys = kb.addKeys('W,A,S,D,UP,DOWN,LEFT,RIGHT,SPACE'); this.keys = kb.addKeys('W,A,S,D,UP,DOWN,LEFT,RIGHT,SPACE,E');
// Click to capture the mouse; once captured, clicks fire. // Click to capture the mouse; once captured, clicks fire.
this.input.on('pointerdown', () => { this.input.on('pointerdown', () => {
@ -105,6 +105,23 @@ export class GameScene extends Phaser.Scene {
}); });
} }
/** Probe a couple of points ahead of the player for a door and use it.
* Locked doors flash a "you need the … key" message instead. */
tryUseDoor() {
const { cam } = this.player;
for (const dist of [1.0, 1.75]) {
const cx = Math.floor(cam.posX + cam.dirX * dist);
const cy = Math.floor(cam.posY + cam.dirY * dist);
const result = this.map.useDoor(cx, cy, this.player.keys);
if (result.used) return;
if (result.locked) {
const keyName = getKeyDef(result.locked)?.name ?? `${result.locked} key`;
this.game.events.emit('door-locked', keyName);
return;
}
}
}
fire() { fire() {
if (this.time.now - this.lastShot < WEAPON_FIRE_COOLDOWN) return; if (this.time.now - this.lastShot < WEAPON_FIRE_COOLDOWN) return;
this.lastShot = this.time.now; this.lastShot = this.time.now;
@ -141,6 +158,15 @@ export class GameScene extends Phaser.Scene {
// Keyboard fire fallback (Space), useful before grabbing the mouse. // Keyboard fire fallback (Space), useful before grabbing the mouse.
if (k.SPACE.isDown) this.fire(); if (k.SPACE.isDown) this.fire();
// E uses the door ahead (open, or close an open one).
if (Phaser.Input.Keyboard.JustDown(k.E)) this.tryUseDoor();
const { cam } = this.player;
this.map.updateDoors(deltaMs, {
x: Math.floor(cam.posX),
y: Math.floor(cam.posY),
});
for (const e of this.entities) e.update(deltaMs); for (const e of this.entities) e.update(deltaMs);
this.handlePickups(); this.handlePickups();
@ -176,7 +202,7 @@ export class GameScene extends Phaser.Scene {
} }
e.collect(); e.collect();
this.applyPickup(e); this.applyPickup(e);
this.game.events.emit('player-pickup', e.type); this.game.events.emit('player-pickup', e.def.name ?? e.type);
} }
} }
@ -211,16 +237,21 @@ export class GameScene extends Phaser.Scene {
this.scene.restart(); this.scene.restart();
} }
/** Apply a collected pickup's data-driven effect (see pickups.json). */
applyPickup(e) { applyPickup(e) {
switch (e.type) { const def = e.def;
switch (def.effect) {
case 'ammo': case 'ammo':
this.player.ammo += 10; this.player.ammo += def.amount ?? 10;
break; break;
case 'health': case 'health':
this.player.health = Math.min(100, this.player.health + 25); this.player.health = Math.min(100, this.player.health + (def.amount ?? 25));
break; break;
case 'treasure': case 'score':
this.player.score += 500; this.player.score += def.amount ?? 100;
break;
case 'key':
if (def.key) this.player.keys.add(def.key);
break; break;
} }
} }

View File

@ -1,4 +1,5 @@
import Phaser from 'phaser'; import Phaser from 'phaser';
import { listPickups } from '../../engine/registry.js';
import { GAME_WIDTH, GAME_HEIGHT } from '../main.js'; import { GAME_WIDTH, GAME_HEIGHT } from '../main.js';
/** /**
@ -23,6 +24,15 @@ export class HudScene extends Phaser.Scene {
this.statusText = this.add.text(16, 12, '', fontStyle).setScrollFactor(0); this.statusText = this.add.text(16, 12, '', fontStyle).setScrollFactor(0);
// Collected keys, drawn as colored chips in the top-right corner. Colors
// come from the pickups registry (effect 'key').
this.keyColors = new Map(
listPickups()
.filter((p) => p.effect === 'key')
.map((p) => [p.key, p.color]),
);
this.keyGfx = this.add.graphics();
this.messageText = this.add this.messageText = this.add
.text(GAME_WIDTH / 2, 48, '', { ...fontStyle, color: '#ffffff' }) .text(GAME_WIDTH / 2, 48, '', { ...fontStyle, color: '#ffffff' })
.setOrigin(0.5, 0) .setOrigin(0.5, 0)
@ -48,7 +58,8 @@ export class HudScene extends Phaser.Scene {
const handlers = { const handlers = {
'player-fired': (result) => this.onFired(result), 'player-fired': (result) => this.onFired(result),
'player-empty': () => this.flash('*click* out of ammo'), 'player-empty': () => this.flash('*click* out of ammo'),
'player-pickup': (type) => this.flash(`Picked up ${type}`), 'player-pickup': (name) => this.flash(`Picked up ${name}`),
'door-locked': (keyName) => this.flash(`Locked — you need the ${keyName}`),
'level-complete': () => this.flash('Level complete!'), 'level-complete': () => this.flash('Level complete!'),
'game-won': (score) => this.showVictory(score), 'game-won': (score) => this.showVictory(score),
}; };
@ -135,6 +146,18 @@ export class HudScene extends Phaser.Scene {
this.statusText.setText( this.statusText.setText(
`HEALTH ${p.health} AMMO ${p.ammo} SCORE ${p.score}`, `HEALTH ${p.health} AMMO ${p.ammo} SCORE ${p.score}`,
); );
// Key chips, top-right (a few rects — cheap to redraw every frame).
this.keyGfx.clear();
let i = 0;
for (const key of p.keys ?? []) {
const x = GAME_WIDTH - 44 - i * 34;
this.keyGfx.fillStyle(this.keyColors.get(key) ?? 0xffffff, 1);
this.keyGfx.fillRect(x, 14, 28, 18);
this.keyGfx.lineStyle(2, 0x000000, 0.6);
this.keyGfx.strokeRect(x, 14, 28, 18);
i++;
}
} }
// Decay the muzzle flash and recoil. // Decay the muzzle flash and recoil.

View File

@ -14,7 +14,7 @@ export default {
1, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 2, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 5, 2, 2, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 6, 2, 2, 2, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
@ -36,8 +36,10 @@ export default {
], ],
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 }, "playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
"entities": [ "entities": [
{ "type": "guard", "x": 6.5, "y": 3.5 }, { "type": "guard", "x": 6.5, "y": 3.5,
"path": [ { "x": 1.5, "y": 3.5 }, { "x": 1.5, "y": 1.5 }, { "x": 6.5, "y": 1.5 } ] },
{ "type": "scout", "x": 12.5, "y": 12.5 }, { "type": "scout", "x": 12.5, "y": 12.5 },
{ "type": "key_gold", "x": 2.5, "y": 13.5 },
{ "type": "ammo", "x": 5.5, "y": 9.5 }, { "type": "ammo", "x": 5.5, "y": 9.5 },
{ "type": "health", "x": 13.5, "y": 5.5 }, { "type": "health", "x": 13.5, "y": 5.5 },
{ "type": "treasure", "x": 2.5, "y": 1.5 }, { "type": "treasure", "x": 2.5, "y": 1.5 },

View File

@ -11,7 +11,7 @@
1, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 2, 2, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 2, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 5, 2, 2, 2, 1, 1, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 6, 2, 2, 2, 1,
1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1, 1, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 4, 0, 0, 0, 1,
@ -33,8 +33,10 @@
], ],
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 }, "playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
"entities": [ "entities": [
{ "type": "guard", "x": 6.5, "y": 3.5 }, { "type": "guard", "x": 6.5, "y": 3.5,
"path": [ { "x": 1.5, "y": 3.5 }, { "x": 1.5, "y": 1.5 }, { "x": 6.5, "y": 1.5 } ] },
{ "type": "scout", "x": 12.5, "y": 12.5 }, { "type": "scout", "x": 12.5, "y": 12.5 },
{ "type": "key_gold", "x": 2.5, "y": 13.5 },
{ "type": "ammo", "x": 5.5, "y": 9.5 }, { "type": "ammo", "x": 5.5, "y": 9.5 },
{ "type": "health", "x": 13.5, "y": 5.5 }, { "type": "health", "x": 13.5, "y": 5.5 },
{ "type": "treasure", "x": 2.5, "y": 1.5 }, { "type": "treasure", "x": 2.5, "y": 1.5 },