core/animation.js

import { GameEvent } from "./event.js";
import { InFlightItem } from "../pieces/effects/effects.js";
import { ROWS, COLUMNS } from "./board.js";
import {
  PLAYER as PLAYER_FLAG,
  AMMUNITION,
  PENETRABLE as PENETRABLE_FLAG,
} from "./flags.js";

const TARGET_FPS = 10;
const FRAME_MS = 1000 / TARGET_FPS;

/**
 * AnimationManager — drives all visual effects at ~6 fps using rAF.
 *
 * Each tick:
 *   1. Calls onTick() on every active effect
 *   2. Advances InFlightItem projectiles one cell in their direction
 *   3. Removes expired effects
 *   4. Calls player.onFrame(cell) so damage/heal flashes fade
 *
 * The board and player references are set by the Game controller just before
 * the game loop starts via setContext().
 */
export class AnimationManager {
  constructor() {
    /** @type {Board|null} */
    this._board = null;
    /** @type {Player|null} */
    this._player = null;
    this._running = false;
    this._rafId = null;
    this._lastTime = 0;
    /** Called by game.js after each tick that moves an InFlightItem. */
    this.onProjectileLanded = null;
  }

  /**
   * Inject game context. Called by the Game controller before start().
   * @param {Board} board
   * @param {Player} player
   */
  setContext(board, player) {
    this._board = board;
    this._player = player;
  }

  start() {
    if (this._running) return;
    this._running = true;
    this._lastTime = performance.now();
    this._rafId = requestAnimationFrame(this._loop.bind(this));
  }

  stop() {
    this._running = false;
    if (this._rafId !== null) {
      cancelAnimationFrame(this._rafId);
      this._rafId = null;
    }
  }

  /** @private */
  _loop(now) {
    if (!this._running) return;
    this._rafId = requestAnimationFrame(this._loop.bind(this));

    if (now - this._lastTime < FRAME_MS) return;
    this._lastTime = now;

    this._tick();
  }

  /** @private */
  _tick() {
    const board = this._board;
    const player = this._player;
    if (!board || !player) return;

    const event = new GameEvent(player, board);

    // Advance player flash counters
    const playerCell = board.getCurrentCell();
    player.onFrame(playerCell);

    // Collect projectiles and regular effects separately (snapshot prevents
    // projectiles moving East/South being double-advanced in the same tick)
    const projectiles = [];
    for (let y = 0; y < ROWS; y++) {
      for (let x = 0; x < COLUMNS; x++) {
        const cell = board.cells[x][y];
        if (cell.hasNoEffects) continue;

        for (const effect of [...cell.effects]) {
          if (effect instanceof InFlightItem) {
            projectiles.push({ cell, effect });
          } else {
            effect.onTick(event, board, cell);
            // Notify the renderer that this cell's appearance may have changed
            // (mirrors Java's fireRerender called from within onFrame).
            // removeEffect also notifies, but intermediate frames need it too.
            if (effect.isExpired) {
              cell.removeEffect(effect);
            } else {
              board._notifyCellChange(cell);
            }
          }
        }
      }
    }

    // Advance each projectile exactly once
    for (const { cell, effect } of projectiles) {
      this._advanceProjectile(event, cell, effect);
    }

    // Dispatch onFrame to animated agents and terrain via AnimationProxy list.
    // Walking backwards mirrors Java: animations can remove themselves (e.g.
    // an agent dies mid-tick) without corrupting the forward iteration.
    const animated = board._animated;
    for (let i = animated.length - 1; i >= 0; i--) {
      animated[i].tick(event, board);
    }
  }

  /**
   * Move an in-flight item one cell in its direction of travel.
   * If the item hits a wall, agent, or penetrable terrain boundary it lands
   * and the onProjectileLanded callback is fired.
   * @private
   */
  _advanceProjectile(event, cell, projectile) {
    const board = this._board;
    const dir = projectile.direction;
    const nextCell = board.getAdjacentCell(cell.x, cell.y, dir);

    cell.removeEffect(projectile);

    if (!nextCell) {
      // Off-board — item lands here
      this._landProjectile(event, cell, projectile);
      return;
    }

    // Check if terrain blocks the projectile
    const flyEvent = new GameEvent(event.player, board);
    nextCell.terrain?.onFlyOver(flyEvent, nextCell, projectile);

    if (flyEvent.isCancelled) {
      this._landProjectile(event, cell, projectile);
      return;
    }

    // Check if agent in next cell intercepts the projectile
    // PENETRABLE agents (e.g. Fence) do not stop projectiles
    if (nextCell.agent && !nextCell.agent.is(PENETRABLE_FLAG)) {
      const agent = nextCell.agent;
      agent.onHitByItem(event, nextCell, projectile.item, dir);
      // Call item.onHit when a player-originated projectile hits an agent,
      // or when an agent projectile hits the player (mirrors Java InFlightItem logic)
      const isPlayerOriginator = projectile.originator?.is?.(PLAYER_FLAG);
      const isPlayerTarget = agent.is(PLAYER_FLAG);
      if (
        agent !== projectile.originator &&
        (isPlayerOriginator || isPlayerTarget)
      ) {
        projectile.item.onHit?.(event, nextCell, agent);
      }
      // Remove any agents killed by the projectile
      if (event.kills) {
        for (const { cell, agent: killed } of event.kills) {
          killed.onDie?.(event, cell);
          cell.removeAgent(killed);
        }
      }
      // Land the projectile at the cell it was in before hitting the agent.
      // Mirrors Java InFlightItem: drop(e, cell, item) when event is cancelled
      // by AbstractAgent.onHitBy, which triggers onThrowEnd (e.g. Grenade).
      this._landProjectile(event, cell, projectile);
      return;
    }

    // Keep flying
    nextCell.addEffect(projectile);
  }

  /** @private */
  _landProjectile(event, cell, projectile) {
    if (projectile.item.is(AMMUNITION)) {
      // Ammunition disappears on landing (matches Java Game.drop() behaviour).
      projectile.item.onThrowEnd?.(event, cell);
    } else {
      // Non-ammunition items (e.g. Grenade): call onThrowEnd first; if it
      // cancels the event (e.g. an explosion), don't place the item on the
      // floor. Mirrors Java Game.drop() for non-ammo items.
      const landEvent = new GameEvent(event.player, this._board);
      projectile.item.onThrowEnd?.(landEvent, cell);
      if (!landEvent.isCancelled) {
        cell.addItem(projectile.item);
      }
    }
    if (this.onProjectileLanded)
      this.onProjectileLanded(event, cell, projectile);
  }
}

export const animation = new AnimationManager();