import { Board, ROWS, COLUMNS } from "./board.js";
import { Player } from "./player.js";
import { GameEvent } from "./event.js";
import { events } from "./events.js";
import { store, serializeBoard, deserializeBoard } from "./store.js";
import { animation } from "./animation.js";
import { loadBoard } from "../scenarios/loader.js";
import { Registry } from "./registry.js";
import { Item } from "./item.js";
import {
PLAYER as PLAYER_FLAG,
TRAVERSABLE,
RANGED_WEAPON,
VERTICAL,
PARALYZED,
TURNED_TO_STONE,
} from "./flags.js";
import {
NORTH,
SOUTH,
EAST,
WEST,
UP,
DOWN,
ADJ_DIRECTIONS,
} from "./direction.js";
import { InFlightItem } from "../pieces/effects/effects.js";
import {
findPathToTarget,
getDirectionToCell,
getDistance,
} from "../pieces/agents/targeting.js";
export const STATE = {
MENU: "menu",
PLAYING: "playing",
ANIMATING: "animating",
GAME_OVER: "game_over",
WON: "won",
};
class Game {
constructor() {
/** @type {Player|null} */
this.player = null;
/** @type {Board|null} */
this.board = null;
/** @type {BoardView|null} */
this.boardView = null;
this.state = STATE.MENU;
/** 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, scenarioURL) {
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));
// 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);
// Place the player
const startCell = board.getCellAt(startX, startY);
if (startCell) {
startCell.setAgent(player);
}
this._transitionTo(STATE.PLAYING);
this._fireFull();
}
/**
* Load a saved game.
* @returns {boolean} true if loaded successfully
*/
async loadGame(slotName) {
const save = await store.load(slotName);
if (!save) return false;
const pd = save.player;
const player = new Player(
pd.name ?? "Player",
pd.scenarioURL ?? "",
pd.boardID ?? "",
pd.startX ?? 0,
pd.startY ?? 0,
);
player.bag.setEventFactory(() => new GameEvent(player, this.board));
// Restore unsaved maps
player.unsavedMaps = new Map(pd.unsavedMaps ?? []);
// Restore player health and status flags
player.health = pd.health ?? 255;
// Restore extra status flags (preserve built-in PLAYER|ORGANIC|NOT_EDITABLE)
const MASK = 0b1111111; // low 7 bits are immutable
player.flags = (player.flags & MASK) | ((pd.flags ?? 0) & ~MASK);
// Restore bag
player.bag._entries.length = 1; // keep EMPTY_HANDED
for (const e of pd.bagEntries ?? []) {
const item = Registry.get(e.key);
if (item instanceof Item) {
for (let i = 0; i < (e.count ?? 1); i++) player.bag.add(item);
}
}
player.bag.setInitialSelection(pd.bagSelectedIndex ?? 0);
// 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);
// 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._transitionTo(STATE.PLAYING);
this._fireFull();
return true;
}
/** @private */
_setContext(player, board) {
this.player = player;
this.board = board;
animation.setContext(board, player);
// why are we doing this?
if (this.boardView) {
this.boardView.detach();
this.boardView.attach(board);
}
animation.start();
animation.onProjectileLanded = (event, _cell, _projectile) => {
this._runAgentTurns(event);
};
}
/** @private */
_transitionTo(newState) {
this.state = newState;
}
/**
* Handle a player action.
* @param {string} action
* @param {Direction} [dir]
*/
async handleInput(action, dir) {
if (this.state !== STATE.PLAYING) return;
switch (action) {
case "move":
if (dir) await this._movePlayer(dir);
break;
case "pickup":
this._pickup();
break;
case "drop":
this._drop(dir);
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(dir);
break;
}
events.fireHandleInventoryMessaging();
}
// ── Movement ────────────────────────────────────────────────────────────────
/** @private */
async _movePlayer(dir) {
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();
if (!fromCell) return;
const event = new GameEvent(player, board);
// Ask current terrain if we may leave
fromCell.terrain?.onExit(event, player, fromCell, dir);
if (event.isCancelled) {
this._showCancelMessage(event);
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)) {
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);
toCell.terrain?.onEnter(enterEvent, player, toCell, dir);
if (enterEvent.isCancelled) {
this._showCancelMessage(enterEvent);
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);
toCell.onSteppedOn(stepEvent, fromCell, player);
// Notify adjacent terrain
this._notifyAdjacent(toCell);
this._runAgentTurns(new GameEvent(player, board));
}
/** @private */
_notifyAdjacent(cell) {
const event = new GameEvent(this.player, this.board);
for (const dir of ADJ_DIRECTIONS) {
const adj = this.board.getAdjacentCell(cell.x, cell.y, dir);
if (adj?.terrain) {
adj.terrain.onAdjacentTo(event, adj);
}
}
}
/** @private */
_notifyNotAdjacent(cell) {
const event = new GameEvent(this.player, this.board);
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);
}
}
}
// ── Board navigation ────────────────────────────────────────────────────────
/** @private */
async _navigateBoard(dir, fromCell) {
const adjacentPath = this.board.getAdjacentBoard(dir);
if (!adjacentPath) return;
const { player, board: oldBoard } = this;
// Serialize current board state
player.unsavedMaps.set(
oldBoard.boardID,
JSON.stringify(serializeBoard(oldBoard)),
);
// Remove player from current board
fromCell.removeAgent(player);
// Load or restore the new board
let newBoard;
const savedJSON = player.unsavedMaps.get(adjacentPath);
if (savedJSON) {
newBoard = new Board();
deserializeBoard(JSON.parse(savedJSON), newBoard);
} else {
const result = await loadBoard(adjacentPath);
newBoard = result.board;
}
// 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;
}
player.boardID = adjacentPath;
this._setContext(player, newBoard);
const entryCell = newBoard.getCellAt(entryX, entryY);
if (entryCell) {
if (entryCell.agent) {
// Fallback: find nearest empty traversable cell
const fallback = newBoard.findRandomCell();
fallback.setAgent(player);
} else {
entryCell.setAgent(player);
}
}
this._fireFull();
}
/**
* Teleport the player to a specific board and position.
* Called by Player.teleport() after the fade animation completes.
* Mirrors Java Game.teleport(): saves current board, loads target, places player.
* @param {string} boardID
* @param {number} x
* @param {number} y
*/
async teleportTo(boardID, x, y) {
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;
// Save current board state
player.unsavedMaps.set(
oldBoard.boardID,
JSON.stringify(serializeBoard(oldBoard)),
);
// Load or restore the target board
let newBoard;
const savedJSON = player.unsavedMaps.get(fullBoardID);
if (savedJSON) {
newBoard = new Board();
deserializeBoard(JSON.parse(savedJSON), newBoard);
} else {
const result = await loadBoard(fullBoardID);
newBoard = result.board;
}
player.boardID = fullBoardID;
player.startX = x;
player.startY = y;
this._setContext(player, newBoard);
const entryCell = newBoard.getCellAt(x, y);
if (entryCell) {
if (entryCell.agent) {
const fallback = newBoard.findRandomCell();
fallback?.setAgent(player);
} else {
entryCell.setAgent(player);
}
}
this._fireFull();
}
/** @private */
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);
cell.terrain.onExit(testEvent, player, cell, dir);
if (!testEvent.isCancelled) {
await this._navigateBoard(dir, cell);
return;
}
}
}
/** @private */
_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').
* @private
* @param {number} index
*/
_pickupAtIndex(index) {
const { player, board } = this;
const cell = board.getCurrentCell();
if (!cell || cell.isBagEmpty) return;
const item = _itemAtDisplayIndex(cell, index);
if (!item) return;
const event = new GameEvent(player, board);
// Let terrain react (e.g., deny pickup on lava)
cell.terrain?.onPickup(event, cell, player, item);
if (event.isCancelled) {
this._showCancelMessage(event);
return;
}
// Weakness check
if (player.enforceWeakness(event, cell, item)) return;
cell.removeItem(item);
player.bag.add(item);
this._runAgentTurns(event);
}
/** @private */
_drop(dir) {
const { player, board } = this;
const cell = board.getCurrentCell();
if (!cell) return;
const item = player.bag.getSelected();
if (!item || item.name === "Empty-handed") return;
// Drop in direction if dir given, else on current cell
let targetCell = cell;
if (dir) {
const adj = board.getAdjacentCell(cell.x, cell.y, dir);
if (adj && adj.terrain?.is?.(TRAVERSABLE)) targetCell = adj;
}
const event = new GameEvent(player, board);
targetCell.terrain?.onDrop(event, targetCell, item);
if (event.isCancelled) {
// Item vanishes into lava etc. — still remove from bag
player.bag.remove(item);
return;
}
player.bag.remove(item);
targetCell.addItem(item);
this._runAgentTurns(event);
}
/** @private */
_useSelected() {
const { player, board } = this;
const cell = board.getCurrentCell();
if (!cell) return;
const item = player.bag.getSelected();
if (!item || item.name === "Empty-handed") return;
const event = new GameEvent(player, board);
item.onUse(event, cell, player);
if (event.isCancelled) {
this._showCancelMessage(event);
return;
}
this._runAgentTurns(event);
}
/** @private */
_throwItem(dir) {
const { player, board } = this;
const cell = board.getCurrentCell();
if (!cell) return;
const item = player.bag.getSelected();
if (!item || item.name === "Empty-handed") return;
player.bag.remove(item);
const projectile = new InFlightItem(item, dir, player);
cell.addEffect(projectile);
}
/** @private */
_fireRanged(dir) {
const { player, board } = this;
if (player.is(PARALYZED) || player.is(TURNED_TO_STONE)) return;
const cell = board.getCurrentCell();
if (!cell) return;
const weapon = player.bag.getSelected();
if (!weapon || !weapon.is(RANGED_WEAPON)) {
events.fireMessage(cell, "You need a ranged weapon equipped.");
return;
}
const event = new GameEvent(player, board);
const ammoItem = weapon.onFire(event);
if (event.isCancelled) {
this._showCancelMessage(event);
return;
}
if (!ammoItem) {
events.fireMessage(cell, "You have no ammunition.");
return;
}
const projectile = new InFlightItem(ammoItem, dir, player);
cell.addEffect(projectile);
}
// ── Combat ──────────────────────────────────────────────────────────────────
/** @private */
_meleeAttack(attackerCell, targetCell, dir) {
const { player, board } = this;
const event = new GameEvent(player, board);
const target = targetCell.agent;
// 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);
}
}
if (event.isCancelled) {
this._showCancelMessage(event);
}
return event;
}
// ── Agent movement ──────────────────────────────────────────────────────────
/**
* Move an agent according to a Targeting specification.
* Called by agent onFrame handlers.
*/
createEvent() {
return new GameEvent(this.player, this.board);
}
agentMove(cell, agent, targeting) {
if (!this.board) return;
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, cell, agent, dir) {
if (!this.board) return;
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;
}
// Check terrain traversability for the agent (mirrors Java onAgentEnter cancel)
if (next.terrain && !next.terrain.canEnter(agent, next, dir)) {
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.onSteppedOn(event, next, next.agent);
}
}
/**
* Fire a projectile from an agent toward the player if within targeting range.
*/
agentShoot(cell, agent, ammo, targeting) {
if (!this.board || !ammo) return;
const playerCell = this.board.getCurrentCell();
if (!playerCell) return;
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).
* @param {GameEvent} event
* @param {Cell} cell
* @param {Piece} originator
* @param {Item} ammo
* @param {Direction} dir
*/
shoot(event, cell, originator, ammo, dir) {
if (!ammo || !dir) return;
const projectile = new InFlightItem(ammo, dir, originator);
cell.terrain?.onFlyOver(event, cell, projectile);
if (!event.isCancelled) {
cell.addEffect(projectile);
}
}
// ── Agent AI turns ──────────────────────────────────────────────────────────
/** @private */
_runAgentTurns(event) {
if (!this.board || !this.player) return;
const board = this.board;
const baseEvent = event ?? new GameEvent(this.player, board);
// Collect all non-player agents
const agentCells = [];
board.visit((cell) => {
if (cell.agent && !cell.agent.is(PLAYER_FLAG)) {
agentCells.push(cell);
}
});
for (const cell of agentCells) {
const agent = cell.agent;
if (!agent) continue;
const agentEvent = new GameEvent(this.player, board);
agent.onTurn(agentEvent, board, cell);
}
}
// ── Save ────────────────────────────────────────────────────────────────────
async save(slotName) {
if (this.player && this.board) {
await store.save(this.player, this.board, slotName);
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────
/** @private */
_showCancelMessage(event) {
const msg = event._cancelMessage;
const cell = event._cancelCell ?? this.board?.getCurrentCell();
if (msg) events.fireMessage(cell, msg);
}
/** @private */
_fireFull() {
if (this.player) {
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').
* @param {Cell} cell
* @param {number} index
* @returns {Item|null}
*/
function _itemAtDisplayIndex(cell, index) {
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;
}