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:
parent
724d4fd1e8
commit
1c3bed3beb
31
README.md
31
README.md
|
|
@ -37,6 +37,7 @@ Then open:
|
|||
| Move / strafe | `W` `A` `S` `D` (arrows also move) |
|
||||
| Turn | `←` `→`, or **mouse** (click to lock) |
|
||||
| Shoot | **Left-click** (mouse locked) / `Space` |
|
||||
| Open / close door | `E` |
|
||||
|
||||
The game opens on a **main menu** listing the campaigns from
|
||||
`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 |
|
||||
| ---------------- | ---------------------- | ---------- |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| `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/`) |
|
||||
|
||||
Notes:
|
||||
|
|
@ -66,8 +68,16 @@ Notes:
|
|||
paintings, banners, vines.
|
||||
- Items sit on **floor cells**. Blocking items stop the player like a wall but
|
||||
are not walls: shots and sight pass over them.
|
||||
- Pickups (`ammo`, `health`, `treasure`) and the `exit` are built-in because
|
||||
they have hardcoded game effects.
|
||||
- **Doors** (`isDoor: true` walls) slide open Wolf3D-style when the player
|
||||
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`,
|
||||
so a missing/broken file can't stop the game booting. Keep them in sync if
|
||||
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`)
|
||||
|
||||
- **Palette** (top): dropdowns for **Wall / Overlay / Item / Enemy** (populated
|
||||
from the config JSONs) plus **Start**, pickup, and **Erase** tool buttons.
|
||||
Focusing a dropdown or clicking a button makes it the active brush.
|
||||
from the config JSONs) plus **Start**, pickup, **Path**, and **Erase** tool
|
||||
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
|
||||
cells; items only to floor cells. Painting a wall clears anything inside it;
|
||||
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.
|
||||
- **Enemy AI**: flesh out `Entity.update()` (patrol → chase → attack); each
|
||||
enemy def already carries a data-driven `speed`.
|
||||
- **Doors / keys**: door tiles are flagged `isDoor` in `walls.json`;
|
||||
wire sliding + locked-door logic into `GameMap`/`Raycaster`.
|
||||
- **More door types**: doors + keys are data-driven — add a wall entry with
|
||||
`isDoor: true` and a `key`, plus a matching key pickup in `pickups.json`.
|
||||
- **Sound, more campaigns**: add campaign entries to `campaigns.json`; hook
|
||||
audio into the scenes in `src/game`.
|
||||
|
|
|
|||
|
|
@ -23,8 +23,9 @@ there if you prefer a different arrangement):
|
|||
| `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/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
|
||||
`texture` path is loaded once and shared.
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@
|
|||
<span class="sep"></span>
|
||||
<button id="btn-tool-start">Start</button>
|
||||
<span id="pickup-tools" style="display: contents"></span>
|
||||
<button id="btn-tool-path">Path</button>
|
||||
<button id="btn-tool-erase">Erase</button>
|
||||
</div>
|
||||
<div id="editor"></div>
|
||||
|
|
|
|||
42
sprites.md
42
sprites.md
|
|
@ -23,21 +23,27 @@ No code changes needed — the paths and frame numbers below come straight from
|
|||
|
||||
## 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 |
|
||||
| ----- | --------- | ------------------- | ------------------------------ |
|
||||
| ----- | --------- | ------------------------ | ------------------------------ |
|
||||
| 0 | 0–191 | id 1 — Gray Brick | gray `#9a9a9a` brick courses |
|
||||
| 1 | 192–383 | id 2 — Blue Stone | blue `#4a6fa5` pattern |
|
||||
| 2 | 384–575 | id 3 — Red Brick | red `#a5503f` pattern |
|
||||
| 3 | 576–767 | id 4 — Wood | brown `#8a6a3a` planks |
|
||||
| 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:
|
||||
- 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).
|
||||
- Make the texture **tileable left-to-right** if you want long runs of the
|
||||
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
|
||||
|
||||
|
|
@ -93,22 +99,38 @@ Notes:
|
|||
- The hurt flash is generated automatically (white-tinted copy) — you don't
|
||||
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)
|
||||
|
||||
- **Pickups** (ammo, health, treasure) and the **exit** currently always use
|
||||
their built-in generated diamond markers — there's no `texture` field for
|
||||
them in a config file yet. If you want art for those too, say so and the
|
||||
same pattern can be extended to them.
|
||||
- The **exit** uses a built-in generated marker (it's a level mechanic, not a
|
||||
configurable pickup).
|
||||
- **Floors/ceilings** are flat colors (`FLOOR_COLOR` / `CEILING_COLOR` in
|
||||
`src/engine/constants.js`); textured floors are a separate engine feature.
|
||||
- **Weapon/hands** on the HUD is a gray rectangle stub (`HudScene`).
|
||||
|
||||
## Adding more content later
|
||||
|
||||
New wall/overlay/item/enemy = one new 192×192 frame appended to the relevant
|
||||
strip + one JSON entry in `src/config/` pointing at its `frame` index. Nothing
|
||||
else. You can also start a fresh PNG per entry if you prefer — the `texture`
|
||||
path is per-entry, not global.
|
||||
New wall/overlay/item/enemy/pickup = one new 192×192 frame appended to the
|
||||
relevant strip + one JSON entry in `src/config/` pointing at its `frame`
|
||||
index. Nothing else. You can also start a fresh PNG per entry if you prefer —
|
||||
the `texture` path is per-entry, not global.
|
||||
|
||||
## Changing resolution later
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,26 @@
|
|||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,11 +49,17 @@ export class EditorScene extends Phaser.Scene {
|
|||
// { kind: 'enemy', type } place an enemy
|
||||
// { kind: 'pickup', type } place a pickup / the exit
|
||||
// { kind: 'start' } move the player start
|
||||
// { kind: 'path' } edit enemy patrol paths (select + points)
|
||||
// { kind: 'erase' } clear everything in the cell
|
||||
this.brush = { kind: 'wall', id: 1 };
|
||||
this.cellSize = 32;
|
||||
this.offsetX = 40;
|
||||
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() {
|
||||
|
|
@ -75,6 +81,12 @@ export class EditorScene extends Phaser.Scene {
|
|||
const controls = [];
|
||||
const setActive = (el, 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');
|
||||
el.classList.add('active-tool');
|
||||
};
|
||||
|
|
@ -145,6 +157,7 @@ export class EditorScene extends Phaser.Scene {
|
|||
wireButton(btn, { kind: 'pickup', type: def.type });
|
||||
}
|
||||
|
||||
wireButton(document.getElementById('btn-tool-path'), { kind: 'path' });
|
||||
wireButton(document.getElementById('btn-tool-erase'), { kind: 'erase' });
|
||||
|
||||
// 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.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) => {
|
||||
if (p.middleButtonDown()) {
|
||||
this.offsetX += p.x - p.prevPosition.x;
|
||||
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.updateHoverStatus(p);
|
||||
|
|
@ -309,6 +328,113 @@ export class EditorScene extends Phaser.Scene {
|
|||
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) {
|
||||
this.level.entities = this.level.entities.filter(
|
||||
(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}`;
|
||||
case 'start':
|
||||
return 'player start';
|
||||
case 'path':
|
||||
return 'path editor';
|
||||
default:
|
||||
return 'erase';
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (!cell) {
|
||||
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);
|
||||
}
|
||||
|
||||
// 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.
|
||||
for (const e of this.level.entities) {
|
||||
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);
|
||||
g.fillStyle(def?.color ?? 0xff00ff, 1);
|
||||
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).
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,15 @@ export class Entity {
|
|||
this.state = 'idle'; // 'idle' | 'hurt' | 'dead' | 'collected'
|
||||
/** Ms remaining in a transient state (e.g. the hurt flinch flash). */
|
||||
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() {
|
||||
|
|
@ -52,6 +61,27 @@ export class Entity {
|
|||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
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 +
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
|
@ -48,9 +69,12 @@ export class GameMap {
|
|||
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) {
|
||||
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. */
|
||||
|
|
@ -65,6 +89,68 @@ export class GameMap {
|
|||
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. */
|
||||
overlayAt(x, y) {
|
||||
const id = this.overlays.get(Math.floor(y) * this.width + Math.floor(x));
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export class Player {
|
|||
this.health = START_HEALTH;
|
||||
this.ammo = START_AMMO;
|
||||
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 dirY = Math.sin(start.dir);
|
||||
|
|
|
|||
|
|
@ -82,9 +82,15 @@ export class Raycaster {
|
|||
}
|
||||
|
||||
// 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 side = 0; // 0 = x-side (NS wall), 1 = y-side (EW wall)
|
||||
let tileId = 1;
|
||||
let isDoorHit = false;
|
||||
let doorDist = 0;
|
||||
let doorTexX = 0;
|
||||
let guard = 0;
|
||||
const maxSteps = (map.width + map.height) * 2;
|
||||
while (!hit && guard++ < maxSteps) {
|
||||
|
|
@ -102,13 +108,49 @@ export class Raycaster {
|
|||
lastDef = getWall(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;
|
||||
|
||||
// Perpendicular distance (avoids fisheye) = how far along the ray we are.
|
||||
const perpDist =
|
||||
side === 0 ? sideDistX - deltaDistX : sideDistY - deltaDistY;
|
||||
const perpDist = isDoorHit
|
||||
? doorDist
|
||||
: side === 0
|
||||
? sideDistX - deltaDistX
|
||||
: sideDistY - deltaDistY;
|
||||
this.zBuffer[x] = perpDist;
|
||||
|
||||
const lineHeight = Math.min(
|
||||
|
|
@ -120,16 +162,25 @@ export class Raycaster {
|
|||
let drawEnd = Math.min(height - 1, top + lineHeight - 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 =
|
||||
side === 0
|
||||
? cam.posY + perpDist * rayDirY
|
||||
: cam.posX + perpDist * rayDirX;
|
||||
wallX -= Math.floor(wallX);
|
||||
let texX = Math.floor(wallX * TEX_SIZE);
|
||||
// Un-mirror the two faces that would otherwise render flipped.
|
||||
if ((side === 0 && rayDirX > 0) || (side === 1 && rayDirY < 0)) {
|
||||
texX = Math.floor(wallX * TEX_SIZE);
|
||||
// Un-mirror the two faces that would otherwise render flipped. Note:
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Wall strip: 1px-wide source column scaled to the strip height. The
|
||||
// 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,
|
||||
// sharing the same texture coordinate so it sticks to the wall.
|
||||
const overlay = map.overlayAt(mapX, mapY);
|
||||
// sharing the same texture coordinate so it sticks to the wall. Doors
|
||||
// are skipped — an overlay on a sliding panel would smear.
|
||||
const overlay = isDoorHit ? null : map.overlayAt(mapX, mapY);
|
||||
if (overlay?.tex) {
|
||||
ctx.drawImage(overlay.tex, texX, 0, 1, TEX_SIZE, x, top, 1, lineHeight);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,15 @@ export function migrateLevel(data) {
|
|||
data.version = LEVEL_FORMAT_VERSION;
|
||||
data.entities ??= [];
|
||||
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
|
||||
// crash the renderer; ids are validated against the registry at use sites.
|
||||
data.overlays = (data.overlays ?? []).filter(
|
||||
|
|
|
|||
|
|
@ -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: 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: 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
|
||||
* they stay a built-in table rather than a config file. They resolve through
|
||||
* the same getEntityDef() as enemies. */
|
||||
const PICKUPS = [
|
||||
{ type: 'ammo', name: 'Ammo', color: '#d0d040' },
|
||||
{ type: 'health', name: 'Health', color: '#40c040' },
|
||||
{ type: 'treasure', name: 'Treasure', color: '#c0a020' },
|
||||
{ type: 'exit', name: 'Exit', color: '#00ff88' },
|
||||
];
|
||||
const DEFAULT_PICKUPS = {
|
||||
pickups: [
|
||||
{ id: 'ammo', name: 'Ammo', color: '#d0d040', effect: 'ammo', amount: 10, texture: 'assets/sprites/pickups.png', frame: 0 },
|
||||
{ id: 'health', name: 'Health', color: '#40c040', effect: 'health', amount: 25, texture: 'assets/sprites/pickups.png', frame: 1 },
|
||||
{ id: 'treasure', name: 'Treasure', color: '#c0a020', effect: 'score', amount: 500, texture: 'assets/sprites/pickups.png', frame: 2 },
|
||||
{ id: 'key_gold', name: 'Gold Key', color: '#ffd700', effect: 'key', key: 'gold', texture: 'assets/sprites/pickups.png', frame: 3 },
|
||||
{ id: 'key_silver', name: 'Silver Key', color: '#c8c8d8', effect: 'key', key: 'silver', texture: 'assets/sprites/pickups.png', frame: 4 },
|
||||
],
|
||||
};
|
||||
|
||||
/** 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. */
|
||||
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}`,
|
||||
solid: w.solid !== false,
|
||||
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),
|
||||
texture: w.texture ?? null,
|
||||
frame: w.frame ?? 0,
|
||||
|
|
@ -193,13 +202,21 @@ function applyCampaigns(data) {
|
|||
}));
|
||||
}
|
||||
|
||||
function applyPickups() {
|
||||
function applyPickups(data) {
|
||||
reg.pickups.clear();
|
||||
for (const p of PICKUPS) {
|
||||
reg.pickups.set(p.type, {
|
||||
type: p.type,
|
||||
name: p.name,
|
||||
reg.pickups.set(EXIT_DEF.type, { ...EXIT_DEF });
|
||||
for (const p of data.pickups ?? []) {
|
||||
if (typeof p.id !== 'string' || !p.id) continue;
|
||||
reg.pickups.set(p.id, {
|
||||
type: p.id,
|
||||
name: p.name ?? p.id,
|
||||
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,
|
||||
health: 0,
|
||||
});
|
||||
|
|
@ -213,7 +230,7 @@ applyOverlays(DEFAULT_OVERLAYS);
|
|||
applyItems(DEFAULT_ITEMS);
|
||||
applyEnemies(DEFAULT_ENEMIES);
|
||||
applyCampaigns(DEFAULT_CAMPAIGNS);
|
||||
applyPickups();
|
||||
applyPickups(DEFAULT_PICKUPS);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loading
|
||||
|
|
@ -250,19 +267,20 @@ export function initRegistry() {
|
|||
}
|
||||
|
||||
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('overlays.json', DEFAULT_OVERLAYS),
|
||||
fetchConfig('items.json', DEFAULT_ITEMS),
|
||||
fetchConfig('enemies.json', DEFAULT_ENEMIES),
|
||||
fetchConfig('campaigns.json', DEFAULT_CAMPAIGNS),
|
||||
fetchConfig('pickups.json', DEFAULT_PICKUPS),
|
||||
]);
|
||||
applyWalls(walls);
|
||||
applyOverlays(overlays);
|
||||
applyItems(items);
|
||||
applyEnemies(enemies);
|
||||
applyCampaigns(campaigns);
|
||||
applyPickups();
|
||||
applyPickups(pickups);
|
||||
|
||||
// Load every referenced spritesheet once.
|
||||
const defs = [
|
||||
|
|
@ -270,6 +288,7 @@ async function doInit() {
|
|||
...reg.overlays.values(),
|
||||
...reg.items.values(),
|
||||
...reg.enemies.values(),
|
||||
...reg.pickups.values(),
|
||||
];
|
||||
const paths = [...new Set(defs.map((d) => d.texture).filter(Boolean))];
|
||||
const images = new Map(
|
||||
|
|
@ -299,7 +318,8 @@ async function doInit() {
|
|||
def.texFlash = makeFlashVariant(def.tex);
|
||||
}
|
||||
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.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);
|
||||
} 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 {
|
||||
// Pickup: floating diamond.
|
||||
ctx.beginPath();
|
||||
|
|
@ -497,6 +527,15 @@ export function getDefaultEnemyType() {
|
|||
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. */
|
||||
export function listWalls() {
|
||||
return [...reg.walls.values()].sort((a, b) => a.id - b.id);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Entity } from '../../engine/Entity.js';
|
|||
import { Raycaster } from '../../engine/Raycaster.js';
|
||||
import { SpriteRenderer } from '../../engine/SpriteRenderer.js';
|
||||
import { fetchLevel, getDefaultLevel } from '../../engine/levelIO.js';
|
||||
import { getItemDef } from '../../engine/registry.js';
|
||||
import { getItemDef, getKeyDef } from '../../engine/registry.js';
|
||||
import {
|
||||
RENDER_WIDTH,
|
||||
RENDER_HEIGHT,
|
||||
|
|
@ -86,7 +86,7 @@ export class GameScene extends Phaser.Scene {
|
|||
|
||||
setupInput() {
|
||||
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.
|
||||
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() {
|
||||
if (this.time.now - this.lastShot < WEAPON_FIRE_COOLDOWN) return;
|
||||
this.lastShot = this.time.now;
|
||||
|
|
@ -141,6 +158,15 @@ export class GameScene extends Phaser.Scene {
|
|||
// Keyboard fire fallback (Space), useful before grabbing the mouse.
|
||||
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);
|
||||
this.handlePickups();
|
||||
|
||||
|
|
@ -176,7 +202,7 @@ export class GameScene extends Phaser.Scene {
|
|||
}
|
||||
e.collect();
|
||||
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();
|
||||
}
|
||||
|
||||
/** Apply a collected pickup's data-driven effect (see pickups.json). */
|
||||
applyPickup(e) {
|
||||
switch (e.type) {
|
||||
const def = e.def;
|
||||
switch (def.effect) {
|
||||
case 'ammo':
|
||||
this.player.ammo += 10;
|
||||
this.player.ammo += def.amount ?? 10;
|
||||
break;
|
||||
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;
|
||||
case 'treasure':
|
||||
this.player.score += 500;
|
||||
case 'score':
|
||||
this.player.score += def.amount ?? 100;
|
||||
break;
|
||||
case 'key':
|
||||
if (def.key) this.player.keys.add(def.key);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Phaser from 'phaser';
|
||||
import { listPickups } from '../../engine/registry.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);
|
||||
|
||||
// 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
|
||||
.text(GAME_WIDTH / 2, 48, '', { ...fontStyle, color: '#ffffff' })
|
||||
.setOrigin(0.5, 0)
|
||||
|
|
@ -48,7 +58,8 @@ export class HudScene extends Phaser.Scene {
|
|||
const handlers = {
|
||||
'player-fired': (result) => this.onFired(result),
|
||||
'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!'),
|
||||
'game-won': (score) => this.showVictory(score),
|
||||
};
|
||||
|
|
@ -135,6 +146,18 @@ export class HudScene extends Phaser.Scene {
|
|||
this.statusText.setText(
|
||||
`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.
|
||||
|
|
|
|||
|
|
@ -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, 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, 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, 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,
|
||||
|
|
@ -36,8 +36,10 @@ export default {
|
|||
],
|
||||
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
|
||||
"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": "key_gold", "x": 2.5, "y": 13.5 },
|
||||
{ "type": "ammo", "x": 5.5, "y": 9.5 },
|
||||
{ "type": "health", "x": 13.5, "y": 5.5 },
|
||||
{ "type": "treasure", "x": 2.5, "y": 1.5 },
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
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, 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, 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,
|
||||
|
|
@ -33,8 +33,10 @@
|
|||
],
|
||||
"playerStart": { "x": 3.5, "y": 12.5, "dir": -1.5707963267948966 },
|
||||
"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": "key_gold", "x": 2.5, "y": 13.5 },
|
||||
{ "type": "ammo", "x": 5.5, "y": 9.5 },
|
||||
{ "type": "health", "x": 13.5, "y": 5.5 },
|
||||
{ "type": "treasure", "x": 2.5, "y": 1.5 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue