import {
AbstractBoulder,
Pusher,
Slider,
} from "../agents/creatures.ts";
import { Agent } from "../../core/agent.ts";
import { Item } from "../../core/item.ts";
import { Terrain } from "../../core/terrain.ts";
import { Registry } from "../../core/registry.ts";
import { events } from "../../core/event-bus.ts";
import { Animated } from "../../core/animated.ts";
import {
is,
getFlag,
description,
DETECT_HIDDEN,
PLAYER,
TRAVERSABLE,
PENETRABLE,
POISONED,
} from "../../core/flags.ts";
import {
WHITE,
BLACK,
colorByName,
NONE,
NEARBLACK,
BARELY_BUILDING_WALL,
Color,
RED,
} from "../../core/color.ts";
import { Sym } from "../../core/sym.ts";
import { Serializer, TypeOnlySerializer, BaseSerializer } from "../../core/serializer.ts";
import { game } from "../../core/game.ts";
import { State, stateFromString } from "../../core/state.ts";
import { Cell } from "../../core/cell.ts";
import { Direction } from "../../core/direction.ts";
import { Player } from "../../core/player.ts";
import { GameEvent } from "../../core/game-event.ts";
import { InFlightItem, Hit, Open } from "../effects/effects.ts";
import { Floor, Wall } from "./basic.ts";
import { Crowbar } from "../items/items.ts";
import { Pit, Chest, Crate } from "./features.ts";
import { Effect } from "../../core/effect.ts";
import { Piece } from "../../core/piece.ts";
/**
* Abstract base class for terrain decorators.
*
* A decorator wraps another terrain and augments its behavior. It uses the
* template-method pattern: the base class delegates all terrain callbacks to
* the wrapped terrain first, then calls `*Internal` hook methods for the
* decorator subclass.
*
* The wrapped terrain's flags, name, and symbol are inherited unless overridden.
*/
export class Decorator extends Terrain {
terrain: Terrain;
constructor(terrain: Terrain, name: string, flags: number, color: Color, symbol: Sym) {
super(name ?? terrain.name, flags, color ?? NONE, symbol ?? terrain.symbol);
this.terrain = terrain;
}
// Delegate flag checks to underlying terrain. Must call is()/not() (not
// read .flags directly) so nested decorators delegate all the way down
// to the real underlying terrain, matching Java's Decorator.is/not.
is(flag: number) {
return this.terrain.is(flag);
}
not(flag: number) {
return this.terrain.not(flag);
}
/** Return the terrain being wrapped (TerrainProxy interface). */
getProxiedTerrain(): Terrain {
return this.terrain;
}
// canEnter / canExit delegate to wrapped terrain
canEnter(agent: Agent, cell: Cell, direction: Direction) {
return this.terrain.canEnter(agent, cell, direction);
}
canExit(agent: Agent, cell: Cell, direction: Direction) {
return this.terrain.canExit(agent, cell, direction);
}
onEnter(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
this.terrain.onEnter(event, player, cell, dir);
this.onEnterInternal(event, player, cell, dir);
}
onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
this.terrain.onExit(event, player, cell, dir);
this.onExitInternal(event, player, cell, dir);
}
onAgentEnter(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
this.terrain.onAgentEnter(event, agent, cell, dir);
this.onAgentEnterInternal(event, agent, cell, dir);
}
onAgentExit(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
this.terrain.onAgentExit(event, agent, cell, dir);
this.onAgentExitInternal(event, agent, cell, dir);
}
onFlyOver(event: GameEvent, cell: Cell, flier: InFlightItem) {
this.terrain.onFlyOver(event, cell, flier);
this.onFlyOverInternal(event, cell, flier);
}
onDrop(event: GameEvent, cell: Cell, item: Item) {
this.terrain.onDrop(event, cell, item);
this.onDropInternal(event, cell, item);
}
onPickup(event: GameEvent, loc: Cell, agent: Agent, item: Item) {
this.terrain.onPickup(event, loc, agent, item);
this.onPickupInternal(event, loc, agent, item);
}
onAdjacentTo(event: GameEvent, cell: Cell) {
this.terrain.onAdjacentTo(event, cell);
this.onAdjacentToInternal(event, cell);
}
onNotAdjacentTo(event: GameEvent, cell: Cell) {
this.terrain.onNotAdjacentTo(event, cell);
this.onNotAdjacentToInternal(event, cell);
}
onColorEvent(event: GameEvent, color: Color, cell: Cell) {
// Forward to wrapped terrain if it handles color events
this.terrain.onColorEvent?.(event, color, cell);
this.onColorEventInternal(event, color, cell);
}
onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) { }
onExitInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) { }
onAgentEnterInternal(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) { }
onAgentExitInternal(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) { }
onFlyOverInternal(event: GameEvent, cell: Cell, flier: InFlightItem) { }
onDropInternal(event: GameEvent, cell: Cell, item: Item) { }
onPickupInternal(event: GameEvent, loc: Cell, agent: Agent, item: Item) { }
onAdjacentToInternal(event: GameEvent, cell: Cell) { }
onNotAdjacentToInternal(event: GameEvent, cell: Cell) { }
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) { }
}
/**
* Holds two terrains and activates one at a time based on state.
* A color event toggles between terrain1 (ON) and terrain2 (OFF).
*/
export class DualTerrain extends Terrain {
terrain1: Terrain;
terrain2: Terrain;
state: State;
constructor(terrain1: Terrain, terrain2: Terrain, state: State, color: Color) {
const active = state.isOn() ? terrain1 : terrain2;
super(active.name, active.flags, color, active.symbol);
this.terrain1 = terrain1;
this.terrain2 = terrain2;
this.state = state;
}
get #activeTerrain() {
return this.state.isOn() ? this.terrain1 : this.terrain2;
}
is(flag: number) {
return this.#activeTerrain.is(flag);
}
not(flag: number) {
return this.#activeTerrain.not(flag);
}
getProxiedTerrain() {
return this.#activeTerrain;
}
// Matches Java's DualTerrain.onColorEvent: swap terrain1/terrain2 (keeping
// state unchanged) so the previously-inactive terrain becomes active, then
// cache/set the new piece. Unlike TerrainUtils.toggleCellState, this does
// NOT skip the swap when an agent occupies the cell — Java's DualTerrain
// always flips regardless of what's standing on it.
onColorEvent(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color) {
const swapped = new DualTerrain(this.terrain2, this.terrain1, this.state, this.color);
cell.setTerrain(Registry.get(DualTerrain.SERIALIZER.store(swapped)) as Terrain);
}
}
canEnter(agent: Agent, cell: Cell, dir: Direction) {
return this.#activeTerrain.canEnter(agent, cell, dir);
}
canExit(agent: Agent, cell: Cell, dir: Direction) {
return this.#activeTerrain.canExit(agent, cell, dir);
}
onEnter(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
this.#activeTerrain.onEnter(event, player, cell, dir);
}
onExit(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
this.#activeTerrain.onExit(event, player, cell, dir);
}
onAgentEnter(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
this.#activeTerrain.onAgentEnter(event, agent, cell, dir);
}
onAgentExit(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
this.#activeTerrain.onAgentExit(event, agent, cell, dir);
}
onFlyOver(event: GameEvent, cell: Cell, flyer: InFlightItem) {
this.#activeTerrain.onFlyOver(event, cell, flyer);
}
onDrop(event: GameEvent, cell: Cell, item: Item) {
this.#activeTerrain.onDrop(event, cell, item);
}
onPickup(event: GameEvent, cell: Cell, agent: Agent, item: Item) {
this.#activeTerrain.onPickup(event, cell, agent, item);
}
onAdjacentTo(event: GameEvent, cell: Cell) {
this.#activeTerrain.onAdjacentTo(event, cell);
}
onNotAdjacentTo(event: GameEvent, cell: Cell) {
this.#activeTerrain.onNotAdjacentTo(event, cell);
}
static SERIALIZER = new (class extends BaseSerializer {
create([t1, t2, state, color]: string[]) {
return new DualTerrain(
Registry.terrain(t1),
Registry.terrain(t2),
stateFromString(state),
colorByName(color) ?? NONE,
);
}
store(d: DualTerrain) {
return `DualTerrain|${this.esc(d.terrain1)}|${this.esc(d.terrain2)}|${d.state.name}|${d.color.name}`;
}
example() {
return new DualTerrain(Registry.terrain("Floor"), Registry.terrain("Wall"), stateFromString("on"), NONE);
}
template() {
return "DualTerrain|{terrain}|{terrain}|{state}|{color}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* A sign on top of traversable terrain — shows a message when the player
* steps onto it.
*/
export class Sign extends Decorator {
message: string;
constructor(terrain: Terrain, message: string) {
super(
terrain,
"Sign",
terrain.flags,
NONE,
Sym.of(
"⌂",
WHITE,
terrain.symbol.getBackground(false),
BLACK,
terrain.symbol.getBackground(true),
),
);
this.message = message;
}
onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
events.fireMessage(this.message, cell);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, message]: string[]) {
return new Sign(Registry.terrain(terrain), message);
}
store(s: Sign) {
return `Sign|${this.esc(s.terrain)}|${s.message}`;
}
example() {
return new Sign(Registry.terrain("Floor"), "Hello!");
}
template() {
return "Sign|{terrain}|{message}";
}
tag() {
return "Room Features";
}
})();
}
export class Rubble extends Decorator {
constructor(terrain: Terrain) {
super(
terrain,
"Rubble",
TRAVERSABLE | PENETRABLE,
NONE,
Sym.of(
"∴",
BARELY_BUILDING_WALL,
terrain.symbol.getBackground(false),
BARELY_BUILDING_WALL,
terrain.symbol.getBackground(true),
),
);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]: string[]) {
return new Rubble(Registry.terrain(terrain));
}
example() {
return new Rubble(Registry.terrain("Floor"));
}
store(s: Rubble) {
return `Rubble|${this.esc(s.terrain)}`;
}
template() {
return "Rubble|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Sets a flag on the player when a matching color event is received.
*
* Typical use: mark the player as having completed a task or entered a
* region — e.g. set the "poisoned" flag when the player steps on a
* trap that fires a color event.
*
* @extends Decorator
*/
export class Flagger extends Decorator {
flag: number;
constructor(terrain: Terrain, color: Color, flag: number) {
super(terrain, terrain.name, 0, color, terrain.symbol);
if (flag === -1) {
throw new Error("Flag is not valid");
}
this.flag = flag;
}
/**
* Adds {@link Flagger#flag} to the player when the received color matches
* {@link Flagger#color}. Skips silently when there is no player in the event.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color && event.player) {
event.player.add(this.flag);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, flagStr]: string[]) {
return new Flagger(
Registry.terrain(terrain),
colorByName(color) ?? NONE,
getFlag(flagStr),
);
}
store(f: Flagger) {
return `Flagger|${this.esc(f.terrain)}|${f.color.name}|${description(f.flag)}`;
}
example() {
return new Flagger(Registry.terrain("Floor"), NONE, POISONED);
}
template() {
return "Flagger|{terrain}|{color}|{flag}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Clears a flag from the player when a matching color event is received.
*
* Typical use: cure a status effect or reset a condition — e.g. remove
* the "poisoned" flag when the player drinks from a fountain that fires
* a color event.
*
* @extends Decorator
*/
export class Unflagger extends Decorator {
flag: number;
constructor(terrain: Terrain, color: Color, flag: number) {
super(terrain, terrain.name, 0, color, terrain.symbol);
if (flag === -1) {
throw new Error("Flag is not valid");
}
this.flag = flag;
}
/**
* Removes {@link Unflagger#flag} from the player when the received color
* matches {@link Unflagger#color}. Skips silently when there is no player
* in the event.
*
* @param {GameEvent} event
* @param {Color} color
* @param {Cell} _cell - Unused.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color && event.player) {
event.player.remove(this.flag);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, flagStr]: string[]) {
return new Unflagger(
Registry.terrain(terrain),
colorByName(color) ?? NONE,
getFlag(flagStr),
);
}
store(f: Unflagger) {
return `Unflagger|${this.esc(f.terrain)}|${f.color.name}|${description(f.flag)}`;
}
example() {
return new Unflagger(Registry.terrain("Floor"), NONE, POISONED);
}
template() {
return "Unflagger|{terrain}|{color}|{flag}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Displays a modal message to the player when a matching color event is
* received.
*
* Typical use: deliver narrative text or instructions at a scripted moment
* — e.g. show a warning when the player triggers a trap, or present lore
* when a lever is pulled elsewhere on the board.
*
* @extends Decorator
*/
export class Messenger extends Decorator {
message: string;
constructor(terrain: Terrain, color: Color, message: string) {
super(terrain, terrain.name, 0, color, terrain.symbol);
this.message = message;
}
/**
* Shows the modal message when the received color matches
* {@link Messenger#color}.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color) {
events.fireModalMessage(this.message);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, message]: string[]) {
return new Messenger(Registry.terrain(terrain), colorByName(color) ?? NONE, message);
}
store(m: Messenger) {
return `Messenger|${this.esc(m.terrain)}|${m.color.name}|${m.message}`;
}
example() {
return new Messenger(Registry.terrain("Floor"), NONE, "Hello!");
}
template() {
return "Messenger|{terrain}|{color}|{message}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Adds a specific item to the player's bag when a matching color event is
* received.
*
* Typical use: grant the player an item as a reward or story beat — e.g.
* place a Key in the player's bag when a puzzle is solved elsewhere on the
* board.
*
* @extends Decorator
*/
export class Equipper extends Decorator {
item: Item;
constructor(terrain: Terrain, color: Color, item: Item) {
super(terrain, terrain.name, 0, color, terrain.symbol);
this.item = item;
}
/**
* Adds {@link Equipper#item} to the player's bag when the received color
* matches {@link Equipper#color}. Skips silently when there is no player
* in the event.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color && event.player) event.player.bag.add(this.item);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, item]: string[]) {
return new Equipper(Registry.terrain(terrain), colorByName(color) ?? NONE, Registry.item(item));
}
store(e: Equipper) {
return `Equipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
}
example() {
return new Equipper(Registry.terrain("Floor"), RED, Registry.item("Crowbar"));
}
template() {
return "Equipper|{terrain}|{color}|{item}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Removes a specific item from the player's bag when a matching color event
* is received.
*
* Typical use: consume a required item as part of a puzzle or trade — e.g.
* take the Chalice from the player when it is delivered to an altar that
* fires a color event.
*
* @extends Decorator
*/
export class Unequipper extends Decorator {
item: Item;
constructor(terrain: Terrain, color: Color, item: Item) {
super(terrain, terrain.name, 0, color, terrain.symbol);
this.item = item;
}
/**
* Removes {@link Unequipper#item} from the player's bag when the received
* color matches {@link Unequipper#color}. Skips silently when there is no
* player in the event.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color && event.player)
event.player.bag.remove(this.item);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, item]: string[]) {
return new Unequipper(
Registry.terrain(terrain),
colorByName(color) ?? NONE,
Registry.item(item),
);
}
store(e: Unequipper) {
return `Unequipper|${this.esc(e.terrain)}|${e.color.name}|${this.esc(e.item)}`;
}
example() {
return new Unequipper(Registry.terrain("Floor"), RED, Registry.item("Crowbar"));
}
template() {
return "Unequipper|{terrain}|{color}|{item}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Re-broadcasts a different color event when it receives its own color event,
* acting as a color-channel multiplexer.
*
* Typical use: fan out one signal to multiple independent listeners on
* different color channels, or chain together sequences of color-triggered
* effects without wiring every cell to the same color.
*
* @extends Decorator
*/
export class ColorRelay extends Decorator {
relayTo: Color;
constructor(terrain: Terrain, color: Color, relayTo: Color) {
super(terrain, terrain.name, 0, color, terrain.symbol);
this.relayTo = relayTo;
}
/**
* Fires {@link ColorRelay#relayTo} on the board when the received color
* matches {@link ColorRelay#color}. Preserves the original origin cell of
* the triggering event (matching Java, which relays with `origin` rather
* than this cell) so chained relays don't lose track of where the event
* actually started.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color) {
event.board.fireColorEvent(event, this.relayTo, event.originCell ?? cell);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, relayTo]: string[]) {
return new ColorRelay(
Registry.terrain(terrain),
colorByName(color) ?? NONE,
colorByName(relayTo) ?? NONE,
);
}
store(cr: ColorRelay) {
return `ColorRelay|${this.esc(cr.terrain)}|${cr.color.name}|${cr.relayTo.name}`;
}
example() {
return new ColorRelay(Registry.terrain("Floor"), NONE, NONE);
}
template() {
return "ColorRelay|{terrain}|{fromColor}|{toColor}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Ends the game with a victory screen when a matching color event is received.
*
* Typical use: trigger the win condition — e.g. place this on the final
* objective cell and fire its color event when the player delivers the last
* required item or solves the final puzzle.
*
* @extends Decorator
*/
export class EndGame extends Decorator {
url: string;
constructor(terrain: Terrain, color: Color, url: string) {
super(terrain, terrain.name, 0, color, terrain.symbol);
this.url = url;
}
/**
* Calls {@link game.gameOver} with the victory URL when the received color
* matches {@link EndGame#color}.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color) {
game.gameOver(this.url, true);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, url]: string[]) {
return new EndGame(Registry.terrain(terrain), colorByName(color) ?? NONE, url);
}
store(w: EndGame) {
return `EndGame|${this.esc(w.terrain)}|${w.color.name}|${w.url}`;
}
example() {
return new EndGame(Registry.terrain("Floor"), NONE, "win.html");
}
template() {
return "EndGame|{terrain}|{color}|{url}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Places an item or spawns an agent on the board when a matching color event
* is received. The piece is placed either at this decorator's cell or at the
* origin cell of the triggering event, depending on {@link PieceCreator#atOrigin}.
*
* Typical use: spawn a reward or enemy at a fixed location when a puzzle is
* solved elsewhere — e.g. place a Key on a pedestal or summon a guardian
* when the player pulls a lever on the other side of the board.
*
* @extends Decorator
*/
export class PieceCreator extends Decorator {
piece: Item | Agent;
atOrigin: boolean;
constructor(terrain: Terrain, color: Color, piece: Item | Agent, atOrigin: boolean) {
super(terrain, terrain.name, 0, color, terrain.symbol);
this.piece = piece;
this.atOrigin = atOrigin;
}
/**
* Places {@link PieceCreator#piece} on the target cell when the received
* color matches {@link PieceCreator#color}. Items are added unconditionally;
* agents are only placed if the target cell has no existing agent.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color !== this.color) return;
const target = this.atOrigin ? (event.originCell ?? cell) : cell;
if (this.piece instanceof Agent && target.agent == null) target.setAgent(this.piece);
else if (this.piece instanceof Item) target.addItem(this.piece);
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color, piece, atOrigin]: string[]) {
return new PieceCreator(
Registry.terrain(terrain),
colorByName(color) ?? NONE,
Registry.item(piece),
atOrigin === "true",
);
}
store(pc: PieceCreator) {
const pieceKey =
typeof pc.piece === "string" ? pc.piece : this.esc(pc.piece);
return `PieceCreator|${this.esc(pc.terrain)}|${pc.color.name}|${pieceKey}|${pc.atOrigin}`;
}
example() {
return new PieceCreator(Registry.terrain("Floor"), NONE, Registry.item("Crowbar"), false);
}
template() {
return "PieceCreator|{terrain}|{color}|{piece}|{atOrigin}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Destroys agents that interact with this cell, operating in one of two modes
* depending on whether a color is configured.
*
* - **Passive mode** (color is {@link NONE}): destroys any non-player agent
* the moment it steps onto this cell, removing it from the cell it entered
* from.
* - **Active mode** (color is set): destroys the agent currently on this cell
* when a matching color event is received.
*
* Typical use: create invisible kill-zones for agents, or trigger a boss
* defeat remotely via a color event.
*
* @extends Decorator
*/
export class AgentDestroyer extends Decorator {
constructor(terrain: Terrain, color: Color) {
super(terrain, terrain.name, 0, color, terrain.symbol);
}
/**
* In passive mode (color is {@link NONE}), removes the agent from the cell
* it entered from as soon as it steps onto this cell, then runs the same
* death sequence as Java's Game.die(): fire onDie and, unless that cancels
* the event, drop a Hit effect on the cell.
*/
onAgentEnterInternal(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
if (this.color === NONE) {
cell.getAdjacentCell(dir.reverse)?.removeAgent(agent);
const dieEvent = game.createEvent();
agent.onDie(dieEvent, cell);
if (!dieEvent.isCancelled) {
cell.addEffect(new Hit(agent));
}
}
}
/**
* In active mode, removes the agent on this cell when the received color
* matches {@link AgentDestroyer#color}, mirroring Java's Game.die(): fire
* onDie on a fresh event, decoupled from whatever event triggered the
* color signal (e.g. a Switch's already-cancelled move event), and,
* unless that cancels the event, remove the agent and drop a Hit effect.
* Skips silently when the cell has no agent.
*/
onColorEventInternal(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color && cell.agent) {
const agent = cell.agent;
const dieEvent = game.createEvent();
agent.onDie(dieEvent, cell);
if (!dieEvent.isCancelled) {
cell.removeAgent(agent);
cell.addEffect(new Hit(agent));
}
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain, color]: string[]) {
return new AgentDestroyer(Registry.terrain(terrain), colorByName(color) ?? NONE);
}
store(ad: AgentDestroyer) {
return `AgentDestroyer|${this.esc(ad.terrain)}|${ad.color.name}`;
}
example() {
return new AgentDestroyer(Registry.terrain("Floor"), NONE);
}
template() {
return "AgentDestroyer|{terrain}|{color}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Blocks the player from entering this cell unless they possess a specific
* flag or item. An optional modal message is shown when movement is blocked,
* matching the conversational feel of an NPC gate.
*
* Typical use: pair with an NPC to simulate a guard who refuses to let the
* player pass until a condition is met — e.g. require the player to carry
* a Pass before crossing a checkpoint.
*
* @extends Decorator
*/
export class PlayerGate extends Decorator {
testable: number | string;
message: string | null;
constructor(terrain: Terrain, testable: number | string, message: string | null) {
super(terrain, terrain.name, 0, NONE, terrain.symbol);
if (testable == null) {
throw new Error("Flag cannot be null");
}
this.testable = testable;
this.message = message ?? null;
}
/**
* Cancels the movement event if the player does NOT satisfy
* {@link PlayerGate#testable}, optionally showing a modal message first.
* Skips silently when the event is already cancelled.
*/
onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
if (event.isCancelled) {
return;
}
if (!player.matchesFlagOrItem(this.testable)) {
event.cancel();
if (this.message) {
events.fireModalMessage(this.message);
}
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args: string[]) {
return new PlayerGate(Registry.terrain(args[0]), args[1], args[2] ?? null);
}
store(pg: PlayerGate) {
return pg.message
? `PlayerGate|${this.esc(pg.terrain)}|${pg.testable}|${pg.message}`
: `PlayerGate|${this.esc(pg.terrain)}|${pg.testable}`;
}
example() {
return new PlayerGate(Registry.terrain("Floor"), POISONED, null);
}
template() {
return "PlayerGate|{terrain}|{testable}|{message?}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Restricts cell traversal to the player only — all non-player agents are
* prevented from both entering and exiting.
*
* Typical use: confine enemies to a region or corridor without affecting
* player movement — e.g. line the border of an arena so its inhabitants
* cannot wander out.
*
* @extends Decorator
*/
export class AgentGate extends Decorator {
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
*/
constructor(terrain: Terrain) {
super(terrain, terrain.name, 0, NONE, terrain.symbol);
}
/**
* Allows entry only for the player; delegates to the wrapped terrain for
* the player. Non-player agents are only allowed in to attack a player
* already standing on the cell (matching Java's AgentGate.canEnter, which
* checks whether the target cell already holds the player).
*/
canEnter(agent: Agent, cell: Cell, direction: Direction) {
if (is(PLAYER, agent.flags)) {
return this.terrain.canEnter(agent, cell, direction);
}
return cell.agent != null && cell.agent.is(PLAYER);
}
/**
* Allows exit only for the player; delegates to the wrapped terrain for
* the player, and returns false for all other agents.
*/
canExit(agent: Agent, cell: Cell, direction: Direction) {
if (is(PLAYER, agent.flags)) {
return this.terrain.canExit(agent, cell, direction);
}
return false;
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]: string[]) {
return new AgentGate(Registry.terrain(terrain));
}
store(ag: AgentGate) {
return `AgentGate|${this.esc(ag.terrain)}`;
}
example() {
return new AgentGate(Registry.terrain("Floor"));
}
template() {
return "AgentGate|{terrain}";
}
tag() {
return "Utility Terrain";
}
})();
}
/**
* Displays the symbol of one terrain while behaving like another, creating
* a hidden terrain effect. When a player with the {@link DETECT_HIDDEN} flag
* is adjacent, the cell briefly reveals the actual terrain's symbol.
*
* Typical use: disguise a dangerous or traversable cell as something else
* — subclasses {@link SecretPassage} and {@link PitTrap} are the canonical
* examples.
*/
export class Mimic extends Decorator {
appearsAs: Terrain;
constructor(appearsAs: Terrain, actual: Terrain, color: Color) {
super(actual, appearsAs.name, 0, color, appearsAs.symbol);
this.appearsAs = appearsAs;
}
/** Returns the behavioral terrain (the one being wrapped). */
getProxiedTerrain() {
return this.terrain;
}
/** Returns the visual terrain (the one whose symbol is displayed). */
getApparentTerrain() {
return this.appearsAs;
}
/**
* When the player enters an adjacent cell and has {@link DETECT_HIDDEN},
* swaps the cell's animation symbol to reveal the actual terrain.
*/
onAdjacentToInternal(event: GameEvent, cell: Cell) {
if (event.player.is(DETECT_HIDDEN)) {
cell.animTerrainSymbol = this.terrain.symbol;
cell.board.notifyCellChange(cell);
}
}
/**
* When the player leaves the adjacent position, restores the cell's
* animation symbol so the disguise is shown again.
*
* @override
* @param {GameEvent} _event - Unused.
* @param {Cell} cell
*/
onNotAdjacentToInternal(event: GameEvent, cell: Cell) {
cell.animTerrainSymbol = null;
cell.board.notifyCellChange(cell);
}
static SERIALIZER: Serializer = new (class extends BaseSerializer {
create([appearsAs, actual, color]: string[]) {
return new Mimic(Registry.terrain(appearsAs), Registry.terrain(actual), colorByName(color) ?? NONE);
}
store(m: Mimic) {
return `Mimic|${this.esc(m.appearsAs)}|${this.esc(m.terrain)}|${m.color.name}`;
}
example() {
return new Mimic(Registry.terrain("Floor"), Registry.terrain("Wall"), NONE);
}
template() {
return "Mimic|{appearsAsTerrain}|{actualTerrain}|{color}";
}
tag() {
return "Utility Terrain";
}
})();
}
/** SecretPassage — looks like Wall but is traversable like Floor. */
export class SecretPassage extends Mimic {
constructor() {
super(Registry.terrain("Wall"), Registry.terrain("Floor"), NONE);
}
canEnter(agent: Agent, cell: Cell, direction: Direction) {
if (is(PLAYER, agent.flags)) {
return this.terrain.canEnter(agent, cell, direction);
}
return false;
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("SecretPassage");
}
create() {
return new SecretPassage();
}
tag() {
return "Terrain";
}
})();
}
/**
* Looks like {@link Floor} but drops the player into a {@link Pit} when
* stepped on. Boulders fill the pit instead of falling; {@link Slider} and
* {@link Pusher} agents fall through and are removed. Regular agents walk
* over hidden pits unaffected.
*
* Players with {@link DETECT_HIDDEN} see the pit symbol while standing
* adjacent, then the disguise is restored when they move away.
*/
export class PitTrap extends Mimic {
constructor() {
super(Registry.terrain("Floor"), Registry.terrain("Pit"), NONE);
}
// PitTrap looks and behaves like Floor for entry purposes. canEnter must
// return true so the JS movement loop lets the player (and boulders) onto
// the cell; the actual trap effect is handled in onEnter/onAgentEnter.
canEnter(agent: Agent, cell: Cell, direction: Direction) {
return this.appearsAs.canEnter(agent, cell, direction);
}
// Override onEnter directly (not onEnterInternal) so that Pit's onEnter
// never runs — otherwise the Decorator base would call Pit.onEnter first,
// which cancels the event with "You'd fall into the pit".
onEnter(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
// Matches Java's PitTrap.onEnter: modal message, terrain revealed
// immediately (not after the delay), then damage, then a 200ms timer
// that only fires the fall-through teleport if the player is still alive.
events.fireModalMessage("You fall through a pit!");
cell.setTerrain(this.terrain); // reveal the pit
player.changeHealth(20);
setTimeout(() => {
if (player.health > 0) {
events.fireFallThrough(cell.x, cell.y);
}
}, 200);
// Do not cancel — player moves onto the cell, then gets teleported.
}
// Override onAgentEnter directly so that Pit's onAgentEnter never runs.
// Java PitTrap.onAgentEnter also overrides directly; the Java Javadoc notes
// "Yes, agents walk right over these things" for non-boulder agents.
onAgentEnter(event: GameEvent, agent: Agent, cell: Cell, dir: Direction) {
if (agent instanceof AbstractBoulder) {
const prevCell = cell.getAdjacentCell(dir.reverse);
prevCell?.removeAgent(agent);
cell.setTerrain(Registry.terrain("Floor"));
events.fireMessage("The boulder fills a hidden pit!", cell);
} else if (agent instanceof Slider || agent instanceof Pusher) {
const prevCell = cell.getAdjacentCell(dir.reverse);
prevCell?.removeAgent(agent);
cell.setTerrain(this.terrain); // reveal the pit
events.fireMessage(
`The ${agent.name.toLowerCase()} falls through a hidden pit`, cell
);
}
// Regular agents walk right over hidden pits (no cancel, no action).
}
onAdjacentTo(event: GameEvent, cell: Cell) {
if (event.player.is(DETECT_HIDDEN)) {
cell.animTerrainSymbol = (Registry.terrain("Pit")).symbol;
cell.board.notifyCellChange(cell);
}
}
onNotAdjacentTo(event: GameEvent, cell: Cell) {
cell.animTerrainSymbol = null;
cell.board.notifyCellChange(cell);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("PitTrap");
}
create() {
return new PitTrap();
}
tag() {
return "Room Features";
}
})();
}
/** Base for containers that release a cloud when opened. */
class TrapContainerBase extends Decorator {
cloudType: string;
#detected: Sym;
constructor(terrain: Terrain, cloudType: string) {
super(terrain, terrain.name, 0, NONE, terrain.symbol);
this.cloudType = cloudType;
const s = terrain.symbol;
this.#detected = Sym.of(s.entity, s.getColor(false) ?? NONE, RED, s.getColor(true) ?? NONE, RED);
}
static #MESSAGES: Record<string, string> = {
PoisonCloud: "A poison trap!",
EnergyCloud: "An energy trap!",
ResistancesCloud: "A resistance trap!",
};
/**
* Rather than triggering immediately on entry, the trap wraps the
* container's just-created Open effect so it fires when the open
* animation completes — mirrors Java's TrappedOpenCommand, which
* decorates the Open effect's Command.
*/
onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
for (const effect of cell.effects) {
if (effect instanceof Open && !effect.trapped) {
const original = effect.onComplete;
effect.trapped = true;
effect.onComplete = () => {
events.fireMessage(TrapContainerBase.#MESSAGES[this.cloudType], cell);
cell.createCloud(this.cloudType);
original();
};
return;
}
}
}
/**
* When the player is adjacent and has {@link DETECT_HIDDEN}, reveals the
* trap by swapping in a red-highlighted version of the container's own
* symbol — but only for Chest/Crate containers, matching Java's
* TrapContainerBase.onAdjacentToInternal.
*/
onAdjacentToInternal(event: GameEvent, cell: Cell) {
if (event.player.is(DETECT_HIDDEN) && (this.terrain instanceof Chest || this.terrain instanceof Crate)) {
cell.animTerrainSymbol = this.#detected;
cell.board.notifyCellChange(cell);
}
}
onNotAdjacentToInternal(event: GameEvent, cell: Cell) {
cell.animTerrainSymbol = null;
cell.board.notifyCellChange(cell);
}
}
/**
* Releases an {@link EnergyCloud} effect onto the cell when the player
* enters, simulating a trap hidden inside a container.
*
* @extends TrapContainerBase
*/
export class EnergyTrapContainer extends TrapContainerBase {
constructor(terrain: Terrain) {
super(terrain, "EnergyCloud");
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]: string[]) {
return new EnergyTrapContainer(Registry.terrain(terrain));
}
store(tc: EnergyTrapContainer) {
return `EnergyTrapContainer|${this.esc(tc.terrain)}`;
}
example() {
return new EnergyTrapContainer(Registry.terrain("Floor"));
}
template() {
return "EnergyTrapContainer|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Releases a {@link PoisonCloud} effect onto the cell when the player
* enters, simulating a trap hidden inside a container.
*
* @extends TrapContainerBase
*/
export class PoisonTrapContainer extends TrapContainerBase {
constructor(terrain: Terrain) {
super(terrain, "PoisonCloud");
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]: string[]) {
return new PoisonTrapContainer(Registry.terrain(terrain));
}
store(tc: PoisonTrapContainer) {
return `PoisonTrapContainer|${this.esc(tc.terrain)}`;
}
example() {
return new PoisonTrapContainer(Registry.terrain("Floor"));
}
template() {
return "PoisonTrapContainer|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Releases a {@link ResistancesCloud} effect onto the cell when the player
* enters, simulating a trap hidden inside a container.
*
* @extends TrapContainerBase
*/
export class ResistancesTrapContainer extends TrapContainerBase {
constructor(terrain: Terrain) {
super(terrain, "ResistancesCloud");
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]: string[]) {
return new ResistancesTrapContainer(Registry.terrain(terrain));
}
store(tc: ResistancesTrapContainer) {
return `ResistancesTrapContainer|${this.esc(tc.terrain)}`;
}
example() {
return new ResistancesTrapContainer(Registry.terrain("Floor"));
}
template() {
return "ResistancesTrapContainer|{terrain}";
}
tag() {
return "Room Features";
}
})();
}
/**
* Cliff — decorator that enforces directional cliff traversal.
*
* The player can only enter a Cliff cell from a matching-terrain side, and
* only exit to a matching-terrain side (enforced by onEnterInternal /
* onExitInternal, which cancel with a "too steep" message otherwise —
* mirrors Java's Cliff.onEnterInternal/onExitInternal exactly).
*
* Non-player agents are the mirror image: Java's canEnter/canExit (which in
* the original engine are only ever consulted for agent AI movement, never
* for the player) allow a monster in/out only via a MISMATCHED neighbor.
* Since this port's canEnter is also consulted for the player's own physical
* traversability check, canEnter/canExit branch on PLAYER (same pattern as
* AgentGate/SecretPassage in this file) so player behavior stays governed by
* the matching-terrain rule above, while non-player agents get Java's
* mismatched-terrain rule.
*/
export class Cliff extends Decorator {
constructor(terrain: Terrain) {
super(terrain, terrain.name, 0, NONE, terrain.symbol);
}
proxy(terrain: Terrain) {
return new Cliff(terrain);
}
canEnter(agent: Agent, cell: Cell, dir: Direction) {
if (!super.canEnter(agent, cell, dir)) {
return false;
}
const behind = cell.getAdjacentCell(dir.reverse);
if (!behind) {
return true;
}
const matches = behind.getApparentTerrain()?.name === this.terrain.name;
return is(PLAYER, agent.flags) ? matches : !matches;
}
canExit(agent: Agent, cell: Cell, dir: Direction) {
if (!super.canExit(agent, cell, dir)) {
return false;
}
const ahead = cell.getAdjacentCell(dir);
if (!ahead) {
return true;
}
const matches = ahead.getApparentTerrain()?.name === this.terrain.name;
return is(PLAYER, agent.flags) ? matches : !matches;
}
onEnterInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
const behind = cell.getAdjacentCell(dir.reverse);
if (behind?.getApparentTerrain()?.name !== this.terrain.name) {
event.cancel("It's too steep to climb up here.", cell);
}
}
onExitInternal(event: GameEvent, player: Player, cell: Cell, dir: Direction) {
const ahead = cell.getAdjacentCell(dir);
if (ahead?.getApparentTerrain()?.name !== this.terrain.name) {
event.cancel("It's too steep to climb down here.", cell);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([terrain]: string[]) {
return new Cliff(Registry.terrain(terrain));
}
store(c: Cliff) {
return `Cliff|${this.esc(c.terrain)}`;
}
example() {
return new Cliff(Registry.terrain("Floor"));
}
template() {
return "Cliff|{terrain}";
}
tag() {
return "Outside Terrain";
}
})();
}
/**
* Fires a color event on the board at a fixed interval, acting as a
* clock signal for other color-event-driven decorators.
*
* Typical use: drive periodic effects such as toggling a {@link DualTerrain}
* or pulsing a {@link ColorRelay} — e.g. open and close a gate every
* 20 frames, or spawn an agent at regular intervals.
*
* @extends Decorator
*/
export class Timer extends Decorator implements Animated {
frames: number;
/**
* @param {Terrain} terrain - The underlying terrain this decorator wraps.
* @param {Color} color - The color event broadcast on each tick.
* @param {number} frames - Interval in animation frames between firings.
* Values ≤ 0 are clamped to 1.
*/
constructor(terrain: Terrain, color: Color, frames: number) {
super(terrain, terrain.name, 0, color, terrain.symbol);
this.frames = frames > 0 ? frames : 1;
}
randomSeed(): boolean {
return false;
}
/** Returns a new {@link Timer} wrapping the given terrain, preserving color and interval. */
proxy(terrain: Terrain) {
return new Timer(terrain, this.color, this.frames);
}
/**
* Called by the {@link AnimationManager} on each animation tick. Fires
* the color event on the board whenever the frame counter is a positive
* multiple of {@link Timer#_frames}.
*/
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame > 0 && frame % this.frames === 0) {
cell.board.fireColorEvent(event, this.color, cell);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args: string[]) {
return new Timer(
Registry.terrain(args[0]),
colorByName(args[1]) ?? NONE,
parseInt(args[2]) || 1,
);
}
store(t: Timer) {
return `Timer|${this.esc(t.terrain)}|${t.color.name}|${t.frames}`;
}
example() {
return new Timer(Registry.terrain("Floor"), NONE, 10);
}
template() {
return "Timer|{terrain}|{color}|{frames}";
}
tag() {
return "Utility Terrain";
}
})();
}
export function registerDecorators() {
Registry.register("Sign", Sign.SERIALIZER);
Registry.register("Rubble", Rubble.SERIALIZER);
Registry.register("DualTerrain", DualTerrain.SERIALIZER);
Registry.register("Flagger", Flagger.SERIALIZER);
Registry.register("Unflagger", Unflagger.SERIALIZER);
Registry.register("Messenger", Messenger.SERIALIZER);
Registry.register("Equipper", Equipper.SERIALIZER);
Registry.register("Unequipper", Unequipper.SERIALIZER);
Registry.register("ColorRelay", ColorRelay.SERIALIZER);
Registry.register("EndGame", EndGame.SERIALIZER);
Registry.register("PieceCreator", PieceCreator.SERIALIZER);
Registry.register("AgentDestroyer", AgentDestroyer.SERIALIZER);
Registry.register("PlayerGate", PlayerGate.SERIALIZER);
Registry.register("AgentGate", AgentGate.SERIALIZER);
Registry.register("Mimic", Mimic.SERIALIZER);
Registry.register("SecretPassage", SecretPassage.SERIALIZER);
Registry.register("PitTrap", PitTrap.SERIALIZER);
Registry.register("EnergyTrapContainer", EnergyTrapContainer.SERIALIZER);
Registry.register("PoisonTrapContainer", PoisonTrapContainer.SERIALIZER);
Registry.register(
"ResistancesTrapContainer",
ResistancesTrapContainer.SERIALIZER,
);
Registry.register("Cliff", Cliff.SERIALIZER);
Registry.register("Timer", Timer.SERIALIZER);
}