import { Agent } from "./agent.js";
import { Item } from "./item.js";
import { GameEvent } from "./event.js";
import { events } from "./events.js";
import { GREEN, RED, NONE } from "./color.js";
import { Symbol } from "./symbol.js";
import { MirrorShield, Parabullet, Stoneray } from "../pieces/items/items.js";
import {
PLAYER,
NOT_EDITABLE,
ORGANIC,
WEAK,
POISONED,
WEAPON,
getFlag,
} from "./flags.js";
// game is imported lazily to avoid circular dep issues at module init time
import { game } from "./game.js";
import { Fade } from "../pieces/effects/effects.js";
export const MAX_HEALTH = 255;
// ── EmptyHanded (the "no item" sentinel) ─────────────────────────────────────
class EmptyHanded extends Item {
constructor() {
super("Empty-handed", 0, NONE, Symbol.of(" ", NONE));
}
}
/**
* Singleton representing the player wielding nothing.
* Always lives at index 0 in the PlayerBag.
*/
export const EMPTY_HANDED = new EmptyHanded();
// ── PlayerSymbol ──────────────────────────────────────────────────────────────
/**
* A live symbol whose color reflects the player's current health and
* damage/heal animation state.
*
* Inside (underground):
* fg = rgb(255, health, health) — white at full health, red when dying
* Outside (above ground):
* fg = rgb(255-health, 0, 0) — black at full health, red when dying
* Background:
* GREEN during heal flash, RED during damage flash, otherwise none
*/
class PlayerSymbol {
/** @param {Player} player */
constructor(player) {
this._player = player;
this.entity = "@";
}
/** @param {boolean} outside */
getColor(outside) {
const h = this._player.health;
if (outside) {
return `rgb(${MAX_HEALTH - h},0,0)`;
}
return `rgb(${MAX_HEALTH},${h},${h})`;
}
/** @param {boolean} _outside */
getBackground(_outside) {
if (this._player._healCountDown > 0) return GREEN;
if (this._player._damageCountDown > 0) return RED;
return null;
}
}
// ── PlayerBag ─────────────────────────────────────────────────────────────────
/**
* The player's inventory bag. Always contains EMPTY_HANDED at index 0.
* One entry is "selected" — the currently wielded item.
*
* Entries are {piece, count, ammo} objects (not Piece subclasses).
*/
export class PlayerBag {
constructor() {
/** @type {Array<{piece: Item, count: number, ammo: number}>} */
this._entries = [{ piece: EMPTY_HANDED, count: 1, ammo: 0 }];
this._selectedIndex = 0;
/**
* Injected by the Game controller after creation.
* Should return a GameEvent for the current turn.
* @type {function(): GameEvent}
*/
this._createEvent = () => new GameEvent(null, null);
}
last() {
const item = this._entries[this._entries.length - 1].piece;
if (item instanceof EmptyHanded) {
return null;
}
return item;
}
/** @param {function(): GameEvent} fn */
setEventFactory(fn) {
this._createEvent = fn;
}
// ── Selection ─────────────────────────────────────────────────────────────
/** The currently selected item (or EMPTY_HANDED). */
getSelected() {
return this._entries[this._selectedIndex].piece;
}
/** True if this entry is the selected one. */
isSelected(entry) {
return this._entries[this._selectedIndex] === entry;
}
selectUp() {
this._changeIndex(-1);
}
selectDown() {
this._changeIndex(1);
}
selectEmptyHanded() {
this._changeSelection(0);
}
select(index) {
this._changeSelection(index);
}
selectFirstWeapon() {
for (let i = 1; i < this._entries.length; i++) {
if (this._entries[i].piece.is(WEAPON)) {
this._changeSelection(i);
break;
}
}
}
// ── Add / remove ──────────────────────────────────────────────────────────
/** @param {Item} item */
add(item) {
const entry = this._findEntry(item);
if (entry) {
entry.count++;
} else {
this._entries.push({ piece: item, count: 1, ammo: 0 });
}
this._combineAmmoIfNecessary(item);
events.fireInventoryChanged(this);
}
/** @param {Item} item */
addAt(index, item) {
const entry = this._findEntry(item);
if (entry) {
entry.count++;
} else {
this._entries.splice(index, 0, { piece: item, count: 1, ammo: 0 });
if (index <= this._selectedIndex) this._selectedIndex++;
}
events.fireInventoryChanged(this);
}
/** @param {Item} item */
remove(item) {
if (item === EMPTY_HANDED) return;
const entry = this._findEntry(item);
if (!entry) return;
if (entry === this._entries[this._selectedIndex] && entry.count <= 1) {
this.selectUp();
}
this._splitAmmoIfNecessary(item);
entry.count--;
if (entry.count <= 0) {
const i = this._entries.indexOf(entry);
this._entries.splice(i, 1);
if (this._selectedIndex >= this._entries.length) {
this._selectedIndex = this._entries.length - 1;
}
}
events.fireInventoryChanged(this);
}
// ── Query ─────────────────────────────────────────────────────────────────
/** @param {Item} item */
contains(item) {
return this._findEntry(item) != null;
}
/**
* Returns the first item matching predicate fn, or null.
* @param {function(Item): boolean} fn
*/
find(fn) {
for (const entry of this._entries) {
if (fn(entry.piece)) return entry.piece;
}
return null;
}
/** @param {string} name item name or type name */
getByName(name) {
for (const entry of this._entries) {
if (entry.piece.name === name) return entry.piece;
}
return null;
}
getCount(item) {
const entry = this._findEntry(item);
return entry ? entry.count : 0;
}
size() {
return this._entries.length;
}
/** All entries (read-only view). @returns {Array} */
get entries() {
return this._entries;
}
// ── Reordering ────────────────────────────────────────────────────────────
moveSelectedUp() {
const sel = this._entries[this._selectedIndex];
if (sel.piece === EMPTY_HANDED) return;
let i = this._selectedIndex;
this._entries.splice(i, 1);
i = i - 1 < 1 ? this._entries.length : i - 1;
this._entries.splice(i, 0, sel);
this._selectedIndex = i;
events.fireInventoryChanged(this);
}
moveSelectedDown() {
const sel = this._entries[this._selectedIndex];
if (sel.piece === EMPTY_HANDED) return;
let i = this._selectedIndex;
this._entries.splice(i, 1);
i = i + 1 > this._entries.length ? 1 : i + 1;
this._entries.splice(i, 0, sel);
this._selectedIndex = i;
events.fireInventoryChanged(this);
}
/**
* Exchange the current selection for a new item.
* @param {Item} item
* @returns {Item|null} the item given up
*/
exchange(item) {
const sel = this._entries[this._selectedIndex];
if (sel.piece === EMPTY_HANDED) return null;
const old = sel.piece;
this.remove(old);
this.add(item);
this._changeSelection(this._getIndex(item));
return old;
}
/**
* Quietly set the selection index (during deserialization, no events).
* @param {number} index
*/
setInitialSelection(index) {
this._selectedIndex = Math.max(
0,
Math.min(index, this._entries.length - 1),
);
}
// ── Private ───────────────────────────────────────────────────────────────
_findEntry(piece) {
return this._entries.find((e) => e.piece === piece) ?? null;
}
_getIndex(piece) {
return this._entries.findIndex((e) => e.piece === piece);
}
_changeIndex(delta) {
let i = this._selectedIndex + delta;
if (i < 0) i = this._entries.length - 1;
else if (i >= this._entries.length) i = 0;
this._changeSelection(i);
}
_changeSelection(newIndex) {
if (newIndex < 0 || newIndex >= this._entries.length) return;
const event = this._createEvent();
const cell = event.board?.getCurrentCell() ?? null;
const oldItem = this._entries[this._selectedIndex].piece;
oldItem.onDeselect(event, cell);
if (event.isCancelled) return;
const newEntry = this._entries[newIndex];
newEntry.piece.onSelect(event, cell);
if (event.isCancelled) return;
this._selectedIndex = newIndex;
events.fireInventoryChanged(this);
}
_combineAmmoIfNecessary(item) {
// Duck-typed ConsumesAmmo: weapon has getAmmoType()
// Duck-typed ProvidesAmmo: ammo item is AMMUNITION and some weapon in bag has getAmmoType() === item
let weapon = null;
let ammo = null;
if (typeof item.getAmmoType === "function") {
// item is the weapon
weapon = item;
ammo = item.getAmmoType();
} else {
// item might be ammo for a weapon already in the bag
for (const e of this._entries) {
if (
typeof e.piece.getAmmoType === "function" &&
e.piece.getAmmoType() === item
) {
weapon = e.piece;
ammo = item;
break;
}
}
}
if (weapon === null || ammo === null) return;
const weaponEntry = this._findEntry(weapon);
const ammoEntry = this._findEntry(ammo);
if (weaponEntry === null || ammoEntry === null) return;
// Transfer all ammo counts into the weapon entry and remove the ammo item.
weaponEntry.ammo += ammoEntry.count;
ammoEntry.count = 0;
const i = this._entries.indexOf(ammoEntry);
if (i !== -1) this._entries.splice(i, 1);
if (this._selectedIndex >= this._entries.length) {
this._selectedIndex = this._entries.length - 1;
}
}
_splitAmmoIfNecessary(item) {
// When the last copy of a ConsumesAmmo weapon is removed and it has loaded
// ammo, split that ammo back into individual items at the same position.
if (typeof item.getAmmoType !== "function") return;
const weaponEntry = this._findEntry(item);
if (weaponEntry === null) return;
if (weaponEntry.count !== 1 || weaponEntry.ammo <= 0) return;
const ammoItem = item.getAmmoType();
const index = this._entries.indexOf(weaponEntry);
const ammoCount = weaponEntry.ammo;
weaponEntry.ammo = 0;
// Insert ammo items directly without triggering another combine cycle.
for (let i = 0; i < ammoCount; i++) {
const existing = this._findEntry(ammoItem);
if (existing) {
existing.count++;
} else {
this._entries.splice(index, 0, { piece: ammoItem, count: 1, ammo: 0 });
}
}
}
}
/**
* The player character. Extends Agent to participate in the normal
* piece/cell/board model, but carries extra state (health, inventory,
* navigation context).
*
* changeHealth(delta):
* positive delta = damage (health decreases)
* negative delta = heal (health increases)
* returns current health after the change
*/
export class Player extends Agent {
/**
* @param {string} name
* @param {string} scenarioURL base URL of the current scenario
* @param {string} boardID path stem of the current board
* @param {number} startX default entry column
* @param {number} startY default entry row
*/
constructor(name, scenarioURL, boardID, startX, startY) {
// Pass a placeholder symbol to satisfy Piece's validation, then replace
// it with the live PlayerSymbol that closes over this player instance.
super(name, NOT_EDITABLE | ORGANIC | PLAYER, NONE, Symbol.of("@", NONE));
this.symbol = new PlayerSymbol(this);
this.health = MAX_HEALTH;
this.bag = new PlayerBag();
this.scenarioURL = scenarioURL ?? "";
this.boardID = boardID ?? "";
this.startX = startX ?? -1;
this.startY = startY ?? -1;
this.unsavedMaps = new Map();
this._damageCountDown = 0;
this._healCountDown = 0;
}
add(flag) {
this.flags |= flag;
events.fireFlagsChanged(this);
}
remove(flag) {
this.flags &= ~flag;
events.fireFlagsChanged(this);
}
setHealth(h) {
this.health = h;
events.firePlayerChanged(this);
}
/**
* Apply a health delta. Positive = damage, negative = heal.
* Returns current health. If health reaches 0, the game-over callback fires.
* @param {number} delta
* @returns {number} current health
*/
changeHealth(delta) {
if (delta === 0) return this.health;
const old = this.health;
this.health = Math.max(0, Math.min(MAX_HEALTH, this.health - delta));
if (this.health < old) {
this._damageCountDown = 2;
} else if (this.health > old) {
this._healCountDown = 2;
}
events.firePlayerChanged(this);
if (this.health <= 0) {
// Game-over is handled by the Game controller listening to playerChanged
// and checking health === 0.
}
return this.health;
}
canEnter(_direction, _from, _to) {
return true;
}
onHit(event, attackerLoc, agentLoc, agent) {
const item = this.bag.getSelected();
item.onHit(event, agentLoc, agent);
}
onHitBy(event, _agentLoc, _agent, _dir) {
event.cancel();
}
onHitByItem(event, itemLoc, item, dir) {
const selected = this.bag.getSelected();
if (
(item instanceof Parabullet || item instanceof Stoneray) &&
selected instanceof MirrorShield
) {
game.shoot(event, itemLoc, this, item, dir.reverse);
}
}
/**
* 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, dir, boardID, x, y) {
event.cancel();
const currentCell = event.board?.getCurrentCell?.();
if (!currentCell) return;
currentCell.removeAgent(this);
const adjCell =
event.board.getAdjacentCell(currentCell.x, currentCell.y, dir) ??
currentCell;
const fade = new Fade(this.symbol, () => game.teleportTo(boardID, x, y));
adjCell.addEffect(fade);
events.fireCellChanged(currentCell);
events.fireCellChanged(adjCell);
}
// ── Animation frame ───────────────────────────────────────────────────────
/**
* Called each animation tick. Counts down the damage/heal flash and
* triggers a re-render when they expire.
* @param {Cell} cell
*/
onFrame(cell) {
if (this._damageCountDown > 0 && --this._damageCountDown === 0) {
cell.board._notifyCellChange(cell);
}
if (this._healCountDown > 0 && --this._healCountDown === 0) {
cell.board._notifyCellChange(cell);
}
}
// ── Resistances ───────────────────────────────────────────────────────────
/**
* Test a resistance flag with a 15% chance of losing it permanently.
* @param {number} resistance flag constant (e.g. FIRE_RESISTANT)
* @returns {boolean}
*/
testResistance(resistance) {
if (this.is(resistance)) {
if (Math.random() < 0.15) {
this.remove(resistance);
}
return true;
}
return false;
}
// ── Inventory helpers ─────────────────────────────────────────────────────
cureWeakness(event, healer) {
if (this.not(WEAK)) {
event.cancelWithMessage("You feel pretty good right now, let's save it");
} else {
this.remove(WEAK);
events.fireModalMessage("Your energy is restored.");
this.bag.remove(healer);
}
}
curePoison(event, healer) {
if (this.not(POISONED)) {
event.cancelWithMessage("If you were poisoned, this would have cured it");
} else {
this.remove(POISONED);
events.fireModalMessage("The sick feeling goes away. ");
this.bag.remove(healer);
}
}
/**
* Heal the player by amount. Consumes healer item if successful.
* @param {GameEvent} event
* @param {Item} healer
* @param {number} amount positive = healing
* @param {Board} board
*/
heal(event, healer, amount, board) {
if (this.is(POISONED)) {
event.cancelWithMessage("You can't heal, you're poisoned.");
} else if (this.health === MAX_HEALTH) {
event.cancelWithMessage("You're at full health already; let's skip it.");
} else {
const old = this.health;
this.changeHealth(-amount); // negative delta = heal
const gained = this.health - old;
events.fireMessage(board.getCurrentCell(), `Healed ${gained} points.`);
this.bag.remove(healer);
}
}
/**
* If the player is WEAK, enforce the single-item inventory limit.
* @param {GameEvent} event
* @param {Cell} loc
* @param {Item} item
* @returns {boolean} true if weakness enforcement fired
*/
enforceWeakness(event, loc, item) {
if (this.not(WEAK) || this.bag.size() <= 1) return false;
event.cancelWithMessage(
"You're weak, you can only hold one thing at a time",
);
const wasEmpty = this.bag.getSelected() === EMPTY_HANDED;
if (wasEmpty) this.bag.select(1);
const oldItem = this.bag.exchange(item);
loc.removeItem(item);
if (oldItem) loc.addItem(oldItem);
if (wasEmpty) this.bag.selectEmptyHanded();
return true;
}
/**
* Apply the WEAK flag and drop all but the selected item.
* @param {Cell} cell
*/
weaken(cell) {
if (this.not(WEAK)) {
this.add(WEAK);
const toRemove = this.bag.entries
.filter((e) => !this.bag.isSelected(e) && e.piece !== EMPTY_HANDED)
.slice();
for (const entry of toRemove) {
for (let i = 0; i < entry.count; i++) {
this.bag.remove(entry.piece);
cell.addItem(entry.piece);
}
}
events.fireMessage(
cell,
"You are weak; you can only hold one item at a time",
);
}
}
/**
* Check if a token matches a flag label or an item in the player's bag.
* Used to evaluate scenario trigger conditions.
* @param {string} token
* @returns {boolean}
*/
matchesFlagOrItem(token) {
return (
this.bag.getByName(token) != null || this.is(this._flagFromToken(token))
);
}
/** @private */
_flagFromToken(token) {
return getFlag(token) ?? 0;
}
}