Files
wownerogue/src/multiplayer/Room.js
T

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;