import { Agent } from "./agent.ts";
import { Color, GREEN, RED, NONE } from "./color.ts";
import { events } from "./event-bus.ts";
import { GameEvent } from "./game-event.ts";
import { hasAmmo, MirrorShield, Parabullet, Stoneray } from "../pieces/items/items.ts";
import { Item } from "./item.ts";
import { Sym } from "./sym.ts";
import {
getFlag,
NOT_EDITABLE,
ORGANIC,
PLAYER,
POISONED,
WEAK,
WEAPON,
} from "./flags.ts";
import { Board } from "./board.ts";
import { Cell } from "./cell.ts";
import { Direction } from "./direction.ts";
import { Fade } from "../pieces/effects/effects.ts";
import { game } from "./game.ts";
export const MAX_HEALTH = 255;
class EmptyHanded extends Item {
constructor() {
super("Empty-handed", 0, NONE, Sym.of(" ", NONE));
}
}
/**
* Singleton representing the player wielding nothing.
* Always lives at index 0 in the PlayerBag.
*/
export const EMPTY_HANDED = new EmptyHanded();
function playerColor(outside: boolean, health: number): Color {
return outside
? new Color("", `rgb(${MAX_HEALTH - health},0,0)`, false)
: new Color("", `rgb(${MAX_HEALTH},${health},${health})`, false);
}
function playerBg(player: Player): Color | null {
if (player.isHealing) {
return GREEN;
} else if (player.isDamaged) {
return RED;
}
return null;
}
/**
* 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 extends Sym {
#player: Player;
backgroundColor: Color;
constructor(player: Player) {
super(
"@",
playerColor(false, player.health),
playerBg(player),
playerColor(true, player.health),
playerBg(player),
);
this.#player = player;
this.entity = "@";
this.color = NONE;
this.backgroundColor = NONE;
}
getColor(outside: boolean): Color | null {
const hex = playerColor(outside, this.#player.health);
if (hex === null) {
return null;
}
this.color = hex;
return this.color;
}
getBackground(outside: boolean): Color | null {
const hex = playerBg(this.#player);
if (hex === null) return null;
this.backgroundColor = hex;
return this.backgroundColor;
}
}
type Entry = {
piece: Item;
count: number;
ammo: number;
}
/**
* 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 {
#entries: Entry[];
#createEvent: (() => GameEvent) | undefined;
selectedIndex: number;
constructor() {
this.#entries = [{ piece: EMPTY_HANDED, count: 1, ammo: 0 }];
this.selectedIndex = 0;
}
last() {
const item = this.#entries[this.#entries.length - 1].piece;
if (item instanceof EmptyHanded) {
return null;
}
return item;
}
setEventFactory(fn: () => GameEvent) {
this.#createEvent = fn;
}
createEvent(): GameEvent {
if (typeof this.#createEvent !== "function") {
throw new Error("Event factory not set");
}
return this.#createEvent();
}
/** The currently selected item (or EMPTY_HANDED). */
getSelected() {
return this.#entries[this.selectedIndex].piece;
}
/** True if this entry is the selected one. */
isSelected(entry: Entry) {
return this.#entries[this.selectedIndex] === entry;
}
selectUp() {
this.#changeIndex(-1);
}
selectDown() {
this.#changeIndex(1);
}
selectEmptyHanded() {
this.#changeSelection(0);
}
select(index: number) {
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(item: 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);
}
addAt(index: number, item: 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);
}
remove(item: 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);
}
contains(item: Item) {
return this.findEntry(item) != null;
}
/**
* Returns the first item matching predicate fn, or null.
*/
find(fn: (i: Item) => boolean) {
for (const entry of this.#entries) {
if (fn(entry.piece)) return entry.piece;
}
return null;
}
getByName(name: string): Item | null {
for (const entry of this.#entries) {
if (entry.piece.name === name) return entry.piece;
}
return null;
}
getCount(item: Item) {
const entry = this.findEntry(item);
return entry ? entry.count : 0;
}
size() {
return this.#entries.length;
}
get entries(): Entry[] {
return this.#entries;
}
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.
*/
exchange(item: Item): Item | null {
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).
*/
setInitialSelection(index: number) {
this.selectedIndex = Math.max(
0,
Math.min(index, this.#entries.length - 1),
);
}
findEntry(piece: Item) {
return this.#entries.find((e) => e.piece === piece) ?? null;
}
#getIndex(piece: Item) {
return this.#entries.findIndex((e) => e.piece === piece);
}
#changeIndex(delta: number) {
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: number) {
if (newIndex < 0 || newIndex >= this.#entries.length) return;
if (typeof this.#createEvent !== "function") {
return;
}
const event = this.#createEvent();
const cell = event.board?.getCurrentCell()
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: 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 (hasAmmo(item)) {
// 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 (hasAmmo(e.piece) && 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: 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 (!hasAmmo(item)) {
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 {
#healCountDown: number;
#damageCountDown: number;
bag: PlayerBag;
scenarioURL: string;
boardID: string;
health: number;
startX: number;
startY: number;
unsavedMaps: Map<any, any>;
constructor(name: string, scenarioURL: string, boardID: string, startX: number, startY: number) {
// 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, Sym.of("@", NONE));
this.symbol = new PlayerSymbol(this);
this.health = MAX_HEALTH;
this.bag = new PlayerBag();
this.scenarioURL = scenarioURL;
this.boardID = boardID;
this.startX = startX;
this.startY = startY;
this.unsavedMaps = new Map();
this.#damageCountDown = 0;
this.#healCountDown = 0;
}
get isHealing(): boolean {
return this.#healCountDown > 0;
}
get isDamaged(): boolean {
return this.#damageCountDown > 0;
}
add(flag: number) {
this.flags |= flag;
events.fireFlagsChanged(this);
}
remove(flag: number) {
this.flags &= ~flag;
events.fireFlagsChanged(this);
}
setHealth(h: number) {
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.
*/
changeHealth(delta: number): number {
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;
const cell = game.board?.getCurrentCell();
cell.board.notifyCellChange(cell);
} else if (this.health > old) {
this.#healCountDown = 2;
const cell = game.board?.getCurrentCell();
cell.board.notifyCellChange(cell);
}
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() {
return true;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
const item = this.bag.getSelected();
item.onHit(event, agentLoc, agent);
}
onHitBy(event: GameEvent, _agentLoc: Cell, _agent: Agent, _dir: Direction) {
event.cancel();
}
onHitByItem(event: GameEvent, itemLoc: Cell, item: Item, dir: Direction) {
const selected = this.bag.getSelected();
if (
(item instanceof Parabullet || item instanceof Stoneray) &&
selected instanceof MirrorShield
) {
game.shoot(event, itemLoc, this, item, dir.reverse);
}
}
/**
* Called each animation tick. Counts down the damage/heal flash and
* triggers a re-render when they expire.
*/
onFrame(cell: Cell) {
if (this.#damageCountDown > 0 && --this.#damageCountDown === 0) {
cell.board.notifyCellChange(cell);
}
if (this.#healCountDown > 0 && --this.#healCountDown === 0) {
cell.board.notifyCellChange(cell);
}
}
/**
* Test a resistance flag with a 15% chance of losing it permanently.
*/
testResistance(resistance: number) {
if (this.is(resistance)) {
if (Math.random() < 0.15) {
this.remove(resistance);
}
return true;
}
return false;
}
cureWeakness(event: GameEvent, healer: Item) {
if (this.not(WEAK)) {
event.cancel("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: GameEvent, healer: Item) {
if (this.not(POISONED)) {
event.cancel("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.
*/
heal(event: GameEvent, healer: Item, amount: number, board: Board) {
const cell = board.getCurrentCell();
if (this.is(POISONED)) {
event.cancel("You can't heal, you're poisoned.", cell);
} else if (this.health === MAX_HEALTH) {
event.cancel("You're at full health already; let's skip it.", cell);
} else {
const old = this.health;
this.changeHealth(-amount); // negative delta = heal
const gained = this.health - old;
events.fireMessage(`Healed ${gained} points.`, cell);
this.bag.remove(healer);
}
}
/**
* If the player is WEAK, enforce the single-item inventory limit.
*/
enforceWeakness(event: GameEvent, loc: Cell, item: Item): boolean {
if (this.not(WEAK) || this.bag.size() <= 1) return false;
event.cancel("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.
*/
weaken(cell: 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("You are weak; you can only hold one item at a time", cell);
}
}
/**
* Check if a token matches a flag label or an item in the player's bag.
* Used to evaluate scenario trigger conditions.
*/
matchesFlagOrItem(token: string | number): boolean {
if (typeof token === "number") {
return this.is(token);
}
return this.bag.getByName(token) != null || this.is(getFlag(token));
}
}