import { Board, ROWS, COLUMNS } from "./board.ts";
import { Player } from "./player.ts";
import { GameEvent } from "./game-event.ts";
import { events } from "./event-bus.ts";
import { store, serializeBoard, deserializeBoard, deserializePlayer } from "./store.ts";
import { animationManager } from "./animation-manager.ts";
import { loadBoard } from "../persistence/loader.ts";
import { Registry } from "./registry.ts";
import { Item } from "./item.ts";
import {
PLAYER as PLAYER_FLAG,
RANGED_WEAPON,
VERTICAL,
PARALYZED,
TURNED_TO_STONE,
} from "./flags.ts";
import {
NORTH,
SOUTH,
EAST,
WEST,
UP,
DOWN,
NONE,
ADJ_DIRECTIONS,
Direction,
} from "./direction.ts";
import { InFlightItem, Hit, Fade } from "../pieces/effects/effects.ts";
import {
findPathToTarget,
getDirectionToCell,
getDistance,
Targeting,
} from "../pieces/agents/targeting.ts";
import { randomColor } from "./color.ts";
import { BoardView } from "../ui/board-view.js";
import { Cell } from "./cell.ts";
import { Agent } from "./agent.ts";
import { Piece } from "./piece.ts";
import type { SaveSlot } from "./save-format.ts";
export const STATE = {
MENU: "menu",
PLAYING: "playing",
ANIMATING: "animating",
GAME_OVER: "game_over",
WON: "won",
};
export class Game {
player!: Player;
board!: Board;
boardView!: BoardView;
state: string = STATE.MENU;
#agentCells: Cell[];
onGameOver: Function | null;
onWon: ((s: string) => void) | null;
constructor() {
this.#agentCells = [];
/** Fired when the game transitions to GAME_OVER. */
this.onGameOver = null;
/** Fired when the game transitions to WON. */
this.onWon = null;
// Listen for player death
events.onPlayerChanged((player) => {
if (this.state === STATE.PLAYING && player.health <= 0) {
this.transitionTo(STATE.GAME_OVER);
if (this.onGameOver) this.onGameOver();
}
});
// Handle pit-trap fall-through: navigate to the board below (DOWN direction)
events.onFallThrough((x, y) => {
if (this.state === STATE.PLAYING) {
const cell = this.board.getCellAt(x, y);
if (cell) this.navigateBoard(DOWN, cell);
}
});
}
/**
* Start a brand-new game.
* @param {string} boardPath e.g. "tutorial/start"
* @param {string} scenarioURL base URL for scenario assets
*/
async newGame(boardPath: string, scenarioURL: string) {
const { board, startX, startY, startInv } = await loadBoard(boardPath);
const player = new Player("Player", scenarioURL, boardPath, startX, startY);
player.bag.setEventFactory(() => new GameEvent(player, this.board, this));
// Grant starting inventory
for (const key of startInv) {
const item = Registry.get(key);
if (item instanceof Item) {
player.bag.add(item);
} else {
console.warn(`could not find ${key} to add to player inventory`);
}
}
this.setContext(player, board);
// Transition before landing: a modal message fired by #fireLandEnter
// snapshots game.state to restore on dismiss, so PLAYING must already
// be current or the state gets reset to MENU when the player dismisses it.
this.transitionTo(STATE.PLAYING);
// Place the player
const startCell = board.getCellAt(startX, startY);
if (startCell) {
startCell.setAgent(player);
this.#fireLandEnter(startCell);
}
this.fireFull();
}
/**
* Load a saved game.
* @returns {boolean} true if loaded successfully
*/
async loadGame(slotName: string): Promise<boolean> {
const save = await store.load(slotName) as SaveSlot | null;
if (!save) return false;
const pd = save.player;
const player = new Player(
pd.name,
pd.scenarioURL,
pd.boardID,
pd.startX,
pd.startY,
);
player.bag.setEventFactory(() => new GameEvent(player, this.board, this));
deserializePlayer(pd, player);
// Load board from unsavedMaps (already visited state)
const boardID = pd.boardID;
let board;
const savedBoardJSON = player.unsavedMaps.get(boardID);
if (savedBoardJSON) {
board = new Board();
deserializeBoard(JSON.parse(savedBoardJSON), board);
} else {
// Fall back to fresh load if state not in unsavedMaps
const result = await loadBoard(boardID);
board = result.board;
}
this.setContext(player, board);
// Transition before landing — see the comment in newGame().
this.transitionTo(STATE.PLAYING);
// Re-place player at their last position
const px = pd.playerX ?? board.startX;
const py = pd.playerY ?? board.startY;
const cell = board.getCellAt(px, py);
if (cell) {
cell.setAgent(player);
this.#fireLandEnter(cell);
}
this.fireFull();
return true;
}
setContext(player: Player, board: Board) {
this.player = player;
this.board = board;
animationManager.setContext(board, player, this);
// why are we doing this?
if (this.boardView) {
this.boardView.detach();
this.boardView.attach(board);
}
animationManager.start();
}
/**
* End the game: flash the background (if hasWon), then call onWon / onGameOver.
* Mirrors Java's Game.gameOver(url, hasWon).
*/
gameOver(url: string, hasWon: boolean) {
this.transitionTo(hasWon ? STATE.WON : STATE.GAME_OVER);
animationManager.stop();
const fullUrl = this.player.scenarioURL + url;
if (!hasWon) {
if (this.onGameOver) this.onGameOver(fullUrl);
return;
}
// Flash the background ~20 times at 100 ms, then invoke onWon.
let flashes = 0;
const body = document.body;
const originalBg = body.style.backgroundColor;
const timer = setInterval(() => {
body.style.backgroundColor = randomColor().toString();
if (++flashes >= 20) {
clearInterval(timer);
body.style.backgroundColor = originalBg;
if (this.onWon) this.onWon(fullUrl);
}
}, 100);
}
transitionTo(newState: string) {
this.state = newState;
}
#showLoadingOverlay() {
if (this.boardView && typeof document !== "undefined") {
const el = document.getElementById("loading-overlay");
if (el) {
el.classList.add("visible");
}
}
}
#hideLoadingOverlay() {
if (typeof document !== "undefined") {
const el = document.getElementById("loading-overlay");
if (el) {
el.classList.remove("visible");
}
}
}
/**
* Handle a player action.
*/
async handleInput(action: string, dirOrNum: Direction | number | null) {
if (this.state !== STATE.PLAYING) return;
if (this.player.is(PARALYZED) || this.player.is(TURNED_TO_STONE)) return;
const dir = (typeof dirOrNum === "number") ? null : dirOrNum;
const index = (typeof dirOrNum === "number") ? dirOrNum : -1;
switch (action) {
case "move":
if (dir) await this.movePlayer(dir);
break;
case "pickup":
this.pickup();
break;
case "drop":
this.drop();
break;
case "use":
this.useSelected();
break;
case "throw":
if (dir) this.throwItem(dir);
break;
case "fire":
if (dir) this.fireRanged(dir);
break;
case "vertical":
await this.verticalMove();
break;
case "cycle-up":
this.player.bag.selectUp();
break;
case "cycle-down":
this.player.bag.selectDown();
break;
case "select-weapon":
this.player.bag.selectFirstWeapon();
break;
case "select-empty":
this.player.bag.selectEmptyHanded();
break;
case "reorder-up":
this.player.bag.moveSelectedUp();
break;
case "reorder-down":
this.player.bag.moveSelectedDown();
break;
case "pickup-index":
this.pickupAtIndex(index);
break;
case "bag-select":
this.player.bag.select(index);
break;
}
events.fireHandleInventoryMessaging();
}
// ── Movement ────────────────────────────────────────────────────────────────
async movePlayer(dir: Direction) {
const { player, board } = this;
// Block movement while the player is paralyzed or turned to stone.
if (player.is(PARALYZED) || player.is(TURNED_TO_STONE)) return;
const fromCell = board.getCurrentCell();
const event = new GameEvent(player, board, this);
// Ask current terrain if we may leave
fromCell.terrain.onExit(event, player, fromCell, dir);
if (event.isCancelled) return;
const toCell = board.getAdjacentCell(fromCell.x, fromCell.y, dir);
if (!toCell) {
// Player walked off the board edge — try to navigate to adjacent board
await this.navigateBoard(dir, fromCell);
return;
}
// Melee: walk into a non-player agent
if (toCell.agent && !toCell.agent.is(PLAYER_FLAG)) {
// Let target terrain respond before the melee attack (mirrors Java move()).
// This ensures terrain like ForceField can strip the player's inventory
// even when they are pushing a boulder into it.
const enterEvent = new GameEvent(player, board, this);
toCell.terrain.onEnter(enterEvent, player, toCell, dir);
if (enterEvent.isCancelled) return;
const meleeEvent = this.meleeAttack(fromCell, toCell, dir);
// If the agent was pushed out (cell now empty) and the event wasn't
// cancelled, move the player into the vacated cell (mirrors Java move()).
if (
!meleeEvent.isCancelled &&
!toCell.agent &&
toCell.canEnter(fromCell, player, dir)
) {
fromCell.moveAgentTo(toCell, player);
this.notifyAdjacent(toCell);
}
this.runAgentTurns(event);
return;
}
// Let target terrain respond (open door, bounce message, etc.)
const enterEvent = new GameEvent(player, board, this);
toCell.terrain.onEnter(enterEvent, player, toCell, dir);
if (enterEvent.isCancelled) return;
// Physical traversability check
if (!toCell.canEnter(fromCell, player, dir)) {
return;
}
// Notify terrain that is no longer adjacent
this.notifyNotAdjacent(fromCell);
// Move
fromCell.moveAgentTo(toCell, player);
// Notify stepped-on items
const stepEvent = new GameEvent(player, board, this);
toCell.onSteppedOn(stepEvent, fromCell, player);
// Notify adjacent terrain
this.notifyAdjacent(toCell);
this.runAgentTurns(new GameEvent(player, board, this));
}
notifyAdjacent(cell: Cell) {
const event = new GameEvent(this.player, this.board, this);
if (cell.terrain) cell.terrain.onAdjacentTo(event, cell);
for (const dir of ADJ_DIRECTIONS) {
const adj = this.board.getAdjacentCell(cell.x, cell.y, dir);
if (adj?.terrain) {
adj.terrain.onAdjacentTo(event, adj);
}
}
}
notifyNotAdjacent(cell: Cell) {
const event = new GameEvent(this.player, this.board, this);
if (cell.terrain) cell.terrain.onNotAdjacentTo(event, cell);
for (const dir of ADJ_DIRECTIONS) {
const adj = this.board.getAdjacentCell(cell.x, cell.y, dir);
if (adj?.terrain) {
adj.terrain.onNotAdjacentTo(event, adj);
}
}
}
async #loadOrRestoreBoard(boardID: string) {
const savedJSON = this.player.unsavedMaps.get(boardID);
if (savedJSON) {
const board = new Board();
deserializeBoard(JSON.parse(savedJSON), board);
return board;
}
this.#showLoadingOverlay();
try {
const { board } = await loadBoard(boardID);
return board;
} finally {
this.#hideLoadingOverlay();
}
}
#switchBoard(newBoardID: string, newBoard: Board, entryX: number, entryY: number) {
this.player.boardID = newBoardID;
this.setContext(this.player, newBoard);
const entryCell = newBoard.getCellAt(entryX, entryY);
if (entryCell) {
if (entryCell.agent) {
const fallback = newBoard.findRandomCell();
fallback.setAgent(this.player);
this.#fireLandEnter(fallback);
} else {
entryCell.setAgent(this.player);
this.#fireLandEnter(entryCell);
}
}
this.fireFull();
}
/**
* Fire the landing cell's onEnter, mirroring Java Game.land(): terrain that
* reacts on entry (e.g. a welcome TriggerOnce) must fire even when the
* player is placed directly — new game, loaded save, board switch — rather
* than walking in. There is no originating direction, so Direction.NONE
* stands in for Java's null.
*/
#fireLandEnter(cell: Cell) {
const event = new GameEvent(this.player, this.board, this);
cell.terrain.onEnter(event, this.player, cell, NONE);
}
async navigateBoard(dir: Direction, fromCell: Cell) {
const adjacentPath = this.board.getAdjacentBoard(dir);
if (!adjacentPath) return;
const { player, board: oldBoard } = this;
player.unsavedMaps.set(
oldBoard.boardID,
JSON.stringify(serializeBoard(oldBoard)),
);
fromCell.removeAgent(player);
const newBoard = await this.#loadOrRestoreBoard(adjacentPath);
// Determine entry position
let entryX, entryY;
if (dir === UP || dir === DOWN) {
// Stairs: land at the same (x,y) on the destination board — Java convention.
// The map designer places matching stairs at the same coordinates on each floor.
entryX = fromCell.x;
entryY = fromCell.y;
} else if (dir === NORTH) {
entryX = fromCell.x;
entryY = ROWS - 1;
} else if (dir === SOUTH) {
entryX = fromCell.x;
entryY = 0;
} else if (dir === EAST) {
entryX = 0;
entryY = fromCell.y;
} else if (dir === WEST) {
entryX = COLUMNS - 1;
entryY = fromCell.y;
} else {
entryX = newBoard.startX;
entryY = newBoard.startY;
}
this.#switchBoard(adjacentPath, newBoard, entryX, entryY);
}
/**
* Teleport the player to a specific board and position.
* Called by Game.teleport() after the fade animation completes.
* Mirrors Java Game.teleport(): saves current board, loads target, places player.
*/
async teleportTo(boardID: string, x: number, y: number) {
const { player, board: oldBoard } = this;
// Resolve relative boardID using the current board's directory, falling
// back to board.folder when the boardID has no directory component.
const boardDir = oldBoard.boardID.includes("/")
? oldBoard.boardID.split("/").slice(0, -1).join("/")
: (oldBoard.folder ?? "");
const fullBoardID = boardDir ? `${boardDir}/${boardID}` : boardID;
player.unsavedMaps.set(
oldBoard.boardID,
JSON.stringify(serializeBoard(oldBoard)),
);
const newBoard = await this.#loadOrRestoreBoard(fullBoardID);
player.startX = x;
player.startY = y;
this.#switchBoard(fullBoardID, newBoard, x, y);
}
async verticalMove() {
const cell = this.board.getCurrentCell();
if (!cell.terrain.is(VERTICAL)) return;
const { player, board } = this;
// Determine direction: try UP first (stair exit cancels wrong dir)
for (const dir of [UP, DOWN]) {
const adjPath = board.getAdjacentBoard(dir);
if (!adjPath) continue;
// Check if terrain allows exit in this direction
const testEvent = new GameEvent(player, board, this);
cell.terrain.onExit(testEvent, player, cell, dir);
if (!testEvent.isCancelled) {
await this.navigateBoard(dir, cell);
return;
}
}
}
pickup() {
this.pickupAtIndex(0);
}
/**
* Pick up the item at the given display-list index on the current cell.
* Index 0 = first item in the grouped display list (same as pressing 'p').
*/
pickupAtIndex(index: number) {
const { player, board } = this;
const cell = board.getCurrentCell();
if (cell.isBagEmpty) return;
const item = _itemAtDisplayIndex(cell, index);
if (!item) return;
const event = new GameEvent(player, board, this);
// Let terrain react (e.g., deny pickup on lava)
cell.terrain.onPickup(event, cell, player, item);
if (event.isCancelled) return;
// Weakness check
if (player.enforceWeakness(event, cell, item)) return;
cell.removeItem(item);
player.bag.add(item);
this.runAgentTurns(event);
}
drop() {
const { player, board } = this;
const cell = board.getCurrentCell();
const item = player.bag.getSelected();
if (!item || item.name === "Empty-handed") return;
const event = new GameEvent(player, board, this);
item.onDrop(event, cell);
if (event.isCancelled) return;
cell.terrain.onDrop(event, cell, item);
if (event.isCancelled) {
// Item vanishes into lava etc. — still remove from bag
player.bag.remove(item);
return;
}
player.bag.remove(item);
cell.addItem(item);
this.runAgentTurns(event);
}
useSelected() {
const { player, board } = this;
const cell = board.getCurrentCell();
const item = player.bag.getSelected();
if (!item || item.name === "Empty-handed") return;
const event = new GameEvent(player, board, this);
item.onUse(event);
if (event.isCancelled) return;
this.runAgentTurns(event);
}
throwItem(dir: Direction) {
const { player, board } = this;
const cell = board.getCurrentCell();
const item = player.bag.getSelected();
if (!item || item.name === "Empty-handed") return;
const event = new GameEvent(player, board, this);
item.onThrow(event, cell);
if (event.isCancelled) return;
player.bag.remove(item);
const projectile = new InFlightItem(item, dir, player);
cell.addEffect(projectile);
}
fireRanged(dir: Direction) {
const { player, board } = this;
if (player.is(PARALYZED) || player.is(TURNED_TO_STONE)) return;
const cell = board.getCurrentCell();
const weapon = player.bag.getSelected();
if (!weapon || !weapon.is(RANGED_WEAPON)) {
events.fireMessage("You need a ranged weapon equipped.", cell);
return;
}
const event = new GameEvent(player, board, this);
const ammoItem = weapon.onFire(event);
if (event.isCancelled) return;
if (!ammoItem) {
events.fireMessage("You have no ammunition.", cell);
return;
}
const projectile = new InFlightItem(ammoItem, dir, player);
cell.addEffect(projectile);
}
meleeAttack(attackerCell: Cell, targetCell: Cell, dir: Direction) {
const { player, board } = this;
const event = new GameEvent(player, board, this);
const target = targetCell.agent;
if (target) {
// Player hits target
player.onHit(event, attackerCell, targetCell, target);
// Target reacts to being hit — pass attackerCell (Java convention: agentLoc = attacker's cell)
target.onHitBy(event, attackerCell, player, dir);
}
// Remove any agents killed by the attack
if (event.kills) {
for (const { cell, agent } of event.kills) {
agent.onDie(event, cell);
cell.removeAgent(agent);
cell.addEffect(new Hit(agent));
}
}
return event;
}
/**
* Move an agent according to a Targeting specification.
* Called by agent onFrame handlers.
*/
createEvent() {
return new GameEvent(this.player, this.board, this);
}
agentMove(cell: Cell, agent: Agent, targeting: Targeting) {
const dir = findPathToTarget(this.board, cell, agent, targeting);
if (dir != null) {
const event = this.createEvent();
this.agentMoveInDirection(event, cell, agent, dir);
}
}
/**
* Execute an agent move in a specific direction (for RollingBoulder, Pusher, Tumbleweed).
*/
agentMoveInDirection(event: GameEvent, cell: Cell, agent: Agent, dir: Direction) {
const next = this.board.getAdjacentCell(cell.x, cell.y, dir);
cell.terrain.onAgentExit(event, agent, cell, dir);
if (event.isCancelled) return;
if (!next) {
event.cancel();
return;
}
next.terrain.onAgentEnter(event, agent, next, dir);
if (event.isCancelled) return;
const nextAgent = next.agent;
if (nextAgent != null) {
if (nextAgent.is(PLAYER_FLAG)) {
agent.onHit(event, cell, next, nextAgent);
}
nextAgent.onHitBy(event, cell, agent, dir);
if (event.isCancelled) return;
}
const moving = cell.agent;
if (moving) {
cell.moveAgentTo(next, moving);
if (!next.isBagEmpty && next.agent) {
next.onSteppedOn(event, next, next.agent);
}
}
}
/**
* Fire a projectile from an agent toward the player if within targeting range.
*/
agentShoot(cell: Cell, agent: Agent, ammo: Item, targeting: Targeting) {
const playerCell = this.board.getCurrentCell();
const d = getDistance(cell, playerCell);
if (d > targeting.range) return;
const dir = getDirectionToCell(cell, playerCell);
if (!dir) return;
const event = this.createEvent();
this.shoot(event, cell, agent, ammo, dir);
}
/**
* Fire a projectile from a cell in a specific direction (used by terrain
* like Shooter, and internally by agentShoot).
*/
shoot(event: GameEvent, cell: Cell, originator: Piece, ammo: Item, dir: Direction) {
const projectile = new InFlightItem(ammo, dir, originator);
cell.terrain.onFlyOver(event, cell, projectile);
if (!event.isCancelled) {
cell.addEffect(projectile);
}
}
runAgentTurns(event: GameEvent) {
const board = this.board;
// Collect all non-player agents
this.#agentCells.length = 0;
board.visit((cell: Cell, n: number) => {
if (cell.agent && !cell.agent.is(PLAYER_FLAG)) {
this.#agentCells.push(cell);
}
return true;
});
for (const cell of this.#agentCells) {
const agent = cell.agent;
if (!agent) continue;
const agentEvent = new GameEvent(this.player, board, this);
agent.onTurn(agentEvent, board, cell);
}
}
/**
* Called by Teleporter (and similar terrain) when the player steps on it.
* Mirrors Java Player.teleport(): cancels the enter event, removes the player
* from the current cell, plays a Fade animation on the adjacent cell in the
* movement direction, then calls Game.teleportTo() when the fade completes.
*/
teleport(event: GameEvent, dir: Direction, boardID: string, x: number, y: number) {
event.cancel();
const currentCell = event.board.getCurrentCell();
currentCell.removeAgent(event.player);
const adjCell =
event.board.getAdjacentCell(currentCell.x, currentCell.y, dir) ??
currentCell;
const fade = new Fade(event.player.symbol, () => game.teleportTo(boardID, x, y));
adjCell.addEffect(fade);
event.board.notifyCellChange(currentCell);
event.board.notifyCellChange(adjCell);
}
async save(slotName: string) {
if (this.player && this.board) {
await store.save(this.player, this.board, slotName);
}
}
fireFull() {
events.firePlayerChanged(this.player);
events.fireInventoryChanged(this.player.bag);
events.fireFlagsChanged(this.player);
if (this.boardView) {
this.boardView.rerender?.();
}
}
}
export const game = new Game();
/**
* Return the item at visual display index `index` in the grouped item list for
* the given cell. Groups items by name (matching popup display order).
* Index 0 = first item shown in the popup (pressing '1' or 'p').
*/
function _itemAtDisplayIndex(cell: Cell, index: number): Item | null {
const seen = new Set();
let count = 0;
for (const item of cell.items) {
if (!seen.has(item.name)) {
if (count === index) return item;
seen.add(item.name);
count++;
}
}
return null;
}