Files
dungeoncraft/src/game.rs
T

6083 lines
231 KiB
Rust

// Game logic: units, AI, economy, hero waves.
use crate::commands::Command;
use crate::map::*;
use serde::{Deserialize, Serialize};
pub const CORE_HP: f32 = 2000.0;
pub const GATE_HP: f32 = 3000.0;
pub const PAYDAY: f32 = 150.0;
pub const PAYDAY_WARN: f32 = 20.0; // the payday is announced this many seconds before
pub const TREASURY_TILE: f32 = 500.0; // value one treasury tile holds
pub const CORE_STORE: f32 = 2000.0; // value the soul core holds without any treasury
pub const TRAIN_COST: f32 = 1.5;
pub const HELPERS_MAX: usize = 4; // up to five imps at one task (one worker + four helpers) // gold per second of training (billed on payday)
pub const FIRST_WAVE: f32 = 300.0;
pub const WAVE_GAP: f32 = 180.0;
pub const MAX_SQUAD: usize = 18;
// Sound events. The game logic reports them, the sound module plays them.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Sfx {
Dig,
GoldDig,
DigDone,
Claim,
Deposit,
Build,
Sell,
MeleeHit,
Shoot,
Death,
HeroDeath,
Lightning,
Heal,
Summon,
PortalArrive,
WaveAlarm,
WaveStart,
CoreHit,
GateHit,
GateDefenders,
LevelUp,
Pickup,
Drop,
Payday,
FlagSet,
Victory,
Defeat,
Click,
Error,
Reinforce,
Cannon,
Magic,
Capture,
Convert,
Slap,
Punch,
// Crookfinger's voice: squeaky syllables
Voice1,
Voice2,
Voice3,
Voice4,
Voice5,
Voice6,
}
pub const VOICE_SYLLABLES: [Sfx; 6] = [Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6];
pub const SFX_ALL: [Sfx; 42] = [
Sfx::Dig, Sfx::GoldDig, Sfx::DigDone, Sfx::Claim, Sfx::Deposit, Sfx::Build, Sfx::Sell, Sfx::MeleeHit,
Sfx::Shoot, Sfx::Death, Sfx::HeroDeath, Sfx::Lightning, Sfx::Heal, Sfx::Summon, Sfx::PortalArrive,
Sfx::WaveAlarm, Sfx::WaveStart, Sfx::CoreHit, Sfx::GateHit, Sfx::GateDefenders, Sfx::LevelUp,
Sfx::Pickup, Sfx::Drop, Sfx::Payday, Sfx::FlagSet, Sfx::Victory, Sfx::Defeat, Sfx::Click, Sfx::Error,
Sfx::Reinforce, Sfx::Cannon, Sfx::Magic, Sfx::Capture, Sfx::Convert, Sfx::Slap, Sfx::Punch,
Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6,
];
impl Sfx {
pub fn index(self) -> usize {
self as usize
}
}
// A game message: language key + arguments (arguments starting with '@' are keys too)
#[derive(Clone, Debug)]
pub struct Msg {
pub key: &'static str,
pub args: Vec<String>,
}
#[derive(Clone, Copy)]
pub struct SfxEvent {
pub sfx: Sfx,
pub pos: Option<(f32, f32)>, // world position (for distance-based volume), None = UI
}
pub const MANA_MAX: f32 = 400.0;
pub const SPRING_MANA: f32 = 0.6; // mana per second of each claimed mana spring
pub const SPRING_MAX: f32 = 150.0; // more mana maximum per claimed spring
pub const CARRY_MAX: i32 = 300;
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
pub enum EK {
Worker,
Kobold,
Spider,
Mage,
Ogre,
Knight,
Archer,
Priest,
Dwarf,
Paladin,
Jailer, // attracted by the torture chamber
Priestess, // attracted by the temple
Chainbreaker, // rare and very strong
// flying creatures, attracted by the aerie
Bat, // fast and weak
Gargoyle, // aerie + torture chamber: stone, tough
Owl, // aerie + library: arcane bolts from afar
Wyrm, // big aerie + treasury: rare, very strong, fire breath
Cogling, // forge: a sooty little tinkerer, builds traps and doors fastest
Revenant, // rises from the graveyard: undead, needs no food and no bed
// more heroes
Giant, // slow, huge, smashes doors and traps
Fairy, // flies, finds the dungeon's traps and heals heroes a little
BattleMage, // blasts reinforced walls from afar, throws fireballs
}
pub struct St {
pub name: &'static str,
pub hp: f32,
pub atk: f32,
pub range: f32,
pub speed: f32,
pub interval: f32,
pub wage: i32,
pub bounty: i32,
}
pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS
pub fn st(k: EK) -> St {
let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty };
match k {
EK::Worker => s("unit.worker", 45.0, 3.0, 1.0, 3.2, 1.0, 0, 0),
EK::Kobold => s("unit.kobold", 75.0, 8.0, 1.0, 2.6, 0.9, 40, 0),
EK::Spider => s("unit.spider", 95.0, 10.0, 1.0, 2.9, 1.0, 60, 0),
EK::Mage => s("unit.mage", 60.0, 13.0, 4.5, 2.0, 1.4, 110, 0),
EK::Ogre => s("unit.ogre", 230.0, 20.0, 1.0, 1.8, 1.4, 150, 0),
// heroes: the wage applies when they defected to the player
EK::Knight => s("unit.knight", 140.0, 11.0, 1.0, 1.9, 1.1, 70, 120),
EK::Archer => s("unit.archer", 75.0, 9.0, 4.5, 2.1, 1.3, 60, 100),
EK::Priest => s("unit.priest", 80.0, 5.0, 3.5, 1.9, 1.5, 60, 120),
EK::Dwarf => s("unit.dwarf", 110.0, 9.0, 1.0, 2.0, 1.0, 60, 100),
EK::Paladin => s("unit.paladin", 700.0, 26.0, 1.0, 1.7, 1.2, 220, 1000),
EK::Jailer => s("unit.jailer", 260.0, 16.0, 1.0, 1.6, 1.3, 140, 0),
EK::Priestess => s("unit.priestess", 70.0, 14.0, 5.0, 2.0, 1.6, 120, 0),
EK::Chainbreaker => s("unit.chainbreaker", 600.0, 36.0, 1.1, 2.3, 1.1, 400, 0),
EK::Bat => s("unit.bat", 55.0, 6.0, 1.0, 3.6, 0.7, 30, 0),
EK::Gargoyle => s("unit.gargoyle", 260.0, 15.0, 1.0, 2.2, 1.2, 120, 0),
EK::Owl => s("unit.owl", 70.0, 12.0, 4.5, 2.8, 1.3, 100, 0),
EK::Wyrm => s("unit.wyrm", 650.0, 30.0, 3.0, 2.5, 1.4, 380, 0),
EK::Cogling => s("unit.cogling", 90.0, 9.0, 1.0, 2.4, 0.9, 70, 0),
EK::Revenant => s("unit.revenant", 160.0, 14.0, 1.0, 1.9, 1.2, 30, 0),
EK::Giant => s("unit.giant", 900.0, 34.0, 1.1, 1.3, 1.6, 200, 600),
EK::Fairy => s("unit.fairy", 70.0, 5.0, 3.5, 3.2, 1.2, 60, 150),
EK::BattleMage => s("unit.battlemage", 120.0, 18.0, 5.0, 1.8, 1.8, 120, 250),
}
}
// flying creatures cross water and lava and sleep in the aerie
pub fn flies(k: EK) -> bool {
matches!(k, EK::Bat | EK::Gargoyle | EK::Owl | EK::Wyrm | EK::Fairy)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum Job {
None,
Dig(usize),
Claim(usize),
Deliver,
Wander,
Sleep(usize),
Eat(usize),
Train(usize),
Study(usize),
Attack(u32),
AttackCore,
Leave,
Rally,
Reinforce(usize), // wall tile
Repair(u32), // tower id
AttackTower(u32), // tower id
Carry(u32), // imp carries an unconscious hero (id) to the torture chamber
Pray(usize), // temple tile
HealCore, // flame priestess heals the soul core
TearBridge(usize), // knight tears down a bridge of the player
Rest(usize), // flying creature recovers in the aerie (flees when badly hurt)
Torment(u32), // the jailer works on a prisoner in the torture chamber
Register, // new creature: reports at the portal
MakeBed(usize), // new creature: sets up its bed
FetchWage(usize), // payday: walks to the treasury (or the core) for the wage
Help(usize), // imp with nothing else to do helps another imp at this tile
Forge(usize), // creature works in the forge (traps and doors)
Install(u32), // imp carries a finished trap or door (id) to its spot and installs it
Brawl(u32), // two creatures that cannot stand each other scuffle (never to the death)
Guard(usize), // holds a guard post
Spar(usize), // sparring in the arena
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Ent {
pub id: u32,
pub kind: EK,
pub hero: bool,
pub x: f32,
pub y: f32,
pub hp: f32,
pub max_hp: f32,
pub level: u32,
pub xp: f32,
pub job: Job,
pub path: Vec<usize>,
pub timer: f32,
pub cd: f32,
pub think: f32,
pub repath: f32,
pub carry: i32,
#[serde(default)]
pub carry_res: u8, // treasure the imp carries (see map::RES_GOLD)
pub hunger: f32,
pub energy: f32,
pub happy: f32,
pub bed: Option<usize>,
pub alive: bool,
pub held: bool,
pub anim: f32,
pub flash: f32,
pub face: f32,
#[serde(default)]
pub dir: f32, // facing direction on the map (radians, 0 = +x), for the eight sprite views
pub moving: bool,
pub working: bool,
#[serde(default)]
pub slow: f32, // seconds of slowdown left (magic tower)
#[serde(default)]
pub captive: u8, // heroes: 0 free, 1 unconscious, 2 carried, 3 in the torture chamber
#[serde(default)]
pub carrier: u32, // imp that has taken care of this captive
#[serde(default)]
pub torture_t: f32,
#[serde(default)]
pub skill_cd: f32, // special abilities (jailer pull, priestess heal)
#[serde(default)]
pub waypoint: Option<(i32, i32)>, // heroes: entry point into the player's realm
#[serde(default)]
pub boost: f32, // seconds of "slapped" speed-up left
#[serde(default)]
pub stun: f32, // seconds of stun left (punched)
#[serde(default)]
pub haste: f32, // spell: seconds of haste left
#[serde(default)]
pub shield: f32, // spell: seconds of stoneskin left (half damage)
#[serde(default)]
pub invis: f32, // spell: seconds of invisibility left (until it attacks)
#[serde(default)]
pub plague: f32, // spell: seconds of plague left (poison that spreads)
#[serde(default)]
pub ab_cd: f32, // cooldown of the learned ability
#[serde(default)]
pub mood_t: f32, // seconds until the next mood check (likes and hatreds)
pub guard: bool, // stays at its home (gate defenders, treasure guards)
#[serde(default)]
pub home: Option<(f32, f32)>, // guard post
#[serde(default)]
pub patrol: Vec<(i32, i32)>, // fortress patrol: route of tiles, walked in a loop
#[serde(default)]
pub patrol_k: usize, // next point of the route
#[serde(default = "yes")]
pub settled: bool, // creatures: reported at the portal and set up their bed
#[serde(default)]
pub pay_due: f32, // wage of the last payday not fetched yet
#[serde(default)]
pub pay_t: f32, // seconds until the creature goes for its wage
#[serde(default)]
pub side: u8, // 0 player, 2 and 3 rival masters (heroes: see side())
#[serde(default)]
pub target: Option<u8>, // attackers: side whose core (or gate) they march on
#[serde(default)]
pub chained: bool, // a lone creature chained in a sealed cave: joins when the cave is claimed
}
fn yes() -> bool {
true
}
impl Ent {
fn dummy() -> Ent {
Ent {
id: 0,
kind: EK::Worker,
hero: false,
x: 0.0,
y: 0.0,
hp: 0.0,
max_hp: 1.0,
level: 1,
xp: 0.0,
job: Job::None,
path: Vec::new(),
timer: 0.0,
cd: 0.0,
think: 0.0,
repath: 0.0,
carry: 0,
carry_res: 0,
hunger: 0.0,
energy: 1.0,
happy: 1.0,
bed: None,
alive: false,
held: false,
anim: 0.0,
flash: 0.0,
face: 1.0,
dir: std::f32::consts::FRAC_PI_2,
moving: false,
working: false,
slow: 0.0,
captive: 0,
carrier: 0,
torture_t: 0.0,
skill_cd: 0.0,
waypoint: None,
boost: 0.0,
stun: 0.0,
haste: 0.0,
shield: 0.0,
invis: 0.0,
plague: 0.0,
ab_cd: 0.0,
mood_t: 0.0,
guard: false,
home: None,
patrol: Vec::new(),
patrol_k: 0,
settled: true,
pay_due: 0.0,
pay_t: 0.0,
side: 0,
target: None,
chained: false,
}
}
pub fn tile(&self) -> usize {
idx((self.x as i32).clamp(0, mw() as i32 - 1), (self.y as i32).clamp(0, mh() as i32 - 1))
}
pub fn atk(&self) -> f32 {
st(self.kind).atk * (1.0 + 0.12 * (self.level - 1) as f32)
}
// the player's creatures (not imps, heroes or the rivals' units)
pub fn creature(&self) -> bool {
!self.hero && self.side == me_side() && self.kind != EK::Worker && !self.chained
}
// 0 player, 1 humans, 2 and 3 rival masters
pub fn side(&self) -> u8 {
if self.hero {
1
} else {
self.side
}
}
// a unit of a rival master
pub fn rival(&self) -> bool {
!self.hero && (self.side == 2 || self.side == 3)
}
// a dwarf of a dwarf cave (neutral, never an opponent for victory)
pub fn dwarf(&self) -> bool {
!self.hero && self.side == crate::dwarves::DWARF_SIDE
}
// a hero that is still fighting (not a prisoner)
pub fn active_hero(&self) -> bool {
self.hero && self.captive == 0
}
}
// Goals of a campaign mission (all must be reached)
#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
pub enum Goal {
Shards, // take back all shards of the soul core
Heart, // destroy the gate of the order
KillAll, // defeat every knight (prisoners count as defeated)
Loot(u32), // take treasure worth this much from knight vaults
Towers, // raze all knight towers
Survive(u32), // beat this many waves
Rivals, // destroy the soul cores of all rival masters
Springs(u32), // claim this many mana springs
Freed(u32), // free this many chained creatures
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MissionState {
pub index: usize, // number of the mission in the campaign
pub goals: Vec<Goal>,
pub tools: Vec<bool>, // allowed tools (ui::TOOLS order)
pub waves: bool,
pub wave_limit: u32, // 0 = endless
pub gate_defenders: bool,
pub heart: bool, // the map has a gate of the order
pub time_limit: f32, // seconds, 0 = none
pub shards: u32, // shards on the map
}
#[derive(Clone, Copy)]
pub struct Fx {
pub kind: u8, // 0 projectile, 1 lightning, 2 hit, 3 smoke, 4 heal, 5 gold, 6 dust, 7 level up, 8 claim
pub x: f32,
pub y: f32,
pub x2: f32,
pub y2: f32,
pub t: f32,
pub max: f32,
pub col: u32,
pub h: f32, // height of the start point (3D)
pub h2: f32, // height of the end point (projectiles)
pub size: f32, // 1 = normal; attacks of higher levels look bigger
pub style: u8, // projectiles: arrow, cannonball, fireball, ... (see particles.rs)
}
thread_local! {
// the master whose turn it is in the simulation (see Game::switch_to)
static ME_SIDE: std::cell::Cell<u8> = std::cell::Cell::new(0);
}
pub fn me_side() -> u8 {
ME_SIDE.with(|s| s.get())
}
fn player_owner() -> Owner {
Owner::Player
}
// The state of a dungeon master that is not being processed right now. The Game's own fields
// (gold, mana, hand, ...) always belong to the master whose side is `me_side`; switching swaps
// them with the stored ones. A single player game has no stored masters at all.
#[derive(Clone, Debug, Default)]
pub struct MasterState {
pub side: u8,
pub owner: Option<Owner>,
pub core: (i32, i32),
pub core_hp: f32,
pub gold: f32,
pub mana: f32,
pub payday: f32,
pub payday_warned: bool,
pub hand: Vec<u32>,
pub flag: Option<usize>,
pub reserve: f32,
pub incoming: Vec<(u8, f32, Option<usize>)>,
pub training_bill: f32,
pub counts: [usize; N_ROOMS],
pub springs: usize,
pub known: Vec<bool>,
pub researchable: Vec<bool>,
pub research: f32,
pub forge_work: f32,
pub rune_free: u32,
pub rune_final: u32,
pub rune_next: u32,
pub portal_timer: f32,
pub punch_ready: f32,
}
#[derive(Serialize, Deserialize)]
pub struct Game {
pub map: Map,
pub ents: Vec<Ent>,
pub next_id: u32,
pub rng: Rng,
pub gold: f32,
pub mana: f32,
pub core_hp: f32,
pub time: f32,
pub wave: u32,
pub wave_timer: f32,
pub warned: bool,
pub portal_timer: f32,
pub payday: f32,
#[serde(skip)]
pub fx: Vec<Fx>,
#[serde(skip)]
pub msgs: Vec<Msg>,
pub state: u8, // 0 running, 1 victory, 2 defeat
pub paused: bool,
pub hand: Vec<u32>,
#[serde(skip)]
pub counts: [usize; N_ROOMS], // room tiles per room kind (ROOM_KINDS order, recounted)
pub gate_hp: f32,
pub gate_hit_t: f32,
pub gate_stage: u32, // defender thresholds triggered (0..3)
pub flag: Option<usize>,
#[serde(skip)]
pub events: Vec<SfxEvent>,
pub last_wave_value: f32,
pub torches: Vec<Torch>,
#[serde(default)]
pub towers: Vec<Tower>,
#[serde(default)]
pub punch_ready: f32, // game time when the hand can punch again
#[serde(default)]
pub entry_history: Vec<(i32, i32)>, // entry points of recent waves
#[serde(default)]
pub planned_entries: Vec<(i32, i32)>, // entry points of the coming/current wave (minimap)
#[serde(default)]
pub reserve: f32, // value stored in the soul core (the rest lies in the treasury tiles)
#[serde(skip)]
pub incoming: Vec<(u8, f32, Option<usize>)>, // treasures added since the last treasury update (kind, value, delivered at)
#[serde(default)]
pub training_bill: f32, // training costs, paid on the next payday
#[serde(default)]
pub payday_warned: bool,
#[serde(default)]
pub loot: f32, // value taken from knight vaults
#[serde(default)]
pub shards_taken: u32,
#[serde(default)]
pub towers_razed: u32, // knight towers destroyed
#[serde(skip)]
pub contested: Vec<bool>,
#[serde(default)]
pub mission: Option<MissionState>, // campaign mission (None: free game)
#[serde(default)]
pub rivals: Vec<crate::rival::Rival>, // AI dungeon masters
#[serde(default = "yes")]
pub gate_on: bool, // free game: the humans' gate exists
#[serde(skip)]
pub springs: usize, // claimed mana springs (recounted)
#[serde(skip)]
pub parts: Vec<crate::particles::Part>, // visual particles (never saved)
#[serde(skip)]
pub stuck: Vec<crate::particles::Part>, // arrows stuck in walls and floors, scorch marks
#[serde(skip, default = "crate::particles::vrng")]
pub vrng: Rng, // random numbers for the particles only
#[serde(default)]
pub traps: Vec<crate::traps::Trap>, // traps and doors (ordered, in the forge, carried, armed)
#[serde(default)]
pub rollers: Vec<crate::traps::Roller>,
#[serde(default)]
pub clouds: Vec<crate::traps::Cloud>,
#[serde(default)]
pub known: Vec<bool>, // tools known (ui::TOOLS order; empty = old savegame: everything)
#[serde(default)]
pub researchable: Vec<bool>, // tools that can be researched in the library
#[serde(default)]
pub research: f32, // research points towards the current item
#[serde(default)]
pub reveals: Vec<(f32, f32, f32)>, // seer's eye: x, y, seconds left
#[serde(default)]
pub graves: Vec<crate::rooms::Grave>,
#[serde(default)]
pub altar_last: Option<EK>, // the last sacrifice that waits for its pair
#[serde(skip)]
pub fog_t: f32, // fog of war: seconds until the next update
#[serde(skip)]
pub forge_work: f32, // work done in the forge this frame
#[serde(skip)]
pub demo: Option<crate::demo::Demo>, // the endless bridge battle behind the title menu
#[serde(default)]
pub dwarf_caves: Vec<crate::dwarves::DwarfCave>,
#[serde(default)]
pub dwarf_t: f32,
#[serde(default)]
pub rune_free: u32, // rune forge towers that can be built for free (from chests)
#[serde(default)]
pub rune_final: u32, // the same, but given back for a sold tower: no second refund
#[serde(default)]
pub rune_next: u32, // sold rune forge towers: back as a chest in the next mission
#[serde(default)]
pub freed: u32, // chained creatures freed // tiles next to fighting heroes: knight ground there cannot be claimed
#[serde(skip, default = "player_owner")]
pub me: Owner, // owner of the ground of the master being processed
#[serde(skip)]
pub me_side: u8, // that master's side (0 = the first player)
#[serde(skip)]
pub local_side: u8, // the master who plays at this computer
#[serde(skip)]
pub masters: Vec<MasterState>, // the other masters (multiplayer)
#[serde(skip)]
pub pending: Vec<Command>, // orders of this master, applied at the next step boundary
#[serde(skip)]
pub cmd_seq: u32, // number of the next command queued here
#[serde(skip)]
pub sim_steps: u64, // fixed simulation steps done so far (the lockstep clock)
#[serde(skip)]
step_acc: f32, // real time not yet paid for with a step
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
#[serde(skip, default = "hint_times")]
hint_t: [f32; 32], // last time each hint slot was shown
}
fn hint_times() -> [f32; 32] {
[-1000.0; 32]
}
// every simulation step has the same length: on every client of a match the same commands are
// applied and then the same number of steps runs, so the worlds stay identical
pub const STEP: f32 = 0.05;
// one frame never runs away into the future (a lagging tab catches up at most this much)
const MAX_STEPS_PER_FRAME: usize = 4;
// torch on a wall face: wall tile + direction (index into DIR4) towards the open side
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Torch {
pub wall: usize,
pub dir: usize,
}
impl Torch {
// world position of the flame (x, z) on the ground plane
pub fn pos(&self) -> (f32, f32) {
let (x, y) = xy(self.wall);
let (dx, dy) = DIR4[self.dir];
(x as f32 + 0.5 + dx as f32 * 0.58, y as f32 + 0.5 + dy as f32 * 0.58)
}
}
pub const N_ROOMS: usize = 14;
pub const ROOM_FORGE: usize = 8;
pub const ROOM_KINDS: [Kind; N_ROOMS] = [
Kind::Treasury,
Kind::Lair,
Kind::Farm,
Kind::Training,
Kind::Library,
Kind::Torture,
Kind::Temple,
Kind::Aerie,
Kind::Forge,
Kind::Prison,
Kind::Graveyard,
Kind::GuardPost,
Kind::Arena,
Kind::Altar,
];
pub const ROOM_COST: [i32; N_ROOMS] = [60, 80, 100, 140, 160, 200, 180, 120, 150, 160, 140, 90, 180, 250];
pub const ROOM_NAMES: [&str; N_ROOMS] = [
"room.treasury",
"room.lair",
"room.farm",
"room.training",
"room.library",
"room.torture",
"room.temple",
"room.aerie",
"room.forge",
"room.prison",
"room.graveyard",
"room.guardpost",
"room.arena",
"room.altar",
];
// bridge cost per tile: over water, over lava
pub const BRIDGE_COST: [i32; 2] = [30, 75];
// steel wall cost per tile: from earth, from a reinforced wall
pub const STEEL_COST: [i32; 2] = [80, 40];
pub const STEEL_HP: f32 = 40.0;
pub fn room_index(k: Kind) -> Option<usize> {
ROOM_KINDS.iter().position(|&r| r == k)
}
fn adj4(a: usize, b: usize) -> bool {
let (ax, ay) = xy(a);
let (bx, by) = xy(b);
(ax - bx).abs() + (ay - by).abs() == 1
}
fn norm(x: f32, y: f32) -> (f32, f32) {
let l = (x * x + y * y).sqrt();
if l < 1e-6 {
(0.0, 0.0)
} else {
(x / l, y / l)
}
}
fn sgn(v: f32) -> f32 {
if v < 0.0 {
-1.0
} else {
1.0
}
}
impl Game {
// free game with the humans' gate only (tests; the menu uses new_free)
#[cfg_attr(not(test), allow(dead_code))]
pub fn new(seed: u32, w: usize, h: usize) -> Game {
let mut g = Game::from_map(Map::generate(seed, w, h), seed);
// heroes take longer to reach the core on big maps: longer grace period
let (cx, cy) = g.map.core;
let (gx, gy) = g.map.gate;
let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32;
g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0);
g.guard_treasures();
g.place_dwarf_caves(seed);
g.add_wilds(seed, true);
g.init_research(None);
g.init_fog();
g.msg("msg.welcome1", vec![]);
g.msg("msg.welcome2", vec![]);
g.msg("msg.welcome3", vec![]);
g.msg("msg.welcome4", vec![]);
g
}
// treasure caves are guarded by heroes
pub fn guard_treasures(&mut self) {
for k in 0..self.map.treasures.len() {
let (tx, ty) = self.map.treasures[k];
let squad = [EK::Knight, EK::Archer, EK::Dwarf, if k % 2 == 0 { EK::Priest } else { EK::Knight }];
for (j, &kind) in squad.iter().enumerate() {
let x = tx as f32 + 0.5 + (j as f32 - 1.5) * 0.6;
let id = self.spawn(kind, true, x, ty as f32 + 0.5, 2);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.guard = true;
e.home = Some((tx as f32 + 0.5, ty as f32 + 0.5));
}
}
}
}
// game state around a generated map: treasury ring around the core, four imps, torches
pub fn from_map(map: Map, seed: u32) -> Game {
let mut g = Game {
map,
ents: Vec::new(),
next_id: 1,
rng: Rng(seed.wrapping_mul(2654435761) | 1),
gold: 1500.0,
mana: 100.0,
core_hp: CORE_HP,
time: 0.0,
wave: 0,
wave_timer: FIRST_WAVE,
warned: false,
portal_timer: 10.0,
payday: PAYDAY,
fx: Vec::new(),
msgs: Vec::new(),
state: 0,
paused: false,
hand: Vec::new(),
counts: [0; N_ROOMS],
gate_hp: GATE_HP,
gate_hit_t: -100.0,
gate_stage: 0,
flag: None,
events: Vec::new(),
last_wave_value: 0.0,
torches: Vec::new(),
towers: Vec::new(),
punch_ready: 0.0,
entry_history: Vec::new(),
planned_entries: Vec::new(),
hints: true,
me: Owner::Player,
me_side: 0,
local_side: 0,
masters: Vec::new(),
pending: Vec::new(),
cmd_seq: 0,
sim_steps: 0,
step_acc: 0.0,
hint_t: hint_times(),
reserve: 0.0,
incoming: Vec::new(),
training_bill: 0.0,
payday_warned: false,
loot: 0.0,
shards_taken: 0,
towers_razed: 0,
contested: Vec::new(),
mission: None,
rivals: Vec::new(),
gate_on: true,
springs: 0,
freed: 0,
demo: None,
traps: Vec::new(),
rollers: Vec::new(),
clouds: Vec::new(),
forge_work: 0.0,
fog_t: 0.0,
graves: Vec::new(),
altar_last: None,
reveals: Vec::new(),
known: Vec::new(),
researchable: Vec::new(),
research: 0.0,
parts: Vec::new(),
stuck: Vec::new(),
vrng: crate::particles::vrng(),
dwarf_caves: Vec::new(),
dwarf_t: 30.0,
rune_free: 0,
rune_final: 0,
rune_next: 0,
};
let (cx, cy) = g.map.core;
// the soul core starts surrounded by a one tile wide ring of treasury
for y in cy - 1..=cy + 3 {
for x in cx - 1..=cx + 3 {
let t = &mut g.map.t[idx(x, y)];
if t.kind == Kind::Floor {
t.kind = Kind::Treasury;
t.owner = Owner::Player;
}
}
}
for k in 0..4 {
g.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + k as f32 * 0.7 + 0.3, 1);
}
g.recount();
g.place_all_torches();
g
}
pub fn place_all_torches(&mut self) {
// torches on walls that are gone (rivers, fortresses) are removed
let map = &self.map;
self.torches.retain(|t| map.t[t.wall].solid());
for i in 0..n() {
if self.map.t[i].owner == self.me {
self.place_torches(i);
}
}
}
// ---------------------------------------------------------------- campaign missions
// the rune forge tower only shows up in the build menu once there is one to build or upgrade
pub fn tool_visible(&self, tool: usize) -> bool {
tool != RUNE_TOOL || self.rune_available() > 0 || self.towers.iter().any(|t| !t.hero && t.kind == TowerKind::Rune)
}
pub fn tool_allowed(&self, tool: usize) -> bool {
if !self.known.is_empty() {
return self.known(tool);
}
self.mission.as_ref().map_or(true, |m| m.tools.get(tool).copied().unwrap_or(true))
}
pub fn goal_done(&self, goal: Goal) -> bool {
match goal {
Goal::Shards => self.shards_taken >= self.mission.as_ref().map_or(0, |m| m.shards),
Goal::Heart => self.gate_hp <= 0.0,
Goal::KillAll => !self.ents.iter().any(|e| e.alive && e.active_hero()),
Goal::Loot(n) => self.loot >= n as f32,
Goal::Towers => !self.towers.iter().any(|t| t.hero),
Goal::Survive(n) => self.wave >= n && !self.ents.iter().any(|e| e.alive && e.active_hero() && !e.guard && e.patrol.is_empty()),
Goal::Rivals => self.rivals.iter().all(|r| !r.alive),
Goal::Springs(n) => self.springs >= n as usize,
Goal::Freed(n) => self.freed >= n,
}
}
pub fn waves_on(&self) -> bool {
self.mission.as_ref().map_or(self.gate_on, |m| m.waves && (m.wave_limit == 0 || self.wave < m.wave_limit))
}
pub fn has_gate(&self) -> bool {
self.mission.as_ref().map_or(self.gate_on, |m| m.heart)
}
// messages, hints and sounds are meant for the master who plays at this computer: while
// another master is being simulated (his economy, his orders) nothing of that reaches the screen
fn speaking(&self) -> bool {
self.me_side == self.local_side
}
pub fn msg(&mut self, key: &'static str, args: Vec<String>) {
if !self.speaking() {
return; // a foreign master ordered something: only he hears about it
}
self.msgs.push(Msg { key, args });
if self.msgs.len() > 50 {
self.msgs.remove(0);
}
}
pub fn sfx(&mut self, sfx: Sfx, x: f32, y: f32) {
if !self.speaking() {
return;
}
if self.events.len() < 64 {
self.events.push(SfxEvent { sfx, pos: Some((x, y)) });
}
}
pub fn sfx_ui(&mut self, sfx: Sfx) {
if !self.speaking() {
return;
}
if self.events.len() < 64 {
self.events.push(SfxEvent { sfx, pos: None });
}
}
pub fn gate_center(&self) -> (f32, f32) {
(self.map.gate.0 as f32 + 1.5, self.map.gate.1 as f32 + 1.5)
}
// Army value: sqrt(sum of HP * sum of damage/s) of all own creatures.
pub fn army_value(&self) -> f32 {
let (mut h, mut d) = (0.0, 0.0);
for e in &self.ents {
if e.alive && e.creature() {
h += e.max_hp;
d += e.atk() / st(e.kind).interval;
}
}
// towers count with half their combat value
for t in self.towers.iter().filter(|t| !t.hero) {
h += 0.5 * t.max_hp();
d += 0.5 * t.dmg() / tower_stats(t.kind).interval;
}
(h * d).sqrt()
}
// imps hang torches on walls next to claimed floor (min. distance 3.5 tiles)
pub fn place_torches(&mut self, i: usize) {
let (x, y) = xy(i);
for (d, &(dx, dy)) in DIR4.iter().enumerate() {
let (wx, wy) = (x + dx, y + dy);
if !inb(wx, wy) || !self.map.t[idx(wx, wy)].solid() {
continue;
}
// the direction from the wall to the floor is the opposite one
let t = Torch { wall: idx(wx, wy), dir: (d + 2) % 4 };
let (px, pz) = t.pos();
let near = self.torches.iter().any(|o| {
let (ox, oz) = o.pos();
(ox - px).powi(2) + (oz - pz).powi(2) < 3.5 * 3.5
});
if !near {
self.torches.push(t);
}
}
}
pub(crate) fn hint(&mut self, slot: usize, key: &'static str) {
if !self.speaking() {
return; // another master ordered something: he keeps his hints for himself
}
if self.hints && self.time - self.hint_t[slot] > 30.0 {
self.hint_t[slot] = self.time;
self.msg(key, vec![]);
}
}
pub fn recount(&mut self) {
let mut c = [0usize; N_ROOMS];
self.springs = self.map.t.iter().filter(|t| t.kind == Kind::ManaSpring && t.owner == self.me).count();
for t in &self.map.t {
if t.owner == self.me {
if let Some(r) = room_index(t.kind) {
c[r] += 1;
}
}
}
self.counts = c;
}
pub fn mana_max(&self) -> f32 {
MANA_MAX + SPRING_MAX * self.springs as f32
}
pub fn gold_cap(&self) -> f32 {
CORE_STORE + TREASURY_TILE * self.counts[0] as f32
}
pub fn add_gold(&mut self, amount: f32) -> f32 {
self.add_res(amount, RES_GOLD)
}
// adds treasure of one kind (as far as there is room); returns the value accepted
pub fn add_res(&mut self, amount: f32, res: u8) -> f32 {
self.add_res_at(amount, res, None)
}
// treasure delivered at a tile: it fills the treasury tiles nearest to that place
pub fn add_res_at(&mut self, amount: f32, res: u8, at: Option<usize>) -> f32 {
let cap = self.gold_cap();
let acc = amount.min((cap - self.gold).max(0.0));
self.gold += acc;
if acc > 0.0 {
self.incoming.push((res, acc, at));
}
acc
}
// what the next payday costs: wages of all creatures plus the training bill
pub fn next_payday_cost(&self) -> f32 {
let wages: f32 = self.ents.iter().filter(|e| e.alive && e.creature()).map(|e| st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)).sum();
wages + self.training_bill
}
// The gold counter is the truth; the treasury tiles show where it lies. Added treasure fills
// tiles of the same kind first, then empty ones, the rest goes into the core. Spending takes
// from random treasury tiles first, then from the core.
pub fn sync_treasury(&mut self) {
let mut tiles = Vec::new();
let mut stored = 0.0;
for i in 0..self.map.t.len() {
let t = &mut self.map.t[i];
if t.kind == Kind::Treasury && t.owner == self.me {
tiles.push(i);
stored += t.gold as f32;
} else if t.gold != 0 && t.vein().is_none() && t.kind != Kind::Treasury {
t.gold = 0; // a sold treasury tile loses its content
t.res = 0;
}
}
let diff = self.gold - (self.reserve + stored);
let incoming = std::mem::take(&mut self.incoming);
if diff < -0.5 {
let mut need = -diff;
while need > 0.5 {
let full: Vec<usize> = tiles.iter().copied().filter(|&i| self.map.t[i].gold > 0).collect();
if full.is_empty() {
break;
}
let i = full[(self.rng.next() as usize) % full.len()];
let t = &mut self.map.t[i];
let take = (t.gold as f32).min(need.ceil());
t.gold -= take as i32;
need -= take;
}
self.reserve = (self.reserve - need.max(0.0)).max(0.0);
} else if diff > 0.5 {
let mut left = diff;
let mut parts: Vec<(u8, f32, Option<usize>)> = incoming;
let typed: f32 = parts.iter().map(|p| p.1).sum();
if typed < left {
parts.push((RES_GOLD, left - typed, None));
}
for (res, amount, at) in parts {
let mut a = amount.min(left);
left -= a;
// delivered treasure goes to the treasury tiles nearest to the place of delivery
let mut order = tiles.clone();
if let Some(at) = at {
let (ax, ay) = xy(at);
order.sort_by_key(|&i| {
let (x, y) = xy(i);
(x - ax).pow(2) + (y - ay).pow(2)
});
}
// without a place: tiles holding this treasure first, then empty tiles;
// with a place: strictly the nearest tiles that can take it
let passes: &[u8] = if at.is_some() { &[2] } else { &[0, 1] };
for &pass in passes {
for &i in &order {
if a <= 0.5 {
break;
}
let t = &mut self.map.t[i];
let fits = match pass {
0 => t.gold > 0 && t.res == res,
1 => t.gold <= 0,
_ => (t.gold > 0 && t.res == res && (t.gold as f32) < TREASURY_TILE) || t.gold <= 0,
};
if !fits {
continue;
}
let room = TREASURY_TILE - t.gold as f32;
let put = a.min(room).floor();
if put >= 1.0 {
t.gold += put as i32;
t.res = res;
a -= put;
}
}
}
self.reserve += a.max(0.0);
}
}
}
pub fn portals_owned(&self) -> usize {
(0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).count()
}
// gate defenders alive (not the treasure guards)
pub fn gate_defenders_alive(&self) -> bool {
let (gx, gy) = self.gate_center();
self.ents.iter().any(|e| e.alive && e.guard && e.captive == 0 && e.home.map_or(false, |(x, y)| (x - gx).abs() < 1.0 && (y - gy).abs() < 1.0))
}
#[allow(dead_code)]
pub fn portal_owned(&self) -> bool {
let Some(&(px, py)) = self.map.portals.first() else { return false };
self.map.t[idx(px, py)].owner == self.me
}
pub fn creature_count(&self) -> usize {
self.ents.iter().filter(|e| e.alive && e.creature()).count()
}
pub fn worker_count(&self) -> usize {
self.ents.iter().filter(|e| e.alive && !e.hero && e.kind == EK::Worker).count()
}
pub fn summon_cost(&self) -> f32 {
80.0 + 20.0 * (self.worker_count() as f32 - 4.0).max(0.0)
}
pub fn spawn(&mut self, kind: EK, hero: bool, x: f32, y: f32, level: u32) -> u32 {
let s = st(kind);
let id = self.next_id;
self.next_id += 1;
let mut e = Ent::dummy();
e.id = id;
e.kind = kind;
e.hero = hero;
e.x = x;
e.y = y;
e.level = level;
e.max_hp = s.hp * (1.0 + 0.15 * (level - 1) as f32);
e.hp = e.max_hp;
e.alive = true;
e.think = self.rng.f();
e.energy = 0.7 + self.rng.f() * 0.3;
e.hunger = self.rng.f() * 0.3;
e.anim = self.rng.f() * 10.0;
self.ents.push(e);
id
}
pub fn fx(&mut self, kind: u8, x: f32, y: f32, x2: f32, y2: f32, max: f32, col: u32) {
let h = if kind == 0 { 0.45 } else { 0.4 };
self.fx_particles(kind, x, y, h, col);
if self.fx.len() < 400 {
self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col, h, h2: 0.45, size: 1.0, style: 0 });
}
}
// projectile from (x, y) at height h to (x2, y2) at height h2
#[allow(clippy::too_many_arguments)]
pub fn shot(&mut self, x: f32, y: f32, h: f32, x2: f32, y2: f32, h2: f32, max: f32, col: u32) {
if self.fx.len() < 400 {
self.fx.push(Fx { kind: 0, x, y, x2, y2, t: 0.0, max, col, h, h2, size: 1.0, style: 0 });
}
}
// the last effect gets bigger (attacks of experienced units)
fn grow_last_fx(&mut self, size: f32) {
if let Some(f) = self.fx.last_mut() {
f.size = size;
}
}
// effect at a given height
pub fn fx_at(&mut self, kind: u8, x: f32, y: f32, h: f32, max: f32, col: u32) {
self.fx_particles(kind, x, y, h, col);
if self.fx.len() < 400 {
self.fx.push(Fx { kind, x, y, x2: 0.0, y2: 0.0, t: 0.0, max, col, h, h2: h, size: 1.0, style: 0 });
}
}
pub fn find(&self, id: u32) -> Option<usize> {
self.ents.iter().position(|e| e.id == id && e.alive && !e.held)
}
// ---------------------------------------------------------------- update
pub fn update(&mut self, dt: f32) {
self.step_acc = (self.step_acc + dt).min(STEP * MAX_STEPS_PER_FRAME as f32);
let mut steps = 0usize;
while self.step_acc >= STEP {
self.step_acc -= STEP;
steps += 1;
}
let cmds = std::mem::take(&mut self.pending);
self.apply_turn(&cmds, steps);
// what only the camera cares about runs in real time: it is not part of the shared state
self.update_particles(dt);
for f in self.fx.iter_mut() {
f.t += dt;
}
self.fx.retain(|f| f.t < f.max);
}
// one fixed step: the world of every master, then the shared bookkeeping
pub(crate) fn step_fixed(&mut self) {
self.sim_steps += 1;
if self.state == 0 && !self.paused {
self.step(STEP);
}
for side in self.master_sides() {
self.switch_to(side);
self.sync_treasury();
}
let local = self.local_side;
self.switch_to(local);
}
// ---------------------------------------------------------------- masters
pub fn master_sides(&self) -> Vec<u8> {
let mut v: Vec<u8> = std::iter::once(self.me_side).chain(self.masters.iter().map(|m| m.side)).collect();
v.sort_unstable();
v
}
pub fn is_master(&self, side: u8) -> bool {
side == self.me_side || self.masters.iter().any(|m| m.side == side)
}
fn take_active(&mut self) -> MasterState {
MasterState {
side: self.me_side,
owner: Some(self.me),
core: self.map.core,
core_hp: self.core_hp,
gold: self.gold,
mana: self.mana,
payday: self.payday,
payday_warned: self.payday_warned,
hand: std::mem::take(&mut self.hand),
flag: self.flag,
reserve: self.reserve,
incoming: std::mem::take(&mut self.incoming),
training_bill: self.training_bill,
counts: self.counts,
springs: self.springs,
known: std::mem::take(&mut self.known),
researchable: std::mem::take(&mut self.researchable),
research: self.research,
forge_work: self.forge_work,
rune_free: self.rune_free,
rune_final: self.rune_final,
rune_next: self.rune_next,
portal_timer: self.portal_timer,
punch_ready: self.punch_ready,
}
}
fn put_active(&mut self, m: MasterState) {
self.me_side = m.side;
self.me = m.owner.unwrap_or(Owner::of_side(m.side));
ME_SIDE.with(|s| s.set(m.side));
self.map.core = m.core;
self.core_hp = m.core_hp;
self.gold = m.gold;
self.mana = m.mana;
self.payday = m.payday;
self.payday_warned = m.payday_warned;
self.hand = m.hand;
self.flag = m.flag;
self.reserve = m.reserve;
self.incoming = m.incoming;
self.training_bill = m.training_bill;
self.counts = m.counts;
self.springs = m.springs;
self.known = m.known;
self.researchable = m.researchable;
self.research = m.research;
self.forge_work = m.forge_work;
self.rune_free = m.rune_free;
self.rune_final = m.rune_final;
self.rune_next = m.rune_next;
self.portal_timer = m.portal_timer;
self.punch_ready = m.punch_ready;
}
// make `side` the master being processed (no-op for sides that are no masters)
pub fn switch_to(&mut self, side: u8) {
if side == self.me_side {
return;
}
let Some(k) = self.masters.iter().position(|m| m.side == side) else { return };
let target = self.masters.swap_remove(k);
let cur = self.take_active();
self.masters.push(cur);
self.put_active(target);
}
// a new master on `side` with its core at (cx, cy) (top-left of the 3x3 core). Used by the
// multiplayer lobby (and its tests); single player games have no stored masters.
#[allow(dead_code)]
pub fn add_master(&mut self, side: u8, core: (i32, i32)) {
let mut m = self.take_active();
let me = std::mem::replace(&mut m, MasterState::default());
// the copy keeps the economy settings; the new one starts fresh
let fresh = MasterState {
side,
owner: Some(Owner::of_side(side)),
core,
core_hp: CORE_HP,
gold: 1500.0,
mana: 100.0,
payday: PAYDAY,
known: me.known.clone(),
researchable: me.researchable.clone(),
portal_timer: 10.0,
..MasterState::default()
};
self.put_active(me);
self.masters.push(fresh);
}
pub(crate) fn update_units(&mut self, dt: f32) {
for i in 0..self.ents.len() {
if !self.ents[i].alive || self.ents[i].held {
continue;
}
let side = self.ents[i].side;
if !self.ents[i].hero && side != self.me_side && !self.masters.is_empty() {
self.switch_to(side);
}
let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy());
let (x0, y0) = (e.x, e.y);
self.update_ent(&mut e, dt);
self.turn(&mut e, x0, y0);
self.ents[i] = e;
}
// units that wasted away (plague)
for j in 0..self.ents.len() {
if self.ents[j].alive && !self.ents[j].held && self.ents[j].hp <= 0.0 {
self.kill(j);
}
}
self.ents.retain(|e| e.alive);
}
fn step(&mut self, dt: f32) {
if self.demo.is_some() {
self.demo_step(dt);
return;
}
self.time += dt;
for t in self.map.t.iter_mut() {
if t.kind == Kind::Farm {
t.food = (t.food + dt / 18.0).min(3.0);
}
}
for side in self.master_sides() {
self.switch_to(side);
self.economy(dt);
}
self.after_economy(dt);
}
// a master's household: mana, core, portals, payday
fn economy(&mut self, dt: f32) {
self.recount();
self.mana = (self.mana + (1.0 + SPRING_MANA * self.springs as f32) * dt).min(self.mana_max());
self.core_hp = (self.core_hp + 0.5 * dt).min(CORE_HP);
let owned = self.portals_owned();
if owned > 0 {
self.portal_timer -= dt;
if self.portal_timer <= 0.0 {
self.portal_timer = (20.0 + self.rng.f() * 14.0) / (owned as f32).sqrt() * self.lair_factor();
self.try_portal();
}
}
self.payday -= dt;
if !self.payday_warned && self.payday <= PAYDAY_WARN {
self.payday_warned = true;
let cost = self.next_payday_cost();
if cost >= 1.0 {
self.msg("msg.payday_soon", vec![(self.payday.ceil() as i32).to_string(), (cost.ceil() as i32).to_string()]);
self.sfx_ui(Sfx::FlagSet);
if cost > self.gold {
self.hint(22, "hint.payday_short");
}
}
}
if self.payday <= 0.0 {
self.payday = PAYDAY;
self.payday_warned = false;
self.do_payday();
}
// every two seconds: corners between reinforced walls are closed
if (self.time / 2.0).floor() != ((self.time - dt) / 2.0).floor() {
self.reinforce_corners();
}
self.update_forge(dt);
self.update_research();
}
fn after_economy(&mut self, dt: f32) {
// from here on it is about the local master and the humans: that side is switched in first
let local = self.local_side;
self.switch_to(local);
if self.waves_on() {
self.wave_tick(dt);
}
if self.time - self.gate_hit_t > 10.0 && self.has_gate() {
self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP);
}
self.update_contested();
self.update_units(dt);
self.switch_to(local); // update_units worked master by master
self.update_towers(dt);
self.update_reveals(dt);
self.update_graves(dt);
if !self.rivals.is_empty() {
self.update_rivals(dt);
}
self.update_dwarves(dt);
self.check_end();
self.update_fog(dt);
}
fn check_end(&mut self) {
if self.core_hp <= 0.0 {
self.core_hp = 0.0;
self.state = 2;
self.msg("msg.defeat", vec![]);
self.sfx_ui(Sfx::Defeat);
return;
}
if let Some(m) = &self.mission {
if m.time_limit > 0.0 && self.time > m.time_limit {
self.state = 2;
self.msg("msg.time_up", vec![]);
self.sfx_ui(Sfx::Defeat);
return;
}
let goals = m.goals.clone();
if !goals.is_empty() && goals.iter().all(|&g| self.goal_done(g)) {
self.state = 1;
self.msg("msg.mission_won", vec![]);
self.sfx_ui(Sfx::Victory);
}
return;
}
// free game: every opponent (rival masters and the humans' gate) must fall
let rivals_out = self.rivals.iter().all(|r| !r.alive);
let gate_out = !self.gate_on || self.gate_hp <= 0.0;
if rivals_out && gate_out {
self.gate_hp = self.gate_hp.max(0.0);
self.state = 1;
self.msg("msg.victory", vec![]);
self.sfx_ui(Sfx::Victory);
}
}
fn wave_tick(&mut self, dt: f32) {
self.wave_timer -= dt;
if !self.warned && self.wave_timer < 30.0 {
self.warned = true;
self.plan_entries();
if self.planned_entries.is_empty() {
self.msg("msg.wave_warn", vec![(self.wave + 1).to_string()]);
} else {
let dirs: Vec<String> = self.planned_entries.iter().map(|&p| format!("@{}", self.direction_key(p))).collect();
if dirs.len() >= 2 {
self.msg("msg.wave_warn_two", vec![(self.wave + 1).to_string(), dirs[0].clone(), dirs[1].clone()]);
} else {
self.msg("msg.wave_warn_dir", vec![(self.wave + 1).to_string(), dirs[0].clone()]);
}
}
self.sfx_ui(Sfx::WaveAlarm);
}
// markers disappear when no hero is on the way to an entry point any more
if !self.planned_entries.is_empty() && self.wave_timer > 30.0 && !self.ents.iter().any(|e| e.alive && e.waypoint.is_some()) {
self.planned_entries.clear();
}
if self.wave_timer <= 0.0 {
self.spawn_wave();
self.wave += 1;
self.wave_timer = WAVE_GAP + self.rng.f() * 40.0;
self.warned = false;
}
}
// the imp working on tile t (dig, claim or reinforce)
fn task_worker(&self, t: usize) -> Option<usize> {
self.ents.iter().position(|o| o.alive && !o.hero && o.kind == EK::Worker && matches!(o.job, Job::Dig(x) | Job::Claim(x) | Job::Reinforce(x) if x == t))
}
// imps helping at tile t (standing next to it)
fn helpers(&self, t: usize) -> usize {
let (tx, ty) = xy(t);
self.ents
.iter()
.filter(|o| o.alive && o.kind == EK::Worker && o.job == Job::Help(t) && o.path.is_empty() && (o.x - tx as f32 - 0.5).abs() < 1.6 && (o.y - ty as f32 - 0.5).abs() < 1.6)
.count()
}
// working speed at a tile: +25% per helper, at most 200% (five imps)
fn help_speed(&self, t: usize) -> f32 {
1.0 + 0.25 * self.helpers(t).min(HELPERS_MAX) as f32
}
// an idle imp walks to the nearest task with room for another helper
fn start_help(&mut self, e: &mut Ent) -> bool {
let tasks: Vec<usize> = self
.ents
.iter()
.filter(|o| o.alive && !o.hero && o.kind == EK::Worker)
.filter_map(|o| match o.job {
Job::Dig(t) | Job::Claim(t) | Job::Reinforce(t) => Some(t),
_ => None,
})
.filter(|&t| self.ents.iter().filter(|o| o.alive && o.job == Job::Help(t)).count() < HELPERS_MAX)
.collect();
if tasks.is_empty() {
return false;
}
let cur = e.tile();
let near = |i: usize| tasks.iter().copied().find(|&t| i != t && adj4(i, t) || (i == t && self.map.t[t].walkable()));
let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| near(i).is_some(), true) else { return false };
let end = p.last().copied().unwrap_or(cur);
let Some(t) = near(end) else { return false };
e.path = p;
e.job = Job::Help(t);
e.think = 1.5;
true
}
// free beds in the lairs: much room lets creatures arrive faster, little room slower
pub fn free_beds(&self) -> usize {
let (lair, aerie) = self.free_lair_aerie();
lair + aerie
}
// free places in the lairs (walking creatures) and in the aerie (flying creatures)
pub fn free_lair_aerie(&self) -> (usize, usize) {
let flyers = self.ents.iter().filter(|e| e.alive && e.creature() && flies(e.kind)).count();
let walkers = self.creature_count() - flyers;
(self.counts[1].min(30).saturating_sub(walkers), self.counts[7].min(20).saturating_sub(flyers))
}
pub fn lair_factor(&self) -> f32 {
(8.0 / (self.free_beds() as f32 + 2.0)).clamp(0.35, 3.0)
}
#[cfg(test)]
pub fn try_portal_for_test(&mut self) {
let (px, py) = self.map.portals[0];
for y in 0..3 {
for x in 0..3 {
self.map.t[idx(px + x, py + y)].owner = self.me;
}
}
self.try_portal();
}
fn try_portal(&mut self) {
let lair = self.counts[1];
let aerie = self.counts[7];
let farm = self.counts[2];
if lair == 0 && aerie == 0 {
self.hint(0, "hint.portal_no_lair");
return;
}
if farm == 0 {
self.hint(1, "hint.no_farm");
return;
}
let (free_lair, free_aerie) = self.free_lair_aerie();
if free_lair == 0 && free_aerie == 0 {
self.hint(2, "hint.lair_full");
return;
}
let mut opts: Vec<(EK, u32)> = Vec::new();
// flying creatures need room in the aerie
if free_aerie > 0 {
if aerie >= 4 {
opts.push((EK::Bat, 4));
}
if aerie >= 6 && self.counts[5] >= 4 {
opts.push((EK::Gargoyle, 2));
}
if aerie >= 6 && self.counts[4] >= 6 {
opts.push((EK::Owl, 2));
}
let has_wyrm = self.ents.iter().any(|e| e.alive && e.kind == EK::Wyrm && !e.hero);
if aerie >= 16 && self.counts[0] >= 16 && !has_wyrm {
opts.push((EK::Wyrm, 1));
}
}
if free_lair == 0 {
if opts.is_empty() {
self.hint(2, "hint.lair_full");
return;
}
} else {
self.portal_walkers(&mut opts);
}
if opts.is_empty() {
return;
}
self.portal_arrival(opts);
}
// walking creatures that the rooms attract
fn portal_walkers(&mut self, opts: &mut Vec<(EK, u32)>) {
let farm = self.counts[2];
opts.push((EK::Kobold, 4));
if farm >= 6 {
opts.push((EK::Spider, 3));
}
if self.counts[4] >= 6 {
opts.push((EK::Mage, 3));
}
if self.counts[3] >= 9 {
opts.push((EK::Ogre, 2));
}
if self.counts[5] >= 6 {
opts.push((EK::Jailer, 2));
}
if self.counts[6] >= 6 {
opts.push((EK::Priestess, 2));
}
if self.counts[ROOM_FORGE] >= 6 {
opts.push((EK::Cogling, 3));
}
// the Chainbreaker only visits big dungeons with experienced creatures and a rich treasury
let room_tiles: usize = self.counts.iter().sum();
let veteran = self.ents.iter().any(|e| e.alive && e.creature() && e.level >= 5);
let has_one = self.ents.iter().any(|e| e.alive && e.kind == EK::Chainbreaker && !e.hero);
if room_tiles >= 40 && veteran && self.gold >= 4000.0 && !has_one {
opts.push((EK::Chainbreaker, 1));
}
}
fn portal_arrival(&mut self, opts: Vec<(EK, u32)>) {
let total: u32 = opts.iter().map(|o| o.1).sum();
let mut r = self.rng.next() % total;
let mut kind = EK::Kobold;
for (k, w) in opts {
if r < w {
kind = k;
break;
}
r -= w;
}
let owned: Vec<usize> = (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).collect();
let pk = owned[(self.rng.next() as usize) % owned.len()];
let (px, py) = self.map.portals[pk];
let id = self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1);
// new creatures report at the portal and set up their bed first
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.settled = false;
e.job = Job::Register;
e.timer = 2.0;
}
self.fx(3, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 0.8, 0xffcc44ff);
self.sfx(Sfx::PortalArrive, px as f32 + 1.5, py as f32 + 1.5);
self.msg("msg.creature_arrived", vec![format!("@{}", st(kind).name)]);
}
// Payday: the training bill is paid at once; every creature fetches its wage from a
// treasury itself, at a random moment within about a minute
fn do_payday(&mut self) {
let bill = self.training_bill.min(self.gold).max(0.0);
self.gold -= bill;
self.training_bill = 0.0;
let mut n = 0;
let mut total = bill;
for i in 0..self.ents.len() {
if !self.ents[i].alive || !self.ents[i].creature() {
continue;
}
let r = self.rng.f();
let e = &mut self.ents[i];
let w = st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32);
e.pay_due += w;
e.pay_t = r * 55.0;
n += 1;
total += w;
}
if n > 0 || bill > 0.0 {
self.sfx_ui(Sfx::Payday);
self.msg("msg.payday_walk", vec![n.to_string(), (total as i32).to_string()]);
}
}
// a creature goes for its wage: the nearest treasury tile holding treasure, else the core
fn start_fetch_wage(&mut self, e: &mut Ent) {
let cur = e.tile();
let goal = |i: usize| {
let t = &self.map.t[i];
(t.kind == Kind::Treasury && t.owner == self.me && t.gold > 0) || (self.counts[0] == 0 && self.map.adj_core(i))
};
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), goal, true) } else { self.map.search(cur, self.map.walk_cost(), goal, true) };
match found {
Some(p) => {
let t = p.last().copied().unwrap_or(cur);
e.path = p;
e.job = Job::FetchWage(t);
}
// no way to a treasury: the wage is paid out anyway
None => self.take_wage(e, None),
}
}
// takes the wage out of the treasury tile (the rest from elsewhere); unpaid = angry
fn take_wage(&mut self, e: &mut Ent, at: Option<usize>) {
let mut need = e.pay_due;
if let Some(t) = at {
let tile = &mut self.map.t[t];
if tile.kind == Kind::Treasury {
let take = (tile.gold as f32).min(need).floor();
tile.gold -= take as i32;
self.gold -= take;
need -= take;
}
}
let rest = need.min(self.gold.max(0.0));
self.gold -= rest;
need -= rest;
e.pay_due = 0.0;
if need > 0.5 {
e.happy -= 0.45;
self.msg("msg.wage_unpaid", vec![format!("@{}", st(e.kind).name)]);
self.sfx(Sfx::Error, e.x, e.y);
} else {
e.happy = (e.happy + 0.2).min(1.0);
self.fx(5, e.x, e.y, 0.0, 0.0, 0.8, 0xff30d0ff);
self.sfx(Sfx::Deposit, e.x, e.y);
}
}
// a new creature: report at the portal, go to the lair (aerie), set up the bed
fn settle(&mut self, e: &mut Ent, dt: f32) {
let me = e.id + 1;
match e.job {
Job::Register => {
e.working = true;
e.timer -= dt;
if e.timer <= 0.0 {
let cur = e.tile();
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
let free = |i: usize| self.map.t[i].kind == bed_kind && self.map.t[i].bed == 0;
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) };
match found {
Some(p) => {
let b = p.last().copied().unwrap_or(cur);
self.map.t[b].bed = me;
e.bed = Some(b);
e.path = p;
e.job = Job::MakeBed(b);
e.timer = 4.0;
}
None => {
e.settled = true;
e.job = Job::None;
}
}
}
}
Job::MakeBed(b) => {
if self.step_path(e, dt) {
return;
}
if self.map.t[b].bed != me {
e.settled = true;
e.job = Job::None;
return;
}
e.working = true;
e.timer -= dt;
if e.timer <= 0.0 {
e.settled = true;
e.job = Job::None;
let (bx, by) = xy(b);
self.fx(8, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff40d0ff);
self.sfx(Sfx::Build, bx as f32 + 0.5, by as f32 + 0.5);
}
}
_ => {
e.job = Job::Register;
e.timer = 2.0;
}
}
}
// strength of a squad (same formula as the army value)
pub(crate) fn squad_value(list: &[(EK, u32)]) -> f32 {
let (mut h, mut d) = (0.0, 0.0);
for &(k, l) in list {
let s = st(k);
h += s.hp * (1.0 + 0.15 * (l - 1) as f32);
d += s.atk * (1.0 + 0.12 * (l - 1) as f32) / s.interval;
}
(h * d).sqrt()
}
// builds a hero squad with at least the target strength
fn build_squad(&mut self, target: f32, base_level: u32, pool: &[(EK, u32)], boss: Option<EK>, min: usize) -> Vec<(EK, u32)> {
let total: u32 = pool.iter().map(|p| p.1).sum();
let mut list = Vec::new();
for level in base_level.min(10)..=10 {
list.clear();
if let Some(b) = boss {
list.push((b, level));
}
while list.len() < MAX_SQUAD && (list.len() < min || Self::squad_value(&list) < target) {
let mut r = self.rng.next() % total;
for &(k, w) in pool {
if r < w {
list.push((k, level));
break;
}
r -= w;
}
}
if Self::squad_value(&list) >= target {
break;
}
}
list
}
fn spawn_squad(&mut self, list: &[(EK, u32)], guard: bool) {
let (gx, gy) = self.map.gate;
for &(k, l) in list {
let x = gx as f32 + 0.5 + self.rng.f() * 2.0;
let y = gy as f32 + 0.5 + self.rng.f() * 2.0;
let id = self.spawn(k, true, x, y, l);
let home = (gx as f32 + 1.5, gy as f32 + 1.5);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.guard = guard;
if guard {
e.home = Some(home);
}
}
}
self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 1.0, 0xffffeecc);
}
// Endless waves: strength 60% -> 100% of the army value, with some randomness, at least 2 knights.
fn spawn_wave(&mut self) {
use EK::*;
let w = self.wave + 1;
let ratio = (0.6 + 0.05 * (w - 1) as f32).min(1.0) * (0.88 + self.rng.f() * 0.24);
let min_value = Self::squad_value(&[(Knight, 1), (Knight, 1)]);
let target = (self.army_value() * ratio).max(min_value);
let mut pool = vec![(Knight, 4), (Archer, 3)];
if w >= 2 {
pool.push((Dwarf, 2));
}
if w >= 3 {
pool.push((Priest, 2));
pool.push((Fairy, 1));
}
if w >= 4 {
pool.push((BattleMage, 1));
}
if w >= 6 {
pool.push((Giant, 1));
}
let boss = if w % 5 == 0 { Some(Paladin) } else { None };
let base_level = 1 + (w - 1) / 3;
let list = self.build_squad(target, base_level, &pool, boss, 2);
self.last_wave_value = Self::squad_value(&list);
let before = self.ents.len();
self.spawn_squad(&list, false);
// the humans attack everybody: now and then a rival master instead of the player
let alive: Vec<u8> = self.rivals.iter().filter(|r| r.alive).map(|r| r.side).collect();
if !alive.is_empty() {
let r = (self.rng.next() as usize) % (alive.len() + 2);
if r >= 2 {
let side = alive[r - 2];
for e in self.ents[before..].iter_mut() {
e.target = Some(side);
e.waypoint = None;
}
self.planned_entries.clear();
self.sfx_ui(Sfx::WaveStart);
self.msg(if side == 2 { "msg.wave_on_rival1" } else { "msg.wave_on_rival2" }, vec![w.to_string(), list.len().to_string()]);
return;
}
}
if self.planned_entries.is_empty() {
self.plan_entries();
}
let entries = self.planned_entries.clone();
if !entries.is_empty() {
let split = list.len() >= 6 && entries.len() >= 2;
// the paladin (if any) is spawned first and joins group A, which gets the extra hero
for (k, e) in self.ents[before..].iter_mut().enumerate() {
e.waypoint = Some(if split && k % 2 == 1 { entries[1] } else { entries[0] });
}
if !split {
self.planned_entries.truncate(1);
}
self.entry_history.extend(self.planned_entries.iter().copied());
let n = self.entry_history.len();
if n > 4 {
self.entry_history.drain(0..n - 4);
}
}
self.sfx_ui(Sfx::WaveStart);
if boss.is_some() {
self.msg("msg.wave_boss", vec![w.to_string(), list.len().to_string()]);
} else {
self.msg("msg.wave", vec![w.to_string(), list.len().to_string()]);
}
}
// defenders at 75 / 50 / 25 % gate HP
fn check_gate_stage(&mut self) {
use EK::*;
let frac = self.gate_hp / GATE_HP;
let want = if frac <= 0.25 { 3 } else if frac <= 0.5 { 2 } else if frac <= 0.75 { 1 } else { 0 };
while self.gate_stage < want {
self.gate_stage += 1;
let st_i = self.gate_stage;
let ratio = [0.5, 0.65, 0.85][(st_i - 1) as usize] * (0.9 + self.rng.f() * 0.2);
let min_value = Self::squad_value(&[(Knight, 2), (Knight, 2), (Archer, 2)]);
let target = (self.army_value() * ratio).max(min_value);
let pool = [(Knight, 4), (Archer, 3), (Priest, 2), (Dwarf, 1)];
let boss = if st_i == 3 { Some(Paladin) } else { None };
let base_level = 2 + self.wave / 3 + st_i;
let list = self.build_squad(target, base_level, &pool, boss, 3);
self.spawn_squad(&list, true);
self.sfx_ui(Sfx::GateDefenders);
if st_i == 3 {
self.msg("msg.gate_boss", vec![(list.len() - 1).to_string()]);
} else {
self.msg("msg.gate_defenders", vec![list.len().to_string()]);
}
}
}
fn hit_gate(&mut self, e: &mut Ent) {
let s = st(e.kind);
e.cd = s.interval;
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
self.gate_hp -= dmg;
self.gate_hit_t = self.time;
e.xp += dmg * 0.3;
let (gx, gy) = self.gate_center();
if s.range > 1.5 {
let col = if e.kind == EK::Mage { 0xffff44cc } else { 0xff70b0d0 };
self.fx(0, e.x, e.y, gx, gy, 0.25, col);
} else {
let fx = (e.x + gx) * 0.5;
let fy = (e.y + gy) * 0.5;
self.fx(2, fx, fy, 0.0, 0.0, 0.25, 0xffffffff);
}
self.sfx(Sfx::GateHit, gx, gy);
self.hint(10, "hint.gate_attack");
if self.mission.as_ref().map_or(true, |m| m.gate_defenders) {
self.check_gate_stage();
}
}
fn update_ent(&mut self, e: &mut Ent, dt: f32) {
e.anim += dt;
e.flash = (e.flash - dt).max(0.0);
e.slow = (e.slow - dt).max(0.0);
e.skill_cd -= dt;
e.boost = (e.boost - dt).max(0.0);
if e.stun > 0.0 {
e.stun -= dt;
e.moving = false;
return;
}
self.tick_effects(e, dt);
// slapped units work and move faster, hasted ones even more
let dt = if e.boost > 0.0 { dt * 1.5 } else { dt } * if e.haste > 0.0 { 1.6 } else { 1.0 };
e.cd -= dt;
e.think -= dt;
e.repath -= dt;
e.moving = false;
e.working = false;
let s = st(e.kind);
while e.level < 10 && e.xp >= 100.0 * e.level as f32 {
e.xp -= 100.0 * e.level as f32;
e.level += 1;
let old = e.max_hp;
e.max_hp = s.hp * (1.0 + 0.15 * (e.level - 1) as f32);
e.hp += e.max_hp - old;
self.fx(7, e.x, e.y, 0.0, 0.0, 0.8, 0xff44ddff);
if !e.hero {
self.sfx(Sfx::LevelUp, e.x, e.y);
}
if !e.hero && e.level >= 3 {
self.msg("msg.level_up", vec![format!("@{}", s.name), e.level.to_string()]);
}
}
if e.chained {
// waits in chains until the player's imps claim its cave
e.moving = false;
let (x, y) = xy(e.tile());
let reached = std::iter::once((0, 0)).chain(DIR8.iter().copied()).any(|(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |t| t.owner == self.me));
if reached {
e.chained = false;
e.settled = false;
e.job = Job::Register;
e.timer = 1.0;
e.happy = 1.0;
self.freed += 1;
self.fx(7, e.x, e.y, 0.0, 0.0, 1.2, 0xff44ddff);
self.sfx(Sfx::Convert, e.x, e.y);
self.msg("msg.creature_freed", vec![format!("@{}", st(e.kind).name)]);
}
} else if self.demo.is_some() {
self.demo_ai(e, dt);
} else if e.hero && e.captive != 0 {
self.captive_update(e, dt);
} else if e.rival() {
self.rival_ai(e, dt);
} else if e.dwarf() {
self.dwarf_ai(e, dt);
} else if e.hero {
self.hero_ai(e, dt);
} else if e.kind == EK::Worker {
self.worker_ai(e, dt);
} else {
self.creature_ai(e, dt);
}
}
// the direction a unit faces: where it walks, otherwise what it works on or fights
fn turn(&self, e: &mut Ent, x0: f32, y0: f32) {
let (dx, dy) = (e.x - x0, e.y - y0);
if dx * dx + dy * dy > 1e-8 {
e.dir = dy.atan2(dx);
return;
}
if !e.working {
return;
}
let tile = |t: usize| {
let (x, y) = xy(t);
Some((x as f32 + 0.5, y as f32 + 0.5))
};
let target = match e.job {
Job::Dig(t) | Job::Reinforce(t) | Job::Help(t) | Job::TearBridge(t) => tile(t),
Job::Attack(id) | Job::Torment(id) | Job::Carry(id) | Job::Brawl(id) => self.find(id).map(|j| (self.ents[j].x, self.ents[j].y)),
Job::Repair(id) | Job::AttackTower(id) => self.towers.iter().find(|t| t.id == id).map(|t| t.pos()),
_ => None,
};
if let Some((tx, ty)) = target {
let (dx, dy) = (tx - e.x, ty - e.y);
if dx * dx + dy * dy > 0.01 {
e.dir = dy.atan2(dx);
}
}
}
// moves a unit along its path; true = still moving
pub(crate) fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool {
if e.path.is_empty() {
return false;
}
let next = e.path[0];
let tile = self.map.t[next];
if tile.kind == Kind::Door {
if e.side() != 0 {
// enemies smash doors on their way
if self.bash_door(e, next, dt) {
return true;
}
} else if tile.res & 1 == 1 && e.tile() != next {
// locked: our own creatures have to find another way
e.path.clear();
return false;
}
}
if !tile.walkable() && !(flies(e.kind) && tile.liquid()) {
if (e.hero || e.rival()) && tile.diggable() {
// heroes dig their way through
e.working = true;
let (tx, ty) = xy(next);
e.face = sgn(tx as f32 + 0.5 - e.x);
e.timer += dt;
if e.timer >= 0.5 {
e.timer = 0.0;
let pow = match e.kind {
EK::Dwarf => 3.0,
EK::Paladin | EK::Giant => 2.0,
_ => 1.0,
} * if e.kind == EK::BattleMage && (tile.reinforced || tile.kind == Kind::Steel) {
// the battle mage blasts reinforced walls (steel resists better)
if tile.kind == Kind::Steel {
0.4
} else {
1.6
}
} else if tile.kind == Kind::Steel {
0.05
} else if tile.reinforced {
0.2
} else {
1.0
};
if e.kind == EK::BattleMage {
let (sx, sy) = (e.x, e.y);
self.shot(sx, sy, 0.6, tx as f32 + 0.5, ty as f32 + 0.5, 0.5, 0.3, 0xff3080ff);
self.style_last_fx(crate::particles::ST_FIRE);
}
self.fx(6, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.4, 0xff3a5a7a);
self.sfx(Sfx::Dig, tx as f32 + 0.5, ty as f32 + 0.5);
let t = &mut self.map.t[next];
t.hp -= pow;
if t.hp <= 0.0 {
self.dig_out(next);
}
}
return true;
}
e.path.clear();
return false;
}
let (tx, ty) = xy(next);
let (cx, cy) = (tx as f32 + 0.5, ty as f32 + 0.5);
let dx = cx - e.x;
let dy = cy - e.y;
let d = (dx * dx + dy * dy).sqrt();
let mut sp = st(e.kind).speed * dt * if e.slow > 0.0 { 0.55 } else { 1.0 };
if !e.hero && e.creature() && e.hunger > 2.0 {
sp *= 0.8;
}
if d <= sp {
e.x = cx;
e.y = cy;
e.path.remove(0);
} else {
e.x += dx / d * sp;
e.y += dy / d * sp;
}
if dx.abs() > 0.01 {
e.face = sgn(dx);
}
e.moving = true;
true
}
pub fn dig_out(&mut self, i: usize) {
self.torches.retain(|t| t.wall != i);
let t = &mut self.map.t[i];
t.kind = Kind::Floor;
t.owner = Owner::None;
t.marked = false;
t.reinforced = false;
t.reserved = 0;
t.hp = 0.0;
t.gold = 0;
let (x, y) = xy(i);
self.fx(3, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff304a66);
self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5);
}
pub(crate) fn unreserve(&mut self, me: u32) {
for t in self.map.t.iter_mut() {
if t.reserved == me {
t.reserved = 0;
}
}
}
pub(crate) fn release_all(&mut self, id: u32) {
let me = id + 1;
for t in self.map.t.iter_mut() {
if t.reserved == me {
t.reserved = 0;
}
if t.bed == me {
t.bed = 0;
}
}
}
pub(crate) fn los(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> bool {
let dx = x1 - x0;
let dy = y1 - y0;
let d = (dx * dx + dy * dy).sqrt();
let n = (d * 4.0) as i32 + 1;
for k in 1..n {
let f = k as f32 / n as f32;
let x = (x0 + dx * f) as i32;
let y = (y0 + dy * f) as i32;
if let Some(t) = self.map.get(x, y) {
if t.solid() || t.kind == Kind::Core {
return false;
}
}
}
true
}
pub(crate) fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option<u32> {
let mut best = None;
let mut bd = radius * radius;
for o in &self.ents {
// every other side is an enemy
if !o.alive || o.held || o.side() == e.side() || o.captive != 0 || o.chained || o.invis > 0.0 {
continue;
}
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
if d < bd {
bd = d;
best = Some(o.id);
}
}
best
}
// fight a specific unit; false = target invalid/unreachable
pub(crate) fn attack_job(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
let Some(j) = self.find(id) else { return false };
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
let s = st(e.kind);
if d > 12.0 {
return false;
}
if e.kind == EK::Jailer && e.skill_cd <= 0.0 && d > 1.4 && d < 4.5 && self.los(e.x, e.y, tx, ty) {
// jailer: pulls the enemy in with a chain
let (dx, dy) = norm(tx - e.x, ty - e.y);
let (nx, ny) = (e.x + dx * 0.9, e.y + dy * 0.9);
if self.map.walk(nx as i32, ny as i32) {
self.fx(0, tx, ty, e.x, e.y, 0.3, 0xff8090a0);
self.sfx(Sfx::Capture, e.x, e.y);
let t = &mut self.ents[j];
t.x = nx;
t.y = ny;
t.path.clear();
e.skill_cd = 6.0;
return true;
}
}
if d <= s.range + 0.45 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
e.path.clear();
e.face = sgn(tx - e.x);
e.working = true;
if e.cd <= 0.0 && self.use_ability(e, j) {
return true;
}
if e.cd <= 0.0 {
e.cd = s.interval;
self.hit(e, j);
}
return true;
}
if e.path.is_empty() || e.repath <= 0.0 {
e.repath = 0.6;
let tt = idx(tx as i32, ty as i32);
let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), tt) } else { self.map.path_to(e.tile(), tt) };
match p {
Some(p) => e.path = p,
None => return false,
}
}
if !self.step_path(e, dt) && e.tile() == idx(tx as i32, ty as i32) {
// same tile: approach directly
let dx = tx - e.x;
let dy = ty - e.y;
let l = (dx * dx + dy * dy).sqrt().max(0.001);
let sp = s.speed * dt;
e.x += dx / l * sp.min(l);
e.y += dy / l * sp.min(l);
}
true
}
fn hit(&mut self, e: &mut Ent, j: usize) {
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
let s = st(e.kind);
// the higher the level, the bigger the attack looks (up to almost twice at Lv 10)
let lv = 1.0 + 0.1 * (e.level.max(1) - 1) as f32;
if s.range > 1.5 {
let col = match e.kind {
EK::Mage => 0xffff44cc,
EK::Priest => 0xff88ffff,
EK::Priestess => 0xff3080ff,
EK::Owl => 0xffffa060,
EK::Wyrm => 0xff2080ff,
_ => 0xff70b0d0,
};
// flyers shoot from the air
let h = if flies(e.kind) { 0.8 } else { 0.45 };
self.shot(e.x, e.y, h, tx, ty, 0.45, 0.25, col);
self.grow_last_fx(lv);
use crate::particles::*;
self.style_last_fx(match e.kind {
EK::Archer => ST_ARROW,
EK::Mage => ST_SHADOW,
EK::Priest => ST_HOLY,
EK::Priestess | EK::Wyrm | EK::BattleMage => ST_FIRE,
EK::Owl => ST_ARCANE,
_ => ST_PLAIN,
});
self.sfx(Sfx::Shoot, e.x, e.y);
} else {
self.fx(2, tx, ty, 0.0, 0.0, 0.25, if e.level >= 9 { 0xff60e0ff } else { 0xffffffff });
self.grow_last_fx(lv);
self.sfx(Sfx::MeleeHit, tx, ty);
}
e.xp += dmg * 0.6;
if e.kind == EK::Chainbreaker {
// twin axes: a second enemy next to the Chainbreaker takes half damage
let me = e.side();
let second = self
.ents
.iter()
.enumerate()
.find(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - e.x).powi(2) + (x.y - e.y).powi(2) < 2.6)
.map(|(o, _)| o);
if let Some(o) = second {
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
self.fx(2, ox, oy, 0.0, 0.0, 0.25, 0xff40a0ff);
self.grow_last_fx(lv);
self.ents[o].hp -= dmg * 0.5;
self.ents[o].flash = 0.15;
if self.ents[o].hp <= 0.0 {
self.kill(o);
}
}
}
if e.kind == EK::Priestess || e.kind == EK::Wyrm || e.kind == EK::BattleMage {
// fireball / fire breath: half damage to enemies around the target
let me = e.side();
let splash: Vec<usize> = self
.ents
.iter()
.enumerate()
.filter(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < if crate::personality::knows(e, "inferno") { 6.25 } else { 1.44 })
.map(|(o, _)| o)
.collect();
for o in splash {
if self.ents[o].alive {
self.ents[o].hp -= dmg * 0.5;
self.ents[o].flash = 0.15;
if self.ents[o].hp <= 0.0 {
self.kill(o);
}
}
}
if !self.ents[j].alive {
return;
}
}
// learned abilities that come with a blow
if crate::personality::knows(e, "drain") {
e.hp = (e.hp + dmg * 0.3).min(e.max_hp);
}
if crate::personality::knows(e, "web") && self.rng.f() < 0.3 {
self.ents[j].slow = self.ents[j].slow.max(3.0);
}
if crate::personality::knows(e, "shackle") && self.rng.f() < 0.2 {
self.ents[j].stun = self.ents[j].stun.max(2.0);
}
if crate::personality::knows(e, "volley") {
let me = e.side();
let second = self.ents.iter().enumerate().find(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 6.0).map(|(o, _)| o);
if let Some(o) = second {
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
self.shot(e.x, e.y, 0.8, ox, oy, 0.45, 0.25, 0xffffa060);
self.style_last_fx(crate::particles::ST_ARCANE);
self.ents[o].hp -= dmg * 0.6;
self.ents[o].flash = 0.15;
if self.ents[o].hp <= 0.0 {
self.kill(o);
}
}
}
let my_id = e.id;
e.invis = 0.0; // attacking breaks invisibility
let t = &mut self.ents[j];
t.hp -= if t.shield > 0.0 { dmg * 0.5 } else { dmg };
t.flash = 0.15;
if t.creature() && !matches!(t.job, Job::Attack(_)) {
t.job = Job::Attack(my_id);
t.path.clear();
}
if t.hp <= 0.0 {
self.kill(j);
}
}
// free places in the torture chamber (one prisoner per 2 tiles)
pub fn prison_space(&self) -> usize {
let cap = self.counts[5] / 2 + self.counts[crate::rooms::ROOM_PRISON];
let used = self.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count();
cap.saturating_sub(used)
}
pub(crate) fn kill(&mut self, j: usize) {
let e = &self.ents[j];
if e.hero && e.captive == 0 && self.prison_space() > 0 {
// knocked out instead of killed: imps carry the hero to the torture chamber
let (kind, x, y) = (e.kind, e.x, e.y);
let id = e.id;
self.release_all(id);
let e = &mut self.ents[j];
e.captive = 1;
e.hp = 1.0;
e.carrier = 0;
e.torture_t = 0.0;
e.job = Job::None;
e.path.clear();
e.guard = false;
e.home = None;
e.patrol.clear();
let b = st(kind).bounty as f32;
let got = self.add_gold(b);
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::Capture, x, y);
self.msg("msg.hero_captured", vec![format!("@{}", st(kind).name), (got as i32).to_string()]);
return;
}
let e = &mut self.ents[j];
e.alive = false;
let (id, kind, hero, x, y, side) = (e.id, e.kind, e.hero, e.x, e.y, e.side);
// prisoners carried by this unit are dropped
for o in self.ents.iter_mut() {
if o.captive == 2 && o.carrier == id {
o.captive = 1;
o.carrier = 0;
}
}
self.release_all(id);
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
if kind != EK::Worker && kind != EK::Revenant {
self.bury(x, y);
}
let s = st(kind);
if hero {
let b = s.bounty as f32;
let got = self.add_gold(b);
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.hero_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
} else if side == crate::dwarves::DWARF_SIDE {
let got = self.add_gold(s.bounty as f32 * 0.5);
self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.dwarf_slain", vec![(got as i32).to_string()]);
} else if side >= 2 {
// a creature of a rival master: half its wage as booty
let got = self.add_gold(s.wage as f32 * 0.5);
self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.rival_unit_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
} else {
self.sfx(Sfx::Death, x, y);
self.msg("msg.creature_died", vec![format!("@{}", s.name)]);
}
}
// falls into lava: dies at once (no capture), heroes still pay their bounty
pub fn burn(&mut self, j: usize) {
let e = &mut self.ents[j];
if !e.alive {
return;
}
e.alive = false;
e.held = false;
let (id, kind, hero, x, y) = (e.id, e.kind, e.hero, e.x, e.y);
for o in self.ents.iter_mut() {
if o.captive == 2 && o.carrier == id {
o.captive = 1;
o.carrier = 0;
}
}
self.release_all(id);
self.hand.retain(|&h| h != id);
self.fx(3, x, y, 0.0, 0.0, 1.0, 0xff2060ff);
self.fx(2, x, y, 0.0, 0.0, 0.5, 0xff30a0ff);
self.sfx(Sfx::Death, x, y);
let s = st(kind);
if hero {
let got = self.add_gold(s.bounty as f32);
self.msg("msg.hero_burned", vec![format!("@{}", s.name), (got as i32).to_string()]);
} else {
self.msg("msg.creature_burned", vec![format!("@{}", s.name)]);
}
}
// pushes a unit; into lava = burnt, never into water or walls
pub fn knock(&mut self, j: usize, dx: f32, dy: f32) {
let e = &self.ents[j];
let (nx, ny) = (e.x + dx, e.y + dy);
let Some(t) = self.map.get(nx as i32, ny as i32).copied() else { return };
if flies(self.ents[j].kind) {
if t.walkable() || t.liquid() {
let e = &mut self.ents[j];
e.x = nx;
e.y = ny;
e.path.clear();
}
return;
}
if t.kind == Kind::Lava {
let e = &mut self.ents[j];
e.x = nx;
e.y = ny;
self.burn(j);
} else if t.walkable() {
let e = &mut self.ents[j];
e.x = nx;
e.y = ny;
e.path.clear();
}
}
// the ground of tile i turned into river (bridge removed): units on it fall in
fn ground_lost(&mut self, i: usize) {
let lava = self.map.t[i].kind == Kind::Lava;
let (x, y) = xy(i);
for j in 0..self.ents.len() {
let e = &self.ents[j];
if !e.alive || e.held || e.tile() != i || flies(e.kind) {
continue;
}
if lava {
self.burn(j);
continue;
}
// water: swim to the nearest bank, otherwise drown
let bank = DIR8.iter().map(|&(dx, dy)| (x + dx, y + dy)).find(|&(bx, by)| self.map.walk(bx, by));
match bank {
Some((bx, by)) => {
let e = &mut self.ents[j];
e.x = bx as f32 + 0.5;
e.y = by as f32 + 0.5;
e.path.clear();
}
None => self.burn(j),
}
}
}
// Bridges grow from own ground into the river: every pass adds the river tiles of the
// rectangle that touch own walkable ground (including bridges built in the pass before)
// Earth walls next to own ground become steel walls
pub fn build_steel(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
let (mut built, mut broke) = (0, false);
for y in y0.min(y1)..=y0.max(y1) {
for x in x0.min(x1)..=x0.max(x1) {
if !inb(x, y) {
continue;
}
let t = self.map.t[idx(x, y)];
if t.kind != Kind::Earth || t.owner == Owner::Hero {
continue;
}
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me && n.walkable()));
if !own {
continue;
}
let cost = STEEL_COST[t.reinforced as usize] as f32;
if self.gold < cost {
broke = true;
continue;
}
self.gold -= cost;
let tl = &mut self.map.t[idx(x, y)];
tl.kind = Kind::Steel;
tl.reinforced = true;
tl.marked = false;
tl.hp = STEEL_HP;
built += 1;
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xffd0d0d0);
if built == 1 {
self.sfx(Sfx::Reinforce, x as f32 + 0.5, y as f32 + 0.5);
}
}
}
if broke {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
} else if built == 0 {
self.hint(23, "hint.steel_where");
}
}
pub fn build_bridges(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
let (mut built, mut broke, mut any_river) = (0, false, false);
loop {
let mut add = Vec::new();
for y in y0.min(y1)..=y0.max(y1) {
for x in x0.min(x1)..=x0.max(x1) {
if !inb(x, y) || !self.map.t[idx(x, y)].liquid() {
continue;
}
any_river = true;
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me && n.walkable()));
if own {
add.push(idx(x, y));
}
}
}
if add.is_empty() {
break;
}
let mut progress = false;
for i in add {
let lava = self.map.t[i].kind == Kind::Lava;
let cost = BRIDGE_COST[lava as usize] as f32;
if self.gold < cost {
broke = true;
continue;
}
self.gold -= cost;
let t = &mut self.map.t[i];
t.kind = if lava { Kind::LavaBridge } else { Kind::Bridge };
t.owner = self.me;
built += 1;
progress = true;
let (x, y) = xy(i);
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff);
if built == 1 {
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
}
}
if !progress {
break;
}
}
if broke {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
} else if built == 0 {
self.hint(21, if any_river { "hint.bridge_where" } else { "hint.bridge_river" });
}
}
// An earth corner between two reinforced walls (and diagonal to own floor) cannot be
// reached by the imps: it is reinforced together with the walls. The floor condition keeps
// this from spreading into the earth behind the walls.
pub fn reinforce_corners(&mut self) {
let (w, h) = (mw() as i32, mh() as i32);
let wall = |m: &Map, x: i32, y: i32| m.get(x, y).map_or(false, |n| n.kind == Kind::Earth && n.reinforced && n.owner != Owner::Hero);
let floor = |m: &Map, x: i32, y: i32| m.get(x, y).map_or(false, |n| n.owner == self.me && n.walkable());
let mut add = Vec::new();
for y in 1..h - 1 {
for x in 1..w - 1 {
let t = &self.map.t[idx(x, y)];
if t.kind != Kind::Earth || t.reinforced || t.marked || t.owner == Owner::Hero {
continue;
}
for (ax, by) in [(1, 1), (1, -1), (-1, 1), (-1, -1)] {
if wall(&self.map, x + ax, y) && wall(&self.map, x, y + by) && floor(&self.map, x + ax, y + by) {
add.push(idx(x, y));
break;
}
}
}
}
for i in add {
let t = &mut self.map.t[i];
t.reinforced = true;
t.hp = t.hp.max(12.0);
let (x, y) = xy(i);
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
}
}
// tiles within two of a fighting hero
fn update_contested(&mut self) {
let n = self.map.t.len();
if self.contested.len() != n {
self.contested = vec![false; n];
} else {
self.contested.iter_mut().for_each(|c| *c = false);
}
for e in &self.ents {
// any enemy of the player (heroes and the rivals' units) keeps ground from being claimed
if !e.alive || e.held || e.captive != 0 || e.side() == 0 {
continue;
}
let (x, y) = (e.x as i32, e.y as i32);
for dy in -2..=2 {
for dx in -2..=2 {
if inb(x + dx, y + dy) {
self.contested[idx(x + dx, y + dy)] = true;
}
}
}
}
}
// a knight tile was claimed: vaults hand over their treasure, shards come back
fn take_knight_tile(&mut self, i: usize) {
let t = self.map.t[i];
let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5);
if t.kind == Kind::Treasury && t.gold > 0 {
// the content stays in the (now own) treasury tile and counts as own gold
self.gold += t.gold as f32;
self.loot += t.gold as f32;
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::Payday, x, y);
self.msg("msg.vault_taken", vec![t.gold.to_string()]);
}
if t.kind == Kind::ManaSpring {
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xffffe060);
self.sfx(Sfx::Magic, x, y);
self.msg("msg.spring_taken", vec![]);
}
if t.kind == Kind::RuneChest {
self.open_rune_chest(i);
}
if t.kind == Kind::Shard {
self.shards_taken += 1;
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff6060ff);
self.sfx(Sfx::LevelUp, x, y);
self.msg("msg.shard_taken", vec![]);
}
}
// a dwarven chest: the plans of a rune forge tower (one free build)
pub fn open_rune_chest(&mut self, i: usize) {
let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5);
self.map.t[i].kind = Kind::Floor;
self.rune_free += 1;
self.fx(7, x, y, 0.0, 0.0, 1.4, 0xff40c0ff);
self.sfx(Sfx::LevelUp, x, y);
self.msg("msg.rune_chest", vec![]);
}
// a fortress unit spotted an enemy: guards and patrols nearby join the fight
fn alarm(&mut self, x: f32, y: f32, target: u32) {
for o in self.ents.iter_mut() {
if !o.alive || o.held || !o.active_hero() || !(o.guard || !o.patrol.is_empty()) {
continue;
}
if matches!(o.job, Job::Attack(_)) || (o.x - x).powi(2) + (o.y - y).powi(2) > 13.0 * 13.0 {
continue;
}
o.job = Job::Attack(target);
o.path.clear();
o.repath = 0.0;
}
}
// fortress units: a bridge of the player near the post (None if none)
fn bridge_to_tear(&self, e: &Ent) -> Option<usize> {
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let (cx, cy) = (hx as i32, hy as i32);
for r in 0..=9i32 {
for dy in -r..=r {
for dx in -r..=r {
if dx.abs() != r && dy.abs() != r {
continue;
}
if let Some(t) = self.map.get(cx + dx, cy + dy) {
if t.bridge() && t.owner == self.me {
return Some(idx(cx + dx, cy + dy));
}
}
}
}
}
None
}
// a knight walks next to the bridge tile and tears it down
fn tear_bridge(&mut self, e: &mut Ent, i: usize, dt: f32) -> bool {
let t = self.map.t[i];
if !t.bridge() || t.owner != self.me {
return false;
}
let (bx, by) = xy(i);
let cur = e.tile();
let (cx, cy) = xy(cur);
if (cx - bx).abs() + (cy - by).abs() == 1 {
e.path.clear();
e.working = true;
e.face = sgn(bx as f32 + 0.5 - e.x);
e.timer += dt;
if e.timer >= 4.0 {
e.timer = 0.0;
let lava = t.kind == Kind::LavaBridge;
let tl = &mut self.map.t[i];
tl.kind = if lava { Kind::Lava } else { Kind::Water };
tl.owner = Owner::None;
tl.reserved = 0;
self.ground_lost(i);
self.fx(6, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff3a5a7a);
self.sfx(Sfx::DigDone, bx as f32 + 0.5, by as f32 + 0.5);
self.msg("msg.bridge_torn", vec![]);
return false;
}
return true;
}
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.5;
e.timer = 0.0;
match self.map.search(cur, self.map.walk_cost(), |j| j != i && adj4(i, j), true) {
Some(p) => e.path = p,
None => return false,
}
}
self.step_path(e, dt);
true
}
// ---------------------------------------------------------------- pit imps (workers)
fn dig_neighbor(&self, i: usize, me: u32) -> Option<usize> {
let (x, y) = xy(i);
let mut found = None;
for &(dx, dy) in &DIR4 {
if let Some(t) = self.map.get(x + dx, y + dy) {
if t.marked && t.diggable() {
if t.reserved == me {
return Some(idx(x + dx, y + dy));
}
if t.reserved == 0 && found.is_none() {
found = Some(idx(x + dx, y + dy));
}
}
}
}
found
}
// a damaged tower next to walkable tile i
fn damaged_tower_near(&self, i: usize) -> Option<u32> {
let (x, y) = xy(i);
self.towers.iter().find(|t| {
let (tx, ty) = xy(t.tile);
!t.hero && (tx - x).abs() <= 1 && (ty - y).abs() <= 1 && t.hp < t.max_hp() - 1.0
}).map(|t| t.id)
}
// an earth wall next to own floor that can be reinforced from tile i
fn reinforce_neighbor(&self, i: usize, me: u32) -> Option<usize> {
if self.map.t[i].owner != self.me {
return None;
}
let (x, y) = xy(i);
for &(dx, dy) in &DIR4 {
if let Some(t) = self.map.get(x + dx, y + dy) {
if t.kind == Kind::Earth && !t.reinforced && !t.marked && (t.reserved == 0 || t.reserved == me) {
return Some(idx(x + dx, y + dy));
}
}
}
None
}
fn claimable(&self, i: usize, me: u32) -> bool {
let t = &self.map.t[i];
let ok = if t.owner == self.me {
false
} else if t.owner == Owner::None {
matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest)
} else {
// knight and other masters' ground (also vaults and shard chambers), but not while enemies fight there
matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Lair | Kind::Training) && !self.contested.get(i).copied().unwrap_or(false)
};
if !ok {
return false;
}
if t.reserved != 0 && t.reserved != me {
return false;
}
let (x, y) = xy(i);
DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me))
}
fn is_deposit(&self, i: usize) -> bool {
let t = &self.map.t[i];
(t.kind == Kind::Treasury && t.owner == self.me) || (self.counts[0] == 0 && self.map.adj_core(i))
}
fn start_deliver(&mut self, e: &mut Ent) -> bool {
let cur = e.tile();
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.is_deposit(i), true) {
e.path = p;
e.job = Job::Deliver;
return true;
}
false
}
// flying creatures patrol far around (also over rivers and the land of the enemy)
fn patrol_flight(&mut self, e: &mut Ent) {
let (x, y) = xy(e.tile());
for _ in 0..8 {
let nx = x + self.rng.range(-14, 14);
let ny = y + self.rng.range(-14, 14);
let ok = self.map.get(nx, ny).map_or(false, |t| t.walkable() || t.liquid());
if ok {
if let Some(p) = self.map.fly_path_to(e.tile(), idx(nx, ny)) {
if p.len() <= 40 {
e.path = p;
break;
}
}
}
}
e.job = Job::Wander;
e.timer = 2.0 + self.rng.f() * 3.0;
}
// badly hurt flyers flee to the aerie and rest until they are healed to 75%
fn start_rest(&mut self, e: &mut Ent) -> bool {
let cur = e.tile();
let target = e.bed.filter(|&b| self.map.t[b].kind == Kind::Aerie).or_else(|| {
self.map.search(cur, self.map.fly_cost(), |i| self.map.t[i].kind == Kind::Aerie, true).map(|p| p.last().copied().unwrap_or(cur))
});
let Some(b) = target else { return false };
e.path = self.map.fly_path_to(cur, b).unwrap_or_default();
e.job = Job::Rest(b);
true
}
fn wander(&mut self, e: &mut Ent, radius: i32) {
let (x, y) = xy(e.tile());
for _ in 0..6 {
let nx = x + self.rng.range(-radius, radius);
let ny = y + self.rng.range(-radius, radius);
if self.map.walk(nx, ny) && self.map.t[idx(nx, ny)].owner == self.me {
if let Some(p) = self.map.path_to(e.tile(), idx(nx, ny)) {
if p.len() as i32 <= radius * 2 + 2 {
e.path = p;
break;
}
}
}
}
e.job = Job::Wander;
e.timer = 1.0 + self.rng.f() * 2.0;
}
fn worker_ai(&mut self, e: &mut Ent, dt: f32) {
if self.step_path(e, dt) {
return;
}
let me = e.id + 1;
let cur = e.tile();
match e.job {
Job::Dig(t) => {
let tile = self.map.t[t];
if !tile.marked || !tile.diggable() || !adj4(cur, t) {
self.unreserve(me);
e.job = Job::None;
return;
}
// an imp carries one kind of treasure: other loads are delivered first
if tile.vein().map_or(false, |r| e.carry > 0 && r != e.carry_res) {
self.unreserve(me);
e.job = Job::None;
self.start_deliver(e);
return;
}
let (tx, ty) = xy(t);
e.face = sgn(tx as f32 + 0.5 - e.x);
e.working = true;
let skilled = if crate::personality::knows(e, "dig") { 1.25 } else { 1.0 };
e.timer += dt * self.help_speed(t) * skilled;
if e.timer >= 0.45 {
e.timer = 0.0;
let fxx = (e.x + tx as f32 + 0.5) * 0.5;
let fxy = (e.y + ty as f32 + 0.5) * 0.5;
let tl = &mut self.map.t[t];
let done;
if let Some(res) = tl.vein() {
let take = tl.gold.min(RES_STRIKE[res as usize]);
tl.gold -= take;
e.carry += take;
e.carry_res = res;
done = tl.gold <= 0;
self.fx(5, fxx, fxy, 0.0, 0.0, 0.5, 0xff30d0ff);
self.sfx(Sfx::GoldDig, fxx, fxy);
} else {
tl.hp -= 1.0;
done = tl.hp <= 0.0;
self.fx(6, fxx, fxy, 0.0, 0.0, 0.4, 0xff3a5a7a);
self.sfx(Sfx::Dig, fxx, fxy);
}
if done {
self.dig_out(t);
e.job = Job::None;
}
if e.carry >= CARRY_MAX {
self.unreserve(me);
e.job = Job::None;
self.start_deliver(e);
}
}
}
Job::Carry(id) => {
self.carry_job(e, id, dt);
}
Job::Install(id) => {
self.install_job(e, id, dt);
}
Job::Repair(id) => {
let Some(k) = self.towers.iter().position(|t| t.id == id) else {
e.job = Job::None;
return;
};
let (tx, ty) = self.towers[k].pos();
if (tx - e.x).abs() > 1.6 || (ty - e.y).abs() > 1.6 {
e.job = Job::None;
return;
}
e.face = sgn(tx - e.x);
e.working = true;
let t = &mut self.towers[k];
t.hp = (t.hp + 30.0 * dt).min(t.max_hp());
if t.hp >= t.max_hp() {
e.job = Job::None;
}
if (e.anim * 3.0).fract() < dt * 3.0 {
self.sfx(Sfx::Reinforce, tx, ty);
}
}
Job::Help(t) => {
// the task is done or nobody works on it any more: back to thinking
if self.task_worker(t).is_none() || self.helpers(t) > HELPERS_MAX {
e.job = Job::None;
e.think = 0.2;
return;
}
let (tx, _) = xy(t);
e.face = sgn(tx as f32 + 0.5 - e.x);
e.working = true;
if e.think <= 0.0 {
// every 1.5 s: is there own work again? (if not, the imp comes back to help)
e.job = Job::None;
}
}
Job::Reinforce(w) => {
let t = self.map.t[w];
if !adj4(cur, w) || t.kind != Kind::Earth || t.reinforced || t.marked {
self.unreserve(me);
e.job = Job::None;
return;
}
let (wx, wy) = xy(w);
e.face = sgn(wx as f32 + 0.5 - e.x);
e.working = true;
e.timer += dt * self.help_speed(w);
if e.timer >= 2.5 {
self.unreserve(me);
e.job = Job::None;
let tl = &mut self.map.t[w];
tl.reinforced = true;
tl.hp = 12.0; // the player's imps need twice as long to dig it out again
self.fx(8, wx as f32 + 0.5, wy as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
self.sfx(Sfx::Reinforce, wx as f32 + 0.5, wy as f32 + 0.5);
}
}
Job::Claim(t) => {
if cur != t || !self.claimable(t, me) {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
e.timer += dt * self.help_speed(t);
// knight ground takes longer
let prev = self.map.t[t].owner;
let knight = prev == Owner::Hero;
if e.timer >= if prev != Owner::None { 3.0 } else { 1.0 } {
self.unreserve(me);
e.job = Job::None;
let (tx, ty) = xy(t);
if knight {
self.alarm(tx as f32 + 0.5, ty as f32 + 0.5, e.id);
}
// vaults, shards and springs (of the knights, rivals or nobody)
self.take_knight_tile(t);
if let (Kind::Portal, Some(pk)) = (self.map.t[t].kind, self.map.portal_at(tx, ty)) {
let (px, py) = self.map.portals[pk];
for y in 0..3 {
for x in 0..3 {
self.map.t[idx(px + x, py + y)].owner = self.me;
}
}
self.msg("msg.portal_claimed", vec![]);
self.fx(7, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 1.0, 0xffff66cc);
} else {
self.map.t[t].owner = self.me;
}
self.place_torches(t);
let (x, y) = xy(t);
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
self.sfx(Sfx::Claim, x as f32 + 0.5, y as f32 + 0.5);
}
}
Job::Deliver => {
if self.is_deposit(cur) {
let amount = e.carry as f32;
let got = self.add_res_at(amount, e.carry_res, Some(cur));
if got < amount {
self.hint(3, "hint.treasury_full");
}
if got > 0.0 {
self.fx(5, e.x, e.y, 0.0, 0.0, 0.8, 0xff30d0ff);
self.sfx(Sfx::Deposit, e.x, e.y);
}
e.carry = 0;
e.job = Job::None;
} else if !self.start_deliver(e) {
e.job = Job::None;
e.think = 1.0;
}
}
Job::Wander => {
e.timer -= dt;
if e.timer <= 0.0 {
e.job = Job::None;
}
}
Job::None => {
if e.think > 0.0 {
return;
}
e.think = 0.3 + self.rng.f() * 0.2;
e.timer = 0.0;
if e.carry >= CARRY_MAX && self.start_deliver(e) {
return;
}
// prisoners lying around are carried to the torture chamber first
let lying = self.ents.iter().any(|o| o.alive && o.captive == 1 && o.carrier == 0);
if lying && self.map.t.iter().any(|t| t.kind == Kind::Torture && t.bed == 0) {
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.captive_near(i).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(id) = self.captive_near(end) {
if let Some(k) = self.find(id) {
self.ents[k].carrier = e.id;
}
e.path = p;
e.job = Job::Carry(id);
return;
}
}
}
// at least 10% of the imps (at least one) claim floor while there is floor to claim
let imps = 1 + self.ents.iter().filter(|o| o.alive && !o.hero && o.kind == EK::Worker).count();
let claiming = self.ents.iter().filter(|o| o.alive && !o.hero && o.kind == EK::Worker && matches!(o.job, Job::Claim(_))).count();
let quota = ((imps as f32 * 0.1).ceil() as usize).max(1);
if claiming < quota {
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) {
let end = p.last().copied().unwrap_or(cur);
self.map.t[end].reserved = me;
e.path = p;
e.job = Job::Claim(end);
return;
}
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.dig_neighbor(i, me).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(t) = self.dig_neighbor(end, me) {
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Dig(t);
return;
}
}
if e.carry > 0 && self.start_deliver(e) {
return;
}
// finished traps and doors are carried to their spots
if let Some((id, p)) = self.crate_to_carry(cur) {
if let Some(k) = self.traps.iter().position(|t| t.id == id) {
self.traps[k].carrier = me;
}
e.path = p;
e.job = Job::Install(id);
e.timer = 0.0;
return;
}
// damaged towers are repaired (free of charge) before more ground is claimed
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(id) = self.damaged_tower_near(end) {
e.path = p;
e.job = Job::Repair(id);
return;
}
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) {
let end = p.last().copied().unwrap_or(cur);
self.map.t[end].reserved = me;
e.path = p;
e.job = Job::Claim(end);
return;
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.reinforce_neighbor(i, me).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(w) = self.reinforce_neighbor(end, me) {
self.map.t[w].reserved = me;
e.path = p;
e.job = Job::Reinforce(w);
e.timer = 0.0;
return;
}
}
// nothing else to do: help another imp (up to five at one task)
if self.start_help(e) {
return;
}
e.think = 1.0;
self.wander(e, 3);
}
_ => e.job = Job::None,
}
}
// ---------------------------------------------------------------- creatures
fn find_room_tile(&self, cur: usize, kind: Kind, me: u32, need_food: bool) -> Option<Vec<usize>> {
self.map.search(
cur,
self.map.walk_cost(),
|i| {
let t = &self.map.t[i];
t.kind == kind && t.owner == self.me && (t.reserved == 0 || t.reserved == me) && (!need_food || t.food >= 1.0)
},
true,
)
}
fn creature_ai(&mut self, e: &mut Ent, dt: f32) {
let me = e.id + 1;
if e.kind == EK::Revenant {
// the undead neither eat nor sleep
e.hunger = 0.0;
e.energy = 1.0;
} else {
e.hunger += dt / 75.0;
if !matches!(e.job, Job::Sleep(_)) {
e.energy -= dt / 110.0;
}
}
if e.hunger > 1.8 {
e.happy -= dt * 0.004;
}
if e.kind == EK::Priestess && e.skill_cd <= 0.0 {
// flame priestess: heals the most hurt own creature nearby (or herself)
let mut best: Option<usize> = None;
let mut worst = if e.hp < e.max_hp * 0.8 { e.hp / e.max_hp } else { 0.8 };
for (j, o) in self.ents.iter().enumerate() {
if o.alive && !o.held && o.creature() && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 4.5 * 4.5 && o.hp / o.max_hp < worst {
worst = o.hp / o.max_hp;
best = Some(j);
}
}
if let Some(j) = best {
let o = &mut self.ents[j];
o.hp = (o.hp + 20.0).min(o.max_hp);
let (ox, oy) = (o.x, o.y);
self.fx(4, ox, oy, 0.0, 0.0, 0.8, 0xff60ff80);
self.sfx(Sfx::Heal, ox, oy);
e.skill_cd = 2.5;
} else if e.hp < e.max_hp * 0.8 {
e.hp = (e.hp + 20.0).min(e.max_hp);
self.fx(4, e.x, e.y, 0.0, 0.0, 0.8, 0xff60ff80);
e.skill_cd = 2.5;
} else {
e.skill_cd = 1.0;
}
}
if e.think <= 0.0 {
e.think = 0.4 + self.rng.f() * 0.3;
// a new creature builds its own bed in the lair soon after its arrival
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
let beds = if flies(e.kind) { self.counts[7] } else { self.counts[1] };
if e.bed.is_none() && beds > 0 && !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rally) && self.rng.f() < 0.15 {
let cur = e.tile();
let free = |i: usize| self.map.t[i].kind == bed_kind && self.map.t[i].bed == 0;
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) };
if let Some(p) = found {
let b = p.last().copied().unwrap_or(cur);
self.map.t[b].bed = me;
e.bed = Some(b);
let (bx, by) = xy(b);
self.fx(8, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff40d0ff);
}
}
// flyers flee when badly hurt and come back at 75%
if flies(e.kind) && e.hp < e.max_hp * 0.25 && !matches!(e.job, Job::Rest(_) | Job::Leave) {
self.unreserve(me);
if self.start_rest(e) {
return;
}
}
if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_) | Job::Brawl(_)) {
let radius = if e.job == Job::Rally { 8.5 } else if matches!(e.job, Job::Guard(_)) { 10.0 } else if flies(e.kind) { 9.0 } else { 7.0 };
if let Some(id) = self.nearest_enemy(e, radius) {
self.unreserve(me);
e.job = Job::Attack(id);
e.path.clear();
e.repath = 0.0;
} else if !matches!(e.job, Job::AttackTower(_) | Job::Sleep(_) | Job::Eat(_)) {
// knight towers in reach are torn down
if let Some(id) = self.enemy_tower_near(e.x, e.y, radius - 1.0) {
self.unreserve(me);
e.job = Job::AttackTower(id);
e.path.clear();
e.repath = 0.0;
}
}
}
}
if let Job::AttackTower(id) = e.job {
if !self.attack_tower(e, id, dt) {
e.job = Job::None;
e.path.clear();
}
return;
}
// arrival: report at the portal and set up the bed before anything else
if !e.settled {
self.settle(e, dt);
return;
}
// payday: the wage is fetched from a treasury
if e.pay_due > 0.0 {
e.pay_t -= dt;
if e.pay_t <= 0.0 && !matches!(e.job, Job::FetchWage(_) | Job::Leave | Job::Rest(_)) {
self.unreserve(me);
self.start_fetch_wage(e);
return;
}
}
if let Job::FetchWage(t) = e.job {
if self.step_path(e, dt) {
return;
}
self.take_wage(e, Some(t));
e.job = Job::None;
return;
}
if let Job::Rest(b) = e.job {
if self.step_path(e, dt) {
return;
}
let (bx, by) = xy(b);
let at_aerie = (e.x - bx as f32 - 0.5).abs() < 1.5 && (e.y - by as f32 - 0.5).abs() < 1.5;
if !at_aerie {
// pushed away on the way: fly on, or give up if there is no way
match self.map.fly_path_to(e.tile(), b) {
Some(p) if !p.is_empty() => e.path = p,
_ => e.job = Job::None,
}
return;
}
// resting in the aerie heals quickly, back to work at 75%
e.hp = (e.hp + e.max_hp * dt / 12.0).min(e.max_hp);
e.energy = (e.energy + dt / 14.0).min(1.0);
if e.hp >= e.max_hp * 0.75 {
e.job = Job::None;
}
return;
}
if e.kind == EK::Chainbreaker {
// restless: without a fight for two minutes its mood sinks (skill_cd counts down as idle time)
if matches!(e.job, Job::Attack(_)) {
e.skill_cd = 0.0;
} else if e.skill_cd < -120.0 {
e.happy -= 0.005 * dt;
self.hint(20, "hint.chainbreaker_restless");
}
}
self.mood_check(e, dt);
self.passive_abilities(e);
if let Job::Brawl(id) = e.job {
self.brawl(e, id, dt);
return;
}
if let Job::Attack(id) = e.job {
if !self.attack_job(e, id, dt) {
e.job = Job::None;
e.path.clear();
}
return;
}
if e.job == Job::Rally {
self.rally(e, dt);
return;
}
if self.step_path(e, dt) {
return;
}
let cur = e.tile();
match e.job {
Job::Sleep(_) => {
e.energy += dt / 14.0;
e.hp = (e.hp + e.max_hp * dt / 25.0).min(e.max_hp);
if e.energy >= 1.0 {
e.energy = 1.0;
e.job = Job::None;
}
}
Job::Eat(t) => {
if cur != t {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
e.timer += dt;
if e.timer >= 2.0 {
let tl = &mut self.map.t[t];
if tl.food >= 1.0 {
tl.food -= 1.0;
e.hunger = 0.0;
e.hp = (e.hp + e.max_hp * 0.2).min(e.max_hp);
}
self.unreserve(me);
e.job = Job::None;
}
}
Job::Torment(id) => {
// working on a prisoner: makes it more likely to change sides (see captive_update)
let Some(k) = self.ents.iter().position(|o| o.id == id && o.alive && o.captive == 3) else {
e.job = Job::None;
return;
};
let px = self.ents[k].x;
e.face = sgn(px - e.x);
e.working = true;
e.xp += 2.0 * dt;
e.timer -= dt;
if e.timer <= 0.0 {
e.job = Job::None;
}
}
Job::Train(t) => {
// training is billed on payday; it stops when the bill would exceed the gold
if cur != t || self.map.t[t].kind != Kind::Training || self.training_bill + 1.0 > self.gold {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
self.training_bill += TRAIN_COST * dt;
e.xp += 7.0 * dt;
e.timer -= dt;
if e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Guard(t) => {
// a guard post, or a guard order in any room
e.timer -= dt;
if cur != t || !self.map.t[t].walkable() || self.map.t[t].owner != self.me || e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Spar(t) => {
if !self.spar(e, t, dt) {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Forge(t) => {
if cur != t || self.map.t[t].kind != Kind::Forge || !self.forge_orders() {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
let rate = if e.kind == EK::Cogling { 2.5 } else { 1.0 } * (1.0 + 0.1 * (e.level - 1) as f32);
self.forge_work += rate * dt;
e.xp += 2.5 * dt;
e.timer -= dt;
if e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Study(t) => {
if cur != t || self.map.t[t].kind != Kind::Library {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
let rate = if e.kind == EK::Mage { 1.2 } else { 0.5 };
self.mana = (self.mana + rate * 0.5 * dt).min(self.mana_max());
// research: mages and owls are the scholars
let points = match e.kind {
EK::Mage => 1.6,
EK::Owl => 1.3,
_ => 0.5,
} * (1.0 + 0.12 * (e.level - 1) as f32);
self.add_research(points * dt);
e.xp += 3.0 * dt;
e.timer -= dt;
if e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Pray(t) => {
if cur != t || self.map.t[t].kind != Kind::Temple {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
let rate = if e.kind == EK::Priestess { 1.6 } else { 0.9 };
self.mana = (self.mana + rate * dt).min(self.mana_max());
e.happy = (e.happy + 0.01 * dt).min(1.0);
e.xp += 1.0 * dt;
e.timer -= dt;
if e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::HealCore => {
if !self.map.adj_core(cur) || self.core_hp >= CORE_HP {
e.job = Job::None;
return;
}
e.working = true;
let (cx, cy) = self.map.core;
e.face = sgn(cx as f32 + 1.5 - e.x);
self.core_hp = (self.core_hp + 8.0 * dt).min(CORE_HP);
if (e.anim * 1.5).fract() < dt * 1.5 {
self.fx(4, cx as f32 + 1.5, cy as f32 + 1.5, 0.0, 0.0, 0.8, 0xff60ff80);
}
}
Job::Wander => {
e.timer -= dt;
if e.timer <= 0.0 {
e.job = Job::None;
}
}
Job::Leave => {
if self.map.t[cur].kind == Kind::Portal {
e.alive = false;
self.release_all(e.id);
self.fx(3, e.x, e.y, 0.0, 0.0, 0.8, 0xffcc44ff);
} else {
self.start_leave(e);
}
}
Job::None => self.creature_choose(e),
_ => e.job = Job::None,
}
}
fn start_leave(&mut self, e: &mut Ent) {
let cur = e.tile();
e.job = Job::Leave;
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.t[i].kind == Kind::Portal, true) {
e.path = p;
} else {
e.alive = false;
self.release_all(e.id);
self.fx(3, e.x, e.y, 0.0, 0.0, 0.8, 0xff505070);
}
}
fn wants_rally(&self, e: &Ent) -> bool {
self.flag.is_some() && e.hp > e.max_hp * 0.5 && e.energy > 0.15 && e.hunger < 1.8 && e.happy > 0.0
}
// Attack flag: march to the flag; if the gate is nearby, attack it.
fn rally(&mut self, e: &mut Ent, dt: f32) {
let Some(f) = self.flag else {
e.job = Job::None;
e.path.clear();
return;
};
if e.hp < e.max_hp * 0.3 || e.energy < 0.1 || e.hunger > 2.2 {
// exhausted or badly hurt: retreat
e.job = Job::None;
e.path.clear();
return;
}
let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
// the flag next to a rival master's core: attack the core
let rival_near = self.rivals.iter().filter(|r| r.alive).find(|r| {
let (cx, cy) = r.centre();
(fx - cx).powi(2) + (fy - cy).powi(2) <= 8.0 * 8.0
});
if let Some(side) = rival_near.map(|r| r.side) {
e.target = Some(side);
if self.march_on(e, dt) {
return;
}
}
let (gx, gy) = self.gate_center();
let gate_near = self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0;
let s = st(e.kind);
if gate_near {
let dg = ((e.x - gx).powi(2) + (e.y - gy).powi(2)).sqrt();
if dg <= s.range + 1.6 && (s.range < 1.5 || self.los(e.x, e.y, gx, gy)) {
e.path.clear();
e.face = sgn(gx - e.x);
e.working = true;
if e.cd <= 0.0 {
self.hit_gate(e);
}
return;
}
}
let (target, tx, ty, near) = if gate_near {
(idx(gx as i32, gy as i32), gx, gy, s.range + 1.2)
} else {
(f, fx, fy, 1.5)
};
let d = ((e.x - tx).powi(2) + (e.y - ty).powi(2)).sqrt();
if d <= near && e.path.is_empty() {
return; // wait at the flag
}
if e.path.is_empty() || e.repath <= 0.0 {
e.repath = 1.5;
let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), target) } else { self.map.path_to(e.tile(), target) };
match p {
Some(p) => e.path = p,
None => {
self.hint(11, "hint.flag_unreachable");
e.job = Job::Wander;
e.timer = 3.0;
return;
}
}
}
self.step_path(e, dt);
}
pub fn set_flag(&mut self, x: i32, y: i32) {
if !inb(x, y) {
return;
}
let i = idx(x, y);
if self.flag == Some(i) {
self.flag = None;
self.msg("msg.flag_removed", vec![]);
self.sfx_ui(Sfx::Click);
for e in self.ents.iter_mut() {
if e.alive && e.creature() && e.job == Job::Rally {
e.job = Job::None;
e.path.clear();
}
}
return;
}
if !self.map.t[i].walkable() {
self.hint(12, "hint.flag_ground");
self.sfx_ui(Sfx::Error);
return;
}
self.flag = Some(i);
self.sfx(Sfx::FlagSet, x as f32 + 0.5, y as f32 + 0.5);
self.msg("msg.flag_set", vec![]);
let mut ids = Vec::new();
for e in self.ents.iter() {
if e.alive && !e.held && e.creature() && matches!(e.job, Job::None | Job::Wander | Job::Train(_) | Job::Study(_) | Job::Rally) {
ids.push(e.id);
}
}
for id in ids {
self.unreserve(id + 1);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.job = Job::None;
e.path.clear();
}
}
}
fn creature_choose(&mut self, e: &mut Ent) {
let me = e.id + 1;
let cur = e.tile();
let name = st(e.kind).name;
if e.happy <= 0.0 {
self.msg("msg.creature_leaves", vec![format!("@{}", name)]);
self.start_leave(e);
return;
}
if e.energy < 0.25 {
// flying creatures sleep in the aerie
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
if let Some(b) = e.bed {
let t = &self.map.t[b];
if t.kind != bed_kind || t.bed != me {
e.bed = None;
}
}
if e.bed.is_none() {
let free = |i: usize| {
let t = &self.map.t[i];
t.kind == bed_kind && t.bed == 0
};
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) };
if let Some(p) = found {
let b = p.last().copied().unwrap_or(cur);
self.map.t[b].bed = me;
e.bed = Some(b);
}
}
if let Some(b) = e.bed {
let way = if flies(e.kind) { self.map.fly_path_to(cur, b) } else { self.map.path_to(cur, b) };
if let Some(p) = way {
e.path = p;
e.job = Job::Sleep(b);
return;
}
}
e.happy -= 0.1;
self.hint(4, "hint.no_beds");
e.job = Job::Sleep(cur);
return;
}
if e.hunger > 1.0 && !(self.flag.is_some() && e.hunger < 1.5) {
if let Some(p) = self.find_room_tile(cur, Kind::Farm, me, true) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Eat(t);
e.timer = 0.0;
return;
}
if e.hunger > 1.8 {
self.hint(5, "hint.hungry");
}
}
if self.wants_rally(e) {
e.job = Job::Rally;
e.path.clear();
e.repath = 0.0;
return;
}
if e.hp < e.max_hp * 0.5 && e.energy >= 0.25 && self.counts[1] > 0 {
// hurt: rest in the lair
e.energy = e.energy.min(0.2);
self.creature_choose(e);
return;
}
if e.kind == EK::Priestess && self.core_hp < CORE_HP * 0.9 && self.nearest_enemy(e, 8.0).is_none() {
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.adj_core(i), true) {
e.path = p;
e.job = Job::HealCore;
return;
}
}
if self.counts[6] > 0 {
let r2 = self.rng.f();
if r2 < 0.3 || (e.kind == EK::Priestess && r2 < 0.7) {
if let Some(p) = self.find_room_tile(cur, Kind::Temple, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Pray(t);
e.timer = 8.0 + self.rng.f() * 6.0;
return;
}
}
}
// the jailer's favourite work: prisoners nobody is working on yet
if e.kind == EK::Jailer && self.rng.f() < 0.85 {
let busy: Vec<u32> = self.ents.iter().filter_map(|o| if let Job::Torment(p) = o.job { Some(p) } else { None }).collect();
let prisoner = self.ents.iter().filter(|o| o.alive && o.captive == 3 && !busy.contains(&o.id)).map(|o| (o.id, o.tile())).next();
if let Some((id, pt)) = prisoner {
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| i != pt && adj4(i, pt) || i == pt, true) {
e.path = p;
e.job = Job::Torment(id);
e.timer = 12.0 + self.rng.f() * 8.0;
return;
}
}
}
// guard posts and the arena
if self.counts[crate::rooms::ROOM_GUARD] > 0 && self.rng.f() < 0.2 {
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.free_post(i, me), true) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Guard(t);
e.timer = 35.0 + self.rng.f() * 20.0;
return;
}
}
if self.counts[crate::rooms::ROOM_ARENA] > 0 && e.hp > e.max_hp * 0.8 && self.rng.f() < 0.25 {
if let Some(p) = self.find_room_tile(cur, Kind::Arena, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Spar(t);
e.timer = 14.0 + self.rng.f() * 6.0;
return;
}
}
// the forge: orders waiting (coglings love it, others lend a hand now and then)
if self.forge_orders() && self.counts[ROOM_FORGE] > 0 {
let r = self.rng.f();
if (e.kind == EK::Cogling && r < 0.9) || r < 0.25 {
if let Some(p) = self.find_room_tile(cur, Kind::Forge, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Forge(t);
e.timer = 10.0 + self.rng.f() * 6.0;
return;
}
}
}
let r = self.rng.f();
if (e.kind == EK::Mage && r < 0.8) || r < 0.2 {
if let Some(p) = self.find_room_tile(cur, Kind::Library, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Study(t);
e.timer = 8.0 + self.rng.f() * 6.0;
return;
}
}
if r < 0.8 && self.gold >= 5.0 {
if let Some(p) = self.find_room_tile(cur, Kind::Training, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Train(t);
e.timer = 8.0 + self.rng.f() * 6.0;
return;
}
}
if flies(e.kind) {
self.patrol_flight(e);
} else {
self.wander(e, 4);
}
}
// ---------------------------------------------------------------- prisoners
fn captive_update(&mut self, e: &mut Ent, dt: f32) {
e.moving = false;
match e.captive {
1 => {
// unconscious: bleeds out if nobody carries the hero away
e.torture_t += dt;
if e.torture_t > 60.0 {
e.alive = false;
self.release_all(e.id);
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
}
}
2 => {
// carried: follows the imp
match self.find(e.carrier) {
Some(k) => {
e.x = self.ents[k].x;
e.y = self.ents[k].y;
}
None => {
e.captive = 1;
e.carrier = 0;
}
}
}
_ if self.map.t[e.tile()].kind == Kind::Prison => {
// in prison: no torture, but hunger wears the prisoner down
e.hp -= e.max_hp / 180.0 * dt;
if e.hp <= 0.0 {
e.alive = false;
self.release_all(e.id);
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
self.msg("msg.prisoner_starved", vec![format!("@{}", st(e.kind).name)]);
self.bury(e.x, e.y);
}
}
_ => {
let t = e.tile();
if self.map.t[t].kind != Kind::Torture {
// the chamber is gone: the prisoner breaks free
e.captive = 0;
e.hp = e.max_hp * 0.3;
self.release_all(e.id);
return;
}
e.working = true;
e.hp -= e.max_hp / 100.0 * dt;
e.torture_t += dt;
if e.torture_t >= 5.0 {
e.torture_t -= 5.0;
// ~40% defect over the whole torture (paladins ~25%); a jailer working on
// the prisoner makes it 2.5 times as likely
let tormented = self.ents.iter().any(|o| o.alive && o.kind == EK::Jailer && o.job == Job::Torment(e.id) && o.path.is_empty() && (o.x - e.x).abs() < 1.6 && (o.y - e.y).abs() < 1.6);
let p = if e.kind == EK::Paladin { 0.0143 } else { 0.0252 } * if tormented { 2.5 } else { 1.0 };
if tormented {
self.fx(2, e.x, e.y, 0.0, 0.0, 0.3, 0xff3040ff);
}
if self.rng.f() < p {
self.defect(e);
return;
}
}
if e.hp <= 0.0 {
e.alive = false;
self.release_all(e.id);
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
self.sfx(Sfx::HeroDeath, e.x, e.y);
self.msg("msg.prisoner_died", vec![format!("@{}", st(e.kind).name)]);
self.bury(e.x, e.y);
}
}
}
}
// a prisoner joins the player's side
fn defect(&mut self, e: &mut Ent) {
self.release_all(e.id);
e.hero = false;
e.captive = 0;
e.carrier = 0;
e.guard = false;
e.home = None;
e.hp = e.max_hp * 0.5;
e.happy = 0.6;
e.hunger = 0.5;
e.energy = 0.4;
e.job = Job::None;
e.path.clear();
e.bed = None;
self.fx(7, e.x, e.y, 0.0, 0.0, 1.0, 0xff4040e0);
self.sfx(Sfx::Convert, e.x, e.y);
self.msg("msg.defected", vec![format!("@{}", st(e.kind).name)]);
}
// unconscious hero lying next to tile i that no imp takes care of yet
fn captive_near(&self, i: usize) -> Option<u32> {
let (x, y) = xy(i);
self.ents.iter().find(|o| {
o.alive && o.hero && o.captive == 1 && o.carrier == 0 && (o.x as i32 - x).abs() <= 1 && (o.y as i32 - y).abs() <= 1
}).map(|o| o.id)
}
fn carry_job(&mut self, e: &mut Ent, id: u32, dt: f32) {
let Some(k) = self.find(id) else {
e.job = Job::None;
return;
};
let cur = e.tile();
match self.ents[k].captive {
1 => {
let (cx, cy) = (self.ents[k].x, self.ents[k].y);
if (cx - e.x).abs() < 1.2 && (cy - e.y).abs() < 1.2 {
// pick up and look for a free place in the torture chamber
match self.map.search(cur, self.map.walk_cost(), |i| self.free_cell(i), true) {
Some(p) => {
let target = p.last().copied().unwrap_or(cur);
self.map.t[target].bed = id + 1;
let c = &mut self.ents[k];
c.captive = 2;
c.carrier = e.id;
e.path = p;
}
None => {
self.ents[k].carrier = 0;
e.job = Job::None;
}
}
} else if e.path.is_empty() {
match self.map.path_to(cur, idx(cx as i32, cy as i32)) {
Some(p) if !p.is_empty() => e.path = p,
_ => {
self.ents[k].carrier = 0;
e.job = Job::None;
}
}
}
}
2 if self.ents[k].carrier == e.id => {
if !self.step_path(e, dt) {
// arrived (or blocked): put the prisoner down on the reserved torture tile
let target = self.map.t.iter().position(|t| t.bed == id + 1);
match target {
Some(t) if t == cur || adj4(t, cur) || t == e.tile() => {
let (tx, ty) = xy(t);
let c = &mut self.ents[k];
c.captive = 3;
c.carrier = 0;
c.hp = c.max_hp; // the torture drains it over ~100 s
c.x = tx as f32 + 0.5;
c.y = ty as f32 + 0.5;
c.torture_t = 0.0;
self.sfx(Sfx::Capture, tx as f32 + 0.5, ty as f32 + 0.5);
e.job = Job::None;
}
Some(t) => {
if let Some(p) = self.map.path_to(cur, t) {
e.path = p;
} else {
self.ents[k].captive = 1;
self.ents[k].carrier = 0;
e.job = Job::None;
}
}
None => {
self.ents[k].captive = 1;
self.ents[k].carrier = 0;
e.job = Job::None;
}
}
}
return;
}
_ => {
e.job = Job::None;
return;
}
}
self.step_path(e, dt);
}
// ---------------------------------------------------------------- heroes
fn hero_ai(&mut self, e: &mut Ent, dt: f32) {
if e.kind == EK::Fairy {
// fairy dust: the heroes around are healed a little all the time
for o in self.ents.iter_mut() {
if o.alive && o.active_hero() && o.hp < o.max_hp && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 9.0 {
o.hp = (o.hp + 6.0 * dt).min(o.max_hp);
}
}
if self.vrng.f() < 5.0 * dt {
let vx = self.vrng.f() * 0.6 - 0.3;
self.part(crate::particles::EMBER, (e.x, e.y, 0.9), (vx, 0.0, 0.2), 1.0, crate::particles::abgr(255, 240, 180), 0.03);
}
}
if e.think <= 0.0 {
e.think = 0.5;
if e.kind == EK::Priest && e.cd <= 0.0 {
let mut best: Option<usize> = None;
let mut worst = 0.95;
for (j, o) in self.ents.iter().enumerate() {
if o.alive && o.hero {
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
let r = o.hp / o.max_hp;
if d < 16.0 && r < worst {
worst = r;
best = Some(j);
}
}
}
if e.hp / e.max_hp < worst {
e.hp = (e.hp + 25.0).min(e.max_hp);
self.fx(4, e.x, e.y, 0.0, 0.0, 0.8, 0xff60ff80);
e.cd = 2.5;
} else if let Some(j) = best {
let o = &mut self.ents[j];
o.hp = (o.hp + 25.0).min(o.max_hp);
let (ox, oy) = (o.x, o.y);
self.fx(4, ox, oy, 0.0, 0.0, 0.8, 0xff60ff80);
e.cd = 2.5;
}
}
let fort = e.guard || !e.patrol.is_empty();
if !matches!(e.job, Job::Attack(_)) {
let radius = if fort { 8.0 } else { 6.5 };
if let Some(id) = self.nearest_enemy(e, radius) {
e.job = Job::Attack(id);
e.path.clear();
e.repath = 0.0;
if fort {
self.alarm(e.x, e.y, id);
}
} else if e.guard && !matches!(e.job, Job::TearBridge(_)) && matches!(e.kind, EK::Knight | EK::Dwarf | EK::Paladin) && self.rng.f() < 0.05 {
// knights cut the player's way in: bridges near their post are torn down
if let Some(b) = self.bridge_to_tear(e) {
e.job = Job::TearBridge(b);
e.path.clear();
e.repath = 0.0;
e.timer = 0.0;
}
} else if !matches!(e.job, Job::AttackTower(_) | Job::TearBridge(_)) {
// no unit around: towers within reach are attacked
let near = self.towers.iter().filter(|t| !t.hero).filter(|t| {
let (tx, ty) = t.pos();
(tx - e.x).powi(2) + (ty - e.y).powi(2) < radius * radius
}).min_by(|a, b| {
let da = (a.pos().0 - e.x).powi(2) + (a.pos().1 - e.y).powi(2);
let db = (b.pos().0 - e.x).powi(2) + (b.pos().1 - e.y).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
}).map(|t| t.id);
if let Some(id) = near {
e.job = Job::AttackTower(id);
e.path.clear();
e.repath = 0.0;
}
}
}
}
if let Job::AttackTower(id) = e.job {
if self.attack_tower(e, id, dt) {
return;
}
e.job = Job::AttackCore;
e.path.clear();
e.think = 1.5;
}
if let Job::TearBridge(b) = e.job {
if self.tear_bridge(e, b, dt) {
return;
}
e.job = Job::None;
e.path.clear();
}
if let Job::Attack(id) = e.job {
// guards and patrols do not follow too far
let (gx, gy) = match (e.home, e.patrol.get(e.patrol_k % e.patrol.len().max(1))) {
(Some(h), _) => h,
(None, Some(&(px, py))) => (px as f32 + 0.5, py as f32 + 0.5),
_ => self.gate_center(),
};
let far = (e.guard || !e.patrol.is_empty()) && (e.x - gx).powi(2) + (e.y - gy).powi(2) > 14.0 * 14.0;
if !far && self.attack_job(e, id, dt) {
return;
}
e.job = Job::AttackCore;
e.path.clear();
e.think = 1.5;
}
if !e.patrol.is_empty() {
// patrols walk their route in a loop
let k = e.patrol_k % e.patrol.len();
let (px, py) = e.patrol[k];
if (e.x - px as f32 - 0.5).powi(2) + (e.y - py as f32 - 0.5).powi(2) < 0.8 {
e.patrol_k = (k + 1) % e.patrol.len();
e.path.clear();
}
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.0;
let (px, py) = e.patrol[e.patrol_k % e.patrol.len()];
match self.map.path_to(e.tile(), idx(px, py)) {
Some(p) => e.path = p,
None => e.patrol_k = (e.patrol_k + 1) % e.patrol.len(), // blocked: next point
}
}
self.step_path(e, dt);
return;
}
if e.guard {
// guards stay at their post (gate or treasure cave)
let (gx, gy) = e.home.unwrap_or_else(|| self.gate_center());
let d2 = (e.x - gx).powi(2) + (e.y - gy).powi(2);
if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.0;
let target = idx(gx as i32, gy as i32);
if let Some(p) = self.map.path_to(e.tile(), target) {
e.path = p;
}
}
self.step_path(e, dt);
return;
}
// a wave sent against a rival master
if e.target.map_or(false, |t| t >= 2) && self.march_on(e, dt) {
return;
}
let cur = e.tile();
if e.path.is_empty() && self.map.adj_core(cur) {
e.working = true;
let (cx, cy) = self.map.core;
e.face = sgn(cx as f32 + 1.5 - e.x);
if e.cd <= 0.0 {
e.cd = st(e.kind).interval;
self.core_hp -= e.atk();
let fx = (e.x + cx as f32 + 1.5) * 0.5;
let fy = (e.y + cy as f32 + 1.5) * 0.5;
self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff);
self.sfx(Sfx::CoreHit, fx, fy);
self.hint(6, "hint.core_attacked");
}
return;
}
if let Some((wx, wy)) = e.waypoint {
if (e.x - wx as f32 - 0.5).abs() < 2.5 && (e.y - wy as f32 - 0.5).abs() < 2.5 {
e.waypoint = None; // broke into the realm: now for the core
e.path.clear();
}
}
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.0;
let path = match e.waypoint {
Some((wx, wy)) => {
let goal = idx(wx, wy);
let p = self.map.search(cur, self.map.hero_cost(), |i| i == goal, true);
if p.is_none() {
e.waypoint = None;
}
p
}
None => self.map.hero_path_to_core(cur),
};
if let Some(p) = path {
e.path = p;
}
}
self.step_path(e, dt);
}
// Wave AI: choose where the next wave breaks into the realm. Candidates are border tiles of
// the player's territory on the side facing the hero gate; towers, creatures and reinforced
// walls nearby make a place less attractive, as does a long way and recent use.
pub fn plan_entries(&mut self) {
self.planned_entries.clear();
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
let (gx, gy) = (self.map.gate.0 + 1, self.map.gate.1 + 1);
let dir = norm((gx - cx) as f32, (gy - cy) as f32);
let mut cands = Vec::new();
for i in 0..self.map.t.len() {
let t = &self.map.t[i];
if t.owner != self.me || !t.walkable() {
continue;
}
let (x, y) = xy(i);
let border = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != self.me));
if !border {
continue;
}
let rel = ((x - cx) as f32, (y - cy) as f32);
let len = (rel.0 * rel.0 + rel.1 * rel.1).sqrt();
if len < 2.0 || (rel.0 * dir.0 + rel.1 * dir.1) / len < 0.0 {
continue;
}
cands.push((x, y));
}
if cands.is_empty() {
return;
}
let step = (cands.len() / 300).max(1);
let mut scored: Vec<((i32, i32), f32)> = Vec::new();
for &(x, y) in cands.iter().step_by(step) {
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
let near = |px: f32, py: f32, r: f32| (px - fx).powi(2) + (py - fy).powi(2) < r * r;
let towers: u32 = self.towers.iter().filter(|t| near(t.pos().0, t.pos().1, 6.0)).map(|t| t.level).sum();
let creatures = self.ents.iter().filter(|e| e.alive && e.creature() && near(e.x, e.y, 6.0)).count();
let mut reinf = 0;
for dy in -2..=2 {
for dx in -2..=2 {
if self.map.get(x + dx, y + dy).map_or(false, |t| t.reinforced) {
reinf += 1;
}
}
}
let dist = (((x - gx).pow(2) + (y - gy).pow(2)) as f32).sqrt();
let recent: f32 = self.entry_history.iter().rev().take(2).filter(|&&(hx, hy)| ((hx - x).pow(2) + (hy - y).pow(2)) < 100).count() as f32 * -60.0;
let score = -dist * 0.8 - 25.0 * towers as f32 - 12.0 * creatures as f32 - 6.0 * reinf as f32 + recent + self.rng.f() * 25.0;
scored.push(((x, y), score));
}
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let first = scored[0].0;
self.planned_entries.push(first);
// second entry point at least 10 tiles away (used by waves of 6+ heroes)
if let Some(&(second, _)) = scored.iter().find(|((x, y), _)| (x - first.0).pow(2) + (y - first.1).pow(2) >= 100) {
self.planned_entries.push(second);
}
}
// compass direction of a tile as seen from the core (language key)
pub fn direction_key(&self, (x, y): (i32, i32)) -> &'static str {
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
let a = ((y - cy) as f32).atan2((x - cx) as f32).to_degrees(); // 0 = east, 90 = south (map y grows downwards)
let k = (((a + 360.0 + 22.5) % 360.0) / 45.0) as usize;
["dir.e", "dir.se", "dir.s", "dir.sw", "dir.w", "dir.nw", "dir.n", "dir.ne"][k % 8]
}
// ---------------------------------------------------------------- player actions
pub fn mark_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, on: bool) {
for y in y0.min(y1)..=y0.max(y1) {
for x in x0.min(x1)..=x0.max(x1) {
if inb(x, y) {
let t = &mut self.map.t[idx(x, y)];
if t.diggable() {
t.marked = on;
}
}
}
}
}
pub fn build_rect(&mut self, room: usize, x0: i32, y0: i32, x1: i32, y1: i32) {
let kind = ROOM_KINDS[room];
let cost = ROOM_COST[room] as f32;
let mut built = 0;
let mut broke = false;
for y in y0.min(y1)..=y0.max(y1) {
for x in x0.min(x1)..=x0.max(x1) {
if !inb(x, y) {
continue;
}
let i = idx(x, y);
let t = self.map.t[i];
if t.kind == Kind::Floor && t.owner == self.me {
if self.gold < cost {
broke = true;
continue;
}
self.gold -= cost;
let t = &mut self.map.t[i];
t.kind = kind;
t.food = 0.0;
t.bed = 0;
built += 1;
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff);
if built == 1 {
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
}
}
}
}
if broke {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
}
if built == 0 && !broke {
self.hint(7, "hint.build_where");
}
self.recount();
}
pub fn sell_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
let mut refund = 0.0;
for y in y0.min(y1)..=y0.max(y1) {
for x in x0.min(x1)..=x0.max(x1) {
if !inb(x, y) {
continue;
}
let i = idx(x, y);
if self.remove_trap_at(i) {
continue;
}
if let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) {
refund += self.towers[k].invested() as f32 * 0.5;
if self.towers[k].kind == TowerKind::Rune && !self.towers[k].rune_final {
// the base tower comes back once: in the next mission (free play: at once)
if self.mission.is_some() {
self.rune_next += 1;
self.msg("msg.rune_next", vec![]);
} else {
self.rune_final += 1;
self.msg("msg.rune_back", vec![]);
}
}
self.towers.remove(k);
self.map.t[i].kind = Kind::Floor;
continue;
}
let t = &mut self.map.t[i];
if t.kind == Kind::Steel {
t.kind = Kind::Earth;
t.reinforced = true;
t.hp = 12.0;
refund += STEEL_COST[1] as f32 * 0.5;
continue;
}
if t.owner == self.me && t.bridge() {
let lava = t.kind == Kind::LavaBridge;
t.kind = if lava { Kind::Lava } else { Kind::Water };
t.owner = Owner::None;
t.reserved = 0;
refund += BRIDGE_COST[lava as usize] as f32 * 0.5;
self.ground_lost(i);
continue;
}
let t = &mut self.map.t[i];
if t.owner == self.me {
if let Some(r) = room_index(t.kind) {
t.kind = Kind::Floor;
t.bed = 0;
t.food = 0.0;
refund += ROOM_COST[r] as f32 * 0.5;
}
}
}
}
self.recount();
if refund > 0.0 {
let got = self.add_gold(refund);
self.sfx_ui(Sfx::Sell);
self.msg("msg.sold", vec![(got as i32).to_string()]);
}
}
pub fn tower_at(&self, i: usize) -> Option<&Tower> {
self.towers.iter().find(|t| t.tile == i)
}
// Build a tower on own floor, or upgrade an own tower of the same kind
pub fn build_tower(&mut self, kind: TowerKind, x: i32, y: i32) {
if !inb(x, y) {
return;
}
let i = idx(x, y);
let s = tower_stats(kind);
if let Some(k) = self.towers.iter().position(|t| t.tile == i) {
let t = &self.towers[k];
if t.hero {
self.hint(16, "hint.tower_where");
self.sfx_ui(Sfx::Error);
return;
}
if t.kind != kind {
self.hint(14, "hint.tower_other");
return;
}
if t.level >= 3 {
self.hint(15, "hint.tower_max");
return;
}
let cost = s.cost[t.level as usize] as f32;
if self.gold < cost {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
self.gold -= cost;
let t = &mut self.towers[k];
let old_max = t.max_hp();
t.level += 1;
t.hp += t.max_hp() - old_max;
let (px, py) = t.pos();
let lvl = t.level;
self.fx(7, px, py, 0.0, 0.0, 0.8, 0xff44ddff);
self.sfx(Sfx::LevelUp, px, py);
self.msg("msg.tower_upgraded", vec![format!("@{}", tower_name(kind)), lvl.to_string()]);
return;
}
let t = self.map.t[i];
if t.kind != Kind::Floor || t.owner != self.me {
self.hint(16, "hint.tower_where");
self.sfx_ui(Sfx::Error);
return;
}
let cost = s.cost[0] as f32;
if self.gold < cost {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
// rune forge towers: one build per opened chest (fresh plans first)
let mut rune_final = false;
if kind == TowerKind::Rune {
if self.rune_free > 0 {
self.rune_free -= 1;
} else if self.rune_final > 0 {
self.rune_final -= 1;
rune_final = true;
} else {
self.hint(24, "hint.rune_none");
self.sfx_ui(Sfx::Error);
return;
}
}
self.gold -= cost;
self.map.t[i].kind = Kind::Tower;
let id = self.next_id;
self.next_id += 1;
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false, rune_final };
let (px, py) = tower.pos();
self.towers.push(tower);
// units standing on the tile step aside
for e in self.ents.iter_mut() {
if e.alive && e.tile() == i {
for &(dx, dy) in &DIR8 {
if self.map.walk(x + dx, y + dy) {
e.x = (x + dx) as f32 + 0.5;
e.y = (y + dy) as f32 + 0.5;
e.path.clear();
break;
}
}
}
}
self.fx(8, px, py, 0.0, 0.0, 0.5, 0xff40d0ff);
self.sfx(Sfx::Build, px, py);
}
fn destroy_tower(&mut self, k: usize) {
let t = self.towers.remove(k);
self.map.t[t.tile].kind = Kind::Floor;
let (px, py) = t.pos();
self.fx(3, px, py, 0.0, 0.0, 1.0, 0xff505070);
self.sfx(Sfx::DigDone, px, py);
if t.hero {
self.towers_razed += 1;
self.msg("msg.enemy_tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
} else {
self.msg("msg.tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
}
}
// a knight tower near the unit (for the player's creatures)
fn enemy_tower_near(&self, x: f32, y: f32, radius: f32) -> Option<u32> {
self.towers
.iter()
.filter(|t| t.hero)
.map(|t| (t.id, (t.pos().0 - x).powi(2) + (t.pos().1 - y).powi(2)))
.filter(|&(_, d)| d < radius * radius)
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(id, _)| id)
}
// damage dealt to a unit by a tower (no attacker unit involved)
fn tower_damage(&mut self, j: usize, dmg: f32) {
let e = &mut self.ents[j];
e.hp -= if e.shield > 0.0 { dmg * 0.5 } else { dmg };
e.flash = 0.15;
if e.hp <= 0.0 && e.alive {
self.kill(j);
}
}
fn tower_target(&self, t: &Tower) -> Option<usize> {
let (px, py) = t.pos();
let r = t.range();
let minr = tower_stats(t.kind).min_range;
let mut best = None;
let mut bd = r * r;
for (j, e) in self.ents.iter().enumerate() {
// knight towers shoot the player's units, the player's towers shoot heroes
// knight towers shoot everybody but the humans, the player's towers everybody else
if !e.alive || e.held || !self.tower_enemy(t, e) || e.invis > 0.0 {
continue;
}
let d = (e.x - px).powi(2) + (e.y - py).powi(2);
if d < bd && d >= minr * minr && self.los(px, py, e.x, e.y) {
bd = d;
best = Some(j);
}
}
best
}
// an enemy tower or the gate of the order within reach: (x, y, height, tower id or None = gate)
fn tower_structure_target(&self, t: &Tower) -> Option<(f32, f32, f32, Option<u32>)> {
let (px, py) = t.pos();
let r = t.range();
let minr = tower_stats(t.kind).min_range;
let in_reach = |x: f32, y: f32| {
let d = (x - px).powi(2) + (y - py).powi(2);
d <= r * r && d >= minr * minr
};
let mut best: Option<(f32, (f32, f32, f32, Option<u32>))> = None;
for o in self.towers.iter().filter(|o| o.hero != t.hero) {
let (ox, oy) = o.pos();
if in_reach(ox, oy) && self.los_to_tile(px, py, o.tile) {
let d = (ox - px).powi(2) + (oy - py).powi(2);
if best.map_or(true, |b| d < b.0) {
best = Some((d, (ox, oy, o.top() * 0.6, Some(o.id))));
}
}
}
if best.is_none() && !t.hero && self.has_gate() && self.gate_hp > 0.0 {
// the nearest tile of the gate counts
let (gx, gy) = self.map.gate;
let (nx, ny) = ((px - 0.5).clamp(gx as f32, gx as f32 + 2.0) + 0.5, (py - 0.5).clamp(gy as f32, gy as f32 + 2.0) + 0.5);
if in_reach(nx, ny) && self.los_to_tile(px, py, idx(nx as i32, ny as i32)) {
best = Some((0.0, (nx, ny, 1.0, None)));
}
}
best.map(|b| b.1)
}
// line of sight from a tower to a target tile: walls and other towers block,
// the tower's own tile and the target tile do not
fn los_to_tile(&self, x0: f32, y0: f32, target: usize) -> bool {
let start = idx(x0 as i32, y0 as i32);
let (tx, ty) = xy(target);
let (x1, y1) = (tx as f32 + 0.5, ty as f32 + 0.5);
let n = (((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt() * 3.0).ceil().max(1.0) as i32;
for k in 1..n {
let f = k as f32 / n as f32;
let i = idx((x0 + (x1 - x0) * f) as i32, (y0 + (y1 - y0) * f) as i32);
if i == start || i == target {
continue;
}
let tl = &self.map.t[i];
if tl.solid() || tl.kind == Kind::Tower {
return false;
}
}
true
}
fn tower_fire_at_structure(&mut self, k: usize, (ex, ey, eh, target): (f32, f32, f32, Option<u32>)) {
let t = self.towers[k].clone();
let s = tower_stats(t.kind);
if s.mana > 0.0 && !t.hero && self.mana < s.mana {
self.towers[k].cd = 1.0;
return;
}
if !t.hero {
self.mana -= s.mana;
}
let (px, py) = t.pos();
let aim = (ey - py).atan2(ex - px);
let tw = &mut self.towers[k];
tw.cd = s.interval;
tw.flash = 0.15;
tw.aim = aim;
let (mx, my, mh) = match t.kind {
TowerKind::Arrow => (px, py, t.top() + 0.25),
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
TowerKind::Magic => (px, py, t.top() + 0.45),
TowerKind::Rune => (px, py, t.top() + 0.55),
};
let col = match t.kind {
TowerKind::Arrow => 0xffd0e0f0,
TowerKind::Cannon => 0xff303030,
TowerKind::Magic => 0xffff60d0,
TowerKind::Rune => 0xff40c0ff,
};
self.shot(mx, my, mh, ex, ey, eh, 0.3, col);
self.style_last_fx(match t.kind {
TowerKind::Arrow => crate::particles::ST_ARROW,
TowerKind::Cannon => crate::particles::ST_CANNON,
TowerKind::Magic => crate::particles::ST_MAGIC,
TowerKind::Rune => crate::particles::ST_RUNE,
});
self.sfx(match t.kind {
TowerKind::Arrow => Sfx::Shoot,
TowerKind::Cannon => Sfx::Cannon,
TowerKind::Magic | TowerKind::Rune => Sfx::Magic,
}, px, py);
let dmg = t.dmg();
match target {
Some(id) => {
if let Some(o) = self.towers.iter().position(|o| o.id == id) {
self.towers[o].hp -= dmg;
if self.towers[o].hp <= 0.0 {
self.destroy_tower(o);
}
}
}
None => {
self.gate_hp -= dmg;
self.gate_hit_t = self.time;
self.sfx(Sfx::GateHit, ex, ey);
if self.mission.as_ref().map_or(true, |m| m.gate_defenders) {
self.check_gate_stage();
}
}
}
}
pub(crate) fn update_towers(&mut self, dt: f32) {
for k in 0..self.towers.len() {
if k >= self.towers.len() {
break;
}
let t = &mut self.towers[k];
t.cd -= dt;
t.flash = (t.flash - dt).max(0.0);
if t.cd > 0.0 {
continue;
}
let t = self.towers[k].clone();
let Some(j) = self.tower_target(&t) else {
// no unit in reach: enemy towers and the enemy gate are shot at
if let Some(target) = self.tower_structure_target(&t) {
self.tower_fire_at_structure(k, target);
}
continue;
};
let s = tower_stats(t.kind);
if s.mana > 0.0 && !t.hero && self.mana < s.mana {
self.hint(17, "hint.tower_mana");
self.towers[k].cd = 1.0;
continue;
}
if !t.hero {
self.mana -= s.mana;
}
self.towers[k].cd = s.interval;
self.towers[k].flash = 0.15;
let (px, py) = t.pos();
let (ex, ey) = (self.ents[j].x, self.ents[j].y);
let aim = (ey - py).atan2(ex - px);
self.towers[k].aim = aim;
// where the shot leaves the tower (see the tower models in render3d.rs)
let (mx, my, mh) = match t.kind {
TowerKind::Arrow => (px, py, t.top() + 0.25),
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
TowerKind::Magic => (px, py, t.top() + 0.45),
TowerKind::Rune => (px, py, t.top() + 0.55),
};
let dmg = t.dmg();
match t.kind {
TowerKind::Arrow => {
// the arrow pierces up to two more heroes behind the target
let dir = norm(ex - px, ey - py);
let (endx, endy, wall) = self.arrow_end(px, py, dir.0, dir.1, t.range());
self.shot(mx, my, mh, endx, endy, 0.45, 0.3, 0xffd0e0f0);
self.style_last_fx(if wall { crate::particles::ST_ARROW_WALL } else { crate::particles::ST_ARROW });
self.sfx(Sfx::Shoot, px, py);
let mut hits = vec![(j, dmg)];
for (o, e) in self.ents.iter().enumerate() {
if o == j || !e.alive || e.held || !e.active_hero() || hits.len() >= 3 {
continue;
}
let rel = (e.x - px, e.y - py);
let along = rel.0 * dir.0 + rel.1 * dir.1;
let side = (rel.0 * dir.1 - rel.1 * dir.0).abs();
if along > 0.0 && along <= t.range() && side < 0.45 {
hits.push((o, dmg * 0.6));
}
}
for (o, d) in hits {
if self.ents[o].alive {
self.tower_damage(o, d);
}
}
}
TowerKind::Cannon => {
// splash damage and knockback around the impact
self.shot(mx, my, mh, ex, ey, 0.4, 0.35, 0xff303030);
self.style_last_fx(crate::particles::ST_CANNON);
self.sfx(Sfx::Cannon, px, py);
let mut hits = Vec::new();
for (o, e) in self.ents.iter().enumerate() {
if !e.alive || e.held || !e.active_hero() {
continue;
}
let d = ((e.x - ex).powi(2) + (e.y - ey).powi(2)).sqrt();
if d < 1.6 {
hits.push((o, dmg * (1.0 - d / 2.0), d));
}
}
for (o, d, dist) in hits {
if !self.ents[o].alive {
continue;
}
// knockback away from the impact, only onto walkable ground
let e = &self.ents[o];
let away = if dist > 0.05 { norm(e.x - ex, e.y - ey) } else { norm(e.x - px, e.y - py) };
self.knock(o, away.0 * 0.7, away.1 * 0.7);
if self.ents[o].alive {
self.tower_damage(o, d);
}
}
}
TowerKind::Magic => {
// sure hit, slows, jumps to a second hero
self.shot(mx, my, mh, ex, ey, 0.45, 0.2, 0xffff60d0);
self.style_last_fx(crate::particles::ST_MAGIC);
self.sfx(Sfx::Magic, px, py);
self.ents[j].slow = 2.0;
let chain = self
.ents
.iter()
.enumerate()
.filter(|(o, e)| *o != j && e.alive && !e.held && e.active_hero() && (e.x - ex).powi(2) + (e.y - ey).powi(2) < 9.0)
.min_by(|a, b| {
let da = (a.1.x - ex).powi(2) + (a.1.y - ey).powi(2);
let db = (b.1.x - ex).powi(2) + (b.1.y - ey).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(o, _)| o);
self.tower_damage(j, dmg);
if let Some(o) = chain {
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
self.fx(0, ex, ey, ox, oy, 0.2, 0xffff60d0);
self.ents[o].slow = 2.0;
if self.ents[o].alive {
self.tower_damage(o, dmg * 0.6);
}
}
}
TowerKind::Rune => {
// a rune bolt that jumps on to up to three more enemies nearby
self.shot(mx, my, mh, ex, ey, 0.45, 0.25, 0xff40c0ff);
self.style_last_fx(crate::particles::ST_RUNE);
self.fx(2, ex, ey, 0.0, 0.0, 0.35, 0xffffc040);
self.sfx(Sfx::Magic, px, py);
let mut hit = vec![j];
let (mut lx, mut ly) = (ex, ey);
self.tower_damage(j, dmg);
for n in 0..3 {
let next = self
.ents
.iter()
.enumerate()
.filter(|(o, e)| !hit.contains(o) && e.alive && !e.held && self.tower_enemy(&t, e) && (e.x - lx).powi(2) + (e.y - ly).powi(2) < 3.5 * 3.5)
.min_by(|a, b| {
let da = (a.1.x - lx).powi(2) + (a.1.y - ly).powi(2);
let db = (b.1.x - lx).powi(2) + (b.1.y - ly).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(o, _)| o);
let Some(o) = next else { break };
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
self.fx(0, lx, ly, ox, oy, 0.25, 0xff40c0ff);
hit.push(o);
if self.ents[o].alive {
self.tower_damage(o, dmg * (0.75 - 0.1 * n as f32));
}
lx = ox;
ly = oy;
}
}
}
}
}
// a unit the tower shoots at
fn tower_enemy(&self, t: &Tower, e: &Ent) -> bool {
e.captive == 0 && !e.chained && if t.hero { e.side() != 1 } else { e.side() != 0 }
}
// a hero attacks a tower; false = tower gone or unreachable
pub(crate) fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
let Some(k) = self.towers.iter().position(|t| t.id == id) else { return false };
let (tx, ty) = self.towers[k].pos();
let hit_h = self.towers[k].top() * 0.6;
let s = st(e.kind);
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
if d <= s.range + 0.75 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
e.path.clear();
e.face = sgn(tx - e.x);
e.working = true;
if e.cd <= 0.0 {
e.cd = s.interval;
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
if s.range > 1.5 {
self.shot(e.x, e.y, 0.45, tx, ty, hit_h, 0.25, 0xff70b0d0);
self.style_last_fx(if e.kind == EK::Archer { crate::particles::ST_ARROW } else { crate::particles::ST_PLAIN });
} else {
self.fx_at(2, tx, ty, hit_h, 0.25, 0xffffffff);
}
self.sfx(Sfx::MeleeHit, tx, ty);
let t = &mut self.towers[k];
t.hp -= dmg;
if t.hp <= 0.0 {
self.destroy_tower(k);
}
}
return true;
}
if e.path.is_empty() || e.repath <= 0.0 {
e.repath = 1.0;
let tile = self.towers[k].tile;
let (x, y) = xy(tile);
match self.map.search(e.tile(), self.map.walk_cost(), |i| {
let (ix, iy) = xy(i);
(ix - x).abs() <= 1 && (iy - y).abs() <= 1 && i != tile
}, true) {
// already next to the tower: step to the middle of the tile (in reach)
Some(p) if p.is_empty() => e.path = vec![e.tile()],
Some(p) => e.path = p,
None => return false,
}
}
self.step_path(e, dt);
true
}
fn on_own_ground(&self, x: f32, y: f32) -> bool {
self.map.get(x as i32, y as i32).map_or(false, |t| t.owner == self.me)
}
// Left click with the hand: own units anywhere, unconscious heroes only on own ground
pub fn pickup(&mut self, target: Option<usize>) -> bool {
if self.hand.len() >= 8 {
self.hint(8, "hint.hand_full");
return false;
}
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return false };
let e = &self.ents[j];
if e.rival() || e.dwarf() {
return false; // the rivals' creatures and the dwarves cannot be picked up
}
if e.hero {
if e.captive != 1 && e.captive != 3 {
return false;
}
if !self.on_own_ground(e.x, e.y) {
self.hint(18, "hint.pickup_prisoner_where");
self.sfx_ui(Sfx::Error);
return false;
}
}
let id = self.ents[j].id;
let me = id + 1;
self.unreserve(me);
if self.ents[j].captive == 3 {
// taken out of its cell
for t in self.map.t.iter_mut().filter(|t| t.bed == me && matches!(t.kind, Kind::Torture | Kind::Prison)) {
t.bed = 0;
}
}
for o in self.ents.iter_mut() {
if o.carrier == id && o.captive != 0 {
o.captive = 1;
o.carrier = 0;
}
}
let e = &mut self.ents[j];
e.held = true;
e.carrier = 0;
e.job = Job::None;
e.path.clear();
self.hand.push(id);
self.sfx_ui(Sfx::Pickup);
true
}
// Right click: drop the last picked unit on own walkable ground. Prisoners dropped on a
// free torture place are locked up at once.
pub fn drop(&mut self, wx: f32, wy: f32) -> bool {
let Some(&id) = self.hand.last() else { return false };
let (x, y) = (wx as i32, wy as i32);
if !inb(x, y) {
return false;
}
let i = idx(x, y);
let t = self.map.t[i];
if !t.walkable() || t.owner != self.me {
self.hint(9, "hint.drop_where");
self.sfx_ui(Sfx::Error);
return false;
}
self.hand.pop();
let imprison = matches!(t.kind, Kind::Torture | Kind::Prison) && t.bed == 0;
// the sacrifice altar takes creatures
if t.kind == Kind::Altar {
if let Some(k) = self.ents.iter().position(|e| e.id == id && !e.hero) {
self.ents[k].held = false;
self.ents[k].x = wx;
self.ents[k].y = wy;
self.sacrifice(k);
return true;
}
}
if let Some(k) = self.ents.iter().position(|e| e.id == id) {
let e = &mut self.ents[k];
e.held = false;
e.x = wx;
e.y = wy;
e.job = Job::None;
e.think = 0.0;
e.path.clear();
if e.hero {
e.carrier = 0;
e.torture_t = 0.0;
if imprison {
e.captive = 3;
e.hp = e.max_hp;
e.x = x as f32 + 0.5;
e.y = y as f32 + 0.5;
self.map.t[i].bed = id + 1;
self.sfx(Sfx::Capture, wx, wy);
} else {
e.captive = 1;
}
}
}
self.fx(3, wx, wy, 0.0, 0.0, 0.4, 0xff707090);
self.sfx(Sfx::Drop, wx, wy);
true
}
// Shift + left click: backhand for own units (speed-up), fist for heroes on own ground.
// Returns 1 = backhand, 2 = punch, 0 = nothing.
pub fn slap(&mut self, target: Option<usize>) -> u8 {
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return 0 };
let (x, y) = (self.ents[j].x, self.ents[j].y);
if self.ents[j].dwarf() {
return 0;
}
if !self.ents[j].hero && !self.ents[j].rival() {
let e = &mut self.ents[j];
e.boost = 15.0;
e.hp = (e.hp - e.max_hp * 0.05).max(1.0);
e.happy -= 0.04;
e.flash = 0.15;
if matches!(e.job, Job::Sleep(_)) {
e.job = Job::None; // woken up
}
self.fx(2, x, y, 0.0, 0.0, 0.3, 0xffffffff);
self.sfx(Sfx::Slap, x, y);
return 1;
}
if self.ents[j].captive != 0 {
return 0;
}
if !self.on_own_ground(x, y) {
self.hint(19, "hint.punch_where");
self.sfx_ui(Sfx::Error);
return 0;
}
if self.time < self.punch_ready {
return 0;
}
self.punch_ready = self.time + 0.5;
self.ents[j].stun = 1.0;
// the punch throws the hero back (into lava = burnt)
let back = -self.ents[j].face;
self.knock(j, back * 0.8, 0.0);
if !self.ents[j].alive {
self.fx(2, x, y, 0.0, 0.0, 0.35, 0xff40e0ff);
self.sfx(Sfx::Punch, x, y);
return 2;
}
self.fx(2, x, y, 0.0, 0.0, 0.35, 0xff40e0ff);
self.fx(7, x, y, 0.0, 0.0, 0.4, 0xff40e0ff);
self.sfx(Sfx::Punch, x, y);
self.tower_damage(j, 15.0);
2
}
pub fn lightning(&mut self, wx: f32, wy: f32) {
const COST: f32 = 50.0;
if self.mana < COST {
self.msg("msg.no_mana_bolt", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
self.mana -= COST;
self.sfx(Sfx::Lightning, wx, wy);
self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa);
let mut kills = Vec::new();
for (j, e) in self.ents.iter_mut().enumerate() {
if e.alive && !e.held && e.active_hero() && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 2.6 {
e.hp -= 55.0;
e.flash = 0.2;
if e.hp <= 0.0 {
kills.push(j);
}
}
}
for j in kills {
self.kill(j);
}
}
pub fn heal(&mut self, wx: f32, wy: f32) {
const COST: f32 = 60.0;
if self.mana < COST {
self.msg("msg.no_mana_heal", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
self.mana -= COST;
self.sfx(Sfx::Heal, wx, wy);
let mut fxs = Vec::new();
for e in self.ents.iter_mut() {
if e.alive && !e.held && !e.hero && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 6.5 {
e.hp = (e.hp + e.max_hp * 0.5).min(e.max_hp);
fxs.push((e.x, e.y));
}
}
self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xff60ff80);
for (x, y) in fxs {
self.fx(4, x, y, 0.0, 0.0, 0.9, 0xff60ff80);
}
}
pub fn summon(&mut self, wx: f32, wy: f32) {
let cost = self.summon_cost();
let (x, y) = (wx as i32, wy as i32);
if !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != self.me {
self.hint(13, "hint.summon_where");
return;
}
if self.mana < cost {
self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]);
self.sfx_ui(Sfx::Error);
return;
}
self.mana -= cost;
self.sfx(Sfx::Summon, wx, wy);
self.spawn(EK::Worker, false, wx, wy, 1);
self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xffff66cc);
}
}
// ------------------------------------------------------------------ defence towers
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum TowerKind {
Arrow,
Cannon,
Magic,
Rune, // dwarven rune forge tower: from a chest of a dwarf cave, built for free
}
pub struct TowerStats {
pub cost: [i32; 3], // build, upgrade to level 2, upgrade to level 3
pub hp: f32,
pub dmg: f32,
pub range: f32,
pub min_range: f32,
pub interval: f32,
pub mana: f32, // per shot
}
pub fn tower_stats(k: TowerKind) -> TowerStats {
match k {
TowerKind::Arrow => TowerStats { cost: [250, 200, 300], hp: 300.0, dmg: 10.0, range: 6.0, min_range: 0.0, interval: 0.8, mana: 0.0 },
TowerKind::Cannon => TowerStats { cost: [450, 350, 500], hp: 450.0, dmg: 32.0, range: 8.5, min_range: 2.0, interval: 2.6, mana: 0.0 },
TowerKind::Magic => TowerStats { cost: [350, 300, 450], hp: 300.0, dmg: 24.0, range: 7.0, min_range: 0.0, interval: 1.5, mana: 8.0 },
TowerKind::Rune => TowerStats { cost: [0, 800, 1200], hp: 1400.0, dmg: 55.0, range: 10.0, min_range: 0.0, interval: 1.3, mana: 0.0 },
}
}
pub fn tower_name(k: TowerKind) -> &'static str {
match k {
TowerKind::Arrow => "tower.arrow",
TowerKind::Cannon => "tower.cannon",
TowerKind::Magic => "tower.magic",
TowerKind::Rune => "tower.rune",
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Tower {
pub id: u32,
pub kind: TowerKind,
pub tile: usize,
pub level: u32,
pub hp: f32,
pub cd: f32,
pub aim: f32, // direction of the last shot (radians, for drawing)
pub flash: f32,
#[serde(default)]
pub hero: bool, // tower of a knight fortress: shoots the player's units
#[serde(default)]
pub rune_final: bool, // rune forge tower built from a refund: selling it gives nothing back
}
impl Tower {
pub fn max_hp(&self) -> f32 {
tower_stats(self.kind).hp * [1.0, 1.4, 1.9][(self.level as usize - 1).min(2)]
}
pub fn dmg(&self) -> f32 {
tower_stats(self.kind).dmg * [1.0, 1.5, 2.2][(self.level as usize - 1).min(2)]
}
pub fn range(&self) -> f32 {
tower_stats(self.kind).range + 0.75 * (self.level - 1) as f32
}
pub fn pos(&self) -> (f32, f32) {
let (x, y) = xy(self.tile);
(x as f32 + 0.5, y as f32 + 0.5)
}
// height of the tower body (3D units); grows with the level
pub fn top(&self) -> f32 {
match self.kind {
TowerKind::Arrow => 1.0 + 0.3 * (self.level - 1) as f32,
TowerKind::Cannon => 0.6 + 0.15 * (self.level - 1) as f32,
TowerKind::Magic => 1.2 + 0.3 * (self.level - 1) as f32,
TowerKind::Rune => 1.4 + 0.25 * (self.level - 1) as f32,
}
}
// gold spent on this tower so far
pub fn invested(&self) -> i32 {
tower_stats(self.kind).cost[..self.level as usize].iter().sum()
}
}
// Language key describing what a unit is doing
pub fn job_name(e: &Ent) -> &'static str {
match e.job {
Job::None if !e.patrol.is_empty() => "job.patrol",
Job::None if e.guard => "job.guard",
Job::None => "job.none",
Job::Dig(_) => "job.dig",
Job::Claim(_) => "job.claim",
Job::Deliver => "job.deliver",
Job::Wander => "job.wander",
Job::Sleep(_) => {
if e.moving {
"job.sleep_go"
} else {
"job.sleep"
}
}
Job::Eat(_) => "job.eat",
Job::Train(_) => "job.train",
Job::Forge(_) => "job.forge",
Job::Brawl(_) => "job.brawl",
Job::Guard(_) => "job.guard_post",
Job::Spar(_) => "job.spar",
Job::Install(_) => "job.install",
Job::Study(_) => "job.study",
Job::Attack(_) => "job.fight",
Job::TearBridge(_) => "job.tear_bridge",
Job::Rest(_) => "job.rest",
Job::Torment(_) => "job.torment",
Job::Register => "job.register",
Job::MakeBed(_) => "job.make_bed",
Job::FetchWage(_) => "job.fetch_wage",
Job::Help(_) => "job.help",
Job::AttackCore => {
if e.working {
"job.core_attack"
} else {
"job.core_march"
}
}
Job::Leave => "job.leave",
Job::Reinforce(_) => "job.reinforce",
Job::Repair(_) => "job.repair",
Job::Carry(_) => "job.carry",
Job::Pray(_) => "job.pray",
Job::HealCore => "job.heal_core",
Job::AttackTower(_) => "job.attack_tower",
Job::Rally => {
if e.working {
"job.gate_attack"
} else {
"job.rally"
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Simulates games with a simple bot player and checks that nothing crashes.
#[test]
fn simulate_long_game() {
for seed in [1u32, 7, 12345] {
let mut g = Game::new(seed, 64, 64);
let (cx, cy) = g.map.core;
let (px, py) = g.map.portals[0];
// dig a way to the portal (through earth/gold, around rock)
let start = idx(cx - 1, cy + 1);
let goal = idx(px + 1, py + 1);
let path = g
.map
.search(start, |i| if g.map.t[i].kind == Kind::Rock { None } else { Some(1) }, |i| i == goal, false)
.expect("Portal erreichbar");
for i in path {
if g.map.t[i].diggable() {
g.map.t[i].marked = true;
}
}
g.mark_rect(cx - 6, cy - 6, cx + 9, cy + 9, true);
let (ox, oy) = (cx - 3, cy - 3);
g.build_rect(1, ox, oy, ox + 2, oy + 1); // lair 3x2
g.build_rect(2, ox + 5, oy, ox + 7, oy + 1); // farm 3x2
let dt = 0.05;
let mut built = false;
let mut portal_logged = false;
for step in 0..(40 * 60 * 20) {
g.update(dt);
g.events.clear();
g.msgs.clear();
if step % 200 == 0 && !built && g.gold > 1900.0 {
g.build_rect(0, ox, oy + 6, ox + 1, oy + 8); // treasury
g.build_rect(3, ox + 6, oy + 6, ox + 8, oy + 8); // training hall
g.build_rect(1, ox, oy + 2, ox + 2, oy + 2); // enlarge the lair
built = true;
}
// the bot uses lightning against heroes at the core
if step % 20 == 0 && g.mana >= 50.0 {
let target = g.ents.iter().find(|e| e.alive && e.hero && (e.x - cx as f32).abs() < 4.0 && (e.y - cy as f32).abs() < 4.0).map(|e| (e.x, e.y));
if let Some((x, y)) = target {
g.lightning(x, y);
}
}
if step == 20 * 60 * 20 {
let (gx, gy) = g.map.gate;
g.set_flag(gx + 1, gy + 1);
}
if step % 2400 == 0 {
println!(
"seed {} t={:>5.0}s gold={:>5.0} creatures={} workers={} wave={} core={:.0} gate={:.0} heroes={} army={:.0}",
seed,
g.time,
g.gold,
g.creature_count(),
g.worker_count(),
g.wave,
g.core_hp,
g.gate_hp,
g.ents.iter().filter(|e| e.alive && e.hero).count(),
g.army_value()
);
}
if g.portals_owned() > 0 && step % 20 == 0 && !portal_logged {
portal_logged = true;
println!("seed {} portal claimed after {:.0}s", seed, g.time);
}
if g.state != 0 {
println!("seed {} game over after {:.0}s, state={}", seed, g.time, g.state);
break;
}
}
}
}
// a vertical river right next to the start area (east of it)
fn river_game(lava: bool) -> (Game, i32) {
let mut g = Game::new(7, 64, 64);
let (cx, cy) = g.map.core;
let rx = cx + 6; // the start area reaches to cx + 5
for y in cy - 6..=cy + 8 {
for x in rx..rx + 3 {
let t = &mut g.map.t[idx(x, y)];
t.kind = if lava { Kind::Lava } else { Kind::Water };
t.owner = Owner::None;
}
}
(g, rx)
}
#[test]
fn bridges_grow_from_own_ground() {
let (mut g, rx) = river_game(false);
let (_, cy) = g.map.core;
g.gold = 1000.0;
// not touching own ground: nothing happens
g.build_bridges(rx + 1, cy, rx + 2, cy);
assert_eq!(g.map.t[idx(rx + 1, cy)].kind, Kind::Water);
// from the bank across the river: three tiles at 30 gold
g.build_bridges(rx - 1, cy, rx + 2, cy);
for x in rx..rx + 3 {
let t = g.map.t[idx(x, cy)];
assert_eq!(t.kind, Kind::Bridge);
assert_eq!(t.owner, Owner::Player);
assert!(t.walkable());
}
assert_eq!(g.gold, 1000.0 - 3.0 * BRIDGE_COST[0] as f32);
// the far bank can be reached on foot now
let (cx, _) = g.map.core;
assert!(g.map.path_to(idx(cx + 4, cy), idx(rx + 3, cy)).is_some() || !g.map.walk(rx + 3, cy));
// selling a bridge tile: units on it swim back to a bank, half the cost is refunded
let id = g.spawn(EK::Kobold, false, rx as f32 + 1.5, cy as f32 + 0.5, 1);
g.sell_rect(rx + 1, cy, rx + 1, cy);
assert_eq!(g.map.t[idx(rx + 1, cy)].kind, Kind::Water);
let e = g.ents.iter().find(|e| e.id == id).unwrap();
assert!(e.alive && g.map.walk(e.x as i32, e.y as i32));
}
#[test]
fn lava_burns_and_needs_stone_bridges() {
let (mut g, rx) = river_game(true);
let (_, cy) = g.map.core;
g.gold = 1000.0;
g.build_bridges(rx - 1, cy, rx, cy);
assert_eq!(g.map.t[idx(rx, cy)].kind, Kind::LavaBridge);
assert_eq!(g.gold, 1000.0 - BRIDGE_COST[1] as f32);
// a hero pushed off the bridge into the lava burns, the bounty is paid
let id = g.spawn(EK::Knight, true, rx as f32 + 0.5, cy as f32 + 0.5, 1);
let j = g.ents.iter().position(|e| e.id == id).unwrap();
let gold = g.gold;
g.knock(j, 0.0, 1.0);
assert!(!g.ents[j].alive);
assert!(g.gold > gold);
// removing the stone bridge under a unit burns it as well
let id = g.spawn(EK::Kobold, false, rx as f32 + 0.5, cy as f32 + 0.5, 1);
g.sell_rect(rx, cy, rx, cy);
assert!(!g.ents.iter().any(|e| e.id == id && e.alive));
// heroes cannot path through lava
assert_eq!(g.map.hero_cost()(idx(rx + 1, cy)), None);
}
fn treasury_total(g: &Game) -> f32 {
g.reserve + g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player).map(|t| t.gold as f32).sum::<f32>()
}
#[test]
fn treasury_shows_its_treasures() {
let mut g = Game::new(3, 64, 64);
g.update(STEP);
// the starting gold lies in the treasury ring
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
assert_eq!(g.reserve, 0.0);
// rubies fill their own tiles
g.add_res(1200.0, 2);
g.update(STEP);
let rubies: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res == 2).map(|t| t.gold).sum();
assert_eq!(rubies, 1200);
assert!(g.map.t.iter().all(|t| t.gold <= TREASURY_TILE as i32 || t.vein().is_some()));
// spending empties tiles (randomly), the total always matches the gold counter
g.gold -= 1700.0;
g.update(STEP);
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
// more than the treasury holds: the core keeps the rest
g.gold = g.gold_cap();
g.update(STEP);
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
// (tiles partly filled with rubies take no gold, so the core may hold a little more)
let gem_room: f32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res != 0).map(|t| TREASURY_TILE - t.gold as f32).sum();
assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + gem_room + 1.0, "reserve {} gold {} cap {}", g.reserve, g.gold, g.gold_cap());
}
#[test]
fn treasure_lands_in_the_nearest_treasury() {
let mut g = Game::new(3, 64, 64);
let (cx, cy) = g.map.core;
// a second treasury far from the core ring
for y in cy - 9..=cy - 7 {
for x in cx + 10..=cx + 12 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Treasury;
t.owner = Owner::Player;
t.gold = 0;
}
}
g.recount();
g.update(STEP);
let far = |g: &Game| -> i32 { (cy - 9..=cy - 7).flat_map(|y| (cx + 10..=cx + 12).map(move |x| (x, y))).map(|(x, y)| g.map.t[idx(x, y)].gold).sum() };
let before = far(&g);
let at = idx(cx + 11, cy - 8);
g.add_res_at(900.0, 2, Some(at));
g.update(STEP);
assert_eq!(far(&g) - before, 900, "the rubies lie where they were delivered");
}
#[test]
fn training_is_billed_on_payday() {
let mut g = Game::new(3, 64, 64);
let (cx, cy) = g.map.core;
g.spawn(EK::Kobold, false, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
g.training_bill = 50.0;
let cost = g.next_payday_cost();
assert_eq!(cost, 50.0 + st(EK::Kobold).wage as f32);
let gold = g.gold;
g.payday = PAYDAY_WARN + 0.05;
g.update(0.1);
assert!(g.payday_warned);
g.payday = 0.01;
g.update(0.1);
// the training bill is paid at once, the wage when the kobold fetches it
assert_eq!(g.training_bill, 0.0);
assert!((gold - g.gold - 50.0).abs() < 1.0);
let mut fetched = false;
for _ in 0..(70 * 20) {
g.update(0.05);
fetched |= g.ents.iter().any(|e| matches!(e.job, Job::FetchWage(_)));
}
assert!(fetched, "the kobold walks to the treasury");
assert!((gold - g.gold - cost).abs() < 1.0, "paid {}", gold - g.gold);
assert!(g.ents.iter().all(|e| e.pay_due == 0.0));
}
#[test]
fn new_creatures_report_and_set_up_their_bed() {
let mut g = Game::new(3, 64, 64);
let (cx, cy) = g.map.core;
g.gold = 3000.0;
g.build_rect(1, cx - 3, cy - 3, cx - 2, cy + 1);
g.build_rect(2, cx + 4, cy - 3, cx + 5, cy + 1);
// arrives like from a portal (the test map's portal is not connected yet)
let id = g.spawn(EK::Kobold, false, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.settled = false;
e.job = Job::Register;
e.timer = 2.0;
}
let mut made_bed = false;
for _ in 0..(90 * 20) {
g.update(0.05);
let e = g.ents.iter().find(|e| e.id == id).unwrap();
made_bed |= matches!(e.job, Job::MakeBed(_)) && e.path.is_empty();
if e.settled {
break;
}
}
let e = g.ents.iter().find(|e| e.id == id).unwrap();
assert!(made_bed && e.settled);
assert_eq!(g.map.t[e.bed.unwrap()].kind, Kind::Lair);
}
// a fortress east of the start area, joined to it by a claimed corridor
fn fort_game(spec: impl Fn(i32, i32) -> crate::fortress::FortSpec) -> (Game, crate::fortress::FortSpec) {
let mut g = Game::new(11, 96, 64);
let (cx, cy) = g.map.core;
let f = spec(cx + 14, cy - 6);
g.build_fortress(&f);
for x in cx + 5..f.x {
for y in cy..=cy + 2 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
(g, f)
}
fn spec(x: i32, y: i32) -> crate::fortress::FortSpec {
crate::fortress::FortSpec { x, y, w: 15, h: 15, doors: vec![3], towers: vec![], tower_level: 1, guards: vec![], patrols: vec![], vaults: 0, vault_value: 0, shard: false, heart: false }
}
#[test]
fn knight_towers_and_creatures_fight() {
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) });
assert_eq!(g.towers.len(), 1);
assert!(g.towers[0].hero);
let (tx, ty) = g.towers[0].pos();
let id = g.spawn(EK::Ogre, false, tx + 3.0, ty + 2.0, 4);
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
let thp0 = g.towers[0].hp;
for _ in 0..200 {
g.update(0.05);
}
let hurt = g.ents.iter().find(|e| e.id == id).map_or(true, |e| e.hp < hp0);
let tower_hurt = g.towers.first().map_or(true, |t| t.hp < thp0);
assert!(hurt, "the knight tower shoots the creature");
assert!(tower_hurt, "the creature attacks the knight tower");
let _ = f;
}
#[test]
fn towers_fight_towers_and_the_gate() {
// a tower of the player in the courtyard of a fortress, no units around
let (mut g, _) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) });
g.ents.clear();
let (kx, ky) = xy(g.towers[0].tile);
let (px, py) = (kx + 3, ky + 2);
g.map.t[idx(px, py)].owner = Owner::Player;
g.gold = 5000.0;
g.build_tower(TowerKind::Arrow, px, py);
assert_eq!(g.towers.len(), 2);
let hp: Vec<f32> = g.towers.iter().map(|t| t.hp).collect();
for _ in 0..100 {
g.update(0.05);
}
assert!(g.towers.iter().zip(&hp).all(|(t, h)| t.hp < *h), "both towers are hit");
// free game: a tower in reach of the hero gate shoots at the gate
let mut g = Game::new(5, 64, 64);
g.ents.clear();
let (gx, gy) = g.map.gate;
let (tx, ty) = (gx - 2, gy + 1);
g.map.t[idx(tx, ty)].kind = Kind::Floor;
g.map.t[idx(tx, ty)].owner = Owner::Player;
g.gold = 5000.0;
g.build_tower(TowerKind::Arrow, tx, ty);
for _ in 0..60 {
g.update(0.05);
}
assert!(g.gate_hp < GATE_HP, "the gate is shot at");
}
#[test]
fn more_lair_room_brings_creatures_faster() {
let mut g = Game::new(3, 64, 64);
g.counts[1] = 4;
let tight = g.lair_factor();
g.counts[1] = 12;
let normal = g.lair_factor();
g.counts[1] = 30;
let roomy = g.lair_factor();
assert!(tight > normal && normal > roomy, "{} {} {}", tight, normal, roomy);
// creatures fill the lair: arrivals slow down again
for _ in 0..20 {
g.spawn(EK::Kobold, false, 10.0, 10.0, 1);
}
assert!(g.lair_factor() > roomy);
}
#[test]
fn vaults_are_claimed_and_looted() {
let (mut g, _) = fort_game(|x, y| crate::fortress::FortSpec { vaults: 2, vault_value: 1800, ..spec(x, y) });
let gold0 = g.gold;
for _ in 0..(240 * 20) {
g.update(0.05);
if g.loot >= 3000.0 {
break;
}
}
println!("loot after claiming: {}", g.loot);
assert!(g.loot > 1000.0);
assert!(g.gold > gold0);
}
#[test]
fn patrols_walk_and_guards_raise_the_alarm() {
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec {
guards: vec![(EK::Knight, 1); 4],
patrols: vec![vec![(EK::Archer, 1)]],
..spec(x, y)
});
// no imps around (the patrol would shoot them through the door)
g.ents.retain(|e| e.hero);
let pj = g.ents.iter().position(|e| !e.patrol.is_empty()).unwrap();
let pid = g.ents[pj].id;
let start = (g.ents[pj].x, g.ents[pj].y);
let mut far = 0.0f32;
for _ in 0..200 {
g.update(0.05);
let p = g.ents.iter().find(|e| e.id == pid).unwrap();
far = far.max((p.x - start.0).abs() + (p.y - start.1).abs());
}
assert!(far > 6.0, "the patrol walks its round");
// an intruder next to one guard: the others join
let (hx, hy) = g.ents.iter().find(|e| e.guard).unwrap().home.unwrap();
g.spawn(EK::Ogre, false, hx + 1.0, hy, 8);
for _ in 0..20 {
g.update(0.05);
}
let fighting = g.ents.iter().filter(|e| e.guard && matches!(e.job, Job::Attack(_))).count();
assert!(fighting >= 3, "alarm: {} guards fight", fighting);
let _ = f;
}
#[test]
fn knights_tear_down_bridges() {
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { guards: vec![(EK::Knight, 1)], ..spec(x, y) });
// a bridge of the player in the courtyard, on a small pond
let (bx, by) = (f.x + f.w / 2 + 2, f.y + f.h / 2 + 2);
for y in by - 1..=by + 1 {
g.map.t[idx(bx, y)].kind = Kind::Water;
g.map.t[idx(bx, y)].owner = Owner::None;
}
g.map.t[idx(bx, by)].kind = Kind::Bridge;
g.map.t[idx(bx, by)].owner = Owner::Player;
let mut torn = false;
for _ in 0..(90 * 20) {
g.update(0.05);
if g.map.t[idx(bx, by)].kind == Kind::Water {
torn = true;
break;
}
}
assert!(torn);
}
#[test]
fn some_imps_always_claim() {
// lots of digging work: still at least 10% (min. one) of the imps claim the dug-out floor
let mut g = Game::new(11, 64, 64);
let (cx, cy) = g.map.core;
for _ in 0..8 {
let (x, y) = (g.ents[0].x, g.ents[0].y);
g.spawn(EK::Worker, false, x, y, 1);
}
g.mark_rect(cx - 12, cy - 12, cx + 12, cy + 12, true);
let (mut samples, mut ok) = (0, 0);
for step in 0..(150 * 20) {
g.update(0.05);
if step % 20 != 0 || step < 200 {
continue;
}
let digging = g.map.t.iter().any(|t| t.marked);
let claimable = (0..g.map.t.len()).any(|i| g.claimable(i, u32::MAX));
if digging && claimable {
let imps = g.worker_count();
let quota = ((imps as f32 * 0.1).ceil() as usize).max(1);
let claiming = g.ents.iter().filter(|e| e.alive && e.kind == EK::Worker && matches!(e.job, Job::Claim(_))).count();
samples += 1;
if claiming >= quota {
ok += 1;
}
}
}
println!("claim quota met in {}/{} samples", ok, samples);
assert!(samples > 10);
assert!(ok * 10 >= samples * 8);
}
#[test]
fn wall_corners_are_reinforced_too() {
let mut g = Game::new(3, 64, 64);
let (cx, cy) = g.map.core;
// the start area spans cx-3..cx+5 / cy-3..cy+5: its walls are at cx-4 and cy-4
let (x0, y0) = (cx - 4, cy - 4);
for k in 1..4 {
g.map.t[idx(x0 + k, y0)].reinforced = true;
g.map.t[idx(x0, y0 + k)].reinforced = true;
}
assert!(g.map.t[idx(x0, y0)].kind == Kind::Earth && !g.map.t[idx(x0, y0)].reinforced);
g.reinforce_corners();
assert!(g.map.t[idx(x0, y0)].reinforced, "the corner is reinforced");
// nothing spreads into the earth behind the walls
g.reinforce_corners();
assert!(!g.map.t[idx(x0 - 1, y0)].reinforced && !g.map.t[idx(x0, y0 - 1)].reinforced && !g.map.t[idx(x0 - 1, y0 - 1)].reinforced);
}
#[test]
fn idle_imps_help_and_speed_up_work() {
let mut g = Game::new(3, 64, 64);
let (cx, cy) = g.map.core;
for _ in 0..6 {
g.spawn(EK::Worker, false, cx as f32 + 4.5, cy as f32 + 1.5, 1);
}
// a single earth tile to dig, next to the start area: one digs, the others help
let (dx, dy) = (cx + 6, (cy - 3..=cy + 5).find(|&y| g.map.t[idx(cx + 6, y)].kind == Kind::Earth).expect("earth next to the start area"));
// no other work: everything around is claimed already, no walls to reinforce
for t in g.map.t.iter_mut() {
if t.kind == Kind::Earth {
t.reinforced = true;
}
}
g.map.t[idx(dx, dy)].reinforced = false;
g.map.t[idx(dx, dy)].marked = true;
let mut best = 0.0f32;
for _ in 0..200 {
g.update(0.05);
best = best.max(g.help_speed(idx(dx, dy)));
if g.map.t[idx(dx, dy)].kind != Kind::Earth {
break;
}
}
println!("best working speed {}", best);
assert!(best >= 1.75, "helpers join: {}", best);
assert!(best <= 2.0);
}
#[test]
fn flyers_cross_rivers_flee_and_heal() {
let (mut g, rx) = river_game(true);
let (cx, cy) = g.map.core;
// an aerie in the start area
g.gold = 5000.0;
g.build_rect(7, cx - 3, cy - 3, cx - 2, cy + 2);
assert!(g.counts[7] >= 8);
// a knight on the far side of the lava: an owl reaches it, a kobold does not
let far = (rx + 4, cy);
for x in rx + 3..rx + 7 {
let t = &mut g.map.t[idx(x, cy)];
t.kind = Kind::Floor;
t.owner = Owner::None;
}
let k = g.spawn(EK::Knight, true, far.0 as f32 + 0.5, far.1 as f32 + 0.5, 1);
let owl = g.spawn(EK::Owl, false, (cx + 4) as f32 + 0.5, cy as f32 + 0.5, 3);
assert!(g.map.fly_path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_some());
assert!(g.map.path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_none());
let hp0 = g.ents.iter().find(|e| e.id == k).unwrap().hp;
for _ in 0..300 {
g.update(0.05);
}
let hit = g.ents.iter().find(|e| e.id == k).map_or(true, |e| e.hp < hp0);
assert!(hit, "the owl attacks across the lava");
// a badly hurt flyer flees to the aerie and heals up to 75%
g.ents.retain(|e| !e.hero);
let j = g.ents.iter().position(|e| e.id == owl).unwrap();
g.ents[j].hp = g.ents[j].max_hp * 0.2;
let mut rested = false;
for _ in 0..800 {
g.update(0.05);
let e = g.ents.iter().find(|e| e.id == owl).unwrap();
rested |= matches!(e.job, Job::Rest(_));
if e.hp >= e.max_hp * 0.75 {
break;
}
}
let e = g.ents.iter().find(|e| e.id == owl).unwrap();
assert!(rested && e.hp >= e.max_hp * 0.74, "fled and healed: {} / {}", e.hp, e.max_hp);
// lava does not burn flyers
let j = g.ents.iter().position(|e| e.id == owl).unwrap();
g.ents[j].x = (rx - 1) as f32 + 0.5;
g.ents[j].y = cy as f32 + 0.5;
g.knock(j, 1.0, 0.0);
assert!(g.ents[j].alive);
}
#[test]
fn aerie_attracts_flyers() {
let mut g = Game::new(3, 64, 64);
g.counts = [16, 0, 9, 0, 6, 4, 0, 16, 0, 0, 0, 0, 0, 0];
let mut seen = std::collections::HashSet::new();
for _ in 0..200 {
g.try_portal_for_test();
for e in g.ents.iter().filter(|e| e.creature()) {
seen.insert(e.kind);
}
g.ents.retain(|e| !e.creature());
}
assert!(seen.contains(&EK::Bat) && seen.contains(&EK::Gargoyle) && seen.contains(&EK::Owl), "{:?}", seen);
assert!(seen.iter().all(|&k| flies(k)), "no lair: only flyers come ({:?})", seen);
}
#[test]
fn steel_walls_hold_heroes_back() {
let mut g = Game::new(3, 64, 64);
let (cx, cy) = g.map.core;
let (wx, wy) = (cx + 6, (cy - 3..=cy + 5).find(|&y| g.map.t[idx(cx + 6, y)].kind == Kind::Earth).unwrap());
g.gold = 1000.0;
g.build_steel(wx, wy, wx, wy);
assert_eq!(g.map.t[idx(wx, wy)].kind, Kind::Steel);
assert_eq!(g.gold, 1000.0 - STEEL_COST[0] as f32);
// far from own ground: nothing happens
g.build_steel(wx + 5, wy, wx + 5, wy);
assert_ne!(g.map.t[idx(wx + 5, wy)].kind, Kind::Steel);
// heroes avoid it and dig 20 times slower than through earth
assert_eq!(g.map.hero_cost()(idx(wx, wy)), Some(300));
// selling: a reinforced earth wall again, half of the plating back
let gold = g.gold;
g.sell_rect(wx, wy, wx, wy);
assert_eq!(g.map.t[idx(wx, wy)].kind, Kind::Earth);
assert!(g.map.t[idx(wx, wy)].reinforced);
assert!(g.gold > gold);
}
#[test]
fn imps_reinforce_walls() {
let mut g = Game::new(3, 64, 64);
for _ in 0..(120 * 20) {
g.update(0.05);
}
let n = g.map.t.iter().filter(|t| t.reinforced).count();
println!("reinforced walls after 2 minutes: {}", n);
assert!(n >= 10);
// heroes avoid them: the hero path cost through reinforced earth is 10x
let (cx, cy) = g.map.core;
let w = (cx - 4, cy);
if g.map.t[idx(w.0, w.1)].reinforced {
assert_eq!(g.map.hero_cost()(idx(w.0, w.1)), Some(70));
}
}
#[test]
fn towers_shoot_and_get_attacked() {
for kind in [TowerKind::Arrow, TowerKind::Cannon, TowerKind::Magic] {
let mut g = Game::new(5, 64, 64);
g.gold = 2000.0;
g.mana = 400.0;
let (cx, cy) = g.map.core;
let (tx, ty) = (cx - 2, cy - 2);
g.build_tower(kind, tx, ty);
assert_eq!(g.towers.len(), 1);
g.build_tower(kind, tx, ty); // upgrade
assert_eq!(g.towers[0].level, 2);
// a hero inside the start area, 4 tiles away
let id = g.spawn(EK::Knight, true, (cx + 3) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
let mut hurt = false;
let mut tower_hit = false;
for _ in 0..400 {
g.update(0.05);
if let Some(e) = g.ents.iter().find(|e| e.id == id) {
hurt |= e.hp < hp0;
} else {
hurt = true;
}
tower_hit |= g.towers.first().map_or(true, |t| t.hp < t.max_hp());
}
println!("{:?}: hero hurt {}, tower attacked {}", kind, hurt, tower_hit);
assert!(hurt, "{:?} did not damage the hero", kind);
}
}
#[test]
fn heroes_attack_towers() {
let mut g = Game::new(5, 64, 64);
g.gold = 2000.0;
for e in g.ents.iter_mut() {
e.alive = false; // no imps: the tower is the only target
}
let (cx, cy) = g.map.core;
g.build_tower(TowerKind::Cannon, cx - 2, cy - 2);
g.spawn(EK::Ogre, true, (cx - 2) as f32 + 0.5, (cy + 4) as f32 + 0.5, 5);
for _ in 0..600 {
g.update(0.05);
}
let t = g.towers.first();
println!("tower after attack: {:?}", t.map(|t| t.hp));
assert!(t.map_or(true, |t| t.hp < t.max_hp()));
}
#[test]
fn prisoners_are_tortured_and_resolved() {
let mut converted = 0;
let mut died = 0;
for seed in 1..=40u32 {
let mut g = Game::new(seed, 64, 64);
g.gold = 5000.0;
let (cx, cy) = g.map.core;
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1); // torture chamber 2x5 = 5 places... 10 tiles
assert!(g.prison_space() >= 2, "space {}", g.prison_space());
let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
let j = g.ents.iter().position(|e| e.id == id).unwrap();
g.kill(j);
assert_eq!(g.ents[j].captive, 1);
let mut reached = false;
for _ in 0..(200 * 20) {
g.update(0.05);
match g.ents.iter().find(|e| e.id == id) {
Some(e) if e.captive == 3 => reached = true,
Some(e) if !e.hero => {
converted += 1;
break;
}
None => {
died += 1;
break;
}
_ => {}
}
}
assert!(reached, "seed {}: prisoner never reached the torture chamber", seed);
}
println!("40 prisoners: {} defected, {} died", converted, died);
assert_eq!(converted + died, 40);
assert!((8..=26).contains(&converted), "defect rate off: {}/40", converted);
}
#[test]
fn jailer_makes_prisoners_defect_more_often() {
let mut converted = 0;
let mut worked = 0;
for seed in 1..=40u32 {
let mut g = Game::new(seed, 64, 64);
g.gold = 5000.0;
let (cx, cy) = g.map.core;
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1);
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 2); // a lair for the jailer
let jailer = g.spawn(EK::Jailer, false, (cx - 1) as f32 + 0.5, (cy - 3) as f32 + 0.5, 1);
let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
let j = g.ents.iter().position(|e| e.id == id).unwrap();
g.kill(j);
for _ in 0..(200 * 20) {
g.update(0.05);
if let Some(jl) = g.ents.iter_mut().find(|e| e.id == jailer) {
// keep the jailer fresh so that it works
jl.hunger = 0.0;
jl.energy = 1.0;
jl.happy = 1.0;
if jl.job == Job::Torment(id) {
worked += 1;
}
}
match g.ents.iter().find(|e| e.id == id) {
Some(e) if !e.hero => {
converted += 1;
break;
}
None => break,
_ => {}
}
}
}
println!("with a jailer: {} of 40 defected (jailer worked {} ticks)", converted, worked);
assert!(worked > 0, "the jailer works on the prisoners");
assert!(converted >= 20, "defections with a jailer: {}/40", converted);
}
#[test]
fn temple_prayer_and_priestess_heals_core() {
let mut g = Game::new(9, 64, 64);
g.gold = 5000.0;
let (cx, cy) = g.map.core;
g.build_rect(6, cx - 3, cy - 3, cx - 2, cy + 1); // temple
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 1); // lair
g.build_rect(2, cx + 4, cy + 3, cx + 5, cy + 5); // farm
g.spawn(EK::Priestess, false, (cx + 4) as f32 + 0.5, (cy + 2) as f32 + 0.5, 1);
for k in 0..3 {
g.spawn(EK::Kobold, false, (cx - 1) as f32 + 0.5, (cy + k) as f32 + 0.5, 1);
}
g.core_hp = 1000.0;
let mut prayed = false;
for _ in 0..(120 * 20) {
g.update(0.05);
prayed |= g.ents.iter().any(|e| matches!(e.job, Job::Pray(_)) && e.working);
}
println!("core after 2 min: {:.0}, prayed: {}", g.core_hp, prayed);
assert!(prayed);
assert!(g.core_hp > 1300.0);
}
#[test]
fn waves_vary_their_entry_points() {
let mut g = Game::new(11, 96, 96);
let (cx, cy) = g.map.core;
// a bigger realm so there are several possible entry points
for y in cy - 8..=cy + 10 {
for x in cx - 6..=cx + 14 {
if inb(x, y) && g.map.t[idx(x, y)].kind != Kind::Core && g.map.t[idx(x, y)].kind != Kind::Rock {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
}
let mut seen = Vec::new();
for _ in 0..4 {
g.plan_entries();
let e = g.planned_entries[0];
println!("entry {:?} ({})", e, g.direction_key(e));
seen.push(e);
g.entry_history.push(e);
}
// consecutive waves use different places
for w in seen.windows(2) {
assert!((w[0].0 - w[1].0).pow(2) + (w[0].1 - w[1].1).pow(2) >= 16, "same entry twice: {:?}", w);
}
// spawning a big wave gives heroes two different waypoints
g.warned = true;
g.planned_entries.clear();
g.plan_entries();
assert_eq!(g.planned_entries.len(), 2);
}
#[test]
fn core_starts_with_treasury_ring() {
let g = Game::new(21, 64, 64);
let (cx, cy) = g.map.core;
let ring = (cy - 1..=cy + 3).flat_map(|y| (cx - 1..=cx + 3).map(move |x| (x, y))).filter(|&(x, y)| g.map.t[idx(x, y)].kind == Kind::Treasury).count();
assert_eq!(ring, 16);
assert_eq!(g.counts[0], 16);
}
#[test]
fn hand_rules() {
let mut g = Game::new(4, 64, 64);
g.gold = 5000.0;
let (cx, cy) = g.map.core;
// backhand on an own imp: faster for 15 s, costs a little HP
let imp = g.ents.iter().position(|e| e.kind == EK::Worker).unwrap();
let hp = g.ents[imp].hp;
assert_eq!(g.slap(Some(imp)), 1);
assert_eq!(g.ents[imp].boost, 15.0);
assert!(g.ents[imp].hp < hp);
// fist: only on own ground
let far = g.spawn(EK::Knight, true, 40.5, 40.5, 1);
let jf = g.ents.iter().position(|e| e.id == far).unwrap();
if g.map.t[idx(40, 40)].owner != Owner::Player {
assert_eq!(g.slap(Some(jf)), 0);
}
let near = g.spawn(EK::Knight, true, (cx - 2) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
let jn = g.ents.iter().position(|e| e.id == near).unwrap();
let hp = g.ents[jn].hp;
assert_eq!(g.slap(Some(jn)), 2);
assert!(g.ents[jn].hp < hp && g.ents[jn].stun > 0.0);
assert_eq!(g.slap(Some(jn)), 0, "cooldown");
// prisoners: pick up on own ground, drop onto a torture place = locked up
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1);
let jn = g.ents.iter().position(|e| e.id == near).unwrap();
g.kill(jn);
assert_eq!(g.ents[jn].captive, 1);
assert!(g.pickup(Some(jn)));
let t = (cx - 3, cy + 1);
assert!(g.drop(t.0 as f32 + 0.5, t.1 as f32 + 0.5));
let e = g.ents.iter().find(|e| e.id == near).unwrap();
assert_eq!(e.captive, 3);
// own units cannot be dropped outside own ground
let imp = g.ents.iter().position(|e| e.kind == EK::Worker).unwrap();
assert!(g.pickup(Some(imp)));
assert!(!g.drop(40.5, 40.5) || g.map.t[idx(40, 40)].owner == Owner::Player);
}
#[test]
fn chainbreaker_visits_only_great_dungeons() {
let mut g = Game::new(8, 64, 64);
g.gold = 9000.0;
// conditions not met: no Chainbreaker among many portal visits
let (px, py) = g.map.portals[0];
for y in 0..3 {
for x in 0..3 {
g.map.t[idx(px + x, py + y)].owner = Owner::Player;
}
}
let (cx, cy) = g.map.core;
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy + 5); // lair 18
g.build_rect(2, cx - 3, cy - 3, cx - 2, cy + 5); // farm 18
g.recount();
for _ in 0..60 {
g.try_portal();
}
assert!(!g.ents.iter().any(|e| e.kind == EK::Chainbreaker));
// a veteran creature makes it possible (rooms 16 ring + 36 = 52 tiles, gold 9000)
if let Some(e) = g.ents.iter_mut().find(|e| e.creature()) {
e.level = 5;
}
for e in g.ents.iter_mut().filter(|e| e.creature()) {
e.alive = false; // free beds
}
g.ents.retain(|e| e.alive);
g.spawn(EK::Kobold, false, cx as f32, cy as f32, 6);
let mut seen = false;
for _ in 0..400 {
g.gold = 9000.0;
g.try_portal();
seen |= g.ents.iter().any(|e| e.kind == EK::Chainbreaker);
g.ents.retain(|e| !e.creature() || e.kind == EK::Chainbreaker || e.level >= 5);
}
assert!(seen);
assert!(g.ents.iter().filter(|e| e.kind == EK::Chainbreaker).count() <= 1);
}
}