import { Piece } from "../../core/piece.ts";
import { Agent } from "../../core/agent.ts";
import { AgentProxy } from "../../core/agent-proxy.ts";
import { Registry } from "../../core/registry.ts";
import { game } from "../../core/game.ts";
import { events } from "../../core/event-bus.ts";
import { EMPTY_HANDED, Player } from "../../core/player.ts";
import { Targeting, findPathInDirection, getRandomDirection } from "./targeting.ts";
import { TerrainUtils } from "../../core/terrain-utils.ts";
import { Animated, isAnimated } from "../../core/animated.ts";
import {
ORGANIC,
PENETRABLE,
ETHEREAL,
FLIER,
CARNIVORE,
PUSHABLE,
FIRE_RESISTANT,
LAVITIC,
AQUATIC,
PLAYER,
WEAK,
NOT_EDITABLE,
PARALYZED,
THEFT_RESISTANT,
AMMUNITION,
PARALYSIS_RESISTANT,
STONING_RESISTANT,
TURNED_TO_STONE,
} from "../../core/flags.ts";
import {
BLACK,
BLUE,
BROWN,
BUILDING_FLOOR,
BUILDING_WALL,
CYAN,
Color,
DARKBLUE,
DARKGOLDENROD,
DARKGREEN,
DARKOLIVEGREEN,
DARKORANGE,
DARKORCHID,
DARKSEAGREEN,
DARKSLATEBLUE,
DARKVIOLET,
FIREBRICK,
GOLD,
GREENYELLOW,
LESSNEARBLACK,
LIGHTSTEELBLUE,
MAROON,
MEDIUMSEAGREEN,
MIDNIGHTBLUE,
NONE,
OLIVEDRAB,
ORANGE,
ORCHID,
POWDERBLUE,
RED,
SADDLEBROWN,
SALMON,
SEAGREEN,
SILVER,
SLATEBLUE,
VIOLET,
WHITE,
YELLOW,
colorByName,
} from "../../core/color.ts";
import { Sym } from "../../core/sym.ts";
import { TypeOnlySerializer, BaseSerializer, Serializer } from "../../core/serializer.ts";
import { State, stateFromString } from "../../core/state.ts";
import {
directionByName,
ADJ_DIRECTIONS,
NORTH,
SOUTH,
EAST,
WEST,
Direction,
} from "../../core/direction.ts";
import { Cell } from "../../core/cell.ts";
import { GameEvent } from "../../core/game-event.ts";
import { Agentray, Arrow, Fireball, Parabullet, PoisonDart, Stoneray, Weakray } from "../items/items.ts";
import { Item } from "../../core/item.ts";
import { Hit } from "../effects/effects.ts";
function colorSerializer(typeId: string, Cls: new (color: Color) => Piece): Serializer {
return new (class extends BaseSerializer {
create([color]: Array<string | Piece>) {
return new Cls(colorByName(color as string) ?? NONE);
}
store(a: Piece) {
return `${typeId}|${(a as any).color.name}`;
}
example() {
return new Cls(NONE);
}
template() {
return `${typeId}|{color}`;
}
tag() {
return "Agents";
}
})();
}
const INDESTRUCTIBLE = -500;
const CTBH = {
ASCIIROTH: -20,
CEPHALID: 0,
CORVID: 25,
FARTHAPOD: 15,
HOOLOOVOO: -20,
KILLER_BEE: 5,
LAVA_WORM: 25,
LIGHTNING_LIZARD: 25,
OPTILISK: 25,
RHINDLE: 15,
SLEESTAK: 15,
TETRITE: 25,
THERMADON: 25,
TRIFFID: 15,
};
const DAMAGE = {
ASCIIROTH: 50,
BOULDER: 30,
CORVID: 5,
FARTHAPOD: 5,
GREAT_OLD_ONE: 130,
HOOLOOVOO: 20,
KILLER_BEE: 10,
LAVA_WORM: 20,
LIGHTNING_LIZARD: 3,
OPTILISK: 40,
PUSHER: 3,
RHINDLE: 30,
SLEESTAK: 30,
TETRITE: 10,
THERMADON: 20,
TRIFFID: 20,
};
/**
* Base for all game agents. Implements the health-as-hit-chance mechanic:
* `chanceToHit` is a baseline %; weapons add their damage value. If the
* resulting roll succeeds the agent is destroyed (returns 0).
*
* INDESTRUCTIBLE agents have chanceToHit = -500 (virtually impossible to kill).
*/
export class AbstractAgent extends Agent {
chanceToHit: number;
constructor(name: string, flags: number, color: Color, chanceToHit: number, symbol: Sym) {
super(name, flags, color, symbol);
this.chanceToHit = chanceToHit;
}
changeHealth(delta: number): number {
const test = this.chanceToHit + delta;
return Math.random() * 100 <= test ? 0 : 1;
}
onDie(event: GameEvent, cell: Cell) {
// Fire this agent's color event on death (mirrors AbstractAgent.onDie)
if (this.color !== NONE) {
event.board.fireColorEvent(event, this.color, cell);
}
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
if (agent.is(PLAYER) && this.is(PUSHABLE)) {
if (agent.is(WEAK)) {
event.cancel("You're too weak to push anything");
} else {
// agentLoc = attacker's cell (Java convention); our cell = agentLoc + dir
const myCell = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir)!;
game.agentMoveInDirection(event, myCell, this, dir);
}
} else if (
agent instanceof RollingBoulder &&
!(this instanceof ImmobileAgent)
) {
const myCell = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir)!;
game.agentMoveInDirection(event, myCell, this, dir);
if (event.isCancelled) {
// Trapped between the rolling boulder and an obstacle: crushed.
// Mirrors Game.damage(cell, this, 500), which is changeHealth(500)
// followed by die() (onDie + removeAgent + Hit effect) on death.
if (this.changeHealth(500) === 0) {
this.onDie(event, myCell);
myCell.removeAgent(this);
myCell.addEffect(new Hit(this));
}
}
} else {
event.cancel();
}
}
}
/** An agent that cannot be moved or destroyed. */
export class ImmobileAgent extends AbstractAgent {
constructor(name: string, flags: number, symbol: Sym) {
super(name, flags, NONE, INDESTRUCTIBLE, symbol);
}
changeHealth(value: number): number {
return 100;
}
}
export class Tree extends ImmobileAgent {
constructor(name: string, symbol: Sym) {
super(name, ORGANIC, symbol);
}
}
export class AbstractBoulder extends AbstractAgent {
constructor(name: string, flags: number, color: Color, chanceToHit: number, symbol: Sym) {
super(name, flags, color, chanceToHit, symbol);
}
}
export class Boulder extends AbstractBoulder {
constructor() {
super(
"Boulder",
PUSHABLE,
NONE,
INDESTRUCTIBLE,
Sym.of("O", DARKGOLDENROD),
);
}
changeHealth(v: number): number {
return 100;
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Boulder");
}
create() {
return new Boulder();
}
tag() {
return "Room Features";
}
})();
}
export class RollingBoulder extends AbstractBoulder implements Animated {
direction: Direction;
state: State;
constructor(direction: Direction, color: Color, state: State) {
super("Rolling Boulder", 0, color, INDESTRUCTIBLE, Sym.of("O", BROWN));
this.direction = direction;
this.state = state;
}
randomSeed(): boolean {
return true;
}
onHit(event: GameEvent, boulderCell: Cell, playerCell: Cell, player: Player) {
// Deal rolling damage; if it kills the player stop here.
if (player.changeHealth(DAMAGE.BOULDER) === 0) return;
// Push the player one step further in the rolling direction if possible.
const beyond = playerCell.getAdjacentCell(this.direction);
if (beyond && beyond.canEnter(playerCell, player, this.direction)) {
game.movePlayer(this.direction);
} else {
// Player is trapped between boulder and obstacle — crush damage.
player.changeHealth(500);
}
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (this.state.isOn() && frame % 5 === 0) {
const event = game.createEvent();
game.agentMoveInDirection(event, cell, this, this.direction);
}
}
onColorEvent(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color && this.state.isOff()) {
cell.removeAgent(this);
const next = Registry.agent(
`RollingBoulder|${this.direction.name}|${this.color.name}|on`,
);
cell.setAgent(next);
}
}
changeHealth(v: number): number {
return 100;
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir, color, state]: string[]) {
return new RollingBoulder(
directionByName(dir) ?? EAST,
colorByName(color) ?? NONE,
stateFromString(state),
);
}
store(b: RollingBoulder) {
return `RollingBoulder|${b.direction.name}|${b.color.name}|${b.state}`;
}
example() {
return new RollingBoulder(EAST, NONE, stateFromString("off"));
}
template() {
return "RollingBoulder|{direction}|{color}|{state}";
}
tag() {
return "Room Features";
}
})();
}
export class Pusher extends AbstractAgent implements Animated {
static SYMBOLS = {
[NORTH.name]: "▲",
[SOUTH.name]: "▼",
[EAST.name]: "►",
[WEST.name]: "◄",
};
direction: Direction;
state: State;
constructor(direction: Direction, color: Color, state: State) {
const glyph = Pusher.SYMBOLS[direction.name];
super(
"Pushable",
0,
color,
INDESTRUCTIBLE,
Sym.of(glyph, LIGHTSTEELBLUE, null, MIDNIGHTBLUE, null),
);
this.direction = direction;
this.state = state;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.PUSHER);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (this.state.isOn() && frame % 6 === 0) {
const event = game.createEvent();
game.agentMoveInDirection(event, cell, this, this.direction);
}
}
onColorEvent(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color && this.state.isOff()) {
cell.removeAgent(this);
const next = Registry.agent(`Pusher|${this.direction.name}|${this.color.name}|on`);
cell.setAgent(next);
}
}
randomSeed() {
return false;
}
changeHealth(v: number): number {
return 100;
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir, color, state]: string[]) {
return new Pusher(
directionByName(dir) ?? NORTH,
colorByName(color) ?? NONE,
stateFromString(state),
);
}
store(p: Pusher) {
return `Pusher|${p.direction.name}|${p.color.name}|${p.state}`;
}
example() {
return new Pusher(NORTH, NONE, stateFromString("off"));
}
template() {
return "Pusher|{direction}|{color}|{state}";
}
tag() {
return "Room Features";
}
})();
}
export class Slider extends AbstractAgent {
direction: Direction;
constructor(direction: Direction) {
const glyph = direction === NORTH ? "↕" : "↔";
super(
"Slider",
PUSHABLE,
NONE,
INDESTRUCTIBLE,
Sym.of(glyph, VIOLET, null, MAROON, null),
);
this.direction = direction;
}
changeHealth(v: number): number {
return 100;
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
if (
(this.direction.isNorthSouth() && dir.isNorthSouth()) ||
(this.direction.isEastWest() && dir.isEastWest())
) {
// agentLoc = attacker's cell (Java convention); our cell = agentLoc + dir
const myCell = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir)!;
game.agentMoveInDirection(event, myCell, this, dir);
} else {
event.cancel();
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([dir]: string[]) {
return new Slider(directionByName(dir) ?? NORTH);
}
store(s: Slider) {
return `Slider|${s.direction.name}`;
}
example() {
return new Slider(NORTH);
}
template() {
return "Slider|{direction}";
}
tag() {
return "Room Features";
}
})();
}
/** A campfire. It blocks movement but projectiles can be thrown across it. */
export class Campfire extends AbstractAgent implements Animated {
static SYMBOLS = [
Sym.of("ω", RED),
Sym.of("ω", ORANGE),
Sym.of("ω", RED),
Sym.of("ω", YELLOW),
];
constructor() {
super(
"Campfire",
PENETRABLE | ETHEREAL,
NONE,
INDESTRUCTIBLE,
Sym.of("ω", RED),
);
}
randomSeed(): boolean {
return true;
}
changeHealth(v: number): number {
return 100;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 4 === 0) {
cell.animAgentSymbol = Campfire.SYMBOLS[Math.floor(frame / 4) % 4];
cell.board.notifyCellChange(cell);
}
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Campfire");
}
create() {
return new Campfire();
}
tag() {
return "Room Features";
}
})();
}
export class Pillar extends ImmobileAgent {
constructor() {
super(
"Pillar",
0,
Sym.of("¶", LESSNEARBLACK, null, BUILDING_WALL, BUILDING_FLOOR),
);
}
static SERIALIZER = new (class extends TypeOnlySerializer {
constructor() {
super("Pillar");
}
create() {
return new Pillar();
}
tag() {
return "Room Features";
}
})();
}
/**
* A boss piece. It can only be killed with Terminus Est, and although this
* alone is not too difficult, it is accompanied by attendants who can be
* deadly, and is sure to weaken the character via Weakray clouds. Asciiroth
* runs away and keeps its distance.
*
* Asciiroth should be placed on the board with "attendant" agents of the
* same color. When Asciiroth dies (or a color event of its own color
* fires), it shoots an Agentray that replaces whatever it hits with a
* fresh copy of its attendant agent.
*/
export class Asciiroth extends AbstractAgent implements Animated {
static SYMBOLS = [
Sym.of("א", CYAN),
Sym.of("א", DARKSEAGREEN),
Sym.of("א", GREENYELLOW),
];
#movTargeting: Targeting;
#fireTargeting: Targeting;
#weakray: Weakray;
agentray: Agentray;
constructor(color: Color, agent: Agent) {
super(
"Asciiroth",
PARALYSIS_RESISTANT | STONING_RESISTANT | FIRE_RESISTANT,
color,
CTBH.ASCIIROTH,
Asciiroth.SYMBOLS[0],
);
this.#movTargeting = new Targeting()
.attackPlayer(25)
.keepDistance(6)
.moveRandomly();
this.#fireTargeting = new Targeting().attackPlayer(20);
this.#weakray = Registry.item("Weakray") as Weakray;
this.agentray = new Agentray(agent);
}
randomSeed(): boolean {
return true;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.ASCIIROTH);
}
onDie(event: GameEvent, cell: Cell) {
this.#shootAgentRay(event, cell);
super.onDie(event, cell);
}
onColorEvent(event: GameEvent, color: Color, cell: Cell) {
if (color === this.color) {
this.#shootAgentRay(event, cell);
}
}
#shootAgentRay(event: GameEvent, cell: Cell) {
// If an agentray bullet is stopped by a wall adjoining Asciiroth, no
// creature is created. To prevent this, keep picking random directions
// until one is viable.
let dir: Direction | null = null;
let next: Cell | null = null;
while (dir == null || next == null || !next.canEnter(cell, this, dir, false)) {
dir = getRandomDirection();
next = cell.getAdjacentCell(dir);
}
const e = game.createEvent();
game.shoot(e, cell, this, this.agentray, dir);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 4 === 0) {
game.agentMove(cell, this, this.#movTargeting);
} else if (frame % 11 === 0) {
game.agentShoot(cell, this, this.#weakray, this.#fireTargeting);
} else {
cell.animAgentSymbol = Asciiroth.SYMBOLS[frame % 3];
cell.board.notifyCellChange(cell);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([color, agentArg]: string[]) {
const agent = Registry.agent(agentArg);
return new Asciiroth(colorByName(color as string) ?? NONE, agent);
}
store(a: Asciiroth) {
return `Asciiroth|${a.color.name}|${this.esc(a.agentray.agent)}`;
}
example() {
return new Asciiroth(NONE, new LightningLizard(NONE));
}
template() {
return "Asciiroth|{color}|{agent}";
}
tag() {
return "Agents";
}
})();
}
export class Cephalid extends AbstractAgent implements Animated {
#movTargeting: Targeting;
#shootTargeting: Targeting;
constructor(color: Color) {
super(
"Cephalid",
ORGANIC,
color,
CTBH.CEPHALID,
Sym.of("€", ORCHID, null, DARKORCHID, null),
);
this.#movTargeting = new Targeting()
.attackPlayer(14)
.moveRandomly()
.trackPlayer();
this.#shootTargeting = new Targeting().attackPlayer(14);
}
randomSeed(): boolean {
return true;
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
if (agent.is(ORGANIC) && !(agent instanceof AgentProxy)) {
if (agent.is(PLAYER)) {
const player = event.player;
if (
player.testResistance(PARALYSIS_RESISTANT) ||
player.testResistance(STONING_RESISTANT)
) {
return;
}
player.add(TURNED_TO_STONE);
}
agentLoc.setAgent(new Statue(agent, this.color));
}
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 5 === 0) game.agentMove(cell, this, this.#movTargeting);
else if (frame % 7 === 0) {
if (game.player?.bag.getSelected().name !== "The Mirror Shield") {
game.agentShoot(cell, this, Registry.item("Stoneray"), this.#shootTargeting);
}
}
}
static SERIALIZER = colorSerializer("Cephalid", Cephalid);
}
export class Corvid extends AbstractAgent implements Animated {
static SYMBOLS = [
Sym.of("∧", POWDERBLUE, null, MIDNIGHTBLUE, null),
Sym.of("∨", POWDERBLUE, null, MIDNIGHTBLUE, null),
];
static STEALING = new Targeting()
.attackPlayer(12)
.moveRandomly()
.dodgeBullets(60);
static RUNNING = new Targeting().fleePlayer(12).dodgeBullets(60);
item: Item | null;
constructor(color: Color, item: Item | null = null) {
super("Corvid", FLIER | ORGANIC, color, CTBH.CORVID, Corvid.SYMBOLS[0]);
this.item = item;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
const player = event.player;
const held = player.bag.getSelected();
if (held !== EMPTY_HANDED) {
if (player.testResistance(THEFT_RESISTANT)) {
return;
}
const e = game.createEvent();
const cell = e.board.getCurrentCell();
held.onDeselect(e, cell);
if (e.isCancelled) {
return;
}
player.bag.remove(held);
events.fireModalMessage(
`The corvid snatches the ${held.name} from your hands!`,
);
attackerLoc.removeAgent(this);
attackerLoc.setAgent(new Corvid(this.color, held));
} else {
event.player.changeHealth(DAMAGE.CORVID);
}
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
randomSeed(): boolean {
return true;
}
onDie(event: GameEvent, cell: Cell) {
if (this.item) {
cell.addItem(this.item);
}
super.onDie(event, cell);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
cell.animAgentSymbol = Corvid.SYMBOLS[frame % 2];
cell.board.notifyCellChange(cell);
if (frame % 3 === 0) {
game.agentMove(
cell,
this,
this.item ? Corvid.RUNNING : Corvid.STEALING,
);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([color, itemArg]: string[]) {
const item = typeof itemArg === "string" ? Registry.item(itemArg) : itemArg;
return new Corvid(colorByName(color) ?? NONE, item);
}
store(c: Corvid) {
return c.item
? `Corvid|${c.color.name}|${this.esc(c.item)}`
: `Corvid|${c.color.name}`;
}
example() {
return new Corvid(NONE, null);
}
template() {
return "Corvid|{color}|{item?}";
}
tag() {
return "Agents";
}
})();
}
export class Farthapod extends AbstractAgent implements Animated {
#targeting: Targeting;
constructor(color: Color) {
super(
"Farthapod",
CARNIVORE | ORGANIC,
color,
CTBH.FARTHAPOD,
Sym.of("¤", SILVER, null, DARKSLATEBLUE, null),
);
this.#targeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(7)
.moveRandomly()
.trackPlayer();
}
randomSeed(): boolean {
return true;
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.FARTHAPOD);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 5 === 0) {
game.agentMove(cell, this, this.#targeting);
}
}
static SERIALIZER = colorSerializer("Farthapod", Farthapod);
}
export class GreatOldOne extends AbstractAgent implements Animated {
static SYMBOLS = [
Sym.of("Ⲝ", WHITE, null, BLACK, null),
Sym.of("ξ", WHITE, null, BLACK, null)
];
#targeting: Targeting;
constructor(color: Color) {
super(
"Great Old One",
0,
color,
INDESTRUCTIBLE,
GreatOldOne.SYMBOLS[0],
);
this.#targeting = new Targeting()
.attackPlayer(12)
.moveRandomly()
.trackPlayer();
}
randomSeed(): boolean {
return true;
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.GREAT_OLD_ONE);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 3 === 0) {
cell.animAgentSymbol = GreatOldOne.SYMBOLS[frame % 2];
cell.board.notifyCellChange(cell);
}
if (frame % 5 === 0) {
game.agentMove(cell, this, this.#targeting);
}
}
static SERIALIZER = colorSerializer("GreatOldOne", GreatOldOne);
}
export class Hooloovoo extends AbstractAgent implements Animated {
#targeting: Targeting;
constructor(color: Color) {
super(
"Hooloovoo",
ORGANIC,
color,
CTBH.HOOLOOVOO,
Sym.of("H", SLATEBLUE, DARKSLATEBLUE),
);
this.#targeting = new Targeting().attackPlayer(8).moveRandomly();
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
const player = event.player;
if (player.bag.size() > 1) {
if (player.testResistance(THEFT_RESISTANT)) {
return;
}
while (player.bag.size() > 1) {
// given bag size, there's an item
const item = (player.bag.last() as any) as Item;
const e = game.createEvent();
item.onDeselect(e, e.board.getCurrentCell());
if (e.isCancelled) {
continue;
}
player.bag.remove(item);
const cell = event.board.findRandomCell();
if (cell) {
cell.addItem(item);
}
}
events.fireModalMessage(
"The Hooloovoo teleports your stuff all over the place",
);
} else {
agent.changeHealth(DAMAGE.HOOLOOVOO);
}
}
onHitByItem(event: GameEvent, itemLoc: Cell, item: Item, dir: Direction) {
// Ammunition bounces off a Hooloovoo and becomes dangerous to the player
if (item.is(AMMUNITION)) {
game.shoot(event, itemLoc, this, item, dir.reverse);
event.cancel();
}
}
randomSeed(): boolean {
return true;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 6 === 0) {
game.agentMove(cell, this, this.#targeting);
}
}
static SERIALIZER = colorSerializer("Hooloovoo", Hooloovoo);
}
export class KillerBee extends AbstractAgent implements Animated {
#targeting: Targeting;
constructor(color: Color) {
super(
"Killer Bee",
FLIER | ORGANIC,
color,
CTBH.KILLER_BEE,
Sym.of("a", GOLD),
);
this.#targeting = new Targeting().attackPlayer(7).moveRandomly();
}
randomSeed(): boolean {
return true;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.KILLER_BEE);
// "a small sting, and they die afterward" — mirrors Game.die(attackerLoc, this)
this.onDie(event, attackerLoc);
attackerLoc.removeAgent(this);
attackerLoc.addEffect(new Hit(this));
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 3 === 0) {
game.agentMove(cell, this, this.#targeting);
}
}
static SERIALIZER = colorSerializer("KillerBee", KillerBee);
}
export class LavaWorm extends AbstractAgent implements Animated {
#targeting: Targeting;
constructor(color: Color) {
super(
"Lava Worm",
LAVITIC | CARNIVORE | FIRE_RESISTANT,
color,
CTBH.LAVA_WORM,
Sym.of("z", RED),
);
this.#targeting = new Targeting()
.attackPlayer(8)
.moveRandomly()
.trackPlayer();
}
randomSeed(): boolean {
return true;
}
canEnter(direction: Direction, from: Cell, to: Cell): boolean {
return to.terrain !== null && to.terrain.is(LAVITIC);
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.LAVA_WORM);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 3 === 0) {
game.agentMove(cell, this, this.#targeting);
}
}
static SERIALIZER = colorSerializer("LavaWorm", LavaWorm);
}
export class LightningLizard extends AbstractAgent implements Animated {
#targeting: Targeting;
constructor(color: Color) {
super(
"Lightning Lizard",
CARNIVORE | ORGANIC,
color,
CTBH.LIGHTNING_LIZARD,
Sym.of("£", ORANGE, null, DARKORANGE, null),
);
this.#targeting = new Targeting().attackPlayer(12).dodgeBullets(90);
}
randomSeed(): boolean {
return true;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.LIGHTNING_LIZARD);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
game.agentMove(cell, this, this.#targeting);
}
static SERIALIZER = colorSerializer("LightningLizard", LightningLizard);
}
export class Optilisk extends AbstractAgent implements Animated {
#movTargeting: Targeting;
#shootTargeting: Targeting;
constructor(color: Color) {
super(
"Optilisk",
0,
color,
CTBH.OPTILISK,
Sym.of("e", BLUE, null, DARKBLUE, null),
);
this.#movTargeting = new Targeting()
.attackPlayer(14)
.moveRandomly()
.trackPlayer();
this.#shootTargeting = new Targeting().attackPlayer(10);
}
randomSeed(): boolean {
return true;
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.OPTILISK);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 6 === 0) {
// Smart enough to stop shooting when the player is already paralyzed,
// or is holding the Mirror Shield.
const disabled = event.board.getCurrentCell().agent instanceof Paralyzed;
if (!disabled && game.player?.bag.getSelected().name !== "The Mirror Shield") {
game.agentShoot(cell, this, Registry.item("Parabullet"), this.#shootTargeting);
}
} else if (frame % 8 === 0) {
game.agentMove(cell, this, this.#movTargeting);
}
}
static SERIALIZER = colorSerializer("Optilisk", Optilisk);
}
export class Rhindle extends AbstractAgent implements Animated {
#targeting: Targeting;
constructor(color: Color) {
super(
"Rhindle",
CARNIVORE | ORGANIC | FIRE_RESISTANT,
color,
CTBH.RHINDLE,
Sym.of("𐁙", WHITE, null, BLACK, null), // &
);
this.#targeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(10)
.moveRandomly()
.trackPlayer();
}
randomSeed(): boolean {
return true;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.RHINDLE);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 5 === 0) game.agentMove(cell, this, this.#targeting);
}
static SERIALIZER = colorSerializer("Rhindle", Rhindle);
}
export class Sleestak extends AbstractAgent implements Animated {
#movTargeting: Targeting;
#shootTargeting: Targeting;
constructor(color: Color) {
super(
"Sleestak",
CARNIVORE | ORGANIC,
color,
CTBH.SLEESTAK,
Sym.of("S", OLIVEDRAB, null, DARKOLIVEGREEN, null),
);
this.#movTargeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(12)
.moveRandomly()
.trackPlayer();
this.#shootTargeting = new Targeting().attackPlayer(10);
}
randomSeed(): boolean {
return true;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.SLEESTAK);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 7 === 0) {
game.agentMove(cell, this, this.#movTargeting);
} else if (frame % 6 === 0) {
game.agentShoot(cell, this, Registry.item("Arrow"), this.#shootTargeting);
}
}
static SERIALIZER = colorSerializer("Sleestak", Sleestak);
}
export class Tetrite extends AbstractAgent implements Animated {
generation: number;
#targeting: Targeting;
constructor(generation: number) {
const color = generation === 0 ? SEAGREEN : generation === 1 ? MEDIUMSEAGREEN : DARKSEAGREEN;
super(
"Tetrite",
CARNIVORE | ORGANIC,
NONE,
CTBH.TETRITE,
Sym.of("∂", color),
);
this.generation = generation;
this.#targeting = new Targeting()
.dodgeBullets(50)
.attackPlayer(14)
.moveRandomly();
}
randomSeed(): boolean {
return true
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.TETRITE);
}
onDie(event: GameEvent, cell: Cell) {
if (this.generation < 2) {
this.#createTwoMore(cell, this.generation + 1);
}
}
#createTwoMore(center: Cell, generation: number) {
const adj = [];
for (const dir of ADJ_DIRECTIONS) {
const c = center.getAdjacentCell(dir);
if (c != null && c.canEnter(center, this, dir, false)) {
adj.push(c);
}
}
// Reuse a single cached Tetrite singleton for both spawned cells.
const child = Registry.agent(`Tetrite|${generation}`);
let count = Math.min(2, adj.length);
while (count > 0) {
const idx = Math.floor(Math.random() * adj.length);
const c = adj.splice(idx, 1)[0];
c.setAgent(child);
count--;
}
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 5 === 0) {
game.agentMove(cell, this, this.#targeting);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create([gen]: string[]) {
return new Tetrite(parseInt(gen) || 0);
}
store(t: Tetrite) {
return `Tetrite|${t.generation}`;
}
example() {
return new Tetrite(0);
}
template() {
return "Tetrite|{generation}";
}
tag() {
return "Agents";
}
})();
}
export class Thermadon extends AbstractAgent implements Animated {
#movTargeting: Targeting;
#shootTargeting: Targeting;
constructor(color: Color) {
super(
"Thermadon",
CARNIVORE | ORGANIC | FIRE_RESISTANT,
color,
CTBH.THERMADON,
Sym.of("Ð", FIREBRICK),
);
this.#movTargeting = new Targeting()
.dodgeBullets(90)
.attackPlayer(14)
.moveRandomly()
.trackPlayer();
this.#shootTargeting = new Targeting().attackPlayer(6);
}
randomSeed(): boolean {
return true;
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.THERMADON);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 15 === 0) {
game.agentShoot(cell, this, Registry.item("Fireball"), this.#shootTargeting);
} else if (frame % 7 === 0) {
game.agentMove(cell, this, this.#movTargeting);
}
}
static SERIALIZER = colorSerializer("Thermadon", Thermadon);
}
export class Triffid extends AbstractAgent implements Animated {
#movTargeting: Targeting;
#dartTargeting: Targeting;
constructor(color: Color) {
super("A Triffid", ORGANIC, color, CTBH.TRIFFID, Sym.of("¥", DARKGREEN));
this.#movTargeting = new Targeting().attackPlayer(20).moveRandomly();
this.#dartTargeting = new Targeting().attackPlayer(10);
}
randomSeed(): boolean {
return true;
}
onHit(event: GameEvent, attackerLoc: Cell, agentLoc: Cell, agent: Agent) {
agent.changeHealth(DAMAGE.TRIFFID);
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 25 === 0) {
game.agentMove(cell, this, this.#movTargeting);
} else if (frame % 8 === 0) {
game.agentShoot(cell, this, Registry.item("PoisonDart"), this.#dartTargeting);
}
}
static SERIALIZER = colorSerializer("Triffid", Triffid);
}
export class Tumbleweed extends AbstractAgent implements Animated {
direction: Direction;
#targeting: Targeting;
constructor(direction: Direction) {
super(
"Tumbleweed",
ORGANIC | PENETRABLE | PUSHABLE,
NONE,
INDESTRUCTIBLE,
Sym.of("*", SADDLEBROWN),
);
this.direction = direction;
this.#targeting = new Targeting();
}
randomSeed(): boolean {
return true;
}
changeHealth() {
return 100;
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
// Rather than a blind push, deflect along the best available direction
// near the point of contact (mirrors Java's Tumbleweed.onHitBy exactly).
const newDir = findPathInDirection(
agentLoc.board,
agentLoc,
agent,
null,
dir,
this.#targeting,
);
if (newDir != null) {
const next = agentLoc.board.getAdjacentCell(agentLoc.x, agentLoc.y, dir);
game.agentMoveInDirection(event, next!, this, newDir);
} else {
event.cancel();
}
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame % 20 === 0) {
const next = cell.board.getAdjacentCell(cell.x, cell.y, this.direction);
if (next != null) {
const dir = findPathInDirection(
cell.board,
cell,
this,
null,
this.direction,
this.#targeting,
);
if (dir != null) {
const event = game.createEvent();
game.agentMoveInDirection(event, cell, this, dir);
}
} else {
// At board edge: wrap to the opposite side.
let wrapCell = TerrainUtils.getCellOnOppositeSide(cell, this.direction);
if (wrapCell && wrapCell.canEnter(cell, this, this.direction, false)) {
cell.removeAgent(this);
wrapCell.setAgent(this);
return;
}
// Try a lateral path then wrap from that position.
const dir = findPathInDirection(
cell.board,
cell,
this,
null,
this.direction,
this.#targeting,
);
if (dir != null) {
const lateralCell = cell.board.getAdjacentCell(cell.x, cell.y, dir);
if (lateralCell) {
wrapCell = TerrainUtils.getCellOnOppositeSide(
lateralCell,
this.direction,
);
if (
wrapCell &&
wrapCell.canEnter(cell, this, this.direction, false)
) {
cell.removeAgent(this);
wrapCell.setAgent(this);
return;
}
}
}
// Last resort: remove the tumbleweed from the board.
cell.removeAgent(this);
}
}
}
static SERIALIZER = new (class extends BaseSerializer {
// TODO: most of thes should be string[] not any[]
create([dir]: string[]) {
return new Tumbleweed(directionByName(dir) ?? WEST);
}
store(t: Tumbleweed) {
return `Tumbleweed|${t.direction.name}`;
}
example() {
return new Tumbleweed(WEST);
}
template() {
return "Tumbleweed|{direction}";
}
tag() {
return "Outside Terrain";
}
})();
}
/**
* Statue — an indestructible agent-proxy that wraps another agent.
* On a color event it transforms back into the wrapped agent.
* Visually it appears stone-grey; it cannot be destroyed.
*/
export class Statue extends AgentProxy implements Animated {
constructor(agent: Agent, color: Color) {
super(agent, 0);
this.name = `${agent.name} Statue`;
this.color = color;
this.symbol = Sym.of(
agent.symbol.entity,
LESSNEARBLACK,
null,
BUILDING_WALL,
null,
);
}
randomSeed(): boolean {
return false;
}
changeHealth(v: number) {
return 100;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (this.is(PLAYER)) {
super.onFrame(event, cell, frame);
}
}
onHitBy(event: GameEvent, agentLoc: Cell, agent: Agent, dir: Direction) {
event.cancel();
}
onColorEvent(event: GameEvent, color: Color, cell: Cell) {
if (color == this.color && this.agent) {
cell.setAgent(this.agent);
}
}
static SERIALIZER = new (class extends BaseSerializer {
create(args: string[]) {
const agent = Registry.agent(args[0]);
const color = colorByName(args[1]) ?? NONE;
return new Statue(agent, color);
}
store(s: Statue) {
return `Statue|${this.esc(s.agent)}|${s.color.name}`;
}
example() {
return new Statue(new Triffid(NONE), NONE);
}
template() {
return "Statue|{agent}|{color}";
}
tag() {
return "Room Features";
}
})();
}
/**
* An AgentProxy decorator that renders the underlying agent paralyzed for
* 40 animation frames, then restores the original agent.
*
* Visually the wrapped agent's symbol is tinted with a DARKVIOLET background.
* `onFrame` is suppressed by AgentProxy for the duration. If wrapping the player,
* the PARALYZED flag is removed on restoration.
*/
export class Paralyzed extends AgentProxy implements Animated {
constructor(agent: Agent) {
super(agent, NOT_EDITABLE);
// Paralyzed symbol: same glyph and color, DARKVIOLET background
const sym = agent.symbol;
this.symbol = Sym.of(
sym.entity,
sym.getColor(false) as Color,
DARKVIOLET,
sym.getColor(true) as Color,
DARKVIOLET,
);
}
randomSeed() {
return false;
}
onFrame(event: GameEvent, cell: Cell, frame: number) {
if (frame <= 40) {
// Keep the wrapped player's animations running during paralysis.
if (this.is(PLAYER) && isAnimated(this.agent)) {
this.agent.onFrame(event, cell, frame);
}
return;
}
// After 40 frames restore the original agent.
if (this.is(PLAYER)) {
event.player.remove(PARALYZED);
}
cell.setAgent(this.agent);
}
static SERIALIZER = new (class extends BaseSerializer {
create([agentArg]: string[]) {
const agent =
typeof agentArg === "string" ? Registry.agent(agentArg) : agentArg;
return new Paralyzed(agent);
}
store(p: Paralyzed) {
return `Paralyzed|${this.esc(p.agent)}`;
}
example() {
return new Paralyzed(new Triffid(NONE));
}
template() {
return "Paralyzed|{agent}";
}
tag() {
return "Room Features";
}
})();
}
export function registerCreatures() {
Registry.register("Boulder", Boulder.SERIALIZER);
Registry.register("RollingBoulder", RollingBoulder.SERIALIZER);
Registry.register("Pusher", Pusher.SERIALIZER);
Registry.register("Slider", Slider.SERIALIZER);
Registry.register("Campfire", Campfire.SERIALIZER);
Registry.register("Pillar", Pillar.SERIALIZER);
Registry.register("Asciiroth", Asciiroth.SERIALIZER);
Registry.register("Cephalid", Cephalid.SERIALIZER);
Registry.register("Corvid", Corvid.SERIALIZER);
Registry.register("Farthapod", Farthapod.SERIALIZER);
Registry.register("GreatOldOne", GreatOldOne.SERIALIZER);
Registry.register("Hooloovoo", Hooloovoo.SERIALIZER);
Registry.register("KillerBee", KillerBee.SERIALIZER);
Registry.register("LavaWorm", LavaWorm.SERIALIZER);
Registry.register("LightningLizard", LightningLizard.SERIALIZER);
Registry.register("Optilisk", Optilisk.SERIALIZER);
Registry.register("Rhindle", Rhindle.SERIALIZER);
Registry.register("Sleestak", Sleestak.SERIALIZER);
Registry.register("Thermadon", Thermadon.SERIALIZER);
Registry.register("Triffid", Triffid.SERIALIZER);
Registry.register("Tetrite", Tetrite.SERIALIZER);
Registry.register("Tumbleweed", Tumbleweed.SERIALIZER);
Registry.register("Statue", Statue.SERIALIZER);
Registry.register("Paralyzed", Paralyzed.SERIALIZER);
}