mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
216 lines
8.3 KiB
JavaScript
216 lines
8.3 KiB
JavaScript
const { v4: uuidv4 } = require('uuid');
|
|
const Occupant = require('./Occupant');
|
|
const { TAVERN_LAYOUT, FLOOR_CHARS } = require('./tavernMap');
|
|
|
|
const CONSOLE_LOGGING = process.env.NODE_ENV === 'debug' || process.env.NODE_ENV === 'development';
|
|
|
|
/**
|
|
* Room — a shared, real-time, multi-occupant world. Shared engine for both new modes:
|
|
*
|
|
* • Tavern (type 'tavern'): a peaceful room — walk around, chat, spectate.
|
|
* • Multiplayer (type 'match'): the same engine with a dungeon, monsters, hazards and
|
|
* combat added (later milestones).
|
|
*
|
|
* Design invariants (kept transport-agnostic so the engine is testable and reusable):
|
|
* • No Socket.IO / DB / Wownerogue coupling here — game-state logic only. A manager layer
|
|
* owns transport (Socket.IO rooms, broadcasts) and the server-tick timer.
|
|
* • Server-authoritative movement: the client requests a step; the Room decides.
|
|
* • Serialisable: snapshot()/fullState() are broadcast-safe (no engine internals).
|
|
*/
|
|
class Room {
|
|
/**
|
|
* @param {object} opts
|
|
* @param {string} [opts.id] Room id (defaults to a uuid).
|
|
* @param {string} [opts.type] 'tavern' | 'match' (informational for now).
|
|
* @param {string[]} [opts.layout] Map rows (defaults to the tavern).
|
|
* @param {boolean} [opts.solidOccupants] If true, occupants block each other's tiles
|
|
* (matches). Tavern leaves this false so players
|
|
* can't grief by blocking doorways.
|
|
* @param {number} [opts.maxOccupants]
|
|
*/
|
|
constructor({ id = null, type = 'tavern', layout = TAVERN_LAYOUT, solidOccupants = false, maxOccupants = 50, roomData = null, roomUrl = null } = {}) {
|
|
this.id = id || uuidv4();
|
|
this.type = type;
|
|
this.solidOccupants = solidOccupants;
|
|
this.maxOccupants = maxOccupants;
|
|
this.createdAt = Date.now();
|
|
this.tickCount = 0;
|
|
this.roomUrl = roomUrl; // client-relative URL of the designed room (if any)
|
|
|
|
// A designed room (imported .tmx) supplies dims + a walkability grid; otherwise build
|
|
// the walkable map from the ASCII layout.
|
|
if (roomData) this._buildFromRoomData(roomData);
|
|
else this._buildMap(layout);
|
|
this.occupants = new Map(); // id -> Occupant
|
|
}
|
|
|
|
_buildFromRoomData(rd) {
|
|
this.roomData = rd;
|
|
this.layout = null;
|
|
this.rows = rd.rows;
|
|
this.cols = rd.cols;
|
|
this.walkable = rd.walkable;
|
|
// Spawn on the walkable cells nearest the room centre, so players start together.
|
|
const cx = this.cols / 2, cy = this.rows / 2, cells = [];
|
|
for (let y = 0; y < this.rows; y++) {
|
|
for (let x = 0; x < this.cols; x++) {
|
|
if (this.walkable[y] && this.walkable[y][x] === true) {
|
|
cells.push({ x, y, d: (x - cx) * (x - cx) + (y - cy) * (y - cy) });
|
|
}
|
|
}
|
|
}
|
|
cells.sort((a, b) => a.d - b.d);
|
|
this.spawns = cells.slice(0, 24).map(c => ({ x: c.x, y: c.y }));
|
|
if (this.spawns.length === 0) this.spawns = [{ x: 1, y: 1 }];
|
|
}
|
|
|
|
_buildMap(layout) {
|
|
this.layout = layout;
|
|
this.rows = layout.length;
|
|
this.cols = layout.reduce((m, r) => Math.max(m, r.length), 0);
|
|
this.spawns = [];
|
|
this.walkable = []; // walkable[y][x] === true means floor
|
|
for (let y = 0; y < this.rows; y++) {
|
|
const row = [];
|
|
for (let x = 0; x < this.cols; x++) {
|
|
const ch = layout[y][x] || '#'; // treat short rows as walled
|
|
row.push(FLOOR_CHARS.has(ch));
|
|
if (ch === '@') this.spawns.push({ x, y });
|
|
}
|
|
this.walkable.push(row);
|
|
}
|
|
if (this.spawns.length === 0) {
|
|
// Fall back to the first walkable tile.
|
|
for (let y = 0; y < this.rows && this.spawns.length === 0; y++) {
|
|
for (let x = 0; x < this.cols; x++) {
|
|
if (this.walkable[y][x]) { this.spawns.push({ x, y }); break; }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
isWalkable(x, y) {
|
|
if (y < 0 || y >= this.rows || x < 0 || x >= this.cols) return false;
|
|
return this.walkable[y][x] === true;
|
|
}
|
|
|
|
isOccupied(x, y, exceptId = null) {
|
|
for (const occ of this.occupants.values()) {
|
|
if (occ.id !== exceptId && occ.x === x && occ.y === y) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
_pickSpawn() {
|
|
// Prefer an unoccupied spawn tile so joiners don't stack; else any spawn.
|
|
for (const s of this.spawns) {
|
|
if (!this.isOccupied(s.x, s.y)) return s;
|
|
}
|
|
return this.spawns[0] || { x: 0, y: 0 };
|
|
}
|
|
|
|
/**
|
|
* @returns {Occupant|null} the occupant, or null if the room is full.
|
|
*/
|
|
addOccupant(id, { name = null, avatar = 'default', appearance = null } = {}) {
|
|
if (this.occupants.has(id)) return this.occupants.get(id);
|
|
if (this.occupants.size >= this.maxOccupants) return null;
|
|
const spawn = this._pickSpawn();
|
|
const occ = new Occupant(id, { x: spawn.x, y: spawn.y, name, avatar, appearance });
|
|
this.occupants.set(id, occ);
|
|
if (CONSOLE_LOGGING) {
|
|
console.log(`[Room ${String(this.id).slice(0, 8)}] +occupant ${String(id).slice(0, 6)} at (${spawn.x},${spawn.y})`);
|
|
}
|
|
return occ;
|
|
}
|
|
|
|
removeOccupant(id) {
|
|
return this.occupants.delete(id);
|
|
}
|
|
|
|
getOccupant(id) {
|
|
return this.occupants.get(id) || null;
|
|
}
|
|
|
|
static facingFor(dx, dy) {
|
|
if (dx === -1) return 'left';
|
|
if (dx === 1) return 'right';
|
|
if (dy === -1) return 'up';
|
|
if (dy === 1) return 'down';
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Attempt a one-tile cardinal move for an occupant. Server-authoritative: the client
|
|
* only requests a direction; here we validate bounds, walls and (optionally) other
|
|
* occupants, and mutate state only if the step is legal.
|
|
* @returns {{moved:boolean, reason?:string, occupant?:object}}
|
|
*/
|
|
moveOccupant(id, dx, dy) {
|
|
const occ = this.occupants.get(id);
|
|
if (!occ) return { moved: false, reason: 'not_in_room' };
|
|
|
|
dx = Math.sign(dx || 0);
|
|
dy = Math.sign(dy || 0);
|
|
if (Math.abs(dx) + Math.abs(dy) !== 1) return { moved: false, reason: 'invalid_step' };
|
|
|
|
const nx = occ.x + dx;
|
|
const ny = occ.y + dy;
|
|
const facing = Room.facingFor(dx, dy);
|
|
|
|
// Turn to face the attempted direction even when blocked (feels responsive).
|
|
if (!this.isWalkable(nx, ny)) {
|
|
if (facing) occ.facing = facing;
|
|
return { moved: false, reason: 'blocked', occupant: occ.getState() };
|
|
}
|
|
if (this.solidOccupants && this.isOccupied(nx, ny, id)) {
|
|
if (facing) occ.facing = facing;
|
|
return { moved: false, reason: 'occupied', occupant: occ.getState() };
|
|
}
|
|
|
|
occ.moveTo(nx, ny, facing);
|
|
return { moved: true, occupant: occ.getState() };
|
|
}
|
|
|
|
/**
|
|
* Advance the world one server tick. For a Tavern this is currently a heartbeat
|
|
* (no autonomous entities yet). Multiplayer match rooms will drive monster movement,
|
|
* environmental hazards and combat resolution here. Returns events for the manager
|
|
* to broadcast. The hook is included from the start so those systems have a home.
|
|
* @returns {{tick:number, events:Array}}
|
|
*/
|
|
tick() {
|
|
this.tickCount++;
|
|
return { tick: this.tickCount, events: [] };
|
|
}
|
|
|
|
/** Occupants-only snapshot — broadcast every tick/update. */
|
|
snapshot() {
|
|
return {
|
|
roomId: this.id,
|
|
type: this.type,
|
|
tick: this.tickCount,
|
|
occupants: Array.from(this.occupants.values()).map(o => o.getState())
|
|
};
|
|
}
|
|
|
|
/** Full state including the static map — sent once when an occupant joins. */
|
|
fullState() {
|
|
return {
|
|
...this.snapshot(),
|
|
cols: this.cols,
|
|
rows: this.rows,
|
|
layout: this.layout,
|
|
walkable: this.walkable,
|
|
solidOccupants: this.solidOccupants,
|
|
roomUrl: this.roomUrl || null
|
|
};
|
|
}
|
|
|
|
get size() {
|
|
return this.occupants.size;
|
|
}
|
|
}
|
|
|
|
module.exports = Room;
|