Files
dungeoncraft/src/game.rs
T
Richard ZinkandClaude Opus 5.5 52d49b0243 Delivered treasure fills the treasury tiles nearest to where the imp put it down
Imps already walk to the nearest treasury; the treasury display now places the delivery there
(nearest tiles that can take it) instead of the first free tiles anywhere. Test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:35:44 +01:00

4632 lines
172 KiB
Rust

// Game logic: units, AI, economy, hero waves.
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,
}
pub const SFX_ALL: [Sfx; 36] = [
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,
];
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 CARRY_MAX: i32 = 300;
#[derive(Clone, Copy, PartialEq, Eq, 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
}
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 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),
}
}
#[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
Help(usize), // imp with nothing else to do helps another imp at this tile
}
#[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,
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)
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
}
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,
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,
guard: false,
home: None,
patrol: Vec::new(),
patrol_k: 0,
}
}
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)
}
pub fn creature(&self) -> bool {
!self.hero && self.kind != EK::Worker
}
// 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
}
#[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)
}
#[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>,
pub counts: [usize; 7], // treasury, lair, farm, training hall, library, torture chamber, temple
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) // tiles next to fighting heroes: knight ground there cannot be claimed
#[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]
}
// 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 ROOM_KINDS: [Kind; 7] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple];
pub const ROOM_COST: [i32; 7] = [60, 80, 100, 140, 160, 200, 180];
pub const ROOM_NAMES: [&str; 7] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple"];
// bridge cost per tile: over water, over lava
pub const BRIDGE_COST: [i32; 2] = [30, 75];
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 {
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.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; 7],
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,
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,
};
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 == Owner::Player {
self.place_torches(i);
}
}
}
// ---------------------------------------------------------------- campaign missions
pub fn tool_allowed(&self, tool: usize) -> bool {
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()),
}
}
pub fn waves_on(&self) -> bool {
self.mission.as_ref().map_or(true, |m| m.waves && (m.wave_limit == 0 || self.wave < m.wave_limit))
}
pub fn has_gate(&self) -> bool {
self.mission.as_ref().map_or(true, |m| m.heart)
}
pub fn msg(&mut self, key: &'static str, args: Vec<String>) {
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.events.len() < 64 {
self.events.push(SfxEvent { sfx, pos: Some((x, y)) });
}
}
pub fn sfx_ui(&mut self, sfx: Sfx) {
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);
}
}
}
fn hint(&mut self, slot: usize, key: &'static str) {
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; 7];
for t in &self.map.t {
if t.owner == Owner::Player {
if let Some(r) = room_index(t.kind) {
c[r] += 1;
}
}
}
self.counts = c;
}
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 == Owner::Player {
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 == Owner::Player
}
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 };
if self.fx.len() < 400 {
self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col, h, h2: 0.45 });
}
}
// 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 });
}
}
// effect at a given height
pub fn fx_at(&mut self, kind: u8, x: f32, y: f32, h: f32, max: f32, col: u32) {
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 });
}
}
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) {
let dt = dt.min(0.1);
if self.state == 0 && !self.paused {
self.step(dt);
}
self.sync_treasury();
for f in self.fx.iter_mut() {
f.t += dt;
}
self.fx.retain(|f| f.t < f.max);
}
fn step(&mut self, dt: f32) {
self.time += dt;
self.recount();
self.mana = (self.mana + 1.0 * dt).min(MANA_MAX);
self.core_hp = (self.core_hp + 0.5 * dt).min(CORE_HP);
for t in self.map.t.iter_mut() {
if t.kind == Kind::Farm {
t.food = (t.food + dt / 18.0).min(3.0);
}
}
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();
}
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);
}
// 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_contested();
for i in 0..self.ents.len() {
if !self.ents[i].alive || self.ents[i].held {
continue;
}
let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy());
self.update_ent(&mut e, dt);
self.ents[i] = e;
}
self.ents.retain(|e| e.alive);
self.update_towers(dt);
self.check_end();
}
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;
}
if self.gate_hp <= 0.0 {
self.gate_hp = 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 {
self.counts[1].min(30).saturating_sub(self.creature_count())
}
pub fn lair_factor(&self) -> f32 {
(8.0 / (self.free_beds() as f32 + 2.0)).clamp(0.35, 3.0)
}
fn try_portal(&mut self) {
let creatures = self.creature_count();
let lair = self.counts[1];
let farm = self.counts[2];
let cap = lair.min(30);
if lair == 0 {
self.hint(0, "hint.portal_no_lair");
return;
}
if farm == 0 {
self.hint(1, "hint.no_farm");
return;
}
if creatures >= cap {
self.hint(2, "hint.lair_full");
return;
}
let mut opts: Vec<(EK, u32)> = vec![(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));
}
// 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));
}
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];
self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1);
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)]);
}
fn do_payday(&mut self) {
let mut paid = 0;
let mut unpaid = 0;
let mut total = 0.0;
for i in 0..self.ents.len() {
if !self.ents[i].alive || !self.ents[i].creature() {
continue;
}
let e = &self.ents[i];
let w = st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32);
if self.gold >= w {
self.gold -= w;
total += w;
paid += 1;
let e = &mut self.ents[i];
e.happy = (e.happy + 0.2).min(1.0);
} else {
unpaid += 1;
self.ents[i].happy -= 0.45;
}
}
// training costs collected since the last payday
let bill = self.training_bill.min(self.gold).max(0.0);
self.gold -= bill;
total += bill;
self.training_bill = 0.0;
if paid + unpaid > 0 {
self.sfx_ui(Sfx::Payday);
if unpaid > 0 {
self.msg("msg.payday_partial", vec![paid.to_string(), (total as i32).to_string(), unpaid.to_string()]);
} else {
self.msg("msg.payday", vec![paid.to_string(), (total as i32).to_string()]);
}
}
}
// strength of a squad (same formula as the army value)
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));
}
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);
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;
}
// slapped units work and move faster
let dt = if e.boost > 0.0 { dt * 1.5 } else { dt };
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.hero && e.captive != 0 {
self.captive_update(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);
}
}
// moves a unit along its path; true = still moving
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.walkable() {
if e.hero && 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 => 2.0,
_ => 1.0,
} * if tile.reinforced { 0.2 } else { 1.0 };
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);
}
fn unreserve(&mut self, me: u32) {
for t in self.map.t.iter_mut() {
if t.reserved == me {
t.reserved = 0;
}
}
}
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;
}
}
}
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
}
fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option<u32> {
let mut best = None;
let mut bd = radius * radius;
for o in &self.ents {
if !o.alive || o.held || o.hero == e.hero || o.captive != 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
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 {
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);
match self.map.path_to(e.tile(), tt) {
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);
if s.range > 1.5 {
let col = match e.kind {
EK::Mage => 0xffff44cc,
EK::Priest => 0xff88ffff,
EK::Priestess => 0xff3080ff,
_ => 0xff70b0d0,
};
self.fx(0, e.x, e.y, tx, ty, 0.25, col);
self.sfx(Sfx::Shoot, e.x, e.y);
} else {
self.fx(2, tx, ty, 0.0, 0.0, 0.25, 0xffffffff);
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 side = !e.hero;
let second = self
.ents
.iter()
.enumerate()
.find(|(o, x)| *o != j && x.alive && !x.held && x.hero == side && 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.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 {
// fireball: half damage to enemies around the target
self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff);
let side = !e.hero;
let splash: Vec<usize> = self
.ents
.iter()
.enumerate()
.filter(|(o, x)| *o != j && x.alive && !x.held && x.hero == side && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 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;
}
}
let my_id = e.id;
let t = &mut self.ents[j];
t.hp -= 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;
let used = self.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count();
cap.saturating_sub(used)
}
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) = (e.id, e.kind, e.hero, e.x, e.y);
// 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);
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 {
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 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 {
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)
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 == Owner::Player && 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 = Owner::Player;
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 == Owner::Player && 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 {
if !e.alive || e.held || !e.active_hero() {
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::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 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 == Owner::Player {
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 != Owner::Player {
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 != Owner::Player {
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 = match t.owner {
Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard),
// knight ground (also vaults and shard chambers), but not while heroes fight there
Owner::Hero => matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard) && !self.contested.get(i).copied().unwrap_or(false),
Owner::Player => 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 == Owner::Player))
}
fn is_deposit(&self, i: usize) -> bool {
let t = &self.map.t[i];
(t.kind == Kind::Treasury && t.owner == Owner::Player) || (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
}
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 == Owner::Player {
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;
e.timer += dt * self.help_speed(t);
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::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 knight = self.map.t[t].owner == Owner::Hero;
if e.timer >= if knight { 3.0 } else { 1.0 } {
self.unreserve(me);
e.job = Job::None;
let (tx, ty) = xy(t);
if knight {
self.take_knight_tile(t);
self.alarm(tx as f32 + 0.5, ty as f32 + 0.5, e.id);
}
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 = Owner::Player;
}
}
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 = Owner::Player;
}
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;
}
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.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.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 == Owner::Player && (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;
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
if e.bed.is_none() && self.counts[1] > 0 && !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rally) && self.rng.f() < 0.15 {
let cur = e.tile();
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.t[i].kind == Kind::Lair && self.map.t[i].bed == 0, true) {
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);
}
}
if !matches!(e.job, Job::Attack(_) | Job::Leave) {
let radius = if e.job == Job::Rally { 8.5 } 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;
}
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");
}
}
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::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::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 * dt).min(MANA_MAX);
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(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);
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;
match self.map.path_to(e.tile(), target) {
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 {
if let Some(b) = e.bed {
let t = &self.map.t[b];
if t.kind != Kind::Lair || t.bed != me {
e.bed = None;
}
}
if e.bed.is_none() {
let found = self.map.search(
cur,
self.map.walk_cost(),
|i| {
let t = &self.map.t[i];
t.kind == Kind::Lair && t.bed == 0
},
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 {
if let Some(p) = self.map.path_to(cur, b) {
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;
}
}
}
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;
}
}
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;
}
}
}
_ => {
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%)
let p = if e.kind == EK::Paladin { 0.0143 } else { 0.0252 };
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)]);
}
}
}
}
// 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 free_torture_tile(&self, i: usize) -> bool {
let t = &self.map.t[i];
t.kind == Kind::Torture && t.owner == Owner::Player && t.bed == 0
}
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_torture_tile(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.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;
}
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 != Owner::Player || !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 != Owner::Player));
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 == Owner::Player {
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 let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) {
refund += self.towers[k].invested() as f32 * 0.5;
self.towers.remove(k);
self.map.t[i].kind = Kind::Floor;
continue;
}
let t = &mut self.map.t[i];
if t.owner == Owner::Player && 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 == Owner::Player {
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 != Owner::Player {
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;
}
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 };
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 -= 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
let enemy = if t.hero { !e.hero } else { e.active_hero() };
if !e.alive || e.held || !enemy {
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),
};
let col = match t.kind {
TowerKind::Arrow => 0xffd0e0f0,
TowerKind::Cannon => 0xff303030,
TowerKind::Magic => 0xffff60d0,
};
self.shot(mx, my, mh, ex, ey, eh, 0.3, col);
self.fx_at(2, ex, ey, eh, 0.3, 0xffffffff);
self.sfx(match t.kind {
TowerKind::Arrow => Sfx::Shoot,
TowerKind::Cannon => Sfx::Cannon,
TowerKind::Magic => 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();
}
}
}
}
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),
};
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 end = (px + dir.0 * t.range(), py + dir.1 * t.range());
self.shot(mx, my, mh, end.0, end.1, 0.45, 0.3, 0xffd0e0f0);
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.fx(3, ex, ey, 0.0, 0.0, 0.6, 0xff4070a0);
self.fx(2, ex, ey, 0.0, 0.0, 0.3, 0xff60c0ff);
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.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);
}
}
}
}
}
}
// a hero attacks a tower; false = tower gone or unreachable
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);
} 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 == Owner::Player)
}
// 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.hero {
if e.captive != 1 {
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);
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 != Owner::Player {
self.hint(9, "hint.drop_where");
self.sfx_ui(Sfx::Error);
return false;
}
self.hand.pop();
let imprison = t.kind == Kind::Torture && t.bed == 0;
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].hero {
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 != Owner::Player {
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,
}
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 },
}
}
pub fn tower_name(k: TowerKind) -> &'static str {
match k {
TowerKind::Arrow => "tower.arrow",
TowerKind::Cannon => "tower.cannon",
TowerKind::Magic => "tower.magic",
}
}
#[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
}
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,
}
}
// 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::Study(_) => "job.study",
Job::Attack(_) => "job.fight",
Job::TearBridge(_) => "job.tear_bridge",
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(0.01);
// 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(0.01);
let rubies: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && 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(0.01);
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(0.01);
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + 1.0);
}
#[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(0.01);
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(0.01);
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);
g.spawn(EK::Kobold, false, 10.0, 10.0, 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);
assert_eq!(g.training_bill, 0.0);
assert!((gold - g.gold - cost).abs() < 1.0);
}
// 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 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 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);
}
}