import { AnimationProxy } from "./animation-proxy.ts";
import { Cell } from "./cell.ts";
import { Color, NONE } from "./color.ts";
import { Direction } from "./direction.ts";
import { GameEvent } from "./game-event.ts";
import { isAnimated } from "./animated.ts";
import { Piece } from "./piece.ts";
import { TRANSIENT, TRAVERSABLE } from "./flags.ts";
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 {
boardID: string;
cells: Cell[][];
creator: string | null;
description: string | null;
folder: string | null;
outside: boolean;
playerX: number;
playerY: number;
scenarioName: string | null;
startInv: string | null;
startX: number;
startY: number;
visitCount: number;
adjacentBoards: Map<string, string>;
animated: AnimationProxy[];
#changeListeners: Array<(c: Cell) => void>;
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.
*/
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 = [];
this.#changeListeners = [];
}
/**
* Register a callback invoked whenever a cell's state changes.
*/
onCellChange(fn: (c: Cell) => void) {
this.#changeListeners.push(fn);
}
notifyCellChange(cell: 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.
*/
addAnimated(x: number, y: number, piece: Piece) {
this.animated.push(
new AnimationProxy(x, y, piece, isAnimated(piece) ? piece.randomSeed() : true),
);
}
/**
* Deregister the proxy for piece at (x, y). Called by Cell when an agent
* or animated terrain is removed.
*/
removeAnimated(x: number, y: number, piece: 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.
*/
moveAnimated(fromX: number, fromY: number, toX: number, toY: number, piece: 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.
*/
getCellAt(x: number, y: number): Cell | null {
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.
*/
getAdjacentCell(x: number, y: number, dir: Direction): Cell | null {
return this.getCellAt(x + dir.xDelta, y + dir.yDelta);
}
/** Return the cell currently occupied by the player. */
getCurrentCell(): Cell {
const cell = this.getCellAt(this.playerX, this.playerY);
if (!cell) {
throw new Error(`Player not located on board: ${this.playerX}/${this.playerY}`);
}
return cell;
}
setAdjacentBoard(directionName: string, boardPath: string) {
this.adjacentBoards.set(directionName, boardPath);
}
getAdjacentBoard(direction: Direction): string | null {
return this.adjacentBoards.get(direction.name) ?? null;
}
/**
* Call visitor(cell) for every cell on the board. Function should
* return false to short-circuit the visitor
*/
visit(visitor: (c: Cell, n: number) => boolean) {
for (let x = 0; x < COLUMNS; x++) {
for (let y = 0; y < ROWS; y++) {
if (!visitor(this.cells[x][y], 0)) {
return;
};
}
}
}
/**
* 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: Cell, range: number, includeCenter: boolean, visitor: (c: Cell, n: number) => boolean) {
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)
*/
find(filter: (c: Cell) => boolean): Cell | null {
for (let x = 0; x < COLUMNS; x++) {
for (let y = 0; y < ROWS; y++) {
if (filter(this.cells[x][y])) {
return this.cells[x][y];
}
}
}
return null;
}
/**
* Find a random traversable cell with no agent present.
*/
findRandomCell(): Cell {
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;
}
/**
* True if any cell has a non-transient effect (used to delay saving).
*/
hasNonTransientEffect(): boolean {
for (let x = 0; x < COLUMNS; x++) {
for (let y = 0; y < ROWS; y++) {
const cell = this.cells[x][y];
if (!cell.hasNoEffects) {
for (const effect of cell.effects) {
if (effect.not(TRANSIENT)) {
return true;
}
}
}
}
}
return false;
}
/**
* 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. The cell that fires the event is set on
* the event as `originCell`.
*/
fireColorEvent(event: GameEvent, color: Color, origin: Cell) {
if (color === NONE) {
return;
}
const previousOrigin = event.originCell;
event.originCell = origin;
for (let x = 0; x < COLUMNS; x++) {
for (let y = 0; y < ROWS; y++) {
const cell = this.cells[x][y];
cell.terrain.onColorEvent(event, color, cell);
cell.agent?.onColorEvent(event, color, cell);
}
}
event.originCell = previousOrigin;
}
}