pieces/effects/effects.js

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