import { Effect } from "../../core/effect.js";
import { Registry } from "../../core/registry.js";
import { TypeOnlySerializer } from "../../core/serializer.js";
import {
NONE,
RED,
ORANGE,
YELLOW,
PURPLE,
YELLOWGREEN,
CORAL,
WHITE,
BLACK,
LESSNEARBLACK,
BUILDING_WALL,
} from "../../core/color.js";
import { Symbol } from "../../core/symbol.js";
import { PLAYER, ORGANIC, FIRE_RESISTANT } from "../../core/flags.js";
// ── Color oscillation (mirrors Util.java#oscillate) ───────────────────────────
function hexToRgb(hex) {
const n = parseInt(hex.replace("#", ""), 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}
function oscillateComponent(start, end, frames, frame) {
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, to, rate, frame) {
const fHex = from?.hex ?? from?.toString() ?? "#000000";
const tHex = to?.hex ?? to?.toString() ?? "#000000";
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 {
hex,
name: hex,
toString() {
return this.hex;
},
};
}
// HTML entity → Unicode
//   → \u2003, ω → \u03C9, ∗ → \u2217,
// ‰ → \u2030, ∟ → \u221F, ∠ → \u2220, ∴ → \u2234
// ── Fire ──────────────────────────────────────────────────────────────────────
/**
* 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 {
constructor() {
super(
"Fire",
[
Symbol.of("ω", RED),
Symbol.of("ω", ORANGE),
Symbol.of("ω", RED),
Symbol.of("ω", YELLOW),
],
NONE,
);
this._tick = 0;
}
isAboveAgent() {
return true;
}
onTick(event, _board, cell) {
const tick = this._tick++;
if (tick < 5) {
this.frameIndex = tick % 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);
}
}
} else {
this.done = true;
// Tree → Stump conversion (mirrors Java Fire.onFrame)
const agent = cell.agent;
if (agent) {
const stump = Registry.get("Stump");
if (stump && agent.name === "Tree") {
cell.removeAgent(agent);
cell.setAgent(stump);
}
}
// Bomb chain-explosion
const bomb = Registry.get("Bomb");
if (bomb && cell.items.includes(bomb)) {
cell.removeItem(bomb);
cell.explosion(event.player);
}
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Fire");
}
create(_args) {
return new Fire();
}
tag() {
return "Effects";
}
})();
}
// ── PoisonCloud ───────────────────────────────────────────────────────────────
/**
* 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 {
constructor() {
super("Poison Cloud", [Symbol.of("\u2003", NONE, PURPLE)], NONE);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("PoisonCloud");
}
create(_args) {
return new PoisonCloud();
}
tag() {
return "Effects";
}
})();
}
// ── EnergyCloud ───────────────────────────────────────────────────────────────
/**
* 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 {
constructor() {
super("Energy Cloud", [Symbol.of("\u2003", NONE, YELLOWGREEN)], NONE);
this._frame = 0;
this._frameSymbol = this.frames[0];
}
get currentSymbol() {
return this._frameSymbol;
}
onTick(event, board, cell) {
const outside = board.outside;
const terrainBg = cell.terrain?.symbol?.getBackground(outside) ?? NONE;
const cloudBg = this.frames[0].getBackground(outside);
const newBg = oscillate(terrainBg, cloudBg, 7, this._frame);
this._frameSymbol = Symbol.of("\u2003", NONE, newBg);
const agent = cell.agent;
if (agent?.is(PLAYER)) {
event.player.weaken(cell);
}
this._frame++;
if (this._frame >= 7) {
this.done = true;
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("EnergyCloud");
}
create(_args) {
return new EnergyCloud();
}
tag() {
return "Effects";
}
})();
}
// ── ResistancesCloud ──────────────────────────────────────────────────────────
/**
* A cloud that removes resistances from the player.
* Rendered as a coral background fill.
*/
export class ResistancesCloud extends Effect {
constructor() {
super("Resistances Cloud", [Symbol.of("\u2003", NONE, CORAL)], NONE);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("ResistancesCloud");
}
create(_args) {
return new ResistancesCloud();
}
tag() {
return "Effects";
}
})();
}
// ── Hit ───────────────────────────────────────────────────────────────────────
/**
* A single-frame hit indicator — shows an agent's glyph with a red background.
* Created dynamically when an agent takes damage.
*/
export class Hit extends Effect {
constructor(agentSymbol) {
const entity = agentSymbol?.getEntity?.() ?? "x";
super("Hit", [Symbol.of(entity, BLACK, RED)], NONE);
}
onTick(event, board, cell) {
this.done = true;
}
}
// ── Open ──────────────────────────────────────────────────────────────────────
/**
* 3-frame open/unlock animation (e.g., for opening a door or chest).
* An optional `onComplete` callback is invoked once all frames have played.
*/
export class Open extends Effect {
constructor(onComplete) {
super(
"Open",
[
Symbol.of("_", WHITE, null, BUILDING_WALL, null),
Symbol.of("∠", WHITE, null, BUILDING_WALL, null),
Symbol.of("∟", WHITE, null, BUILDING_WALL, null),
],
NONE,
);
this._onComplete = onComplete ?? null;
this._cycled = false;
}
onTick(event, board, cell) {
const prev = this.frameIndex;
super.onTick(event, board, cell);
// Mark done after we've wrapped back past the last frame
if (prev === this.frames.length - 1) {
if (this._onComplete) this._onComplete();
this.done = true;
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Open");
}
create(_args) {
return new Open();
}
tag() {
return "Effects";
}
})();
}
// ── Smash ─────────────────────────────────────────────────────────────────────
/**
* 5-frame smash/destroy animation (e.g., for breaking an urn or crate).
*/
export class Smash extends Effect {
constructor() {
super(
"Smash",
[
Symbol.of("∗", WHITE, null, BUILDING_WALL, null),
Symbol.of("%", WHITE, null, BUILDING_WALL, null),
Symbol.of("‰", WHITE, null, BUILDING_WALL, null),
Symbol.of("⁻", WHITE, null, BUILDING_WALL, null),
Symbol.of("\u10FB", LESSNEARBLACK, null, BUILDING_WALL, null),
],
NONE,
);
}
onTick(event, board, cell) {
super.onTick(event, board, cell);
if (this.frameIndex === 0) this.done = true;
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Smash");
}
create(_args) {
return new Smash();
}
tag() {
return "Effects";
}
})();
}
// ── Fade ─────────────────────────────────────────────────────────────────────
/**
* Generic fade effect — takes a symbol and fades it from white to black.
* Used for death animations and teleportation.
* An optional `onComplete` callback is invoked when the animation finishes.
*/
export class Fade extends Effect {
constructor(symbol, onComplete) {
const entity = symbol?.entity ?? "x";
super(
"Fade",
[
Symbol.of(entity, WHITE),
Symbol.of(entity, LESSNEARBLACK),
Symbol.of(entity, BLACK),
],
NONE,
);
this._onComplete = onComplete ?? null;
}
isAboveAgent() {
return true;
}
onTick(event, board, cell) {
super.onTick(event, board, cell);
if (this.frameIndex === 0) {
this.done = true;
// 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.
if (this._onComplete) setTimeout(this._onComplete, 0);
}
}
}
// ── InFlightItem ──────────────────────────────────────────────────────────────
/**
* 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.
*/
export class InFlightItem extends Effect {
constructor(item, direction, originator) {
super(item.name, [item.symbol], NONE);
this.symbol = item.symbol;
this.item = item;
this.direction = direction;
this.originator = originator;
}
get currentSymbol() {
return this.item.getSymbol?.() ?? this.frames[this.frameIndex];
}
}
// ── Registry ──────────────────────────────────────────────────────────────────
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);
}