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 * place so the two never disagree about what is solid or in-bounds. */ export class GameMap { constructor(level) { this.width = level.width; this.height = level.height; // Defensive copy so mutations (editor painting) don't alias the source. this.tiles = level.tiles.slice(); // Sparse per-cell wall overlays (paintings etc.), keyed y*width+x. this.overlays = new Map(); for (const o of level.overlays ?? []) { if (this.inBounds(o.x, o.y) && typeof o.id === 'string') { this.overlays.set(o.y * this.width + o.x, o.id); } } // Cells made impassable by blocking items (plants, lamps, …). Items are // not walls: rays, shots, and sight pass over them; only movement stops. this.blocked = new Set(); for (const it of level.items ?? []) { if (getItemDef(it.id)?.blocking && this.inBounds(it.x, it.y)) { 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) { return x >= 0 && y >= 0 && x < this.width && y < this.height; } /** Tile id at integer cell (x, y). Out-of-bounds reads as solid wall id 1 so * rays and movement treat the world edge as a wall. */ tileAt(x, y) { const cx = Math.floor(x); const cy = Math.floor(y); if (!this.inBounds(cx, cy)) return 1; return this.tiles[cy * this.width + cx]; } setTile(x, y, id) { const cx = Math.floor(x); const cy = Math.floor(y); if (!this.inBounds(cx, cy)) return; this.tiles[cy * this.width + cx] = id; } /** 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) { 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. */ isBlocked(x, y) { if (this.isWall(x, y)) return true; const cx = Math.floor(x); const cy = Math.floor(y); return this.inBounds(cx, cy) && this.blocked.has(cy * this.width + cx); } isDoor(x, y) { 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)); return id ? getOverlayDef(id) : null; } /** Set (id string) or clear (null) the overlay on a cell — editor brush. */ setOverlay(x, y, id) { const cx = Math.floor(x); const cy = Math.floor(y); if (!this.inBounds(cx, cy)) return; const key = cy * this.width + cx; if (id) this.overlays.set(key, id); else this.overlays.delete(key); } /** Snapshot the grid back into a plain array (for serialization). */ getTiles() { return this.tiles.slice(); } /** Snapshot overlays as the level file's [{x, y, id}] array. */ getOverlays() { return [...this.overlays.entries()].map(([key, id]) => ({ x: key % this.width, y: Math.floor(key / this.width), id, })); } }