escape/src/engine/GameMap.js

184 lines
6.0 KiB
JavaScript

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,
}));
}
}