import { ADJ_DIRECTIONS, EAST, NORTH, NORTHEAST, NORTHWEST, SOUTH, SOUTHEAST, SOUTHWEST, WEST } from "./direction.ts";
import { Agent } from "./agent.ts";
import { AgentProxy } from "./agent-proxy.ts";
import { Board } from "./board.ts";
import { Color } from "./color.ts";
import { Direction } from "./direction.ts";
import { Effect } from "./effect.ts";
import { events } from "./event-bus.ts";
import { GameEvent } from "./game-event.ts";
import { isAnimated } from "./animated.ts";
import { Item } from "./item.ts";
import { Open, Fire } from "../pieces/effects/effects.ts";
import { Player } from "./player.ts";
import { PLAYER, HIDES_ITEMS, MEAT, PENETRABLE } from "./flags.ts";
import { Registry } from "./registry.ts";
import { Sym } from "./sym.ts";
import { Terrain } from "./terrain.ts";
/**
* 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 {
board: Board;
x: number;
y: number;
terrain!: Terrain;
agent: Agent | null;
items: Item[];
effects: Effect[];
animTerrainSymbol: Sym | null;
animItemSymbol: Sym | null;
animAgentSymbol: Sym | null;
visited: number;
#meatCount: number;
constructor(board: Board, x: number, y: number) {
this.board = board;
this.x = x;
this.y = y;
this.agent = null;
this.items = [];
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.
*/
this.animTerrainSymbol = null;
/**
* Per-cell animated item symbol set by onFrame. Same mechanism as
* animTerrainSymbol but for the topmost item.
*/
this.animItemSymbol = null;
/**
* Per-cell animated agent symbol set by onFrame. Same mechanism as
* animTerrainSymbol but for the agent layer, allowing singleton agents
* to animate independently on each cell.
*/
this.animAgentSymbol = null;
/**
* Breadcrumb counter: updated each time the player steps here.
* Used for pathfinding heuristics.
*/
this.visited = 0;
/** Count of MEAT-flagged items on this cell. Mirrors Java's ItemBag.meatCount. */
this.#meatCount = 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).
*/
getAdjacentCell(direction: Direction): Cell | null {
if (direction.isVertical()) {
return null;
}
return this.board.getCellAt(
this.x + direction.xDelta,
this.y + direction.yDelta,
);
}
/**
* Get the adjacent cells that this agent can enter from this cell.
*/
public getAdjacentCells(agent: Agent): Cell[] {
const cells: Cell[] = [];
const dirs: Direction[] = [NORTH, NORTHEAST, EAST, SOUTHEAST, SOUTH, SOUTHWEST, WEST, NORTHWEST];
for (var i = 0; i < dirs.length; i++) {
let cell = this.getAdjacentCell(dirs[i]);
if (cell && this.canEnter(this, agent, dirs[i])) {
cells.push(cell);
}
}
return cells;
}
/**
* Replace the terrain on this cell. Fires a cell-change notification.
*/
setTerrain(terrain: Terrain) {
if (isAnimated(this.terrain)) {
this.board.removeAnimated(this.x, this.y, this.terrain);
}
this.animTerrainSymbol = null;
this.terrain = terrain;
if (isAnimated(terrain)) {
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.
*/
getApparentTerrain() {
return this.terrain;
}
/**
* Place an agent on this cell. Any previously present agent is cleared first.
* Do NOT pass null — use removeAgent() to clear.
*/
setAgent(agent: 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.#shouldAnimate(agent)) {
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).
*/
removeAgent(agent: Agent | null) {
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).
*/
moveAgentTo(next: Cell, agent: Agent) {
if (this.#shouldAnimate(agent)) {
this.board.moveAnimated(this.x, this.y, next.x, next.y, agent);
}
this.agent = null;
this.animAgentSymbol = 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);
}
#clearAgent(agent: Agent) {
if (this.#shouldAnimate(agent)) {
this.board.removeAnimated(this.x, this.y, agent);
}
this.animAgentSymbol = null;
}
/**
* Regular players are not in the animated list (their onFrame has a
* different signature and is called separately). Proxy-wrapped players
* (e.g. Paralyzed, Statue) ARE registered, so they're included here.
*/
#shouldAnimate(agent: Agent): boolean {
return isAnimated(agent) && (!agent.is(PLAYER) || agent instanceof AgentProxy);
}
/** True if there are no items on the ground in this cell. */
get isBagEmpty() {
return this.items.length === 0;
}
/** True if at least one MEAT-flagged item is on the ground here. Mirrors Java's ItemBag.containsMeat(). */
containsMeat() {
return this.#meatCount > 0;
}
/** The topmost item, or null. */
get topItem() {
return this.items.length > 0 ? this.items[this.items.length - 1] : null;
}
addItem(item: Item) {
if (isAnimated(item)) {
this.board.addAnimated(this.x, this.y, item);
}
this.animItemSymbol = null;
this.items.push(item);
if (item.is(MEAT)) {
this.#meatCount++;
}
this.board.notifyCellChange(this);
}
/**
* Remove the last occurrence of the given item from this cell.
*/
removeItem(item: Item) {
if (isAnimated(item)) {
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);
if (item.is(MEAT)) {
this.#meatCount--;
}
}
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;
}
addEffect(effect: Effect) {
this.board.addAnimated(this.x, this.y, effect);
this.effects.push(effect);
this.board.notifyCellChange(this);
}
removeEffect(effect: Effect) {
this.board.removeAnimated(this.x, this.y, effect);
const i = this.effects.indexOf(effect);
if (i !== -1) {
this.effects.splice(i, 1);
}
this.board.notifyCellChange(this);
}
/**
* Can an agent enter this cell from agentLoc in direction dir?
*/
canEnter(agentLoc: Cell, agent: Agent, dir: Direction, targetPlayer = false) {
if (!agent.canEnter(dir, agentLoc, this)) {
return false;
}
if (this.agent !== null && !(this.agent.is(PLAYER) && targetPlayer)) {
return false;
}
return this.terrain instanceof Terrain
? this.terrain.canEnter(agent, this, dir)
: false;
}
/**
* Notify items on this cell that an agent has stepped onto it.
*/
onSteppedOn(event: GameEvent, agentLoc: Cell, agent: 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().
*/
explosion(player: Player) {
for (const dir of ADJ_DIRECTIONS) {
const adj = this.board.getAdjacentCell(this.x, this.y, dir);
if (adj && adj.terrain instanceof Terrain) {
if (adj.terrain.canEnter(player, adj, dir)) {
adj.addEffect(new Fire());
}
}
}
this.addEffect(new Fire());
}
/**
* Spread an effect cloud from this cell to nearby penetrable cells within
* a radius of 2.5. Mirrors Java Cell.createCloud().
*/
createCloud(effectKey: string) {
const cloud = Registry.effect(effectKey);
const origin = this;
this.board.visitRange(this, 4, true, (cell) => {
const dx = cell.x - origin.x;
const dy = cell.y - origin.y;
if (cell.terrain.is(PENETRABLE) && Math.sqrt(dx * dx + dy * dy) <= 2.5) {
cell.addEffect(cloud);
}
return true;
});
}
/**
* Open a container (Chest, Crate, etc.): change the terrain to the empty
* state, add items to the player's bag, and fire a message.
*/
openContainer(containerName: string, item: Item | null, count: number, emptyTerrainKey: string, player: Player) {
// Swap terrain immediately so the container can't be triggered again
// while the animation plays (mirrors Java Cell.openContainer).
this.setTerrain(Registry.terrain(emptyTerrainKey));
// Play the Open animation, then drop items onto the cell floor on completion.
const openEffect = new Open(() => {
if (item) {
for (let i = 0; i < count; i++) {
this.addItem(item);
}
} else {
events.fireMessage(`The ${containerName} is empty`, this);
}
});
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)
*/
getDisplaySymbol(outside: boolean): { entity: string, fg: Color | null, bg: Color | null } {
let entity = " ";
let fg: Color | null = null;
let bg: Color | null = null;
const layer = (sym: Sym | null | undefined) => {
if (!sym) {
return;
}
if (sym.entity && sym.entity !== " ") {
entity = sym.entity;
}
fg = sym.getColor(outside) ?? fg;
bg = sym.getBackground(outside) ?? bg;
};
const t = this.getApparentTerrain();
layer(this.animTerrainSymbol ?? t?.symbol);
if (t && !t.is(HIDES_ITEMS)) {
layer(this.animItemSymbol ?? (this.topItem?.symbol as Sym));
}
const topFx = this.topEffect;
if (topFx && !topFx.isAboveAgent()) {
layer(topFx.currentSymbol);
}
layer(this.animAgentSymbol ?? (this.agent?.symbol as Sym));
if (topFx && topFx.isAboveAgent()) {
layer(topFx.currentSymbol);
}
return { entity, fg, bg };
}
}