import { PLAYER, HIDES_ITEMS } from "./flags.js";
import { ADJ_DIRECTIONS } from "./direction.js";
import { Registry } from "./registry.js";
import { events } from "./events.js";
import { Open, Fire } from "../pieces/effects/effects.js";
/**
* A single cell on the board. Holds terrain, one optional agent, a list of
* items on the ground, and a list of active effects.
*
* Cells do not fire DOM or network events — they notify the board, which fans
* out to any registered change listeners (e.g. the renderer).
*/
export class Cell {
/**
* @param {Board} board
* @param {number} x column (0-based)
* @param {number} y row (0-based)
*/
constructor(board, x, y) {
this.board = board;
this.x = x;
this.y = y;
/** @type {Terrain|null} */
this.terrain = null;
/** @type {Agent|null} */
this.agent = null;
/** @type {Item[]} */
this.items = [];
/** @type {Effect[]} */
this.effects = [];
/**
* Per-cell animated terrain symbol set by onFrame. Equivalent to Java's
* fireRerender(cell, piece, symbol) — lets singleton terrain pieces render
* different symbols at different positions without lock-step.
* @type {Symbol|null}
*/
this._animTerrainSymbol = null;
/**
* Per-cell animated item symbol set by onFrame. Same mechanism as
* _animTerrainSymbol but for the topmost item.
* @type {Symbol|null}
*/
this._animItemSymbol = null;
/**
* Breadcrumb counter: updated each time the player steps here.
* Used for pathfinding heuristics.
*/
this.visited = 0;
}
/** True if the player is currently standing on this cell. */
containsPlayer() {
return this.x === this.board.playerX && this.y === this.board.playerY;
}
/**
* The adjacent cell in the given direction, or null for vertical directions
* (UP/DOWN cross board boundaries, handled at a higher level).
* @param {Direction} direction
* @returns {Cell|null}
*/
getAdjacentCell(direction) {
if (direction == null || direction.isVertical()) {
return null;
}
return this.board.getCellAt(
this.x + direction.xDelta,
this.y + direction.yDelta,
);
}
// ── Terrain ──────────────────────────────────────────────────────────────
/**
* Replace the terrain on this cell. Fires a cell-change notification.
* @param {Terrain} terrain
*/
setTerrain(terrain) {
if (this.terrain?.onFrame) {
this.board.removeAnimated(this.x, this.y, this.terrain);
}
this._animTerrainSymbol = null;
this.terrain = terrain;
if (terrain?.onFrame) {
this.board.addAnimated(this.x, this.y, terrain);
}
this.board._notifyCellChange(this);
}
/**
* The terrain as it should appear to the player. Subclasses that implement
* a TerrainProxy decorator should override this to return the wrapped terrain.
* @returns {Terrain}
*/
getApparentTerrain() {
return this.terrain;
}
// ── Agent ─────────────────────────────────────────────────────────────────
/**
* Place an agent on this cell. Any previously present agent is cleared first.
* Do NOT pass null — use removeAgent() to clear.
* @param {Agent} agent
*/
setAgent(agent) {
if (agent == null) {
throw new Error("Use removeAgent() to clear an agent");
}
if (this.agent != null) {
this._clearAgent(this.agent);
}
this.agent = agent;
if (agent.is(PLAYER)) {
this.board.playerX = this.x;
this.board.playerY = this.y;
this.visited = this.board.visitCount++;
// If this is a proxy-wrapped player (e.g. Paralyzed), register its
// onFrame countdown in the animation system.
if (agent.isAgentProxy && agent.onFrame) {
this.board.addAnimated(this.x, this.y, agent);
}
} else if (agent.onFrame) {
this.board.addAnimated(this.x, this.y, agent);
}
this.board._notifyCellChange(this);
}
/**
* Remove the given agent from this cell (no-op if agent is null or differs).
* @param {Agent} agent
*/
removeAgent(agent) {
if (agent != null && this.agent === agent) {
this._clearAgent(agent);
this.agent = null;
this.board._notifyCellChange(this);
}
}
/**
* Move an agent from this cell to another cell in one operation (more
* efficient than remove + set, avoids creating an intermediate state).
* @param {Cell} next
* @param {Agent} agent
*/
moveAgentTo(next, agent) {
if (agent != null) {
if (agent.onFrame && !agent.is(PLAYER)) {
this.board.moveAnimated(this.x, this.y, next.x, next.y, agent);
}
this.agent = null;
next.agent = agent;
if (agent.is(PLAYER)) {
this.board.playerX = next.x;
this.board.playerY = next.y;
next.visited = this.board.visitCount++;
}
}
this.board._notifyCellChange(this);
this.board._notifyCellChange(next);
}
/** @private */
_clearAgent(agent) {
// Deregister from animation if it was ever registered. Regular players are
// not in the animated list (their onFrame has a different signature and is
// called separately). Proxy-wrapped players (isAgentProxy) ARE registered,
// so include them here.
if (agent.onFrame && (!agent.is(PLAYER) || agent.isAgentProxy)) {
this.board.removeAnimated(this.x, this.y, agent);
}
}
/** True if there are no items on the ground in this cell. */
get isBagEmpty() {
return this.items.length === 0;
}
/** The topmost item, or null. */
get topItem() {
return this.items.length > 0 ? this.items[this.items.length - 1] : null;
}
/** @param {Item} item */
addItem(item) {
if (item.onFrame) {
this.board.addAnimated(this.x, this.y, item);
}
this._animItemSymbol = null;
this.items.push(item);
this.board._notifyCellChange(this);
}
/**
* Remove the last occurrence of the given item from this cell.
* @param {Item} item
*/
removeItem(item) {
if (item.onFrame) this.board.removeAnimated(this.x, this.y, item);
this._animItemSymbol = null;
const i = this.items.lastIndexOf(item);
if (i !== -1) {
this.items.splice(i, 1);
}
this.board._notifyCellChange(this);
}
/** True if there are no active effects in this cell. */
get hasNoEffects() {
return this.effects.length === 0;
}
/** The topmost effect, or null. */
get topEffect() {
return this.effects.length > 0
? this.effects[this.effects.length - 1]
: null;
}
/** @param {Effect} effect */
addEffect(effect) {
this.effects.push(effect);
this.board._notifyCellChange(this);
}
/** @param {Effect} effect */
removeEffect(effect) {
const i = this.effects.indexOf(effect);
if (i !== -1) {
this.effects.splice(i, 1);
}
this.board._notifyCellChange(this);
}
// ── Movement / game logic ─────────────────────────────────────────────────
/**
* Can an agent enter this cell from agentLoc in direction dir?
* @param {Cell} agentLoc current cell of the moving agent
* @param {Agent} agent
* @param {Direction} dir
* @param {boolean} [targetPlayer=false] true if the agent intends to attack the player
* @returns {boolean}
*/
canEnter(agentLoc, agent, dir, targetPlayer = false) {
if (!agent.canEnter(dir, agentLoc, this)) return false;
if (this.agent !== null && !(this.agent.is(PLAYER) && targetPlayer))
return false;
if (this.terrain && !this.terrain.canEnter(agent, this, dir)) return false;
if (!this.terrain) return false;
return true;
}
/**
* Notify items on this cell that an agent has stepped onto it.
* @param {GameEvent} event
* @param {Cell} agentLoc
* @param {Agent} agent
*/
onSteppedOn(event, agentLoc, agent) {
// Iterate a copy in case an item removes itself during iteration
for (const item of [...this.items]) {
item.onSteppedOn(event, agentLoc, agent);
}
}
/**
* Trigger a fire explosion at this cell, spreading to all adjacent cells
* whose terrain can be entered. Mirrors Java Cell.explosion().
* @param {Player} player
*/
explosion(player) {
for (const dir of ADJ_DIRECTIONS) {
const adj = this.board.getAdjacentCell(this.x, this.y, dir);
if (adj && adj.terrain?.canEnter(player, adj, dir)) {
adj.addEffect(new Fire());
}
}
this.addEffect(new Fire());
}
// ── Container helpers ─────────────────────────────────────────────────────
/**
* Open a container (Chest, Crate, etc.): change the terrain to the empty
* state, add items to the player's bag, and fire a message.
*
* @param {string} containerName e.g. "chest" or "crate"
* @param {Item|null} item item inside (null if empty)
* @param {number} count how many to give
* @param {string} emptyTerrainKey Registry key for the empty terrain
* @param {Player} player
*/
openContainer(containerName, item, count, emptyTerrainKey, player) {
// Swap terrain immediately so the container can't be triggered again
// while the animation plays (mirrors Java Cell.openContainer).
this.setTerrain(Registry.get(emptyTerrainKey));
// Play the Open animation, then drop items onto the cell floor on completion.
const cell = this;
const openEffect = new Open(() => {
if (item) {
for (let i = 0; i < count; i++) {
cell.addItem(item);
}
} else {
events.fireMessage(cell, `The ${containerName} is empty`);
}
});
this.addEffect(openEffect);
}
/**
* Compute the display symbol for this cell by layering terrain → item →
* under-effects → agent → above-effects. Used by the board renderer.
*
* The layering order mirrors the original Java getCurrentSymbol() logic:
* 1. terrain (or the piece being queried if it IS the terrain)
* 2. top item (unless terrain has HIDES_ITEMS)
* 3. under-effects (isAboveAgent === false)
* 4. agent
* 5. above-effects (isAboveAgent === true)
*
* @param {boolean} outside whether the board is in outside (daylight) mode
* @returns {{ entity: string, fg: string|null, bg: string|null }}
*/
getDisplaySymbol(outside) {
const t = this.getApparentTerrain();
const tSym = this._animTerrainSymbol ?? t?.symbol;
let entity = tSym?.entity ?? " ";
let fg = tSym?.getColor(outside) ?? null;
let bg = tSym?.getBackground(outside) ?? null;
const layer = (sym) => {
if (!sym) return;
if (sym.entity && sym.entity !== " ") entity = sym.entity;
const c = sym.getColor(outside);
if (c) fg = c;
const b = sym.getBackground(outside);
if (b) bg = b;
};
if (t && !t.is(HIDES_ITEMS)) {
layer(this._animItemSymbol ?? this.topItem?.symbol);
}
const topFx = this.topEffect;
if (topFx && !topFx.isAboveAgent()) {
layer(topFx.currentSymbol);
}
layer(this.agent?.symbol);
if (topFx && topFx.isAboveAgent()) {
layer(topFx.currentSymbol);
}
return { entity, fg, bg };
}
}