import { Cell } from "./cell.js";
import { TRANSIENT, TRAVERSABLE } from "./flags.js";
import { AnimationProxy } from "./animation-proxy.js";
import { NONE } from "./color.js";
export const COLUMNS = 40;
export const ROWS = 25;
/**
* The main model object in the game.
*
* Holds a 40×25 grid of Cell objects (indexed cells[x][y], x-major), tracks
* the player's position, adjacent board references (for board-to-board
* navigation), and animation state.
*
* Board is a plain data model — rendering is handled by the board-view module.
* Change notifications are delivered by calling registered listeners whenever
* a cell's state is mutated.
*/
export class Board {
constructor() {
this.cells = Array.from({ length: COLUMNS }, (_, x) =>
Array.from({ length: ROWS }, (_, y) => new Cell(this, x, y)),
);
/** direction name → board path stem (e.g. "north" → "desert.rimmos") */
this._adjacentBoards = new Map();
/** True if the board is set outdoors (affects symbol color rendering). */
this.outside = false;
/** Current player column (-1 if not placed). */
this.playerX = -1;
/** Current player row (-1 if not placed). */
this.playerY = -1;
/** Default player entry column when entering without a transition. */
this.startX = -1;
/** Default player entry row when entering without a transition. */
this.startY = -1;
/**
* Monotonically increasing counter stamped onto each cell the player
* visits. Used for breadcrumb-style pathfinding.
*/
this.visitCount = 0;
this.scenarioName = null;
this.creator = null;
this.description = null;
this.startInv = null;
/**
* Scenario folder name (e.g. "malloc-wizard"). Used by the editor to
* resolve adjacent board paths without needing the full server path.
* @type {string|null}
*/
this.folder = null;
/** Path stem identifying this board, e.g. "tutorial/start". */
this.boardID = "";
/**
* List of AnimationProxy objects — one per animated piece currently on the
* board. Maintained by Cell as agents/terrain are placed and removed.
* @type {AnimationProxy[]}
*/
this._animated = [];
/** @type {Array<function(Cell): void>} */
this._changeListeners = [];
}
/**
* Register a callback invoked whenever a cell's state changes.
* @param {function(Cell): void} fn
*/
onCellChange(fn) {
this._changeListeners.push(fn);
}
/** @param {Cell} cell */
_notifyCellChange(cell) {
for (const fn of this._changeListeners) fn(cell);
}
/**
* Register an animated piece at (x, y). Called by Cell when an agent or
* terrain with an onFrame method is placed.
* @param {number} x
* @param {number} y
* @param {object} piece
*/
addAnimated(x, y, piece) {
this._animated.push(
new AnimationProxy(x, y, piece, piece.randomSeed?.() ?? true),
);
}
/**
* Deregister the proxy for piece at (x, y). Called by Cell when an agent
* or animated terrain is removed.
* @param {number} x
* @param {number} y
* @param {object} piece
*/
removeAnimated(x, y, piece) {
const i = this._animated.findIndex((p) => p.proxyFor(x, y, piece));
if (i !== -1) this._animated.splice(i, 1);
}
/**
* Update the (x, y) of the proxy for piece when it moves to an adjacent
* cell without being removed and re-added. Called by Cell.moveAgentTo.
* @param {number} fromX
* @param {number} fromY
* @param {number} toX
* @param {number} toY
* @param {object} piece
*/
moveAnimated(fromX, fromY, toX, toY, piece) {
const proxy = this._animated.find((p) => p.proxyFor(fromX, fromY, piece));
if (proxy) proxy.setXY(toX, toY);
}
/**
* Return the cell at (x, y), or null if out-of-bounds.
* @param {number} x
* @param {number} y
* @returns {Cell|null}
*/
getCellAt(x, y) {
if (x < 0 || x >= COLUMNS || y < 0 || y >= ROWS) return null;
return this.cells[x][y];
}
/**
* Return the cell adjacent to (x, y) in dir, or null.
* @param {number} x
* @param {number} y
* @param {Direction} dir
* @returns {Cell|null}
*/
getAdjacentCell(x, y, dir) {
return this.getCellAt(x + dir.xDelta, y + dir.yDelta);
}
/** Return the cell currently occupied by the player. */
getCurrentCell() {
return this.getCellAt(this.playerX, this.playerY);
}
/**
* @param {string} directionName e.g. "north"
* @param {string} boardPath path stem, e.g. "desert.rimmos"
*/
setAdjacentBoard(directionName, boardPath) {
this._adjacentBoards.set(directionName, boardPath);
}
/**
* @param {Direction} direction
* @returns {string|null}
*/
getAdjacentBoard(direction) {
return this._adjacentBoards.get(direction.name) ?? null;
}
/**
* Call visitor(cell) for every cell on the board.
* @param {function(Cell, number): void} visitor
*/
visit(visitor) {
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLUMNS; x++) {
visitor(this.cells[x][y]);
}
}
}
/**
* Visit cells in expanding rings around center, closest first.
* visitor(cell, dist) should return false to stop early.
* @param {Cell} center
* @param {number} range
* @param {boolean} includeCenter
* @param {function(Cell, number): boolean} visitor
*/
visitRange(center, range, includeCenter, visitor) {
const sx = center.x;
const sy = center.y;
if (includeCenter && !visitor(center, 0)) {
return;
}
for (let dist = 1; dist < range; dist++) {
for (let dy = -dist; dy <= dist; dy++) {
if (dy === -dist || dy === dist) {
for (let dx = -dist; dx <= dist; dx++) {
const cell = this.getCellAt(sx + dx, sy + dy);
if (cell && !visitor(cell, dist)) {
return;
}
}
} else {
let cell = this.getCellAt(sx + dist, sy + dy);
if (cell && !visitor(cell, dist)) {
return;
}
cell = this.getCellAt(sx - dist, sy + dy);
if (cell && !visitor(cell, dist)) {
return;
}
}
}
}
}
/**
* Find the first cell matching filter(cell, null), or null.
* @param {function(Cell, null): boolean} filter
* @returns {Cell|null}
*/
find(filter) {
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLUMNS; x++) {
if (filter(this.cells[x][y], null)) {
return this.cells[x][y];
}
}
}
return null;
}
/**
* Find a random traversable cell with no agent present.
* @returns {Cell}
*/
findRandomCell() {
let found = null;
do {
const x = Math.floor(Math.random() * COLUMNS);
const y = Math.floor(Math.random() * ROWS);
found = this.cells[x][y];
} while (
found.terrain == null ||
found.terrain.not(TRAVERSABLE) ||
found.agent !== null
);
return found;
}
// ── Effect state queries ──────────────────────────────────────────────────
/**
* True if any cell has a non-transient effect (used to delay saving).
* @returns {boolean}
*/
hasNonTransientEffect() {
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLUMNS; x++) {
const cell = this.cells[x][y];
if (!cell.hasNoEffects) {
for (const effect of cell.effects) {
if (effect.not(TRANSIENT)) return true;
}
}
}
}
return false;
}
// ── Color events ──────────────────────────────────────────────────────────
/**
* Broadcast a color event to all terrain and agents on the board.
* Each piece's onColorEvent() is called; pieces compare their own color
* to decide whether to react.
* @param {GameEvent} event
* @param {string} color CSS color hex string
* @param {Cell} _origin cell that originated the event (informational)
*/
fireColorEvent(event, color, _origin) {
if (color === NONE) {
return;
}
for (let y = 0; y < ROWS; y++) {
for (let x = 0; x < COLUMNS; x++) {
const cell = this.cells[x][y];
if (cell.terrain?.onColorEvent) {
cell.terrain.onColorEvent(event, color, cell);
}
if (cell.agent?.onColorEvent) {
cell.agent.onColorEvent(event, color, cell);
}
}
}
}
}