pieces/effects/effects.ts

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
// &emsp; → \u2003, &omega; → \u03C9, &lowast; → \u2217,
// &permil; → \u2030, &#8735; → \u221F, &ang; → \u2220, &there4; → \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);
}