184 lines
6.0 KiB
JavaScript
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,
|
|
}));
|
|
}
|
|
}
|