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();