diff --git a/README.md b/README.md index 0176750..eb28ea7 100644 --- a/README.md +++ b/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`. diff --git a/assets/README.md b/assets/README.md index 2da59d3..66e0e27 100644 --- a/assets/README.md +++ b/assets/README.md @@ -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. diff --git a/editor.html b/editor.html index 9859ff8..3ca6375 100644 --- a/editor.html +++ b/editor.html @@ -124,6 +124,7 @@ +
diff --git a/sprites.md b/sprites.md index f1d0878..3573a4b 100644 --- a/sprites.md +++ b/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 | +| 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 diff --git a/src/config/walls.json b/src/config/walls.json index 2ca9147..cd23415 100644 --- a/src/config/walls.json +++ b/src/config/walls.json @@ -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 } ] } diff --git a/src/editor/EditorScene.js b/src/editor/EditorScene.js index 153aacd..c358c3f 100644 --- a/src/editor/EditorScene.js +++ b/src/editor/EditorScene.js @@ -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,8 +607,15 @@ 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; if (ps.x >= minX - 1 && ps.x <= maxX + 1 && ps.y >= minY - 1 && ps.y <= maxY + 1) { @@ -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); + } } diff --git a/src/engine/Entity.js b/src/engine/Entity.js index 9e9cb21..a82cc38 100644 --- a/src/engine/Entity.js +++ b/src/engine/Entity.js @@ -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; + } } } diff --git a/src/engine/GameMap.js b/src/engine/GameMap.js index df0973c..03f7a40 100644 --- a/src/engine/GameMap.js +++ b/src/engine/GameMap.js @@ -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)); diff --git a/src/engine/Player.js b/src/engine/Player.js index 8b2062f..881d9d5 100644 --- a/src/engine/Player.js +++ b/src/engine/Player.js @@ -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); diff --git a/src/engine/Raycaster.js b/src/engine/Raycaster.js index 74717aa..d2d4100 100644 --- a/src/engine/Raycaster.js +++ b/src/engine/Raycaster.js @@ -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,15 +162,24 @@ 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). - 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 = TEX_SIZE - 1 - texX; + // 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); + 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 @@ -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); } diff --git a/src/engine/levelIO.js b/src/engine/levelIO.js index 5821213..18f0719 100644 --- a/src/engine/levelIO.js +++ b/src/engine/levelIO.js @@ -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( diff --git a/src/engine/registry.js b/src/engine/registry.js index 95348d0..3d23f04 100644 --- a/src/engine/registry.js +++ b/src/engine/registry.js @@ -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); diff --git a/src/game/scenes/GameScene.js b/src/game/scenes/GameScene.js index 2aacc60..d9ca797 100644 --- a/src/game/scenes/GameScene.js +++ b/src/game/scenes/GameScene.js @@ -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; } } diff --git a/src/game/scenes/HudScene.js b/src/game/scenes/HudScene.js index 5dc6384..5bfae10 100644 --- a/src/game/scenes/HudScene.js +++ b/src/game/scenes/HudScene.js @@ -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. diff --git a/src/levels/level1.data.js b/src/levels/level1.data.js index 8200155..fe3050f 100644 --- a/src/levels/level1.data.js +++ b/src/levels/level1.data.js @@ -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 }, diff --git a/src/levels/level1.json b/src/levels/level1.json index 8cb5439..df93b8b 100644 --- a/src/levels/level1.json +++ b/src/levels/level1.json @@ -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 },