import { Effect } from "../../core/effect.ts";
import { Registry } from "../../core/registry.ts";
import { TypeOnlySerializer } from "../../core/serializer.ts";
import { Tree } from "../agents/creatures.ts";
import {
Color,
NONE,
RED,
ORANGE,
YELLOW,
PURPLE,
YELLOWGREEN,
CORAL,
WHITE,
BLACK,
LESSNEARBLACK,
BUILDING_WALL,
} from "../../core/color.ts";
import { Sym } from "../../core/sym.ts";
import {
PLAYER,
ORGANIC,
FIRE_RESISTANT,
POISONED,
POISON_RESISTANT,
PARALYSIS_RESISTANT,
STONING_RESISTANT,
THEFT_RESISTANT,
AMMUNITION,
PENETRABLE,
AMMO_ENLIVENER,
} from "../../core/flags.ts";
import { events } from "../../core/event-bus.ts";
import { GameEvent } from "../../core/game-event.ts";
import { Cell } from "../../core/cell.ts";
import { Piece } from "../../core/piece.ts";
import { Agent } from "../../core/agent.ts";
import { Animated } from "../../core/animated.ts";
import { Item } from "../../core/item.ts";
import { Direction } from "../../core/direction.ts";
// ── Color oscillation (mirrors Util.java#oscillate) ───────────────────────────
function hexToRgb(hex: string): number[] {
const n = parseInt(hex.replace("#", ""), 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}
function oscillateComponent(start: number, end: number, frames: number, frame: number): number {
if (start === end) return start;
const diff = Math.abs(end - start);
const steps = Math.ceil(diff / frames);
const inc = Math.abs((frame % (frames * 2)) - frames) * steps;
if (end < start) {
const c = start - inc;
return c < end ? end : c > start ? start : c;
}
const c = start + inc;
return c < start ? start : c > end ? end : c;
}
export function oscillate(from: Color, to: Color, rate: number, frame: number): Color {
const fHex = from.hex;
const tHex = to.hex;
const [fr, fg, fb] = hexToRgb(fHex);
const [tr, tg, tb] = hexToRgb(tHex);
const r = oscillateComponent(fr, tr, rate, frame);
const g = oscillateComponent(fg, tg, rate, frame);
const b = oscillateComponent(fb, tb, rate, frame);
const hex =
"#" +
[r, g, b]
.map((c) => c.toString(16).padStart(2, "0"))
.join("")
.toUpperCase();
return new Color(hex, hex);
}
// HTML entity → Unicode
//   → \u2003, ω → \u03C9, ∗ → \u2217,
// ‰ → \u2030, ∟ → \u221F, ∠ → \u2220, ∴ → \u2234
/**
* Animated fire — 4-frame loop using ω glyph cycling through red/orange/yellow.
* Deals fire damage to non-fire-resistant agents and converts trees to stumps.
*/
const FIRE_DAMAGE = 30;
export class Fire extends Effect implements Animated {
static SYMBOLS = [
Sym.of("ω", RED),
Sym.of("ω", ORANGE),
Sym.of("ω", RED),
Sym.of("ω", YELLOW),
];
constructor() {
super("Fire", Fire.SYMBOLS, NONE);
}
randomSeed(): boolean {
return false;
}
isAboveAgent() {
return true;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame < 5) {
this.frameIndex = frame % this.frames.length;
const agent = cell.agent;
if (agent && agent.is(ORGANIC) && agent.not(FIRE_RESISTANT)) {
if (agent.changeHealth(FIRE_DAMAGE) === 0) {
agent.onDie(event, cell);
cell.removeAgent(agent);
cell.addEffect(new Hit(agent));
}
}
cell.board.notifyCellChange(cell);
} else {
cell.removeEffect(this);
// Tree → Stump conversion (mirrors Java Fire.onFrame)
const agent = cell.agent;
if (agent instanceof Tree) {
const stump = Registry.agent("Stump");
cell.removeAgent(agent);
cell.setAgent(stump);
}
// Bomb chain-explosion
const bomb = Registry.item("Bomb");
if (cell.items.includes(bomb)) {
cell.removeItem(bomb);
cell.explosion(event.player);
}
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Fire");
}
create() {
return new Fire();
}
tag() {
return "Effects";
}
})();
}
/**
* A cloud of poison that applies the POISONED flag to non-resistant players.
* Fades over 5 frames using a purple background fill.
*/
export class PoisonCloud extends Effect implements Animated {
constructor() {
super("Poison Cloud", [Sym.of("\u2003", NONE, PURPLE)], NONE);
}
randomSeed(): boolean {
return false;
}
get currentSymbol(): Sym {
return this.frames[0];
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame < 5) {
const outside = cell.board.outside;
const terrainBg = cell.terrain.symbol.getBackground(outside) ?? NONE;
const cloudBg = this.frames[0].getBackground(outside) as Color;
const newBg = oscillate(terrainBg, cloudBg, 5, frame);
cell.animTerrainSymbol = Sym.of("\u2003", NONE, newBg);
cell.board.notifyCellChange(cell);
const agent = cell.agent;
if (agent?.is(PLAYER)) {
if (!event.player.testResistance(POISON_RESISTANT)) {
event.player.add(POISONED);
}
}
} else {
cell.animTerrainSymbol = null;
cell.removeEffect(this);
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("PoisonCloud");
}
create() {
return new PoisonCloud();
}
tag() {
return "Effects";
}
})();
}
/**
* An energy field that will weaken the player.
* Oscillates from the terrain background to yellowgreen over 7 frames,
* then removes itself. Weakens the player each tick they stand in it.
*/
export class EnergyCloud extends Effect implements Animated {
constructor() {
super("Energy Cloud", [Sym.of("\u2003", NONE, YELLOWGREEN)], NONE);
}
randomSeed(): boolean {
return false;
}
get currentSymbol(): Sym {
return this.frames[0];
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame < 7) {
const outside = cell.board.outside;
const terrainBg = cell.terrain.symbol.getBackground(outside) ?? NONE;
const cloudBg = this.frames[0].getBackground(outside) as Color;
const newBg = oscillate(terrainBg, cloudBg, 7, frame);
cell.animTerrainSymbol = Sym.of("\u2003", NONE, newBg);
cell.board.notifyCellChange(cell);
const agent = cell.agent;
if (agent?.is(PLAYER)) {
event.player.weaken(cell);
}
} else {
cell.animTerrainSymbol = null;
cell.removeEffect(this);
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("EnergyCloud");
}
create() {
return new EnergyCloud();
}
tag() {
return "Effects";
}
})();
}
/**
* A cloud that removes resistances from the player.
* Rendered as a coral background fill.
* @implements {Animated}
*/
export class ResistancesCloud extends Effect {
constructor() {
super("Resistances Cloud", [Sym.of("\u2003", NONE, CORAL)], NONE);
}
// TODO: All of these are null, are they used?
get currentSymbol(): Sym {
return this.frames[0];
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame < 7) {
const outside = cell.board.outside;
const terrainBg = cell.terrain.symbol.getBackground(outside) ?? NONE;
const cloudBg = this.frames[0].getBackground(outside) as Color;
const newBg = oscillate(terrainBg, cloudBg, 7, frame);
cell.animTerrainSymbol = Sym.of("\u2003", NONE, newBg);
cell.board.notifyCellChange(cell);
const agent = cell.agent;
if (agent?.is(PLAYER)) {
const player = event.player;
if (player.is(THEFT_RESISTANT)) {
player.remove(THEFT_RESISTANT);
events.fireMessage("Theft resistance is gone.", cell);
}
if (player.is(POISON_RESISTANT)) {
player.remove(POISON_RESISTANT);
events.fireMessage("Poison resistance is gone.", cell);
}
if (player.is(PARALYSIS_RESISTANT)) {
player.remove(PARALYSIS_RESISTANT);
events.fireMessage("Paralysis resistance is gone.", cell);
}
if (player.is(STONING_RESISTANT)) {
player.remove(STONING_RESISTANT);
events.fireMessage("Resistance to stoning gone.", cell);
}
}
} else {
cell.animTerrainSymbol = null;
cell.removeEffect(this);
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("ResistancesCloud");
}
create() {
return new ResistancesCloud();
}
tag() {
return "Effects";
}
})();
}
/**
* A single-frame hit indicator — shows an agent's glyph with a red background.
* Created dynamically when an agent dies.
* @implements {Animated}
*/
export class Hit extends Effect {
constructor(agent: Agent) {
const entity = agent.symbol.entity;
super("Hit", [Sym.of(entity, BLACK, RED)], NONE);
}
isAboveAgent() {
return true;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame === 0) {
cell.board.notifyCellChange(cell);
} else {
cell.removeEffect(this);
}
}
}
/**
* Open/unlock animation (e.g., for opening a door or chest). Each of the 3
* symbols is held for 5 frames (15 frames total), mirroring Java's
* `i = frame/5` indexing.
* An optional `onComplete` callback is invoked once all frames have played.
* @implements {Animated}
*/
export class Open extends Effect {
static SYMBOLS = [
Sym.of("_", WHITE, null, BUILDING_WALL, null),
Sym.of("∠", WHITE, null, BUILDING_WALL, null),
Sym.of("∟", WHITE, null, BUILDING_WALL, null),
];
static DURATION = 15;
/** True once a decorator (e.g. TrapContainerBase) has wrapped `onComplete`. */
trapped = false;
#onComplete: () => void;
constructor(onComplete: () => void) {
super("Open", Open.SYMBOLS, NONE);
this.#onComplete = onComplete;
}
/** The behavior to run once the open animation finishes. */
get onComplete(): () => void {
return this.#onComplete;
}
set onComplete(fn: () => void) {
this.#onComplete = fn;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame >= Open.DURATION) {
this.#onComplete();
cell.removeEffect(this);
return;
}
this.frameIndex = Math.floor(frame / 5);
cell.board.notifyCellChange(cell);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Open");
}
create() {
// You can't place effects on the map so this never gets called with dummy func
return new Open(() => { });
}
tag() {
return "Effects";
}
})();
}
/**
* 5-frame smash/destroy animation (e.g., for breaking an urn or crate).
* @implements {Animated}
*/
export class Smash extends Effect {
static SYMBOLS = [
Sym.of("∗", WHITE, null, BUILDING_WALL, null),
Sym.of("%", WHITE, null, BUILDING_WALL, null),
Sym.of("‰", WHITE, null, BUILDING_WALL, null),
Sym.of("⁻", WHITE, null, BUILDING_WALL, null),
Sym.of("჻", LESSNEARBLACK, null, BUILDING_WALL, null),
];
constructor() {
super("Smash", Smash.SYMBOLS, NONE);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame >= this.frames.length) {
cell.removeEffect(this);
return;
}
this.frameIndex = frame;
cell.board.notifyCellChange(cell);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Smash");
}
create() {
return new Smash();
}
tag() {
return "Effects";
}
})();
}
/**
* Generic fade effect — takes a symbol and oscillates its foreground color
* from its original color toward the terrain background (or BLACK if there
* is none) over 10 frames, then completes. Mirrors Java's Fade, which
* recomputes `Util.oscillate(bg, symbol.getColor(outside), 10, frame)` each
* tick rather than stepping through a fixed set of frames.
* Used for death animations and teleportation.
* An optional `onComplete` callback is invoked when the animation finishes.
* @implements {Animated}
*/
export class Fade extends Effect {
static DURATION = 10;
#symbol: Sym;
#onComplete: () => void;
constructor(symbol: Sym, onComplete: () => void) {
super("Fade", [Sym.of(symbol.entity, symbol.getColor(false) ?? BLACK)], NONE);
this.#symbol = symbol;
this.#onComplete = onComplete;
}
isAboveAgent() {
return true;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame < Fade.DURATION) {
const outside = cell.board.outside;
const terrainBg = cell.terrain.symbol.getBackground(outside) ?? BLACK;
const target = this.#symbol.getColor(outside) ?? BLACK;
const fg = oscillate(terrainBg, target, Fade.DURATION, frame);
this.frames[0] = Sym.of(this.#symbol.entity, fg);
cell.board.notifyCellChange(cell);
} else {
cell.removeEffect(this);
// Defer so the callback runs after the current animation tick completes.
// This mirrors Java's loadingTimer pattern: the board swap never happens
// mid-tick, so the old board's animation loop finishes cleanly first.
setTimeout(this.#onComplete, 0);
}
}
}
/**
* A thrown or fired item in mid-flight. Created dynamically by the game engine.
* Not registered in the Registry (no serialized key) — it cannot be placed in
* a map file.
*
* Wraps an Item and a Direction; the game loop moves it one cell per tick.
* @implements {Animated}
*/
export class InFlightItem extends Effect {
item: Item;
direction: Direction;
originator: Piece;
constructor(item: Item, direction: Direction, originator: Piece) {
super(item.name, [item.symbol], NONE);
this.symbol = item.symbol;
this.item = item;
this.direction = direction;
this.originator = originator;
}
get currentSymbol() {
return this.item.symbol;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
const board = cell.board;
const projEvent = new GameEvent(event.player, board, event.game);
const nextCell = board.getAdjacentCell(cell.x, cell.y, this.direction);
cell.terrain.onFlyOut(projEvent, cell, this);
cell.removeEffect(this);
if (projEvent.isCancelled) return;
if (!nextCell) {
this.#land(projEvent, cell);
return;
}
// Mirrors Java: an agent occupying the next cell always takes precedence
// over the terrain there — terrain.onFlyOver is only consulted when the
// cell is unoccupied.
const flyEvent = new GameEvent(event.player, board, event.game);
const agent = nextCell.agent;
if (agent) {
agent.onHitByItem(flyEvent, nextCell, this.item, this.direction);
// Ammo that bounces off a reflector becomes deadly to everyone. An
// agent cannot hit itself, and two non-player agents normally can't
// hit one another.
const isPlayerOriginator = this.originator.is(PLAYER);
const isPlayerTarget = agent.is(PLAYER);
if (
this.originator.is(AMMO_ENLIVENER) ||
(agent !== this.originator && (isPlayerOriginator || isPlayerTarget))
) {
this.item.onHit(flyEvent, nextCell, agent);
}
if (flyEvent.kills) {
for (const { cell: killCell, agent: killed } of flyEvent.kills) {
killed.onDie(flyEvent, killCell);
killCell.removeAgent(killed);
killCell.addEffect(new Hit(killed));
}
}
if (!agent.is(PENETRABLE)) {
this.#land(flyEvent, cell);
return;
}
} else {
nextCell.terrain.onFlyOver(flyEvent, nextCell, this);
if (flyEvent.isCancelled) {
this.#land(flyEvent, cell);
return;
}
}
nextCell.addEffect(this);
}
#land(event: GameEvent, cell: Cell) {
if (this.item.is(AMMUNITION)) {
this.item.onThrowEnd(event, cell);
} else {
// Mirrors Java's Game.drop(): terrain gets first refusal, then the
// item's own onThrowEnd, before it's added to the cell's item bag.
const landEvent = new GameEvent(event.player, cell.board, event.game);
cell.terrain.onDrop(landEvent, cell, this.item);
if (!landEvent.isCancelled) {
this.item.onThrowEnd(landEvent, cell);
if (!landEvent.isCancelled) {
cell.addItem(this.item);
}
}
}
event.game.runAgentTurns(event);
}
}
export function registerEffects() {
Registry.register("Fire", Fire.SERIALIZER);
Registry.register("PoisonCloud", PoisonCloud.SERIALIZER);
Registry.register("EnergyCloud", EnergyCloud.SERIALIZER);
Registry.register("ResistancesCloud", ResistancesCloud.SERIALIZER);
Registry.register("Open", Open.SERIALIZER);
Registry.register("Smash", Smash.SERIALIZER);
}