- five sprite views per kind and gear tier (front, front three-quarter, profile, back three-quarter, back), mirrored for the other three directions; chosen from the unit's facing and the camera - units face where they walk, dig, repair or fight - animations: imps stomp while claiming (squash on landing), swing while digging, hammer while repairing; lunges in combat, punches in training, nodding while eating, swaying while reading or praying, breathing when idle, leaning back with loads, lying in bed, chained creatures pulling at their chains, flyers flapping Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5475 lines
208 KiB
Rust
5475 lines
208 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 SPRING_MANA: f32 = 0.6; // mana per second of each claimed mana spring
|
|
pub const SPRING_MAX: f32 = 150.0; // more mana maximum per claimed spring
|
|
pub const CARRY_MAX: i32 = 300;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
|
|
pub enum EK {
|
|
Worker,
|
|
Kobold,
|
|
Spider,
|
|
Mage,
|
|
Ogre,
|
|
Knight,
|
|
Archer,
|
|
Priest,
|
|
Dwarf,
|
|
Paladin,
|
|
Jailer, // attracted by the torture chamber
|
|
Priestess, // attracted by the temple
|
|
Chainbreaker, // rare and very strong
|
|
// flying creatures, attracted by the aerie
|
|
Bat, // fast and weak
|
|
Gargoyle, // aerie + torture chamber: stone, tough
|
|
Owl, // aerie + library: arcane bolts from afar
|
|
Wyrm, // big aerie + treasury: rare, very strong, fire breath
|
|
}
|
|
|
|
pub struct St {
|
|
pub name: &'static str,
|
|
pub hp: f32,
|
|
pub atk: f32,
|
|
pub range: f32,
|
|
pub speed: f32,
|
|
pub interval: f32,
|
|
pub wage: i32,
|
|
pub bounty: i32,
|
|
}
|
|
|
|
pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS
|
|
|
|
pub fn st(k: EK) -> St {
|
|
let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty };
|
|
match k {
|
|
EK::Worker => s("unit.worker", 45.0, 3.0, 1.0, 3.2, 1.0, 0, 0),
|
|
EK::Kobold => s("unit.kobold", 75.0, 8.0, 1.0, 2.6, 0.9, 40, 0),
|
|
EK::Spider => s("unit.spider", 95.0, 10.0, 1.0, 2.9, 1.0, 60, 0),
|
|
EK::Mage => s("unit.mage", 60.0, 13.0, 4.5, 2.0, 1.4, 110, 0),
|
|
EK::Ogre => s("unit.ogre", 230.0, 20.0, 1.0, 1.8, 1.4, 150, 0),
|
|
// heroes: the wage applies when they defected to the player
|
|
EK::Knight => s("unit.knight", 140.0, 11.0, 1.0, 1.9, 1.1, 70, 120),
|
|
EK::Archer => s("unit.archer", 75.0, 9.0, 4.5, 2.1, 1.3, 60, 100),
|
|
EK::Priest => s("unit.priest", 80.0, 5.0, 3.5, 1.9, 1.5, 60, 120),
|
|
EK::Dwarf => s("unit.dwarf", 110.0, 9.0, 1.0, 2.0, 1.0, 60, 100),
|
|
EK::Paladin => s("unit.paladin", 700.0, 26.0, 1.0, 1.7, 1.2, 220, 1000),
|
|
EK::Jailer => s("unit.jailer", 260.0, 16.0, 1.0, 1.6, 1.3, 140, 0),
|
|
EK::Priestess => s("unit.priestess", 70.0, 14.0, 5.0, 2.0, 1.6, 120, 0),
|
|
EK::Chainbreaker => s("unit.chainbreaker", 600.0, 36.0, 1.1, 2.3, 1.1, 400, 0),
|
|
EK::Bat => s("unit.bat", 55.0, 6.0, 1.0, 3.6, 0.7, 30, 0),
|
|
EK::Gargoyle => s("unit.gargoyle", 260.0, 15.0, 1.0, 2.2, 1.2, 120, 0),
|
|
EK::Owl => s("unit.owl", 70.0, 12.0, 4.5, 2.8, 1.3, 100, 0),
|
|
EK::Wyrm => s("unit.wyrm", 650.0, 30.0, 3.0, 2.5, 1.4, 380, 0),
|
|
}
|
|
}
|
|
|
|
// flying creatures cross water and lava and sleep in the aerie
|
|
pub fn flies(k: EK) -> bool {
|
|
matches!(k, EK::Bat | EK::Gargoyle | EK::Owl | EK::Wyrm)
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum Job {
|
|
None,
|
|
Dig(usize),
|
|
Claim(usize),
|
|
Deliver,
|
|
Wander,
|
|
Sleep(usize),
|
|
Eat(usize),
|
|
Train(usize),
|
|
Study(usize),
|
|
Attack(u32),
|
|
AttackCore,
|
|
Leave,
|
|
Rally,
|
|
Reinforce(usize), // wall tile
|
|
Repair(u32), // tower id
|
|
AttackTower(u32), // tower id
|
|
Carry(u32), // imp carries an unconscious hero (id) to the torture chamber
|
|
Pray(usize), // temple tile
|
|
HealCore, // flame priestess heals the soul core
|
|
TearBridge(usize), // knight tears down a bridge of the player
|
|
Rest(usize), // flying creature recovers in the aerie (flees when badly hurt)
|
|
Torment(u32), // the jailer works on a prisoner in the torture chamber
|
|
Register, // new creature: reports at the portal
|
|
MakeBed(usize), // new creature: sets up its bed
|
|
FetchWage(usize), // payday: walks to the treasury (or the core) for the wage
|
|
Help(usize), // imp with nothing else to do helps another imp at this tile
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct Ent {
|
|
pub id: u32,
|
|
pub kind: EK,
|
|
pub hero: bool,
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub hp: f32,
|
|
pub max_hp: f32,
|
|
pub level: u32,
|
|
pub xp: f32,
|
|
pub job: Job,
|
|
pub path: Vec<usize>,
|
|
pub timer: f32,
|
|
pub cd: f32,
|
|
pub think: f32,
|
|
pub repath: f32,
|
|
pub carry: i32,
|
|
#[serde(default)]
|
|
pub carry_res: u8, // treasure the imp carries (see map::RES_GOLD)
|
|
pub hunger: f32,
|
|
pub energy: f32,
|
|
pub happy: f32,
|
|
pub bed: Option<usize>,
|
|
pub alive: bool,
|
|
pub held: bool,
|
|
pub anim: f32,
|
|
pub flash: f32,
|
|
pub face: f32,
|
|
#[serde(default)]
|
|
pub dir: f32, // facing direction on the map (radians, 0 = +x), for the eight sprite views
|
|
pub moving: bool,
|
|
pub working: bool,
|
|
#[serde(default)]
|
|
pub slow: f32, // seconds of slowdown left (magic tower)
|
|
#[serde(default)]
|
|
pub captive: u8, // heroes: 0 free, 1 unconscious, 2 carried, 3 in the torture chamber
|
|
#[serde(default)]
|
|
pub carrier: u32, // imp that has taken care of this captive
|
|
#[serde(default)]
|
|
pub torture_t: f32,
|
|
#[serde(default)]
|
|
pub skill_cd: f32, // special abilities (jailer pull, priestess heal)
|
|
#[serde(default)]
|
|
pub waypoint: Option<(i32, i32)>, // heroes: entry point into the player's realm
|
|
#[serde(default)]
|
|
pub boost: f32, // seconds of "slapped" speed-up left
|
|
#[serde(default)]
|
|
pub stun: f32, // seconds of stun left (punched)
|
|
pub guard: bool, // stays at its home (gate defenders, treasure guards)
|
|
#[serde(default)]
|
|
pub home: Option<(f32, f32)>, // guard post
|
|
#[serde(default)]
|
|
pub patrol: Vec<(i32, i32)>, // fortress patrol: route of tiles, walked in a loop
|
|
#[serde(default)]
|
|
pub patrol_k: usize, // next point of the route
|
|
#[serde(default = "yes")]
|
|
pub settled: bool, // creatures: reported at the portal and set up their bed
|
|
#[serde(default)]
|
|
pub pay_due: f32, // wage of the last payday not fetched yet
|
|
#[serde(default)]
|
|
pub pay_t: f32, // seconds until the creature goes for its wage
|
|
#[serde(default)]
|
|
pub side: u8, // 0 player, 2 and 3 rival masters (heroes: see side())
|
|
#[serde(default)]
|
|
pub target: Option<u8>, // attackers: side whose core (or gate) they march on
|
|
#[serde(default)]
|
|
pub chained: bool, // a lone creature chained in a sealed cave: joins when the cave is claimed
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
impl Ent {
|
|
fn dummy() -> Ent {
|
|
Ent {
|
|
id: 0,
|
|
kind: EK::Worker,
|
|
hero: false,
|
|
x: 0.0,
|
|
y: 0.0,
|
|
hp: 0.0,
|
|
max_hp: 1.0,
|
|
level: 1,
|
|
xp: 0.0,
|
|
job: Job::None,
|
|
path: Vec::new(),
|
|
timer: 0.0,
|
|
cd: 0.0,
|
|
think: 0.0,
|
|
repath: 0.0,
|
|
carry: 0,
|
|
carry_res: 0,
|
|
hunger: 0.0,
|
|
energy: 1.0,
|
|
happy: 1.0,
|
|
bed: None,
|
|
alive: false,
|
|
held: false,
|
|
anim: 0.0,
|
|
flash: 0.0,
|
|
face: 1.0,
|
|
dir: std::f32::consts::FRAC_PI_2,
|
|
moving: false,
|
|
working: false,
|
|
slow: 0.0,
|
|
captive: 0,
|
|
carrier: 0,
|
|
torture_t: 0.0,
|
|
skill_cd: 0.0,
|
|
waypoint: None,
|
|
boost: 0.0,
|
|
stun: 0.0,
|
|
guard: false,
|
|
home: None,
|
|
patrol: Vec::new(),
|
|
patrol_k: 0,
|
|
settled: true,
|
|
pay_due: 0.0,
|
|
pay_t: 0.0,
|
|
side: 0,
|
|
target: None,
|
|
chained: false,
|
|
}
|
|
}
|
|
pub fn tile(&self) -> usize {
|
|
idx((self.x as i32).clamp(0, mw() as i32 - 1), (self.y as i32).clamp(0, mh() as i32 - 1))
|
|
}
|
|
pub fn atk(&self) -> f32 {
|
|
st(self.kind).atk * (1.0 + 0.12 * (self.level - 1) as f32)
|
|
}
|
|
// the player's creatures (not imps, heroes or the rivals' units)
|
|
pub fn creature(&self) -> bool {
|
|
!self.hero && self.side == 0 && self.kind != EK::Worker && !self.chained
|
|
}
|
|
// 0 player, 1 humans, 2 and 3 rival masters
|
|
pub fn side(&self) -> u8 {
|
|
if self.hero {
|
|
1
|
|
} else {
|
|
self.side
|
|
}
|
|
}
|
|
// a unit of a rival master
|
|
pub fn rival(&self) -> bool {
|
|
!self.hero && (self.side == 2 || self.side == 3)
|
|
}
|
|
// a dwarf of a dwarf cave (neutral, never an opponent for victory)
|
|
pub fn dwarf(&self) -> bool {
|
|
!self.hero && self.side == crate::dwarves::DWARF_SIDE
|
|
}
|
|
// a hero that is still fighting (not a prisoner)
|
|
pub fn active_hero(&self) -> bool {
|
|
self.hero && self.captive == 0
|
|
}
|
|
}
|
|
|
|
// Goals of a campaign mission (all must be reached)
|
|
#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
|
|
pub enum Goal {
|
|
Shards, // take back all shards of the soul core
|
|
Heart, // destroy the gate of the order
|
|
KillAll, // defeat every knight (prisoners count as defeated)
|
|
Loot(u32), // take treasure worth this much from knight vaults
|
|
Towers, // raze all knight towers
|
|
Survive(u32), // beat this many waves
|
|
Rivals, // destroy the soul cores of all rival masters
|
|
Springs(u32), // claim this many mana springs
|
|
Freed(u32), // free this many chained creatures
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct MissionState {
|
|
pub index: usize, // number of the mission in the campaign
|
|
pub goals: Vec<Goal>,
|
|
pub tools: Vec<bool>, // allowed tools (ui::TOOLS order)
|
|
pub waves: bool,
|
|
pub wave_limit: u32, // 0 = endless
|
|
pub gate_defenders: bool,
|
|
pub heart: bool, // the map has a gate of the order
|
|
pub time_limit: f32, // seconds, 0 = none
|
|
pub shards: u32, // shards on the map
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct Fx {
|
|
pub kind: u8, // 0 projectile, 1 lightning, 2 hit, 3 smoke, 4 heal, 5 gold, 6 dust, 7 level up, 8 claim
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub x2: f32,
|
|
pub y2: f32,
|
|
pub t: f32,
|
|
pub max: f32,
|
|
pub col: u32,
|
|
pub h: f32, // height of the start point (3D)
|
|
pub h2: f32, // height of the end point (projectiles)
|
|
pub size: f32, // 1 = normal; attacks of higher levels look bigger
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct Game {
|
|
pub map: Map,
|
|
pub ents: Vec<Ent>,
|
|
pub next_id: u32,
|
|
pub rng: Rng,
|
|
pub gold: f32,
|
|
pub mana: f32,
|
|
pub core_hp: f32,
|
|
pub time: f32,
|
|
pub wave: u32,
|
|
pub wave_timer: f32,
|
|
pub warned: bool,
|
|
pub portal_timer: f32,
|
|
pub payday: f32,
|
|
#[serde(skip)]
|
|
pub fx: Vec<Fx>,
|
|
#[serde(skip)]
|
|
pub msgs: Vec<Msg>,
|
|
pub state: u8, // 0 running, 1 victory, 2 defeat
|
|
pub paused: bool,
|
|
pub hand: Vec<u32>,
|
|
#[serde(skip)]
|
|
pub counts: [usize; 8], // treasury, lair, farm, training hall, library, torture chamber, temple, aerie (recounted)
|
|
pub gate_hp: f32,
|
|
pub gate_hit_t: f32,
|
|
pub gate_stage: u32, // defender thresholds triggered (0..3)
|
|
pub flag: Option<usize>,
|
|
#[serde(skip)]
|
|
pub events: Vec<SfxEvent>,
|
|
pub last_wave_value: f32,
|
|
pub torches: Vec<Torch>,
|
|
#[serde(default)]
|
|
pub towers: Vec<Tower>,
|
|
#[serde(default)]
|
|
pub punch_ready: f32, // game time when the hand can punch again
|
|
#[serde(default)]
|
|
pub entry_history: Vec<(i32, i32)>, // entry points of recent waves
|
|
#[serde(default)]
|
|
pub planned_entries: Vec<(i32, i32)>, // entry points of the coming/current wave (minimap)
|
|
#[serde(default)]
|
|
pub reserve: f32, // value stored in the soul core (the rest lies in the treasury tiles)
|
|
#[serde(skip)]
|
|
pub incoming: Vec<(u8, f32, Option<usize>)>, // treasures added since the last treasury update (kind, value, delivered at)
|
|
#[serde(default)]
|
|
pub training_bill: f32, // training costs, paid on the next payday
|
|
#[serde(default)]
|
|
pub payday_warned: bool,
|
|
#[serde(default)]
|
|
pub loot: f32, // value taken from knight vaults
|
|
#[serde(default)]
|
|
pub shards_taken: u32,
|
|
#[serde(default)]
|
|
pub towers_razed: u32, // knight towers destroyed
|
|
#[serde(skip)]
|
|
pub contested: Vec<bool>,
|
|
#[serde(default)]
|
|
pub mission: Option<MissionState>, // campaign mission (None: free game)
|
|
#[serde(default)]
|
|
pub rivals: Vec<crate::rival::Rival>, // AI dungeon masters
|
|
#[serde(default = "yes")]
|
|
pub gate_on: bool, // free game: the humans' gate exists
|
|
#[serde(skip)]
|
|
pub springs: usize, // claimed mana springs (recounted)
|
|
#[serde(default)]
|
|
pub dwarf_caves: Vec<crate::dwarves::DwarfCave>,
|
|
#[serde(default)]
|
|
pub dwarf_t: f32,
|
|
#[serde(default)]
|
|
pub rune_free: u32, // rune forge towers that can be built for free (from chests)
|
|
#[serde(default)]
|
|
pub rune_final: u32, // the same, but given back for a sold tower: no second refund
|
|
#[serde(default)]
|
|
pub rune_next: u32, // sold rune forge towers: back as a chest in the next mission
|
|
#[serde(default)]
|
|
pub freed: u32, // chained creatures freed // tiles next to fighting heroes: knight ground there cannot be claimed
|
|
#[serde(skip)]
|
|
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; 8] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple, Kind::Aerie];
|
|
pub const ROOM_COST: [i32; 8] = [60, 80, 100, 140, 160, 200, 180, 120];
|
|
pub const ROOM_NAMES: [&str; 8] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple", "room.aerie"];
|
|
|
|
// bridge cost per tile: over water, over lava
|
|
pub const BRIDGE_COST: [i32; 2] = [30, 75];
|
|
// steel wall cost per tile: from earth, from a reinforced wall
|
|
pub const STEEL_COST: [i32; 2] = [80, 40];
|
|
pub const STEEL_HP: f32 = 40.0;
|
|
|
|
pub fn room_index(k: Kind) -> Option<usize> {
|
|
ROOM_KINDS.iter().position(|&r| r == k)
|
|
}
|
|
|
|
fn adj4(a: usize, b: usize) -> bool {
|
|
let (ax, ay) = xy(a);
|
|
let (bx, by) = xy(b);
|
|
(ax - bx).abs() + (ay - by).abs() == 1
|
|
}
|
|
|
|
fn norm(x: f32, y: f32) -> (f32, f32) {
|
|
let l = (x * x + y * y).sqrt();
|
|
if l < 1e-6 {
|
|
(0.0, 0.0)
|
|
} else {
|
|
(x / l, y / l)
|
|
}
|
|
}
|
|
|
|
fn sgn(v: f32) -> f32 {
|
|
if v < 0.0 {
|
|
-1.0
|
|
} else {
|
|
1.0
|
|
}
|
|
}
|
|
|
|
impl Game {
|
|
// free game with the humans' gate only (tests; the menu uses new_free)
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub fn new(seed: u32, w: usize, h: usize) -> Game {
|
|
let mut g = Game::from_map(Map::generate(seed, w, h), seed);
|
|
// heroes take longer to reach the core on big maps: longer grace period
|
|
let (cx, cy) = g.map.core;
|
|
let (gx, gy) = g.map.gate;
|
|
let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32;
|
|
g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0);
|
|
g.guard_treasures();
|
|
g.place_dwarf_caves(seed);
|
|
g.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; 8],
|
|
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,
|
|
rivals: Vec::new(),
|
|
gate_on: true,
|
|
springs: 0,
|
|
freed: 0,
|
|
dwarf_caves: Vec::new(),
|
|
dwarf_t: 30.0,
|
|
rune_free: 0,
|
|
rune_final: 0,
|
|
rune_next: 0,
|
|
};
|
|
let (cx, cy) = g.map.core;
|
|
// the soul core starts surrounded by a one tile wide ring of treasury
|
|
for y in cy - 1..=cy + 3 {
|
|
for x in cx - 1..=cx + 3 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
if t.kind == Kind::Floor {
|
|
t.kind = Kind::Treasury;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
}
|
|
for k in 0..4 {
|
|
g.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + k as f32 * 0.7 + 0.3, 1);
|
|
}
|
|
g.recount();
|
|
g.place_all_torches();
|
|
g
|
|
}
|
|
|
|
pub fn place_all_torches(&mut self) {
|
|
// torches on walls that are gone (rivers, fortresses) are removed
|
|
let map = &self.map;
|
|
self.torches.retain(|t| map.t[t.wall].solid());
|
|
for i in 0..n() {
|
|
if self.map.t[i].owner == Owner::Player {
|
|
self.place_torches(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- campaign missions
|
|
|
|
// the rune forge tower only shows up in the build menu once there is one to build or upgrade
|
|
pub fn tool_visible(&self, tool: usize) -> bool {
|
|
tool != RUNE_TOOL || self.rune_available() > 0 || self.towers.iter().any(|t| !t.hero && t.kind == TowerKind::Rune)
|
|
}
|
|
|
|
pub fn tool_allowed(&self, tool: usize) -> bool {
|
|
self.mission.as_ref().map_or(true, |m| m.tools.get(tool).copied().unwrap_or(true))
|
|
}
|
|
|
|
pub fn goal_done(&self, goal: Goal) -> bool {
|
|
match goal {
|
|
Goal::Shards => self.shards_taken >= self.mission.as_ref().map_or(0, |m| m.shards),
|
|
Goal::Heart => self.gate_hp <= 0.0,
|
|
Goal::KillAll => !self.ents.iter().any(|e| e.alive && e.active_hero()),
|
|
Goal::Loot(n) => self.loot >= n as f32,
|
|
Goal::Towers => !self.towers.iter().any(|t| t.hero),
|
|
Goal::Survive(n) => self.wave >= n && !self.ents.iter().any(|e| e.alive && e.active_hero() && !e.guard && e.patrol.is_empty()),
|
|
Goal::Rivals => self.rivals.iter().all(|r| !r.alive),
|
|
Goal::Springs(n) => self.springs >= n as usize,
|
|
Goal::Freed(n) => self.freed >= n,
|
|
}
|
|
}
|
|
|
|
pub fn waves_on(&self) -> bool {
|
|
self.mission.as_ref().map_or(self.gate_on, |m| m.waves && (m.wave_limit == 0 || self.wave < m.wave_limit))
|
|
}
|
|
|
|
pub fn has_gate(&self) -> bool {
|
|
self.mission.as_ref().map_or(self.gate_on, |m| m.heart)
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) 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; 8];
|
|
self.springs = self.map.t.iter().filter(|t| t.kind == Kind::ManaSpring && t.owner == Owner::Player).count();
|
|
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 mana_max(&self) -> f32 {
|
|
MANA_MAX + SPRING_MAX * self.springs as f32
|
|
}
|
|
|
|
pub fn gold_cap(&self) -> f32 {
|
|
CORE_STORE + TREASURY_TILE * self.counts[0] as f32
|
|
}
|
|
|
|
pub fn add_gold(&mut self, amount: f32) -> f32 {
|
|
self.add_res(amount, RES_GOLD)
|
|
}
|
|
|
|
// adds treasure of one kind (as far as there is room); returns the value accepted
|
|
pub fn add_res(&mut self, amount: f32, res: u8) -> f32 {
|
|
self.add_res_at(amount, res, None)
|
|
}
|
|
|
|
// treasure delivered at a tile: it fills the treasury tiles nearest to that place
|
|
pub fn add_res_at(&mut self, amount: f32, res: u8, at: Option<usize>) -> f32 {
|
|
let cap = self.gold_cap();
|
|
let acc = amount.min((cap - self.gold).max(0.0));
|
|
self.gold += acc;
|
|
if acc > 0.0 {
|
|
self.incoming.push((res, acc, at));
|
|
}
|
|
acc
|
|
}
|
|
|
|
// what the next payday costs: wages of all creatures plus the training bill
|
|
pub fn next_payday_cost(&self) -> f32 {
|
|
let wages: f32 = self.ents.iter().filter(|e| e.alive && e.creature()).map(|e| st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32)).sum();
|
|
wages + self.training_bill
|
|
}
|
|
|
|
// The gold counter is the truth; the treasury tiles show where it lies. Added treasure fills
|
|
// tiles of the same kind first, then empty ones, the rest goes into the core. Spending takes
|
|
// from random treasury tiles first, then from the core.
|
|
pub fn sync_treasury(&mut self) {
|
|
let mut tiles = Vec::new();
|
|
let mut stored = 0.0;
|
|
for i in 0..self.map.t.len() {
|
|
let t = &mut self.map.t[i];
|
|
if t.kind == Kind::Treasury && t.owner == 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, size: 1.0 });
|
|
}
|
|
}
|
|
|
|
// projectile from (x, y) at height h to (x2, y2) at height h2
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn shot(&mut self, x: f32, y: f32, h: f32, x2: f32, y2: f32, h2: f32, max: f32, col: u32) {
|
|
if self.fx.len() < 400 {
|
|
self.fx.push(Fx { kind: 0, x, y, x2, y2, t: 0.0, max, col, h, h2, size: 1.0 });
|
|
}
|
|
}
|
|
|
|
// the last effect gets bigger (attacks of experienced units)
|
|
fn grow_last_fx(&mut self, size: f32) {
|
|
if let Some(f) = self.fx.last_mut() {
|
|
f.size = size;
|
|
}
|
|
}
|
|
|
|
// effect at a given height
|
|
pub fn fx_at(&mut self, kind: u8, x: f32, y: f32, h: f32, max: f32, col: u32) {
|
|
if self.fx.len() < 400 {
|
|
self.fx.push(Fx { kind, x, y, x2: 0.0, y2: 0.0, t: 0.0, max, col, h, h2: h, size: 1.0 });
|
|
}
|
|
}
|
|
|
|
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 + SPRING_MANA * self.springs as f32) * dt).min(self.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());
|
|
let (x0, y0) = (e.x, e.y);
|
|
self.update_ent(&mut e, dt);
|
|
self.turn(&mut e, x0, y0);
|
|
self.ents[i] = e;
|
|
}
|
|
self.ents.retain(|e| e.alive);
|
|
self.update_towers(dt);
|
|
if !self.rivals.is_empty() {
|
|
self.update_rivals(dt);
|
|
}
|
|
self.update_dwarves(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;
|
|
}
|
|
// free game: every opponent (rival masters and the humans' gate) must fall
|
|
let rivals_out = self.rivals.iter().all(|r| !r.alive);
|
|
let gate_out = !self.gate_on || self.gate_hp <= 0.0;
|
|
if rivals_out && gate_out {
|
|
self.gate_hp = self.gate_hp.max(0.0);
|
|
self.state = 1;
|
|
self.msg("msg.victory", vec![]);
|
|
self.sfx_ui(Sfx::Victory);
|
|
}
|
|
}
|
|
|
|
fn wave_tick(&mut self, dt: f32) {
|
|
self.wave_timer -= dt;
|
|
if !self.warned && self.wave_timer < 30.0 {
|
|
self.warned = true;
|
|
self.plan_entries();
|
|
if self.planned_entries.is_empty() {
|
|
self.msg("msg.wave_warn", vec![(self.wave + 1).to_string()]);
|
|
} else {
|
|
let dirs: Vec<String> = self.planned_entries.iter().map(|&p| format!("@{}", self.direction_key(p))).collect();
|
|
if dirs.len() >= 2 {
|
|
self.msg("msg.wave_warn_two", vec![(self.wave + 1).to_string(), dirs[0].clone(), dirs[1].clone()]);
|
|
} else {
|
|
self.msg("msg.wave_warn_dir", vec![(self.wave + 1).to_string(), dirs[0].clone()]);
|
|
}
|
|
}
|
|
self.sfx_ui(Sfx::WaveAlarm);
|
|
}
|
|
// markers disappear when no hero is on the way to an entry point any more
|
|
if !self.planned_entries.is_empty() && self.wave_timer > 30.0 && !self.ents.iter().any(|e| e.alive && e.waypoint.is_some()) {
|
|
self.planned_entries.clear();
|
|
}
|
|
if self.wave_timer <= 0.0 {
|
|
self.spawn_wave();
|
|
self.wave += 1;
|
|
self.wave_timer = WAVE_GAP + self.rng.f() * 40.0;
|
|
self.warned = false;
|
|
}
|
|
}
|
|
|
|
// the imp working on tile t (dig, claim or reinforce)
|
|
fn task_worker(&self, t: usize) -> Option<usize> {
|
|
self.ents.iter().position(|o| o.alive && !o.hero && o.kind == EK::Worker && matches!(o.job, Job::Dig(x) | Job::Claim(x) | Job::Reinforce(x) if x == t))
|
|
}
|
|
|
|
// imps helping at tile t (standing next to it)
|
|
fn helpers(&self, t: usize) -> usize {
|
|
let (tx, ty) = xy(t);
|
|
self.ents
|
|
.iter()
|
|
.filter(|o| o.alive && o.kind == EK::Worker && o.job == Job::Help(t) && o.path.is_empty() && (o.x - tx as f32 - 0.5).abs() < 1.6 && (o.y - ty as f32 - 0.5).abs() < 1.6)
|
|
.count()
|
|
}
|
|
|
|
// working speed at a tile: +25% per helper, at most 200% (five imps)
|
|
fn help_speed(&self, t: usize) -> f32 {
|
|
1.0 + 0.25 * self.helpers(t).min(HELPERS_MAX) as f32
|
|
}
|
|
|
|
// an idle imp walks to the nearest task with room for another helper
|
|
fn start_help(&mut self, e: &mut Ent) -> bool {
|
|
let tasks: Vec<usize> = self
|
|
.ents
|
|
.iter()
|
|
.filter(|o| o.alive && !o.hero && o.kind == EK::Worker)
|
|
.filter_map(|o| match o.job {
|
|
Job::Dig(t) | Job::Claim(t) | Job::Reinforce(t) => Some(t),
|
|
_ => None,
|
|
})
|
|
.filter(|&t| self.ents.iter().filter(|o| o.alive && o.job == Job::Help(t)).count() < HELPERS_MAX)
|
|
.collect();
|
|
if tasks.is_empty() {
|
|
return false;
|
|
}
|
|
let cur = e.tile();
|
|
let near = |i: usize| tasks.iter().copied().find(|&t| i != t && adj4(i, t) || (i == t && self.map.t[t].walkable()));
|
|
let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| near(i).is_some(), true) else { return false };
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
let Some(t) = near(end) else { return false };
|
|
e.path = p;
|
|
e.job = Job::Help(t);
|
|
e.think = 1.5;
|
|
true
|
|
}
|
|
|
|
// free beds in the lairs: much room lets creatures arrive faster, little room slower
|
|
pub fn free_beds(&self) -> usize {
|
|
let (lair, aerie) = self.free_lair_aerie();
|
|
lair + aerie
|
|
}
|
|
|
|
// free places in the lairs (walking creatures) and in the aerie (flying creatures)
|
|
pub fn free_lair_aerie(&self) -> (usize, usize) {
|
|
let flyers = self.ents.iter().filter(|e| e.alive && e.creature() && flies(e.kind)).count();
|
|
let walkers = self.creature_count() - flyers;
|
|
(self.counts[1].min(30).saturating_sub(walkers), self.counts[7].min(20).saturating_sub(flyers))
|
|
}
|
|
|
|
pub fn lair_factor(&self) -> f32 {
|
|
(8.0 / (self.free_beds() as f32 + 2.0)).clamp(0.35, 3.0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn try_portal_for_test(&mut self) {
|
|
let (px, py) = self.map.portals[0];
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
self.map.t[idx(px + x, py + y)].owner = Owner::Player;
|
|
}
|
|
}
|
|
self.try_portal();
|
|
}
|
|
|
|
fn try_portal(&mut self) {
|
|
let lair = self.counts[1];
|
|
let aerie = self.counts[7];
|
|
let farm = self.counts[2];
|
|
if lair == 0 && aerie == 0 {
|
|
self.hint(0, "hint.portal_no_lair");
|
|
return;
|
|
}
|
|
if farm == 0 {
|
|
self.hint(1, "hint.no_farm");
|
|
return;
|
|
}
|
|
let (free_lair, free_aerie) = self.free_lair_aerie();
|
|
if free_lair == 0 && free_aerie == 0 {
|
|
self.hint(2, "hint.lair_full");
|
|
return;
|
|
}
|
|
let mut opts: Vec<(EK, u32)> = Vec::new();
|
|
// flying creatures need room in the aerie
|
|
if free_aerie > 0 {
|
|
if aerie >= 4 {
|
|
opts.push((EK::Bat, 4));
|
|
}
|
|
if aerie >= 6 && self.counts[5] >= 4 {
|
|
opts.push((EK::Gargoyle, 2));
|
|
}
|
|
if aerie >= 6 && self.counts[4] >= 6 {
|
|
opts.push((EK::Owl, 2));
|
|
}
|
|
let has_wyrm = self.ents.iter().any(|e| e.alive && e.kind == EK::Wyrm && !e.hero);
|
|
if aerie >= 16 && self.counts[0] >= 16 && !has_wyrm {
|
|
opts.push((EK::Wyrm, 1));
|
|
}
|
|
}
|
|
if free_lair == 0 {
|
|
if opts.is_empty() {
|
|
self.hint(2, "hint.lair_full");
|
|
return;
|
|
}
|
|
} else {
|
|
self.portal_walkers(&mut opts);
|
|
}
|
|
if opts.is_empty() {
|
|
return;
|
|
}
|
|
self.portal_arrival(opts);
|
|
}
|
|
|
|
// walking creatures that the rooms attract
|
|
fn portal_walkers(&mut self, opts: &mut Vec<(EK, u32)>) {
|
|
let farm = self.counts[2];
|
|
opts.push((EK::Kobold, 4));
|
|
if farm >= 6 {
|
|
opts.push((EK::Spider, 3));
|
|
}
|
|
if self.counts[4] >= 6 {
|
|
opts.push((EK::Mage, 3));
|
|
}
|
|
if self.counts[3] >= 9 {
|
|
opts.push((EK::Ogre, 2));
|
|
}
|
|
if self.counts[5] >= 6 {
|
|
opts.push((EK::Jailer, 2));
|
|
}
|
|
if self.counts[6] >= 6 {
|
|
opts.push((EK::Priestess, 2));
|
|
}
|
|
// the Chainbreaker only visits big dungeons with experienced creatures and a rich treasury
|
|
let room_tiles: usize = self.counts.iter().sum();
|
|
let veteran = self.ents.iter().any(|e| e.alive && e.creature() && e.level >= 5);
|
|
let has_one = self.ents.iter().any(|e| e.alive && e.kind == EK::Chainbreaker && !e.hero);
|
|
if room_tiles >= 40 && veteran && self.gold >= 4000.0 && !has_one {
|
|
opts.push((EK::Chainbreaker, 1));
|
|
}
|
|
}
|
|
|
|
fn portal_arrival(&mut self, opts: Vec<(EK, u32)>) {
|
|
let total: u32 = opts.iter().map(|o| o.1).sum();
|
|
let mut r = self.rng.next() % total;
|
|
let mut kind = EK::Kobold;
|
|
for (k, w) in opts {
|
|
if r < w {
|
|
kind = k;
|
|
break;
|
|
}
|
|
r -= w;
|
|
}
|
|
let owned: Vec<usize> = (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).collect();
|
|
let pk = owned[(self.rng.next() as usize) % owned.len()];
|
|
let (px, py) = self.map.portals[pk];
|
|
let id = self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1);
|
|
// new creatures report at the portal and set up their bed first
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.settled = false;
|
|
e.job = Job::Register;
|
|
e.timer = 2.0;
|
|
}
|
|
self.fx(3, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 0.8, 0xffcc44ff);
|
|
self.sfx(Sfx::PortalArrive, px as f32 + 1.5, py as f32 + 1.5);
|
|
self.msg("msg.creature_arrived", vec![format!("@{}", st(kind).name)]);
|
|
}
|
|
|
|
// Payday: the training bill is paid at once; every creature fetches its wage from a
|
|
// treasury itself, at a random moment within about a minute
|
|
fn do_payday(&mut self) {
|
|
let bill = self.training_bill.min(self.gold).max(0.0);
|
|
self.gold -= bill;
|
|
self.training_bill = 0.0;
|
|
let mut n = 0;
|
|
let mut total = bill;
|
|
for i in 0..self.ents.len() {
|
|
if !self.ents[i].alive || !self.ents[i].creature() {
|
|
continue;
|
|
}
|
|
let r = self.rng.f();
|
|
let e = &mut self.ents[i];
|
|
let w = st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32);
|
|
e.pay_due += w;
|
|
e.pay_t = r * 55.0;
|
|
n += 1;
|
|
total += w;
|
|
}
|
|
if n > 0 || bill > 0.0 {
|
|
self.sfx_ui(Sfx::Payday);
|
|
self.msg("msg.payday_walk", vec![n.to_string(), (total as i32).to_string()]);
|
|
}
|
|
}
|
|
|
|
// a creature goes for its wage: the nearest treasury tile holding treasure, else the core
|
|
fn start_fetch_wage(&mut self, e: &mut Ent) {
|
|
let cur = e.tile();
|
|
let goal = |i: usize| {
|
|
let t = &self.map.t[i];
|
|
(t.kind == Kind::Treasury && t.owner == Owner::Player && t.gold > 0) || (self.counts[0] == 0 && self.map.adj_core(i))
|
|
};
|
|
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), goal, true) } else { self.map.search(cur, self.map.walk_cost(), goal, true) };
|
|
match found {
|
|
Some(p) => {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
e.path = p;
|
|
e.job = Job::FetchWage(t);
|
|
}
|
|
// no way to a treasury: the wage is paid out anyway
|
|
None => self.take_wage(e, None),
|
|
}
|
|
}
|
|
|
|
// takes the wage out of the treasury tile (the rest from elsewhere); unpaid = angry
|
|
fn take_wage(&mut self, e: &mut Ent, at: Option<usize>) {
|
|
let mut need = e.pay_due;
|
|
if let Some(t) = at {
|
|
let tile = &mut self.map.t[t];
|
|
if tile.kind == Kind::Treasury {
|
|
let take = (tile.gold as f32).min(need).floor();
|
|
tile.gold -= take as i32;
|
|
self.gold -= take;
|
|
need -= take;
|
|
}
|
|
}
|
|
let rest = need.min(self.gold.max(0.0));
|
|
self.gold -= rest;
|
|
need -= rest;
|
|
e.pay_due = 0.0;
|
|
if need > 0.5 {
|
|
e.happy -= 0.45;
|
|
self.msg("msg.wage_unpaid", vec![format!("@{}", st(e.kind).name)]);
|
|
self.sfx(Sfx::Error, e.x, e.y);
|
|
} else {
|
|
e.happy = (e.happy + 0.2).min(1.0);
|
|
self.fx(5, e.x, e.y, 0.0, 0.0, 0.8, 0xff30d0ff);
|
|
self.sfx(Sfx::Deposit, e.x, e.y);
|
|
}
|
|
}
|
|
|
|
// a new creature: report at the portal, go to the lair (aerie), set up the bed
|
|
fn settle(&mut self, e: &mut Ent, dt: f32) {
|
|
let me = e.id + 1;
|
|
match e.job {
|
|
Job::Register => {
|
|
e.working = true;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
let cur = e.tile();
|
|
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
|
|
let free = |i: usize| self.map.t[i].kind == bed_kind && self.map.t[i].bed == 0;
|
|
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) };
|
|
match found {
|
|
Some(p) => {
|
|
let b = p.last().copied().unwrap_or(cur);
|
|
self.map.t[b].bed = me;
|
|
e.bed = Some(b);
|
|
e.path = p;
|
|
e.job = Job::MakeBed(b);
|
|
e.timer = 4.0;
|
|
}
|
|
None => {
|
|
e.settled = true;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Job::MakeBed(b) => {
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
if self.map.t[b].bed != me {
|
|
e.settled = true;
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
e.settled = true;
|
|
e.job = Job::None;
|
|
let (bx, by) = xy(b);
|
|
self.fx(8, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff40d0ff);
|
|
self.sfx(Sfx::Build, bx as f32 + 0.5, by as f32 + 0.5);
|
|
}
|
|
}
|
|
_ => {
|
|
e.job = Job::Register;
|
|
e.timer = 2.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// strength of a squad (same formula as the army value)
|
|
pub(crate) fn squad_value(list: &[(EK, u32)]) -> f32 {
|
|
let (mut h, mut d) = (0.0, 0.0);
|
|
for &(k, l) in list {
|
|
let s = st(k);
|
|
h += s.hp * (1.0 + 0.15 * (l - 1) as f32);
|
|
d += s.atk * (1.0 + 0.12 * (l - 1) as f32) / s.interval;
|
|
}
|
|
(h * d).sqrt()
|
|
}
|
|
|
|
// builds a hero squad with at least the target strength
|
|
fn build_squad(&mut self, target: f32, base_level: u32, pool: &[(EK, u32)], boss: Option<EK>, min: usize) -> Vec<(EK, u32)> {
|
|
let total: u32 = pool.iter().map(|p| p.1).sum();
|
|
let mut list = Vec::new();
|
|
for level in base_level.min(10)..=10 {
|
|
list.clear();
|
|
if let Some(b) = boss {
|
|
list.push((b, level));
|
|
}
|
|
while list.len() < MAX_SQUAD && (list.len() < min || Self::squad_value(&list) < target) {
|
|
let mut r = self.rng.next() % total;
|
|
for &(k, w) in pool {
|
|
if r < w {
|
|
list.push((k, level));
|
|
break;
|
|
}
|
|
r -= w;
|
|
}
|
|
}
|
|
if Self::squad_value(&list) >= target {
|
|
break;
|
|
}
|
|
}
|
|
list
|
|
}
|
|
|
|
fn spawn_squad(&mut self, list: &[(EK, u32)], guard: bool) {
|
|
let (gx, gy) = self.map.gate;
|
|
for &(k, l) in list {
|
|
let x = gx as f32 + 0.5 + self.rng.f() * 2.0;
|
|
let y = gy as f32 + 0.5 + self.rng.f() * 2.0;
|
|
let id = self.spawn(k, true, x, y, l);
|
|
let home = (gx as f32 + 1.5, gy as f32 + 1.5);
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.guard = guard;
|
|
if guard {
|
|
e.home = Some(home);
|
|
}
|
|
}
|
|
}
|
|
self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 1.0, 0xffffeecc);
|
|
}
|
|
|
|
// Endless waves: strength 60% -> 100% of the army value, with some randomness, at least 2 knights.
|
|
fn spawn_wave(&mut self) {
|
|
use EK::*;
|
|
let w = self.wave + 1;
|
|
let ratio = (0.6 + 0.05 * (w - 1) as f32).min(1.0) * (0.88 + self.rng.f() * 0.24);
|
|
let min_value = Self::squad_value(&[(Knight, 1), (Knight, 1)]);
|
|
let target = (self.army_value() * ratio).max(min_value);
|
|
let mut pool = vec![(Knight, 4), (Archer, 3)];
|
|
if w >= 2 {
|
|
pool.push((Dwarf, 2));
|
|
}
|
|
if w >= 3 {
|
|
pool.push((Priest, 2));
|
|
}
|
|
let boss = if w % 5 == 0 { Some(Paladin) } else { None };
|
|
let base_level = 1 + (w - 1) / 3;
|
|
let list = self.build_squad(target, base_level, &pool, boss, 2);
|
|
self.last_wave_value = Self::squad_value(&list);
|
|
let before = self.ents.len();
|
|
self.spawn_squad(&list, false);
|
|
// the humans attack everybody: now and then a rival master instead of the player
|
|
let alive: Vec<u8> = self.rivals.iter().filter(|r| r.alive).map(|r| r.side).collect();
|
|
if !alive.is_empty() {
|
|
let r = (self.rng.next() as usize) % (alive.len() + 2);
|
|
if r >= 2 {
|
|
let side = alive[r - 2];
|
|
for e in self.ents[before..].iter_mut() {
|
|
e.target = Some(side);
|
|
e.waypoint = None;
|
|
}
|
|
self.planned_entries.clear();
|
|
self.sfx_ui(Sfx::WaveStart);
|
|
self.msg(if side == 2 { "msg.wave_on_rival1" } else { "msg.wave_on_rival2" }, vec![w.to_string(), list.len().to_string()]);
|
|
return;
|
|
}
|
|
}
|
|
if self.planned_entries.is_empty() {
|
|
self.plan_entries();
|
|
}
|
|
let entries = self.planned_entries.clone();
|
|
if !entries.is_empty() {
|
|
let split = list.len() >= 6 && entries.len() >= 2;
|
|
// the paladin (if any) is spawned first and joins group A, which gets the extra hero
|
|
for (k, e) in self.ents[before..].iter_mut().enumerate() {
|
|
e.waypoint = Some(if split && k % 2 == 1 { entries[1] } else { entries[0] });
|
|
}
|
|
if !split {
|
|
self.planned_entries.truncate(1);
|
|
}
|
|
self.entry_history.extend(self.planned_entries.iter().copied());
|
|
let n = self.entry_history.len();
|
|
if n > 4 {
|
|
self.entry_history.drain(0..n - 4);
|
|
}
|
|
}
|
|
self.sfx_ui(Sfx::WaveStart);
|
|
if boss.is_some() {
|
|
self.msg("msg.wave_boss", vec![w.to_string(), list.len().to_string()]);
|
|
} else {
|
|
self.msg("msg.wave", vec![w.to_string(), list.len().to_string()]);
|
|
}
|
|
}
|
|
|
|
// defenders at 75 / 50 / 25 % gate HP
|
|
fn check_gate_stage(&mut self) {
|
|
use EK::*;
|
|
let frac = self.gate_hp / GATE_HP;
|
|
let want = if frac <= 0.25 { 3 } else if frac <= 0.5 { 2 } else if frac <= 0.75 { 1 } else { 0 };
|
|
while self.gate_stage < want {
|
|
self.gate_stage += 1;
|
|
let st_i = self.gate_stage;
|
|
let ratio = [0.5, 0.65, 0.85][(st_i - 1) as usize] * (0.9 + self.rng.f() * 0.2);
|
|
let min_value = Self::squad_value(&[(Knight, 2), (Knight, 2), (Archer, 2)]);
|
|
let target = (self.army_value() * ratio).max(min_value);
|
|
let pool = [(Knight, 4), (Archer, 3), (Priest, 2), (Dwarf, 1)];
|
|
let boss = if st_i == 3 { Some(Paladin) } else { None };
|
|
let base_level = 2 + self.wave / 3 + st_i;
|
|
let list = self.build_squad(target, base_level, &pool, boss, 3);
|
|
self.spawn_squad(&list, true);
|
|
self.sfx_ui(Sfx::GateDefenders);
|
|
if st_i == 3 {
|
|
self.msg("msg.gate_boss", vec![(list.len() - 1).to_string()]);
|
|
} else {
|
|
self.msg("msg.gate_defenders", vec![list.len().to_string()]);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hit_gate(&mut self, e: &mut Ent) {
|
|
let s = st(e.kind);
|
|
e.cd = s.interval;
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
|
|
self.gate_hp -= dmg;
|
|
self.gate_hit_t = self.time;
|
|
e.xp += dmg * 0.3;
|
|
let (gx, gy) = self.gate_center();
|
|
if s.range > 1.5 {
|
|
let col = if e.kind == EK::Mage { 0xffff44cc } else { 0xff70b0d0 };
|
|
self.fx(0, e.x, e.y, gx, gy, 0.25, col);
|
|
} else {
|
|
let fx = (e.x + gx) * 0.5;
|
|
let fy = (e.y + gy) * 0.5;
|
|
self.fx(2, fx, fy, 0.0, 0.0, 0.25, 0xffffffff);
|
|
}
|
|
self.sfx(Sfx::GateHit, gx, gy);
|
|
self.hint(10, "hint.gate_attack");
|
|
if self.mission.as_ref().map_or(true, |m| m.gate_defenders) {
|
|
self.check_gate_stage();
|
|
}
|
|
}
|
|
|
|
fn update_ent(&mut self, e: &mut Ent, dt: f32) {
|
|
e.anim += dt;
|
|
e.flash = (e.flash - dt).max(0.0);
|
|
e.slow = (e.slow - dt).max(0.0);
|
|
e.skill_cd -= dt;
|
|
e.boost = (e.boost - dt).max(0.0);
|
|
if e.stun > 0.0 {
|
|
e.stun -= dt;
|
|
e.moving = false;
|
|
return;
|
|
}
|
|
// 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.chained {
|
|
// waits in chains until the player's imps claim its cave
|
|
e.moving = false;
|
|
let (x, y) = xy(e.tile());
|
|
let reached = std::iter::once((0, 0)).chain(DIR8.iter().copied()).any(|(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |t| t.owner == Owner::Player));
|
|
if reached {
|
|
e.chained = false;
|
|
e.settled = false;
|
|
e.job = Job::Register;
|
|
e.timer = 1.0;
|
|
e.happy = 1.0;
|
|
self.freed += 1;
|
|
self.fx(7, e.x, e.y, 0.0, 0.0, 1.2, 0xff44ddff);
|
|
self.sfx(Sfx::Convert, e.x, e.y);
|
|
self.msg("msg.creature_freed", vec![format!("@{}", st(e.kind).name)]);
|
|
}
|
|
} else if e.hero && e.captive != 0 {
|
|
self.captive_update(e, dt);
|
|
} else if e.rival() {
|
|
self.rival_ai(e, dt);
|
|
} else if e.dwarf() {
|
|
self.dwarf_ai(e, dt);
|
|
} else if e.hero {
|
|
self.hero_ai(e, dt);
|
|
} else if e.kind == EK::Worker {
|
|
self.worker_ai(e, dt);
|
|
} else {
|
|
self.creature_ai(e, dt);
|
|
}
|
|
}
|
|
|
|
// the direction a unit faces: where it walks, otherwise what it works on or fights
|
|
fn turn(&self, e: &mut Ent, x0: f32, y0: f32) {
|
|
let (dx, dy) = (e.x - x0, e.y - y0);
|
|
if dx * dx + dy * dy > 1e-8 {
|
|
e.dir = dy.atan2(dx);
|
|
return;
|
|
}
|
|
if !e.working {
|
|
return;
|
|
}
|
|
let tile = |t: usize| {
|
|
let (x, y) = xy(t);
|
|
Some((x as f32 + 0.5, y as f32 + 0.5))
|
|
};
|
|
let target = match e.job {
|
|
Job::Dig(t) | Job::Reinforce(t) | Job::Help(t) | Job::TearBridge(t) => tile(t),
|
|
Job::Attack(id) | Job::Torment(id) | Job::Carry(id) => self.find(id).map(|j| (self.ents[j].x, self.ents[j].y)),
|
|
Job::Repair(id) | Job::AttackTower(id) => self.towers.iter().find(|t| t.id == id).map(|t| t.pos()),
|
|
_ => None,
|
|
};
|
|
if let Some((tx, ty)) = target {
|
|
let (dx, dy) = (tx - e.x, ty - e.y);
|
|
if dx * dx + dy * dy > 0.01 {
|
|
e.dir = dy.atan2(dx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// moves a unit along its path; true = still moving
|
|
pub(crate) fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool {
|
|
if e.path.is_empty() {
|
|
return false;
|
|
}
|
|
let next = e.path[0];
|
|
let tile = self.map.t[next];
|
|
if !tile.walkable() && !(flies(e.kind) && tile.liquid()) {
|
|
if (e.hero || e.rival()) && tile.diggable() {
|
|
// heroes dig their way through
|
|
e.working = true;
|
|
let (tx, ty) = xy(next);
|
|
e.face = sgn(tx as f32 + 0.5 - e.x);
|
|
e.timer += dt;
|
|
if e.timer >= 0.5 {
|
|
e.timer = 0.0;
|
|
let pow = match e.kind {
|
|
EK::Dwarf => 3.0,
|
|
EK::Paladin => 2.0,
|
|
_ => 1.0,
|
|
} * if tile.kind == Kind::Steel {
|
|
0.05
|
|
} else 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
|
|
}
|
|
|
|
pub(crate) fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option<u32> {
|
|
let mut best = None;
|
|
let mut bd = radius * radius;
|
|
for o in &self.ents {
|
|
// every other side is an enemy
|
|
if !o.alive || o.held || o.side() == e.side() || o.captive != 0 || o.chained {
|
|
continue;
|
|
}
|
|
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
|
|
if d < bd {
|
|
bd = d;
|
|
best = Some(o.id);
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
// fight a specific unit; false = target invalid/unreachable
|
|
pub(crate) fn attack_job(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
|
|
let Some(j) = self.find(id) else { return false };
|
|
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
|
|
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
|
|
let s = st(e.kind);
|
|
if d > 12.0 {
|
|
return false;
|
|
}
|
|
if e.kind == EK::Jailer && e.skill_cd <= 0.0 && d > 1.4 && d < 4.5 && self.los(e.x, e.y, tx, ty) {
|
|
// jailer: pulls the enemy in with a chain
|
|
let (dx, dy) = norm(tx - e.x, ty - e.y);
|
|
let (nx, ny) = (e.x + dx * 0.9, e.y + dy * 0.9);
|
|
if self.map.walk(nx as i32, ny as i32) {
|
|
self.fx(0, tx, ty, e.x, e.y, 0.3, 0xff8090a0);
|
|
self.sfx(Sfx::Capture, e.x, e.y);
|
|
let t = &mut self.ents[j];
|
|
t.x = nx;
|
|
t.y = ny;
|
|
t.path.clear();
|
|
e.skill_cd = 6.0;
|
|
return true;
|
|
}
|
|
}
|
|
if d <= s.range + 0.45 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
|
|
e.path.clear();
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
if e.cd <= 0.0 {
|
|
e.cd = s.interval;
|
|
self.hit(e, j);
|
|
}
|
|
return true;
|
|
}
|
|
if e.path.is_empty() || e.repath <= 0.0 {
|
|
e.repath = 0.6;
|
|
let tt = idx(tx as i32, ty as i32);
|
|
let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), tt) } else { self.map.path_to(e.tile(), tt) };
|
|
match p {
|
|
Some(p) => e.path = p,
|
|
None => return false,
|
|
}
|
|
}
|
|
if !self.step_path(e, dt) && e.tile() == idx(tx as i32, ty as i32) {
|
|
// same tile: approach directly
|
|
let dx = tx - e.x;
|
|
let dy = ty - e.y;
|
|
let l = (dx * dx + dy * dy).sqrt().max(0.001);
|
|
let sp = s.speed * dt;
|
|
e.x += dx / l * sp.min(l);
|
|
e.y += dy / l * sp.min(l);
|
|
}
|
|
true
|
|
}
|
|
|
|
fn hit(&mut self, e: &mut Ent, j: usize) {
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
|
|
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
|
|
let s = st(e.kind);
|
|
// the higher the level, the bigger the attack looks (up to almost twice at Lv 10)
|
|
let lv = 1.0 + 0.1 * (e.level.max(1) - 1) as f32;
|
|
if s.range > 1.5 {
|
|
let col = match e.kind {
|
|
EK::Mage => 0xffff44cc,
|
|
EK::Priest => 0xff88ffff,
|
|
EK::Priestess => 0xff3080ff,
|
|
EK::Owl => 0xffffa060,
|
|
EK::Wyrm => 0xff2080ff,
|
|
_ => 0xff70b0d0,
|
|
};
|
|
// flyers shoot from the air
|
|
let h = if flies(e.kind) { 0.8 } else { 0.45 };
|
|
self.shot(e.x, e.y, h, tx, ty, 0.45, 0.25, col);
|
|
self.grow_last_fx(lv);
|
|
self.sfx(Sfx::Shoot, e.x, e.y);
|
|
} else {
|
|
self.fx(2, tx, ty, 0.0, 0.0, 0.25, if e.level >= 9 { 0xff60e0ff } else { 0xffffffff });
|
|
self.grow_last_fx(lv);
|
|
self.sfx(Sfx::MeleeHit, tx, ty);
|
|
}
|
|
e.xp += dmg * 0.6;
|
|
if e.kind == EK::Chainbreaker {
|
|
// twin axes: a second enemy next to the Chainbreaker takes half damage
|
|
let me = e.side();
|
|
let second = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - e.x).powi(2) + (x.y - e.y).powi(2) < 2.6)
|
|
.map(|(o, _)| o);
|
|
if let Some(o) = second {
|
|
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
|
|
self.fx(2, ox, oy, 0.0, 0.0, 0.25, 0xff40a0ff);
|
|
self.grow_last_fx(lv);
|
|
self.ents[o].hp -= dmg * 0.5;
|
|
self.ents[o].flash = 0.15;
|
|
if self.ents[o].hp <= 0.0 {
|
|
self.kill(o);
|
|
}
|
|
}
|
|
}
|
|
if e.kind == EK::Priestess || e.kind == EK::Wyrm {
|
|
// fireball / fire breath: half damage to enemies around the target
|
|
self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff);
|
|
self.grow_last_fx(lv);
|
|
let me = e.side();
|
|
let splash: Vec<usize> = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 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, side) = (e.id, e.kind, e.hero, e.x, e.y, e.side);
|
|
// prisoners carried by this unit are dropped
|
|
for o in self.ents.iter_mut() {
|
|
if o.captive == 2 && o.carrier == id {
|
|
o.captive = 1;
|
|
o.carrier = 0;
|
|
}
|
|
}
|
|
self.release_all(id);
|
|
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
|
|
let s = st(kind);
|
|
if hero {
|
|
let b = s.bounty as f32;
|
|
let got = self.add_gold(b);
|
|
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
|
|
self.sfx(Sfx::HeroDeath, x, y);
|
|
self.msg("msg.hero_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
|
|
} else if side == crate::dwarves::DWARF_SIDE {
|
|
let got = self.add_gold(s.bounty as f32 * 0.5);
|
|
self.sfx(Sfx::HeroDeath, x, y);
|
|
self.msg("msg.dwarf_slain", vec![(got as i32).to_string()]);
|
|
} else if side >= 2 {
|
|
// a creature of a rival master: half its wage as booty
|
|
let got = self.add_gold(s.wage as f32 * 0.5);
|
|
self.sfx(Sfx::HeroDeath, x, y);
|
|
self.msg("msg.rival_unit_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
|
|
} else {
|
|
self.sfx(Sfx::Death, x, y);
|
|
self.msg("msg.creature_died", vec![format!("@{}", s.name)]);
|
|
}
|
|
}
|
|
|
|
// falls into lava: dies at once (no capture), heroes still pay their bounty
|
|
pub fn burn(&mut self, j: usize) {
|
|
let e = &mut self.ents[j];
|
|
if !e.alive {
|
|
return;
|
|
}
|
|
e.alive = false;
|
|
e.held = false;
|
|
let (id, kind, hero, x, y) = (e.id, e.kind, e.hero, e.x, e.y);
|
|
for o in self.ents.iter_mut() {
|
|
if o.captive == 2 && o.carrier == id {
|
|
o.captive = 1;
|
|
o.carrier = 0;
|
|
}
|
|
}
|
|
self.release_all(id);
|
|
self.hand.retain(|&h| h != id);
|
|
self.fx(3, x, y, 0.0, 0.0, 1.0, 0xff2060ff);
|
|
self.fx(2, x, y, 0.0, 0.0, 0.5, 0xff30a0ff);
|
|
self.sfx(Sfx::Death, x, y);
|
|
let s = st(kind);
|
|
if hero {
|
|
let got = self.add_gold(s.bounty as f32);
|
|
self.msg("msg.hero_burned", vec![format!("@{}", s.name), (got as i32).to_string()]);
|
|
} else {
|
|
self.msg("msg.creature_burned", vec![format!("@{}", s.name)]);
|
|
}
|
|
}
|
|
|
|
// pushes a unit; into lava = burnt, never into water or walls
|
|
pub fn knock(&mut self, j: usize, dx: f32, dy: f32) {
|
|
let e = &self.ents[j];
|
|
let (nx, ny) = (e.x + dx, e.y + dy);
|
|
let Some(t) = self.map.get(nx as i32, ny as i32).copied() else { return };
|
|
if flies(self.ents[j].kind) {
|
|
if t.walkable() || t.liquid() {
|
|
let e = &mut self.ents[j];
|
|
e.x = nx;
|
|
e.y = ny;
|
|
e.path.clear();
|
|
}
|
|
return;
|
|
}
|
|
if t.kind == Kind::Lava {
|
|
let e = &mut self.ents[j];
|
|
e.x = nx;
|
|
e.y = ny;
|
|
self.burn(j);
|
|
} else if t.walkable() {
|
|
let e = &mut self.ents[j];
|
|
e.x = nx;
|
|
e.y = ny;
|
|
e.path.clear();
|
|
}
|
|
}
|
|
|
|
// the ground of tile i turned into river (bridge removed): units on it fall in
|
|
fn ground_lost(&mut self, i: usize) {
|
|
let lava = self.map.t[i].kind == Kind::Lava;
|
|
let (x, y) = xy(i);
|
|
for j in 0..self.ents.len() {
|
|
let e = &self.ents[j];
|
|
if !e.alive || e.held || e.tile() != i || flies(e.kind) {
|
|
continue;
|
|
}
|
|
if lava {
|
|
self.burn(j);
|
|
continue;
|
|
}
|
|
// water: swim to the nearest bank, otherwise drown
|
|
let bank = DIR8.iter().map(|&(dx, dy)| (x + dx, y + dy)).find(|&(bx, by)| self.map.walk(bx, by));
|
|
match bank {
|
|
Some((bx, by)) => {
|
|
let e = &mut self.ents[j];
|
|
e.x = bx as f32 + 0.5;
|
|
e.y = by as f32 + 0.5;
|
|
e.path.clear();
|
|
}
|
|
None => self.burn(j),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bridges grow from own ground into the river: every pass adds the river tiles of the
|
|
// rectangle that touch own walkable ground (including bridges built in the pass before)
|
|
// Earth walls next to own ground become steel walls
|
|
pub fn build_steel(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
|
let (mut built, mut broke) = (0, false);
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) {
|
|
continue;
|
|
}
|
|
let t = self.map.t[idx(x, y)];
|
|
if t.kind != Kind::Earth || t.owner == Owner::Hero {
|
|
continue;
|
|
}
|
|
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == Owner::Player && n.walkable()));
|
|
if !own {
|
|
continue;
|
|
}
|
|
let cost = STEEL_COST[t.reinforced as usize] as f32;
|
|
if self.gold < cost {
|
|
broke = true;
|
|
continue;
|
|
}
|
|
self.gold -= cost;
|
|
let tl = &mut self.map.t[idx(x, y)];
|
|
tl.kind = Kind::Steel;
|
|
tl.reinforced = true;
|
|
tl.marked = false;
|
|
tl.hp = STEEL_HP;
|
|
built += 1;
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xffd0d0d0);
|
|
if built == 1 {
|
|
self.sfx(Sfx::Reinforce, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
}
|
|
}
|
|
if broke {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
} else if built == 0 {
|
|
self.hint(23, "hint.steel_where");
|
|
}
|
|
}
|
|
|
|
pub fn build_bridges(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
|
let (mut built, mut broke, mut any_river) = (0, false, false);
|
|
loop {
|
|
let mut add = Vec::new();
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) || !self.map.t[idx(x, y)].liquid() {
|
|
continue;
|
|
}
|
|
any_river = true;
|
|
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == 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 {
|
|
// any enemy of the player (heroes and the rivals' units) keeps ground from being claimed
|
|
if !e.alive || e.held || e.captive != 0 || e.side() == 0 {
|
|
continue;
|
|
}
|
|
let (x, y) = (e.x as i32, e.y as i32);
|
|
for dy in -2..=2 {
|
|
for dx in -2..=2 {
|
|
if inb(x + dx, y + dy) {
|
|
self.contested[idx(x + dx, y + dy)] = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a knight tile was claimed: vaults hand over their treasure, shards come back
|
|
fn take_knight_tile(&mut self, i: usize) {
|
|
let t = self.map.t[i];
|
|
let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5);
|
|
if t.kind == Kind::Treasury && t.gold > 0 {
|
|
// the content stays in the (now own) treasury tile and counts as own gold
|
|
self.gold += t.gold as f32;
|
|
self.loot += t.gold as f32;
|
|
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
|
|
self.sfx(Sfx::Payday, x, y);
|
|
self.msg("msg.vault_taken", vec![t.gold.to_string()]);
|
|
}
|
|
if t.kind == Kind::ManaSpring {
|
|
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xffffe060);
|
|
self.sfx(Sfx::Magic, x, y);
|
|
self.msg("msg.spring_taken", vec![]);
|
|
}
|
|
if t.kind == Kind::RuneChest {
|
|
self.open_rune_chest(i);
|
|
}
|
|
if t.kind == Kind::Shard {
|
|
self.shards_taken += 1;
|
|
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff6060ff);
|
|
self.sfx(Sfx::LevelUp, x, y);
|
|
self.msg("msg.shard_taken", vec![]);
|
|
}
|
|
}
|
|
|
|
// a dwarven chest: the plans of a rune forge tower (one free build)
|
|
pub fn open_rune_chest(&mut self, i: usize) {
|
|
let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5);
|
|
self.map.t[i].kind = Kind::Floor;
|
|
self.rune_free += 1;
|
|
self.fx(7, x, y, 0.0, 0.0, 1.4, 0xff40c0ff);
|
|
self.sfx(Sfx::LevelUp, x, y);
|
|
self.msg("msg.rune_chest", vec![]);
|
|
}
|
|
|
|
// a fortress unit spotted an enemy: guards and patrols nearby join the fight
|
|
fn alarm(&mut self, x: f32, y: f32, target: u32) {
|
|
for o in self.ents.iter_mut() {
|
|
if !o.alive || o.held || !o.active_hero() || !(o.guard || !o.patrol.is_empty()) {
|
|
continue;
|
|
}
|
|
if matches!(o.job, Job::Attack(_)) || (o.x - x).powi(2) + (o.y - y).powi(2) > 13.0 * 13.0 {
|
|
continue;
|
|
}
|
|
o.job = Job::Attack(target);
|
|
o.path.clear();
|
|
o.repath = 0.0;
|
|
}
|
|
}
|
|
|
|
// fortress units: a bridge of the player near the post (None if none)
|
|
fn bridge_to_tear(&self, e: &Ent) -> Option<usize> {
|
|
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
|
|
let (cx, cy) = (hx as i32, hy as i32);
|
|
for r in 0..=9i32 {
|
|
for dy in -r..=r {
|
|
for dx in -r..=r {
|
|
if dx.abs() != r && dy.abs() != r {
|
|
continue;
|
|
}
|
|
if let Some(t) = self.map.get(cx + dx, cy + dy) {
|
|
if t.bridge() && t.owner == 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 | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest),
|
|
// knight ground (also vaults and shard chambers), but not while heroes fight there
|
|
// knight and rival ground (also vaults and shard chambers), but not while enemies fight there
|
|
Owner::Hero | Owner::Rival1 | Owner::Rival2 => {
|
|
matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Lair | Kind::Training) && !self.contested.get(i).copied().unwrap_or(false)
|
|
}
|
|
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
|
|
}
|
|
|
|
// flying creatures patrol far around (also over rivers and the land of the enemy)
|
|
fn patrol_flight(&mut self, e: &mut Ent) {
|
|
let (x, y) = xy(e.tile());
|
|
for _ in 0..8 {
|
|
let nx = x + self.rng.range(-14, 14);
|
|
let ny = y + self.rng.range(-14, 14);
|
|
let ok = self.map.get(nx, ny).map_or(false, |t| t.walkable() || t.liquid());
|
|
if ok {
|
|
if let Some(p) = self.map.fly_path_to(e.tile(), idx(nx, ny)) {
|
|
if p.len() <= 40 {
|
|
e.path = p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
e.job = Job::Wander;
|
|
e.timer = 2.0 + self.rng.f() * 3.0;
|
|
}
|
|
|
|
// badly hurt flyers flee to the aerie and rest until they are healed to 75%
|
|
fn start_rest(&mut self, e: &mut Ent) -> bool {
|
|
let cur = e.tile();
|
|
let target = e.bed.filter(|&b| self.map.t[b].kind == Kind::Aerie).or_else(|| {
|
|
self.map.search(cur, self.map.fly_cost(), |i| self.map.t[i].kind == Kind::Aerie, true).map(|p| p.last().copied().unwrap_or(cur))
|
|
});
|
|
let Some(b) = target else { return false };
|
|
e.path = self.map.fly_path_to(cur, b).unwrap_or_default();
|
|
e.job = Job::Rest(b);
|
|
true
|
|
}
|
|
|
|
fn wander(&mut self, e: &mut Ent, radius: i32) {
|
|
let (x, y) = xy(e.tile());
|
|
for _ in 0..6 {
|
|
let nx = x + self.rng.range(-radius, radius);
|
|
let ny = y + self.rng.range(-radius, radius);
|
|
if self.map.walk(nx, ny) && self.map.t[idx(nx, ny)].owner == 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 prev = self.map.t[t].owner;
|
|
let knight = prev == Owner::Hero;
|
|
if e.timer >= if prev != Owner::None { 3.0 } else { 1.0 } {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
let (tx, ty) = xy(t);
|
|
if knight {
|
|
self.alarm(tx as f32 + 0.5, ty as f32 + 0.5, e.id);
|
|
}
|
|
// vaults, shards and springs (of the knights, rivals or nobody)
|
|
self.take_knight_tile(t);
|
|
if let (Kind::Portal, Some(pk)) = (self.map.t[t].kind, self.map.portal_at(tx, ty)) {
|
|
let (px, py) = self.map.portals[pk];
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
self.map.t[idx(px + x, py + y)].owner = 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;
|
|
}
|
|
// damaged towers are repaired (free of charge) before more ground is claimed
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(id) = self.damaged_tower_near(end) {
|
|
e.path = p;
|
|
e.job = Job::Repair(id);
|
|
return;
|
|
}
|
|
}
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
self.map.t[end].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Claim(end);
|
|
return;
|
|
}
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.reinforce_neighbor(i, me).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(w) = self.reinforce_neighbor(end, me) {
|
|
self.map.t[w].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Reinforce(w);
|
|
e.timer = 0.0;
|
|
return;
|
|
}
|
|
}
|
|
// nothing else to do: help another imp (up to five at one task)
|
|
if self.start_help(e) {
|
|
return;
|
|
}
|
|
e.think = 1.0;
|
|
self.wander(e, 3);
|
|
}
|
|
_ => e.job = Job::None,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- creatures
|
|
|
|
fn find_room_tile(&self, cur: usize, kind: Kind, me: u32, need_food: bool) -> Option<Vec<usize>> {
|
|
self.map.search(
|
|
cur,
|
|
self.map.walk_cost(),
|
|
|i| {
|
|
let t = &self.map.t[i];
|
|
t.kind == kind && t.owner == 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
|
|
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
|
|
let beds = if flies(e.kind) { self.counts[7] } else { self.counts[1] };
|
|
if e.bed.is_none() && beds > 0 && !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rally) && self.rng.f() < 0.15 {
|
|
let cur = e.tile();
|
|
let free = |i: usize| self.map.t[i].kind == bed_kind && self.map.t[i].bed == 0;
|
|
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) };
|
|
if let Some(p) = found {
|
|
let b = p.last().copied().unwrap_or(cur);
|
|
self.map.t[b].bed = me;
|
|
e.bed = Some(b);
|
|
let (bx, by) = xy(b);
|
|
self.fx(8, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff40d0ff);
|
|
}
|
|
}
|
|
// flyers flee when badly hurt and come back at 75%
|
|
if flies(e.kind) && e.hp < e.max_hp * 0.25 && !matches!(e.job, Job::Rest(_) | Job::Leave) {
|
|
self.unreserve(me);
|
|
if self.start_rest(e) {
|
|
return;
|
|
}
|
|
}
|
|
if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_)) {
|
|
let radius = if e.job == Job::Rally { 8.5 } else if flies(e.kind) { 9.0 } else { 7.0 };
|
|
if let Some(id) = self.nearest_enemy(e, radius) {
|
|
self.unreserve(me);
|
|
e.job = Job::Attack(id);
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
} else if !matches!(e.job, Job::AttackTower(_) | Job::Sleep(_) | Job::Eat(_)) {
|
|
// knight towers in reach are torn down
|
|
if let Some(id) = self.enemy_tower_near(e.x, e.y, radius - 1.0) {
|
|
self.unreserve(me);
|
|
e.job = Job::AttackTower(id);
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Job::AttackTower(id) = e.job {
|
|
if !self.attack_tower(e, id, dt) {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
return;
|
|
}
|
|
// arrival: report at the portal and set up the bed before anything else
|
|
if !e.settled {
|
|
self.settle(e, dt);
|
|
return;
|
|
}
|
|
// payday: the wage is fetched from a treasury
|
|
if e.pay_due > 0.0 {
|
|
e.pay_t -= dt;
|
|
if e.pay_t <= 0.0 && !matches!(e.job, Job::FetchWage(_) | Job::Leave | Job::Rest(_)) {
|
|
self.unreserve(me);
|
|
self.start_fetch_wage(e);
|
|
return;
|
|
}
|
|
}
|
|
if let Job::FetchWage(t) = e.job {
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
self.take_wage(e, Some(t));
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
if let Job::Rest(b) = e.job {
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
let (bx, by) = xy(b);
|
|
let at_aerie = (e.x - bx as f32 - 0.5).abs() < 1.5 && (e.y - by as f32 - 0.5).abs() < 1.5;
|
|
if !at_aerie {
|
|
// pushed away on the way: fly on, or give up if there is no way
|
|
match self.map.fly_path_to(e.tile(), b) {
|
|
Some(p) if !p.is_empty() => e.path = p,
|
|
_ => e.job = Job::None,
|
|
}
|
|
return;
|
|
}
|
|
// resting in the aerie heals quickly, back to work at 75%
|
|
e.hp = (e.hp + e.max_hp * dt / 12.0).min(e.max_hp);
|
|
e.energy = (e.energy + dt / 14.0).min(1.0);
|
|
if e.hp >= e.max_hp * 0.75 {
|
|
e.job = Job::None;
|
|
}
|
|
return;
|
|
}
|
|
if e.kind == EK::Chainbreaker {
|
|
// restless: without a fight for two minutes its mood sinks (skill_cd counts down as idle time)
|
|
if matches!(e.job, Job::Attack(_)) {
|
|
e.skill_cd = 0.0;
|
|
} else if e.skill_cd < -120.0 {
|
|
e.happy -= 0.005 * dt;
|
|
self.hint(20, "hint.chainbreaker_restless");
|
|
}
|
|
}
|
|
if let Job::Attack(id) = e.job {
|
|
if !self.attack_job(e, id, dt) {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
return;
|
|
}
|
|
if e.job == Job::Rally {
|
|
self.rally(e, dt);
|
|
return;
|
|
}
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
let cur = e.tile();
|
|
match e.job {
|
|
Job::Sleep(_) => {
|
|
e.energy += dt / 14.0;
|
|
e.hp = (e.hp + e.max_hp * dt / 25.0).min(e.max_hp);
|
|
if e.energy >= 1.0 {
|
|
e.energy = 1.0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Eat(t) => {
|
|
if cur != t {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
e.timer += dt;
|
|
if e.timer >= 2.0 {
|
|
let tl = &mut self.map.t[t];
|
|
if tl.food >= 1.0 {
|
|
tl.food -= 1.0;
|
|
e.hunger = 0.0;
|
|
e.hp = (e.hp + e.max_hp * 0.2).min(e.max_hp);
|
|
}
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Torment(id) => {
|
|
// working on a prisoner: makes it more likely to change sides (see captive_update)
|
|
let Some(k) = self.ents.iter().position(|o| o.id == id && o.alive && o.captive == 3) else {
|
|
e.job = Job::None;
|
|
return;
|
|
};
|
|
let px = self.ents[k].x;
|
|
e.face = sgn(px - e.x);
|
|
e.working = true;
|
|
e.xp += 2.0 * dt;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Train(t) => {
|
|
// training is billed on payday; it stops when the bill would exceed the gold
|
|
if cur != t || self.map.t[t].kind != Kind::Training || self.training_bill + 1.0 > self.gold {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
self.training_bill += TRAIN_COST * dt;
|
|
e.xp += 7.0 * dt;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::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(self.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(self.mana_max());
|
|
e.happy = (e.happy + 0.01 * dt).min(1.0);
|
|
e.xp += 1.0 * dt;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::HealCore => {
|
|
if !self.map.adj_core(cur) || self.core_hp >= CORE_HP {
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
let (cx, cy) = self.map.core;
|
|
e.face = sgn(cx as f32 + 1.5 - e.x);
|
|
self.core_hp = (self.core_hp + 8.0 * dt).min(CORE_HP);
|
|
if (e.anim * 1.5).fract() < dt * 1.5 {
|
|
self.fx(4, cx as f32 + 1.5, cy as f32 + 1.5, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
}
|
|
}
|
|
Job::Wander => {
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Leave => {
|
|
if self.map.t[cur].kind == Kind::Portal {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.8, 0xffcc44ff);
|
|
} else {
|
|
self.start_leave(e);
|
|
}
|
|
}
|
|
Job::None => self.creature_choose(e),
|
|
_ => e.job = Job::None,
|
|
}
|
|
}
|
|
|
|
fn start_leave(&mut self, e: &mut Ent) {
|
|
let cur = e.tile();
|
|
e.job = Job::Leave;
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.t[i].kind == Kind::Portal, true) {
|
|
e.path = p;
|
|
} else {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.8, 0xff505070);
|
|
}
|
|
}
|
|
|
|
fn wants_rally(&self, e: &Ent) -> bool {
|
|
self.flag.is_some() && e.hp > e.max_hp * 0.5 && e.energy > 0.15 && e.hunger < 1.8 && e.happy > 0.0
|
|
}
|
|
|
|
// Attack flag: march to the flag; if the gate is nearby, attack it.
|
|
fn rally(&mut self, e: &mut Ent, dt: f32) {
|
|
let Some(f) = self.flag else {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
return;
|
|
};
|
|
if e.hp < e.max_hp * 0.3 || e.energy < 0.1 || e.hunger > 2.2 {
|
|
// exhausted or badly hurt: retreat
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
return;
|
|
}
|
|
let (fx, fy) = xy(f);
|
|
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
|
|
// the flag next to a rival master's core: attack the core
|
|
let rival_near = self.rivals.iter().filter(|r| r.alive).find(|r| {
|
|
let (cx, cy) = r.centre();
|
|
(fx - cx).powi(2) + (fy - cy).powi(2) <= 8.0 * 8.0
|
|
});
|
|
if let Some(side) = rival_near.map(|r| r.side) {
|
|
e.target = Some(side);
|
|
if self.march_on(e, dt) {
|
|
return;
|
|
}
|
|
}
|
|
let (gx, gy) = self.gate_center();
|
|
let gate_near = self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0;
|
|
let s = st(e.kind);
|
|
if gate_near {
|
|
let dg = ((e.x - gx).powi(2) + (e.y - gy).powi(2)).sqrt();
|
|
if dg <= s.range + 1.6 && (s.range < 1.5 || self.los(e.x, e.y, gx, gy)) {
|
|
e.path.clear();
|
|
e.face = sgn(gx - e.x);
|
|
e.working = true;
|
|
if e.cd <= 0.0 {
|
|
self.hit_gate(e);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
let (target, tx, ty, near) = if gate_near {
|
|
(idx(gx as i32, gy as i32), gx, gy, s.range + 1.2)
|
|
} else {
|
|
(f, fx, fy, 1.5)
|
|
};
|
|
let d = ((e.x - tx).powi(2) + (e.y - ty).powi(2)).sqrt();
|
|
if d <= near && e.path.is_empty() {
|
|
return; // wait at the flag
|
|
}
|
|
if e.path.is_empty() || e.repath <= 0.0 {
|
|
e.repath = 1.5;
|
|
let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), target) } else { self.map.path_to(e.tile(), target) };
|
|
match p {
|
|
Some(p) => e.path = p,
|
|
None => {
|
|
self.hint(11, "hint.flag_unreachable");
|
|
e.job = Job::Wander;
|
|
e.timer = 3.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
}
|
|
|
|
pub fn set_flag(&mut self, x: i32, y: i32) {
|
|
if !inb(x, y) {
|
|
return;
|
|
}
|
|
let i = idx(x, y);
|
|
if self.flag == Some(i) {
|
|
self.flag = None;
|
|
self.msg("msg.flag_removed", vec![]);
|
|
self.sfx_ui(Sfx::Click);
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && e.creature() && e.job == Job::Rally {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if !self.map.t[i].walkable() {
|
|
self.hint(12, "hint.flag_ground");
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.flag = Some(i);
|
|
self.sfx(Sfx::FlagSet, x as f32 + 0.5, y as f32 + 0.5);
|
|
self.msg("msg.flag_set", vec![]);
|
|
let mut ids = Vec::new();
|
|
for e in self.ents.iter() {
|
|
if e.alive && !e.held && e.creature() && matches!(e.job, Job::None | Job::Wander | Job::Train(_) | Job::Study(_) | Job::Rally) {
|
|
ids.push(e.id);
|
|
}
|
|
}
|
|
for id in ids {
|
|
self.unreserve(id + 1);
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn creature_choose(&mut self, e: &mut Ent) {
|
|
let me = e.id + 1;
|
|
let cur = e.tile();
|
|
let name = st(e.kind).name;
|
|
if e.happy <= 0.0 {
|
|
self.msg("msg.creature_leaves", vec![format!("@{}", name)]);
|
|
self.start_leave(e);
|
|
return;
|
|
}
|
|
if e.energy < 0.25 {
|
|
// flying creatures sleep in the aerie
|
|
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
|
|
if let Some(b) = e.bed {
|
|
let t = &self.map.t[b];
|
|
if t.kind != bed_kind || t.bed != me {
|
|
e.bed = None;
|
|
}
|
|
}
|
|
if e.bed.is_none() {
|
|
let free = |i: usize| {
|
|
let t = &self.map.t[i];
|
|
t.kind == bed_kind && t.bed == 0
|
|
};
|
|
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) };
|
|
if let Some(p) = found {
|
|
let b = p.last().copied().unwrap_or(cur);
|
|
self.map.t[b].bed = me;
|
|
e.bed = Some(b);
|
|
}
|
|
}
|
|
if let Some(b) = e.bed {
|
|
let way = if flies(e.kind) { self.map.fly_path_to(cur, b) } else { self.map.path_to(cur, b) };
|
|
if let Some(p) = way {
|
|
e.path = p;
|
|
e.job = Job::Sleep(b);
|
|
return;
|
|
}
|
|
}
|
|
e.happy -= 0.1;
|
|
self.hint(4, "hint.no_beds");
|
|
e.job = Job::Sleep(cur);
|
|
return;
|
|
}
|
|
if e.hunger > 1.0 && !(self.flag.is_some() && e.hunger < 1.5) {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Farm, me, true) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Eat(t);
|
|
e.timer = 0.0;
|
|
return;
|
|
}
|
|
if e.hunger > 1.8 {
|
|
self.hint(5, "hint.hungry");
|
|
}
|
|
}
|
|
if self.wants_rally(e) {
|
|
e.job = Job::Rally;
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
return;
|
|
}
|
|
if e.hp < e.max_hp * 0.5 && e.energy >= 0.25 && self.counts[1] > 0 {
|
|
// hurt: rest in the lair
|
|
e.energy = e.energy.min(0.2);
|
|
self.creature_choose(e);
|
|
return;
|
|
}
|
|
if e.kind == EK::Priestess && self.core_hp < CORE_HP * 0.9 && self.nearest_enemy(e, 8.0).is_none() {
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.adj_core(i), true) {
|
|
e.path = p;
|
|
e.job = Job::HealCore;
|
|
return;
|
|
}
|
|
}
|
|
if self.counts[6] > 0 {
|
|
let r2 = self.rng.f();
|
|
if r2 < 0.3 || (e.kind == EK::Priestess && r2 < 0.7) {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Temple, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Pray(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// the jailer's favourite work: prisoners nobody is working on yet
|
|
if e.kind == EK::Jailer && self.rng.f() < 0.85 {
|
|
let busy: Vec<u32> = self.ents.iter().filter_map(|o| if let Job::Torment(p) = o.job { Some(p) } else { None }).collect();
|
|
let prisoner = self.ents.iter().filter(|o| o.alive && o.captive == 3 && !busy.contains(&o.id)).map(|o| (o.id, o.tile())).next();
|
|
if let Some((id, pt)) = prisoner {
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| i != pt && adj4(i, pt) || i == pt, true) {
|
|
e.path = p;
|
|
e.job = Job::Torment(id);
|
|
e.timer = 12.0 + self.rng.f() * 8.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
let r = self.rng.f();
|
|
if (e.kind == EK::Mage && r < 0.8) || r < 0.2 {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Library, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Study(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
if r < 0.8 && self.gold >= 5.0 {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Training, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Train(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
if flies(e.kind) {
|
|
self.patrol_flight(e);
|
|
} else {
|
|
self.wander(e, 4);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- prisoners
|
|
|
|
fn captive_update(&mut self, e: &mut Ent, dt: f32) {
|
|
e.moving = false;
|
|
match e.captive {
|
|
1 => {
|
|
// unconscious: bleeds out if nobody carries the hero away
|
|
e.torture_t += dt;
|
|
if e.torture_t > 60.0 {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
|
}
|
|
}
|
|
2 => {
|
|
// carried: follows the imp
|
|
match self.find(e.carrier) {
|
|
Some(k) => {
|
|
e.x = self.ents[k].x;
|
|
e.y = self.ents[k].y;
|
|
}
|
|
None => {
|
|
e.captive = 1;
|
|
e.carrier = 0;
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
let t = e.tile();
|
|
if self.map.t[t].kind != Kind::Torture {
|
|
// the chamber is gone: the prisoner breaks free
|
|
e.captive = 0;
|
|
e.hp = e.max_hp * 0.3;
|
|
self.release_all(e.id);
|
|
return;
|
|
}
|
|
e.working = true;
|
|
e.hp -= e.max_hp / 100.0 * dt;
|
|
e.torture_t += dt;
|
|
if e.torture_t >= 5.0 {
|
|
e.torture_t -= 5.0;
|
|
// ~40% defect over the whole torture (paladins ~25%); a jailer working on
|
|
// the prisoner makes it 2.5 times as likely
|
|
let tormented = self.ents.iter().any(|o| o.alive && o.kind == EK::Jailer && o.job == Job::Torment(e.id) && o.path.is_empty() && (o.x - e.x).abs() < 1.6 && (o.y - e.y).abs() < 1.6);
|
|
let p = if e.kind == EK::Paladin { 0.0143 } else { 0.0252 } * if tormented { 2.5 } else { 1.0 };
|
|
if tormented {
|
|
self.fx(2, e.x, e.y, 0.0, 0.0, 0.3, 0xff3040ff);
|
|
}
|
|
if self.rng.f() < p {
|
|
self.defect(e);
|
|
return;
|
|
}
|
|
}
|
|
if e.hp <= 0.0 {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
|
self.sfx(Sfx::HeroDeath, e.x, e.y);
|
|
self.msg("msg.prisoner_died", vec![format!("@{}", st(e.kind).name)]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
// a wave sent against a rival master
|
|
if e.target.map_or(false, |t| t >= 2) && self.march_on(e, dt) {
|
|
return;
|
|
}
|
|
let cur = e.tile();
|
|
if e.path.is_empty() && self.map.adj_core(cur) {
|
|
e.working = true;
|
|
let (cx, cy) = self.map.core;
|
|
e.face = sgn(cx as f32 + 1.5 - e.x);
|
|
if e.cd <= 0.0 {
|
|
e.cd = st(e.kind).interval;
|
|
self.core_hp -= e.atk();
|
|
let fx = (e.x + cx as f32 + 1.5) * 0.5;
|
|
let fy = (e.y + cy as f32 + 1.5) * 0.5;
|
|
self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff);
|
|
self.sfx(Sfx::CoreHit, fx, fy);
|
|
self.hint(6, "hint.core_attacked");
|
|
}
|
|
return;
|
|
}
|
|
if let Some((wx, wy)) = e.waypoint {
|
|
if (e.x - wx as f32 - 0.5).abs() < 2.5 && (e.y - wy as f32 - 0.5).abs() < 2.5 {
|
|
e.waypoint = None; // broke into the realm: now for the core
|
|
e.path.clear();
|
|
}
|
|
}
|
|
if e.path.is_empty() && e.repath <= 0.0 {
|
|
e.repath = 1.0;
|
|
let path = match e.waypoint {
|
|
Some((wx, wy)) => {
|
|
let goal = idx(wx, wy);
|
|
let p = self.map.search(cur, self.map.hero_cost(), |i| i == goal, true);
|
|
if p.is_none() {
|
|
e.waypoint = None;
|
|
}
|
|
p
|
|
}
|
|
None => self.map.hero_path_to_core(cur),
|
|
};
|
|
if let Some(p) = path {
|
|
e.path = p;
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
}
|
|
|
|
// Wave AI: choose where the next wave breaks into the realm. Candidates are border tiles of
|
|
// the player's territory on the side facing the hero gate; towers, creatures and reinforced
|
|
// walls nearby make a place less attractive, as does a long way and recent use.
|
|
pub fn plan_entries(&mut self) {
|
|
self.planned_entries.clear();
|
|
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
|
|
let (gx, gy) = (self.map.gate.0 + 1, self.map.gate.1 + 1);
|
|
let dir = norm((gx - cx) as f32, (gy - cy) as f32);
|
|
let mut cands = Vec::new();
|
|
for i in 0..self.map.t.len() {
|
|
let t = &self.map.t[i];
|
|
if t.owner != 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;
|
|
if self.towers[k].kind == TowerKind::Rune && !self.towers[k].rune_final {
|
|
// the base tower comes back once: in the next mission (free play: at once)
|
|
if self.mission.is_some() {
|
|
self.rune_next += 1;
|
|
self.msg("msg.rune_next", vec![]);
|
|
} else {
|
|
self.rune_final += 1;
|
|
self.msg("msg.rune_back", vec![]);
|
|
}
|
|
}
|
|
self.towers.remove(k);
|
|
self.map.t[i].kind = Kind::Floor;
|
|
continue;
|
|
}
|
|
let t = &mut self.map.t[i];
|
|
if t.kind == Kind::Steel {
|
|
t.kind = Kind::Earth;
|
|
t.reinforced = true;
|
|
t.hp = 12.0;
|
|
refund += STEEL_COST[1] as f32 * 0.5;
|
|
continue;
|
|
}
|
|
if t.owner == 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;
|
|
}
|
|
// rune forge towers: one build per opened chest (fresh plans first)
|
|
let mut rune_final = false;
|
|
if kind == TowerKind::Rune {
|
|
if self.rune_free > 0 {
|
|
self.rune_free -= 1;
|
|
} else if self.rune_final > 0 {
|
|
self.rune_final -= 1;
|
|
rune_final = true;
|
|
} else {
|
|
self.hint(24, "hint.rune_none");
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
}
|
|
self.gold -= cost;
|
|
self.map.t[i].kind = Kind::Tower;
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false, rune_final };
|
|
let (px, py) = tower.pos();
|
|
self.towers.push(tower);
|
|
// units standing on the tile step aside
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && e.tile() == i {
|
|
for &(dx, dy) in &DIR8 {
|
|
if self.map.walk(x + dx, y + dy) {
|
|
e.x = (x + dx) as f32 + 0.5;
|
|
e.y = (y + dy) as f32 + 0.5;
|
|
e.path.clear();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.fx(8, px, py, 0.0, 0.0, 0.5, 0xff40d0ff);
|
|
self.sfx(Sfx::Build, px, py);
|
|
}
|
|
|
|
fn destroy_tower(&mut self, k: usize) {
|
|
let t = self.towers.remove(k);
|
|
self.map.t[t.tile].kind = Kind::Floor;
|
|
let (px, py) = t.pos();
|
|
self.fx(3, px, py, 0.0, 0.0, 1.0, 0xff505070);
|
|
self.sfx(Sfx::DigDone, px, py);
|
|
if t.hero {
|
|
self.towers_razed += 1;
|
|
self.msg("msg.enemy_tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
|
|
} else {
|
|
self.msg("msg.tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
|
|
}
|
|
}
|
|
|
|
// a knight tower near the unit (for the player's creatures)
|
|
fn enemy_tower_near(&self, x: f32, y: f32, radius: f32) -> Option<u32> {
|
|
self.towers
|
|
.iter()
|
|
.filter(|t| t.hero)
|
|
.map(|t| (t.id, (t.pos().0 - x).powi(2) + (t.pos().1 - y).powi(2)))
|
|
.filter(|&(_, d)| d < radius * radius)
|
|
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
|
.map(|(id, _)| id)
|
|
}
|
|
|
|
// damage dealt to a unit by a tower (no attacker unit involved)
|
|
fn tower_damage(&mut self, j: usize, dmg: f32) {
|
|
let e = &mut self.ents[j];
|
|
e.hp -= dmg;
|
|
e.flash = 0.15;
|
|
if e.hp <= 0.0 && e.alive {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
|
|
fn tower_target(&self, t: &Tower) -> Option<usize> {
|
|
let (px, py) = t.pos();
|
|
let r = t.range();
|
|
let minr = tower_stats(t.kind).min_range;
|
|
let mut best = None;
|
|
let mut bd = r * r;
|
|
for (j, e) in self.ents.iter().enumerate() {
|
|
// knight towers shoot the player's units, the player's towers shoot heroes
|
|
// knight towers shoot everybody but the humans, the player's towers everybody else
|
|
if !e.alive || e.held || !self.tower_enemy(t, e) {
|
|
continue;
|
|
}
|
|
let d = (e.x - px).powi(2) + (e.y - py).powi(2);
|
|
if d < bd && d >= minr * minr && self.los(px, py, e.x, e.y) {
|
|
bd = d;
|
|
best = Some(j);
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
// an enemy tower or the gate of the order within reach: (x, y, height, tower id or None = gate)
|
|
fn tower_structure_target(&self, t: &Tower) -> Option<(f32, f32, f32, Option<u32>)> {
|
|
let (px, py) = t.pos();
|
|
let r = t.range();
|
|
let minr = tower_stats(t.kind).min_range;
|
|
let in_reach = |x: f32, y: f32| {
|
|
let d = (x - px).powi(2) + (y - py).powi(2);
|
|
d <= r * r && d >= minr * minr
|
|
};
|
|
let mut best: Option<(f32, (f32, f32, f32, Option<u32>))> = None;
|
|
for o in self.towers.iter().filter(|o| o.hero != t.hero) {
|
|
let (ox, oy) = o.pos();
|
|
if in_reach(ox, oy) && self.los_to_tile(px, py, o.tile) {
|
|
let d = (ox - px).powi(2) + (oy - py).powi(2);
|
|
if best.map_or(true, |b| d < b.0) {
|
|
best = Some((d, (ox, oy, o.top() * 0.6, Some(o.id))));
|
|
}
|
|
}
|
|
}
|
|
if best.is_none() && !t.hero && self.has_gate() && self.gate_hp > 0.0 {
|
|
// the nearest tile of the gate counts
|
|
let (gx, gy) = self.map.gate;
|
|
let (nx, ny) = ((px - 0.5).clamp(gx as f32, gx as f32 + 2.0) + 0.5, (py - 0.5).clamp(gy as f32, gy as f32 + 2.0) + 0.5);
|
|
if in_reach(nx, ny) && self.los_to_tile(px, py, idx(nx as i32, ny as i32)) {
|
|
best = Some((0.0, (nx, ny, 1.0, None)));
|
|
}
|
|
}
|
|
best.map(|b| b.1)
|
|
}
|
|
|
|
// line of sight from a tower to a target tile: walls and other towers block,
|
|
// the tower's own tile and the target tile do not
|
|
fn los_to_tile(&self, x0: f32, y0: f32, target: usize) -> bool {
|
|
let start = idx(x0 as i32, y0 as i32);
|
|
let (tx, ty) = xy(target);
|
|
let (x1, y1) = (tx as f32 + 0.5, ty as f32 + 0.5);
|
|
let n = (((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt() * 3.0).ceil().max(1.0) as i32;
|
|
for k in 1..n {
|
|
let f = k as f32 / n as f32;
|
|
let i = idx((x0 + (x1 - x0) * f) as i32, (y0 + (y1 - y0) * f) as i32);
|
|
if i == start || i == target {
|
|
continue;
|
|
}
|
|
let tl = &self.map.t[i];
|
|
if tl.solid() || tl.kind == Kind::Tower {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn tower_fire_at_structure(&mut self, k: usize, (ex, ey, eh, target): (f32, f32, f32, Option<u32>)) {
|
|
let t = self.towers[k].clone();
|
|
let s = tower_stats(t.kind);
|
|
if s.mana > 0.0 && !t.hero && self.mana < s.mana {
|
|
self.towers[k].cd = 1.0;
|
|
return;
|
|
}
|
|
if !t.hero {
|
|
self.mana -= s.mana;
|
|
}
|
|
let (px, py) = t.pos();
|
|
let aim = (ey - py).atan2(ex - px);
|
|
let tw = &mut self.towers[k];
|
|
tw.cd = s.interval;
|
|
tw.flash = 0.15;
|
|
tw.aim = aim;
|
|
let (mx, my, mh) = match t.kind {
|
|
TowerKind::Arrow => (px, py, t.top() + 0.25),
|
|
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
|
|
TowerKind::Magic => (px, py, t.top() + 0.45),
|
|
TowerKind::Rune => (px, py, t.top() + 0.55),
|
|
};
|
|
let col = match t.kind {
|
|
TowerKind::Arrow => 0xffd0e0f0,
|
|
TowerKind::Cannon => 0xff303030,
|
|
TowerKind::Magic => 0xffff60d0,
|
|
TowerKind::Rune => 0xff40c0ff,
|
|
};
|
|
self.shot(mx, my, mh, ex, ey, eh, 0.3, col);
|
|
self.fx_at(2, ex, ey, eh, 0.3, 0xffffffff);
|
|
self.sfx(match t.kind {
|
|
TowerKind::Arrow => Sfx::Shoot,
|
|
TowerKind::Cannon => Sfx::Cannon,
|
|
TowerKind::Magic | TowerKind::Rune => Sfx::Magic,
|
|
}, px, py);
|
|
let dmg = t.dmg();
|
|
match target {
|
|
Some(id) => {
|
|
if let Some(o) = self.towers.iter().position(|o| o.id == id) {
|
|
self.towers[o].hp -= dmg;
|
|
if self.towers[o].hp <= 0.0 {
|
|
self.destroy_tower(o);
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
self.gate_hp -= dmg;
|
|
self.gate_hit_t = self.time;
|
|
self.sfx(Sfx::GateHit, ex, ey);
|
|
if self.mission.as_ref().map_or(true, |m| m.gate_defenders) {
|
|
self.check_gate_stage();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn update_towers(&mut self, dt: f32) {
|
|
for k in 0..self.towers.len() {
|
|
if k >= self.towers.len() {
|
|
break;
|
|
}
|
|
let t = &mut self.towers[k];
|
|
t.cd -= dt;
|
|
t.flash = (t.flash - dt).max(0.0);
|
|
if t.cd > 0.0 {
|
|
continue;
|
|
}
|
|
let t = self.towers[k].clone();
|
|
let Some(j) = self.tower_target(&t) else {
|
|
// no unit in reach: enemy towers and the enemy gate are shot at
|
|
if let Some(target) = self.tower_structure_target(&t) {
|
|
self.tower_fire_at_structure(k, target);
|
|
}
|
|
continue;
|
|
};
|
|
let s = tower_stats(t.kind);
|
|
if s.mana > 0.0 && !t.hero && self.mana < s.mana {
|
|
self.hint(17, "hint.tower_mana");
|
|
self.towers[k].cd = 1.0;
|
|
continue;
|
|
}
|
|
if !t.hero {
|
|
self.mana -= s.mana;
|
|
}
|
|
self.towers[k].cd = s.interval;
|
|
self.towers[k].flash = 0.15;
|
|
let (px, py) = t.pos();
|
|
let (ex, ey) = (self.ents[j].x, self.ents[j].y);
|
|
let aim = (ey - py).atan2(ex - px);
|
|
self.towers[k].aim = aim;
|
|
// where the shot leaves the tower (see the tower models in render3d.rs)
|
|
let (mx, my, mh) = match t.kind {
|
|
TowerKind::Arrow => (px, py, t.top() + 0.25),
|
|
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
|
|
TowerKind::Magic => (px, py, t.top() + 0.45),
|
|
TowerKind::Rune => (px, py, t.top() + 0.55),
|
|
};
|
|
let dmg = t.dmg();
|
|
match t.kind {
|
|
TowerKind::Arrow => {
|
|
// the arrow pierces up to two more heroes behind the target
|
|
let dir = norm(ex - px, ey - py);
|
|
let 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);
|
|
}
|
|
}
|
|
}
|
|
TowerKind::Rune => {
|
|
// a rune bolt that jumps on to up to three more enemies nearby
|
|
self.shot(mx, my, mh, ex, ey, 0.45, 0.25, 0xff40c0ff);
|
|
self.fx(2, ex, ey, 0.0, 0.0, 0.35, 0xffffc040);
|
|
self.sfx(Sfx::Magic, px, py);
|
|
let mut hit = vec![j];
|
|
let (mut lx, mut ly) = (ex, ey);
|
|
self.tower_damage(j, dmg);
|
|
for n in 0..3 {
|
|
let next = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(o, e)| !hit.contains(o) && e.alive && !e.held && self.tower_enemy(&t, e) && (e.x - lx).powi(2) + (e.y - ly).powi(2) < 3.5 * 3.5)
|
|
.min_by(|a, b| {
|
|
let da = (a.1.x - lx).powi(2) + (a.1.y - ly).powi(2);
|
|
let db = (b.1.x - lx).powi(2) + (b.1.y - ly).powi(2);
|
|
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
|
})
|
|
.map(|(o, _)| o);
|
|
let Some(o) = next else { break };
|
|
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
|
|
self.fx(0, lx, ly, ox, oy, 0.25, 0xff40c0ff);
|
|
hit.push(o);
|
|
if self.ents[o].alive {
|
|
self.tower_damage(o, dmg * (0.75 - 0.1 * n as f32));
|
|
}
|
|
lx = ox;
|
|
ly = oy;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a unit the tower shoots at
|
|
fn tower_enemy(&self, t: &Tower, e: &Ent) -> bool {
|
|
e.captive == 0 && !e.chained && if t.hero { e.side() != 1 } else { e.side() != 0 }
|
|
}
|
|
|
|
// a hero attacks a tower; false = tower gone or unreachable
|
|
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.rival() || e.dwarf() {
|
|
return false; // the rivals' creatures and the dwarves cannot be picked up
|
|
}
|
|
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].dwarf() {
|
|
return 0;
|
|
}
|
|
if !self.ents[j].hero && !self.ents[j].rival() {
|
|
let e = &mut self.ents[j];
|
|
e.boost = 15.0;
|
|
e.hp = (e.hp - e.max_hp * 0.05).max(1.0);
|
|
e.happy -= 0.04;
|
|
e.flash = 0.15;
|
|
if matches!(e.job, Job::Sleep(_)) {
|
|
e.job = Job::None; // woken up
|
|
}
|
|
self.fx(2, x, y, 0.0, 0.0, 0.3, 0xffffffff);
|
|
self.sfx(Sfx::Slap, x, y);
|
|
return 1;
|
|
}
|
|
if self.ents[j].captive != 0 {
|
|
return 0;
|
|
}
|
|
if !self.on_own_ground(x, y) {
|
|
self.hint(19, "hint.punch_where");
|
|
self.sfx_ui(Sfx::Error);
|
|
return 0;
|
|
}
|
|
if self.time < self.punch_ready {
|
|
return 0;
|
|
}
|
|
self.punch_ready = self.time + 0.5;
|
|
self.ents[j].stun = 1.0;
|
|
// the punch throws the hero back (into lava = burnt)
|
|
let back = -self.ents[j].face;
|
|
self.knock(j, back * 0.8, 0.0);
|
|
if !self.ents[j].alive {
|
|
self.fx(2, x, y, 0.0, 0.0, 0.35, 0xff40e0ff);
|
|
self.sfx(Sfx::Punch, x, y);
|
|
return 2;
|
|
}
|
|
self.fx(2, x, y, 0.0, 0.0, 0.35, 0xff40e0ff);
|
|
self.fx(7, x, y, 0.0, 0.0, 0.4, 0xff40e0ff);
|
|
self.sfx(Sfx::Punch, x, y);
|
|
self.tower_damage(j, 15.0);
|
|
2
|
|
}
|
|
|
|
pub fn lightning(&mut self, wx: f32, wy: f32) {
|
|
const COST: f32 = 50.0;
|
|
if self.mana < COST {
|
|
self.msg("msg.no_mana_bolt", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= COST;
|
|
self.sfx(Sfx::Lightning, wx, wy);
|
|
self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa);
|
|
let mut kills = Vec::new();
|
|
for (j, e) in self.ents.iter_mut().enumerate() {
|
|
if e.alive && !e.held && e.active_hero() && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 2.6 {
|
|
e.hp -= 55.0;
|
|
e.flash = 0.2;
|
|
if e.hp <= 0.0 {
|
|
kills.push(j);
|
|
}
|
|
}
|
|
}
|
|
for j in kills {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
|
|
pub fn heal(&mut self, wx: f32, wy: f32) {
|
|
const COST: f32 = 60.0;
|
|
if self.mana < COST {
|
|
self.msg("msg.no_mana_heal", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= COST;
|
|
self.sfx(Sfx::Heal, wx, wy);
|
|
let mut fxs = Vec::new();
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && !e.held && !e.hero && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 6.5 {
|
|
e.hp = (e.hp + e.max_hp * 0.5).min(e.max_hp);
|
|
fxs.push((e.x, e.y));
|
|
}
|
|
}
|
|
self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xff60ff80);
|
|
for (x, y) in fxs {
|
|
self.fx(4, x, y, 0.0, 0.0, 0.9, 0xff60ff80);
|
|
}
|
|
}
|
|
|
|
pub fn summon(&mut self, wx: f32, wy: f32) {
|
|
let cost = self.summon_cost();
|
|
let (x, y) = (wx as i32, wy as i32);
|
|
if !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != 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,
|
|
Rune, // dwarven rune forge tower: from a chest of a dwarf cave, built for free
|
|
}
|
|
|
|
pub struct TowerStats {
|
|
pub cost: [i32; 3], // build, upgrade to level 2, upgrade to level 3
|
|
pub hp: f32,
|
|
pub dmg: f32,
|
|
pub range: f32,
|
|
pub min_range: f32,
|
|
pub interval: f32,
|
|
pub mana: f32, // per shot
|
|
}
|
|
|
|
pub fn tower_stats(k: TowerKind) -> TowerStats {
|
|
match k {
|
|
TowerKind::Arrow => TowerStats { cost: [250, 200, 300], hp: 300.0, dmg: 10.0, range: 6.0, min_range: 0.0, interval: 0.8, mana: 0.0 },
|
|
TowerKind::Cannon => TowerStats { cost: [450, 350, 500], hp: 450.0, dmg: 32.0, range: 8.5, min_range: 2.0, interval: 2.6, mana: 0.0 },
|
|
TowerKind::Magic => TowerStats { cost: [350, 300, 450], hp: 300.0, dmg: 24.0, range: 7.0, min_range: 0.0, interval: 1.5, mana: 8.0 },
|
|
TowerKind::Rune => TowerStats { cost: [0, 800, 1200], hp: 1400.0, dmg: 55.0, range: 10.0, min_range: 0.0, interval: 1.3, mana: 0.0 },
|
|
}
|
|
}
|
|
|
|
pub fn tower_name(k: TowerKind) -> &'static str {
|
|
match k {
|
|
TowerKind::Arrow => "tower.arrow",
|
|
TowerKind::Cannon => "tower.cannon",
|
|
TowerKind::Magic => "tower.magic",
|
|
TowerKind::Rune => "tower.rune",
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct Tower {
|
|
pub id: u32,
|
|
pub kind: TowerKind,
|
|
pub tile: usize,
|
|
pub level: u32,
|
|
pub hp: f32,
|
|
pub cd: f32,
|
|
pub aim: f32, // direction of the last shot (radians, for drawing)
|
|
pub flash: f32,
|
|
#[serde(default)]
|
|
pub hero: bool, // tower of a knight fortress: shoots the player's units
|
|
#[serde(default)]
|
|
pub rune_final: bool, // rune forge tower built from a refund: selling it gives nothing back
|
|
}
|
|
|
|
impl Tower {
|
|
pub fn max_hp(&self) -> f32 {
|
|
tower_stats(self.kind).hp * [1.0, 1.4, 1.9][(self.level as usize - 1).min(2)]
|
|
}
|
|
pub fn dmg(&self) -> f32 {
|
|
tower_stats(self.kind).dmg * [1.0, 1.5, 2.2][(self.level as usize - 1).min(2)]
|
|
}
|
|
pub fn range(&self) -> f32 {
|
|
tower_stats(self.kind).range + 0.75 * (self.level - 1) as f32
|
|
}
|
|
pub fn pos(&self) -> (f32, f32) {
|
|
let (x, y) = xy(self.tile);
|
|
(x as f32 + 0.5, y as f32 + 0.5)
|
|
}
|
|
|
|
// height of the tower body (3D units); grows with the level
|
|
pub fn top(&self) -> f32 {
|
|
match self.kind {
|
|
TowerKind::Arrow => 1.0 + 0.3 * (self.level - 1) as f32,
|
|
TowerKind::Cannon => 0.6 + 0.15 * (self.level - 1) as f32,
|
|
TowerKind::Magic => 1.2 + 0.3 * (self.level - 1) as f32,
|
|
TowerKind::Rune => 1.4 + 0.25 * (self.level - 1) as f32,
|
|
}
|
|
}
|
|
// gold spent on this tower so far
|
|
pub fn invested(&self) -> i32 {
|
|
tower_stats(self.kind).cost[..self.level as usize].iter().sum()
|
|
}
|
|
}
|
|
|
|
// Language key describing what a unit is doing
|
|
pub fn job_name(e: &Ent) -> &'static str {
|
|
match e.job {
|
|
Job::None if !e.patrol.is_empty() => "job.patrol",
|
|
Job::None if e.guard => "job.guard",
|
|
Job::None => "job.none",
|
|
Job::Dig(_) => "job.dig",
|
|
Job::Claim(_) => "job.claim",
|
|
Job::Deliver => "job.deliver",
|
|
Job::Wander => "job.wander",
|
|
Job::Sleep(_) => {
|
|
if e.moving {
|
|
"job.sleep_go"
|
|
} else {
|
|
"job.sleep"
|
|
}
|
|
}
|
|
Job::Eat(_) => "job.eat",
|
|
Job::Train(_) => "job.train",
|
|
Job::Study(_) => "job.study",
|
|
Job::Attack(_) => "job.fight",
|
|
Job::TearBridge(_) => "job.tear_bridge",
|
|
Job::Rest(_) => "job.rest",
|
|
Job::Torment(_) => "job.torment",
|
|
Job::Register => "job.register",
|
|
Job::MakeBed(_) => "job.make_bed",
|
|
Job::FetchWage(_) => "job.fetch_wage",
|
|
Job::Help(_) => "job.help",
|
|
Job::AttackCore => {
|
|
if e.working {
|
|
"job.core_attack"
|
|
} else {
|
|
"job.core_march"
|
|
}
|
|
}
|
|
Job::Leave => "job.leave",
|
|
Job::Reinforce(_) => "job.reinforce",
|
|
Job::Repair(_) => "job.repair",
|
|
Job::Carry(_) => "job.carry",
|
|
Job::Pray(_) => "job.pray",
|
|
Job::HealCore => "job.heal_core",
|
|
Job::AttackTower(_) => "job.attack_tower",
|
|
Job::Rally => {
|
|
if e.working {
|
|
"job.gate_attack"
|
|
} else {
|
|
"job.rally"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Simulates games with a simple bot player and checks that nothing crashes.
|
|
#[test]
|
|
fn simulate_long_game() {
|
|
for seed in [1u32, 7, 12345] {
|
|
let mut g = Game::new(seed, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let (px, py) = g.map.portals[0];
|
|
// dig a way to the portal (through earth/gold, around rock)
|
|
let start = idx(cx - 1, cy + 1);
|
|
let goal = idx(px + 1, py + 1);
|
|
let path = g
|
|
.map
|
|
.search(start, |i| if g.map.t[i].kind == Kind::Rock { None } else { Some(1) }, |i| i == goal, false)
|
|
.expect("Portal erreichbar");
|
|
for i in path {
|
|
if g.map.t[i].diggable() {
|
|
g.map.t[i].marked = true;
|
|
}
|
|
}
|
|
g.mark_rect(cx - 6, cy - 6, cx + 9, cy + 9, true);
|
|
let (ox, oy) = (cx - 3, cy - 3);
|
|
g.build_rect(1, ox, oy, ox + 2, oy + 1); // lair 3x2
|
|
g.build_rect(2, ox + 5, oy, ox + 7, oy + 1); // farm 3x2
|
|
let dt = 0.05;
|
|
let mut built = false;
|
|
let mut portal_logged = false;
|
|
for step in 0..(40 * 60 * 20) {
|
|
g.update(dt);
|
|
g.events.clear();
|
|
g.msgs.clear();
|
|
if step % 200 == 0 && !built && g.gold > 1900.0 {
|
|
g.build_rect(0, ox, oy + 6, ox + 1, oy + 8); // treasury
|
|
g.build_rect(3, ox + 6, oy + 6, ox + 8, oy + 8); // training hall
|
|
g.build_rect(1, ox, oy + 2, ox + 2, oy + 2); // enlarge the lair
|
|
built = true;
|
|
}
|
|
// the bot uses lightning against heroes at the core
|
|
if step % 20 == 0 && g.mana >= 50.0 {
|
|
let target = g.ents.iter().find(|e| e.alive && e.hero && (e.x - cx as f32).abs() < 4.0 && (e.y - cy as f32).abs() < 4.0).map(|e| (e.x, e.y));
|
|
if let Some((x, y)) = target {
|
|
g.lightning(x, y);
|
|
}
|
|
}
|
|
if step == 20 * 60 * 20 {
|
|
let (gx, gy) = g.map.gate;
|
|
g.set_flag(gx + 1, gy + 1);
|
|
}
|
|
if step % 2400 == 0 {
|
|
println!(
|
|
"seed {} t={:>5.0}s gold={:>5.0} creatures={} workers={} wave={} core={:.0} gate={:.0} heroes={} army={:.0}",
|
|
seed,
|
|
g.time,
|
|
g.gold,
|
|
g.creature_count(),
|
|
g.worker_count(),
|
|
g.wave,
|
|
g.core_hp,
|
|
g.gate_hp,
|
|
g.ents.iter().filter(|e| e.alive && e.hero).count(),
|
|
g.army_value()
|
|
);
|
|
}
|
|
if g.portals_owned() > 0 && step % 20 == 0 && !portal_logged {
|
|
portal_logged = true;
|
|
println!("seed {} portal claimed after {:.0}s", seed, g.time);
|
|
}
|
|
if g.state != 0 {
|
|
println!("seed {} game over after {:.0}s, state={}", seed, g.time, g.state);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a vertical river right next to the start area (east of it)
|
|
fn river_game(lava: bool) -> (Game, i32) {
|
|
let mut g = Game::new(7, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let rx = cx + 6; // the start area reaches to cx + 5
|
|
for y in cy - 6..=cy + 8 {
|
|
for x in rx..rx + 3 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = if lava { Kind::Lava } else { Kind::Water };
|
|
t.owner = Owner::None;
|
|
}
|
|
}
|
|
(g, rx)
|
|
}
|
|
|
|
#[test]
|
|
fn bridges_grow_from_own_ground() {
|
|
let (mut g, rx) = river_game(false);
|
|
let (_, cy) = g.map.core;
|
|
g.gold = 1000.0;
|
|
// not touching own ground: nothing happens
|
|
g.build_bridges(rx + 1, cy, rx + 2, cy);
|
|
assert_eq!(g.map.t[idx(rx + 1, cy)].kind, Kind::Water);
|
|
// from the bank across the river: three tiles at 30 gold
|
|
g.build_bridges(rx - 1, cy, rx + 2, cy);
|
|
for x in rx..rx + 3 {
|
|
let t = g.map.t[idx(x, cy)];
|
|
assert_eq!(t.kind, Kind::Bridge);
|
|
assert_eq!(t.owner, Owner::Player);
|
|
assert!(t.walkable());
|
|
}
|
|
assert_eq!(g.gold, 1000.0 - 3.0 * BRIDGE_COST[0] as f32);
|
|
// the far bank can be reached on foot now
|
|
let (cx, _) = g.map.core;
|
|
assert!(g.map.path_to(idx(cx + 4, cy), idx(rx + 3, cy)).is_some() || !g.map.walk(rx + 3, cy));
|
|
// selling a bridge tile: units on it swim back to a bank, half the cost is refunded
|
|
let id = g.spawn(EK::Kobold, false, rx as f32 + 1.5, cy as f32 + 0.5, 1);
|
|
g.sell_rect(rx + 1, cy, rx + 1, cy);
|
|
assert_eq!(g.map.t[idx(rx + 1, cy)].kind, Kind::Water);
|
|
let e = g.ents.iter().find(|e| e.id == id).unwrap();
|
|
assert!(e.alive && g.map.walk(e.x as i32, e.y as i32));
|
|
}
|
|
|
|
#[test]
|
|
fn lava_burns_and_needs_stone_bridges() {
|
|
let (mut g, rx) = river_game(true);
|
|
let (_, cy) = g.map.core;
|
|
g.gold = 1000.0;
|
|
g.build_bridges(rx - 1, cy, rx, cy);
|
|
assert_eq!(g.map.t[idx(rx, cy)].kind, Kind::LavaBridge);
|
|
assert_eq!(g.gold, 1000.0 - BRIDGE_COST[1] as f32);
|
|
// a hero pushed off the bridge into the lava burns, the bounty is paid
|
|
let id = g.spawn(EK::Knight, true, rx as f32 + 0.5, cy as f32 + 0.5, 1);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
let gold = g.gold;
|
|
g.knock(j, 0.0, 1.0);
|
|
assert!(!g.ents[j].alive);
|
|
assert!(g.gold > gold);
|
|
// removing the stone bridge under a unit burns it as well
|
|
let id = g.spawn(EK::Kobold, false, rx as f32 + 0.5, cy as f32 + 0.5, 1);
|
|
g.sell_rect(rx, cy, rx, cy);
|
|
assert!(!g.ents.iter().any(|e| e.id == id && e.alive));
|
|
// heroes cannot path through lava
|
|
assert_eq!(g.map.hero_cost()(idx(rx + 1, cy)), None);
|
|
}
|
|
|
|
fn treasury_total(g: &Game) -> f32 {
|
|
g.reserve + g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player).map(|t| t.gold as f32).sum::<f32>()
|
|
}
|
|
|
|
#[test]
|
|
fn treasury_shows_its_treasures() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
g.update(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);
|
|
// (tiles partly filled with rubies take no gold, so the core may hold a little more)
|
|
let gem_room: f32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res != 0).map(|t| TREASURY_TILE - t.gold as f32).sum();
|
|
assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + gem_room + 1.0, "reserve {} gold {} cap {}", g.reserve, g.gold, g.gold_cap());
|
|
}
|
|
|
|
#[test]
|
|
fn treasure_lands_in_the_nearest_treasury() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
// a second treasury far from the core ring
|
|
for y in cy - 9..=cy - 7 {
|
|
for x in cx + 10..=cx + 12 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Treasury;
|
|
t.owner = Owner::Player;
|
|
t.gold = 0;
|
|
}
|
|
}
|
|
g.recount();
|
|
g.update(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);
|
|
let (cx, cy) = g.map.core;
|
|
g.spawn(EK::Kobold, false, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
g.training_bill = 50.0;
|
|
let cost = g.next_payday_cost();
|
|
assert_eq!(cost, 50.0 + st(EK::Kobold).wage as f32);
|
|
let gold = g.gold;
|
|
g.payday = PAYDAY_WARN + 0.05;
|
|
g.update(0.1);
|
|
assert!(g.payday_warned);
|
|
g.payday = 0.01;
|
|
g.update(0.1);
|
|
// the training bill is paid at once, the wage when the kobold fetches it
|
|
assert_eq!(g.training_bill, 0.0);
|
|
assert!((gold - g.gold - 50.0).abs() < 1.0);
|
|
let mut fetched = false;
|
|
for _ in 0..(70 * 20) {
|
|
g.update(0.05);
|
|
fetched |= g.ents.iter().any(|e| matches!(e.job, Job::FetchWage(_)));
|
|
}
|
|
assert!(fetched, "the kobold walks to the treasury");
|
|
assert!((gold - g.gold - cost).abs() < 1.0, "paid {}", gold - g.gold);
|
|
assert!(g.ents.iter().all(|e| e.pay_due == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn new_creatures_report_and_set_up_their_bed() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
g.gold = 3000.0;
|
|
g.build_rect(1, cx - 3, cy - 3, cx - 2, cy + 1);
|
|
g.build_rect(2, cx + 4, cy - 3, cx + 5, cy + 1);
|
|
// arrives like from a portal (the test map's portal is not connected yet)
|
|
let id = g.spawn(EK::Kobold, false, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
|
e.settled = false;
|
|
e.job = Job::Register;
|
|
e.timer = 2.0;
|
|
}
|
|
let mut made_bed = false;
|
|
for _ in 0..(90 * 20) {
|
|
g.update(0.05);
|
|
let e = g.ents.iter().find(|e| e.id == id).unwrap();
|
|
made_bed |= matches!(e.job, Job::MakeBed(_)) && e.path.is_empty();
|
|
if e.settled {
|
|
break;
|
|
}
|
|
}
|
|
let e = g.ents.iter().find(|e| e.id == id).unwrap();
|
|
assert!(made_bed && e.settled);
|
|
assert_eq!(g.map.t[e.bed.unwrap()].kind, Kind::Lair);
|
|
}
|
|
|
|
// a fortress east of the start area, joined to it by a claimed corridor
|
|
fn fort_game(spec: impl Fn(i32, i32) -> crate::fortress::FortSpec) -> (Game, crate::fortress::FortSpec) {
|
|
let mut g = Game::new(11, 96, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let f = spec(cx + 14, cy - 6);
|
|
g.build_fortress(&f);
|
|
for x in cx + 5..f.x {
|
|
for y in cy..=cy + 2 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
(g, f)
|
|
}
|
|
|
|
fn spec(x: i32, y: i32) -> crate::fortress::FortSpec {
|
|
crate::fortress::FortSpec { x, y, w: 15, h: 15, doors: vec![3], towers: vec![], tower_level: 1, guards: vec![], patrols: vec![], vaults: 0, vault_value: 0, shard: false, heart: false }
|
|
}
|
|
|
|
#[test]
|
|
fn knight_towers_and_creatures_fight() {
|
|
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) });
|
|
assert_eq!(g.towers.len(), 1);
|
|
assert!(g.towers[0].hero);
|
|
let (tx, ty) = g.towers[0].pos();
|
|
let id = g.spawn(EK::Ogre, false, tx + 3.0, ty + 2.0, 4);
|
|
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
|
|
let thp0 = g.towers[0].hp;
|
|
for _ in 0..200 {
|
|
g.update(0.05);
|
|
}
|
|
let hurt = g.ents.iter().find(|e| e.id == id).map_or(true, |e| e.hp < hp0);
|
|
let tower_hurt = g.towers.first().map_or(true, |t| t.hp < thp0);
|
|
assert!(hurt, "the knight tower shoots the creature");
|
|
assert!(tower_hurt, "the creature attacks the knight tower");
|
|
let _ = f;
|
|
}
|
|
|
|
#[test]
|
|
fn towers_fight_towers_and_the_gate() {
|
|
// a tower of the player in the courtyard of a fortress, no units around
|
|
let (mut g, _) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) });
|
|
g.ents.clear();
|
|
let (kx, ky) = xy(g.towers[0].tile);
|
|
let (px, py) = (kx + 3, ky + 2);
|
|
g.map.t[idx(px, py)].owner = Owner::Player;
|
|
g.gold = 5000.0;
|
|
g.build_tower(TowerKind::Arrow, px, py);
|
|
assert_eq!(g.towers.len(), 2);
|
|
let hp: Vec<f32> = g.towers.iter().map(|t| t.hp).collect();
|
|
for _ in 0..100 {
|
|
g.update(0.05);
|
|
}
|
|
assert!(g.towers.iter().zip(&hp).all(|(t, h)| t.hp < *h), "both towers are hit");
|
|
// free game: a tower in reach of the hero gate shoots at the gate
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.ents.clear();
|
|
let (gx, gy) = g.map.gate;
|
|
let (tx, ty) = (gx - 2, gy + 1);
|
|
g.map.t[idx(tx, ty)].kind = Kind::Floor;
|
|
g.map.t[idx(tx, ty)].owner = Owner::Player;
|
|
g.gold = 5000.0;
|
|
g.build_tower(TowerKind::Arrow, tx, ty);
|
|
for _ in 0..60 {
|
|
g.update(0.05);
|
|
}
|
|
assert!(g.gate_hp < GATE_HP, "the gate is shot at");
|
|
}
|
|
|
|
#[test]
|
|
fn more_lair_room_brings_creatures_faster() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
g.counts[1] = 4;
|
|
let tight = g.lair_factor();
|
|
g.counts[1] = 12;
|
|
let normal = g.lair_factor();
|
|
g.counts[1] = 30;
|
|
let roomy = g.lair_factor();
|
|
assert!(tight > normal && normal > roomy, "{} {} {}", tight, normal, roomy);
|
|
// creatures fill the lair: arrivals slow down again
|
|
for _ in 0..20 {
|
|
g.spawn(EK::Kobold, false, 10.0, 10.0, 1);
|
|
}
|
|
assert!(g.lair_factor() > roomy);
|
|
}
|
|
|
|
#[test]
|
|
fn vaults_are_claimed_and_looted() {
|
|
let (mut g, _) = fort_game(|x, y| crate::fortress::FortSpec { vaults: 2, vault_value: 1800, ..spec(x, y) });
|
|
let gold0 = g.gold;
|
|
for _ in 0..(240 * 20) {
|
|
g.update(0.05);
|
|
if g.loot >= 3000.0 {
|
|
break;
|
|
}
|
|
}
|
|
println!("loot after claiming: {}", g.loot);
|
|
assert!(g.loot > 1000.0);
|
|
assert!(g.gold > gold0);
|
|
}
|
|
|
|
#[test]
|
|
fn patrols_walk_and_guards_raise_the_alarm() {
|
|
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec {
|
|
guards: vec![(EK::Knight, 1); 4],
|
|
patrols: vec![vec![(EK::Archer, 1)]],
|
|
..spec(x, y)
|
|
});
|
|
// no imps around (the patrol would shoot them through the door)
|
|
g.ents.retain(|e| e.hero);
|
|
let pj = g.ents.iter().position(|e| !e.patrol.is_empty()).unwrap();
|
|
let pid = g.ents[pj].id;
|
|
let start = (g.ents[pj].x, g.ents[pj].y);
|
|
let mut far = 0.0f32;
|
|
for _ in 0..200 {
|
|
g.update(0.05);
|
|
let p = g.ents.iter().find(|e| e.id == pid).unwrap();
|
|
far = far.max((p.x - start.0).abs() + (p.y - start.1).abs());
|
|
}
|
|
assert!(far > 6.0, "the patrol walks its round");
|
|
// an intruder next to one guard: the others join
|
|
let (hx, hy) = g.ents.iter().find(|e| e.guard).unwrap().home.unwrap();
|
|
g.spawn(EK::Ogre, false, hx + 1.0, hy, 8);
|
|
for _ in 0..20 {
|
|
g.update(0.05);
|
|
}
|
|
let fighting = g.ents.iter().filter(|e| e.guard && matches!(e.job, Job::Attack(_))).count();
|
|
assert!(fighting >= 3, "alarm: {} guards fight", fighting);
|
|
let _ = f;
|
|
}
|
|
|
|
#[test]
|
|
fn knights_tear_down_bridges() {
|
|
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { guards: vec![(EK::Knight, 1)], ..spec(x, y) });
|
|
// a bridge of the player in the courtyard, on a small pond
|
|
let (bx, by) = (f.x + f.w / 2 + 2, f.y + f.h / 2 + 2);
|
|
for y in by - 1..=by + 1 {
|
|
g.map.t[idx(bx, y)].kind = Kind::Water;
|
|
g.map.t[idx(bx, y)].owner = Owner::None;
|
|
}
|
|
g.map.t[idx(bx, by)].kind = Kind::Bridge;
|
|
g.map.t[idx(bx, by)].owner = Owner::Player;
|
|
let mut torn = false;
|
|
for _ in 0..(90 * 20) {
|
|
g.update(0.05);
|
|
if g.map.t[idx(bx, by)].kind == Kind::Water {
|
|
torn = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(torn);
|
|
}
|
|
|
|
#[test]
|
|
fn some_imps_always_claim() {
|
|
// lots of digging work: still at least 10% (min. one) of the imps claim the dug-out floor
|
|
let mut g = Game::new(11, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
for _ in 0..8 {
|
|
let (x, y) = (g.ents[0].x, g.ents[0].y);
|
|
g.spawn(EK::Worker, false, x, y, 1);
|
|
}
|
|
g.mark_rect(cx - 12, cy - 12, cx + 12, cy + 12, true);
|
|
let (mut samples, mut ok) = (0, 0);
|
|
for step in 0..(150 * 20) {
|
|
g.update(0.05);
|
|
if step % 20 != 0 || step < 200 {
|
|
continue;
|
|
}
|
|
let digging = g.map.t.iter().any(|t| t.marked);
|
|
let claimable = (0..g.map.t.len()).any(|i| g.claimable(i, u32::MAX));
|
|
if digging && claimable {
|
|
let imps = g.worker_count();
|
|
let quota = ((imps as f32 * 0.1).ceil() as usize).max(1);
|
|
let claiming = g.ents.iter().filter(|e| e.alive && e.kind == EK::Worker && matches!(e.job, Job::Claim(_))).count();
|
|
samples += 1;
|
|
if claiming >= quota {
|
|
ok += 1;
|
|
}
|
|
}
|
|
}
|
|
println!("claim quota met in {}/{} samples", ok, samples);
|
|
assert!(samples > 10);
|
|
assert!(ok * 10 >= samples * 8);
|
|
}
|
|
|
|
#[test]
|
|
fn wall_corners_are_reinforced_too() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
// the start area spans cx-3..cx+5 / cy-3..cy+5: its walls are at cx-4 and cy-4
|
|
let (x0, y0) = (cx - 4, cy - 4);
|
|
for k in 1..4 {
|
|
g.map.t[idx(x0 + k, y0)].reinforced = true;
|
|
g.map.t[idx(x0, y0 + k)].reinforced = true;
|
|
}
|
|
assert!(g.map.t[idx(x0, y0)].kind == Kind::Earth && !g.map.t[idx(x0, y0)].reinforced);
|
|
g.reinforce_corners();
|
|
assert!(g.map.t[idx(x0, y0)].reinforced, "the corner is reinforced");
|
|
// nothing spreads into the earth behind the walls
|
|
g.reinforce_corners();
|
|
assert!(!g.map.t[idx(x0 - 1, y0)].reinforced && !g.map.t[idx(x0, y0 - 1)].reinforced && !g.map.t[idx(x0 - 1, y0 - 1)].reinforced);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_imps_help_and_speed_up_work() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
for _ in 0..6 {
|
|
g.spawn(EK::Worker, false, cx as f32 + 4.5, cy as f32 + 1.5, 1);
|
|
}
|
|
// a single earth tile to dig, next to the start area: one digs, the others help
|
|
let (dx, dy) = (cx + 6, (cy - 3..=cy + 5).find(|&y| g.map.t[idx(cx + 6, y)].kind == Kind::Earth).expect("earth next to the start area"));
|
|
// no other work: everything around is claimed already, no walls to reinforce
|
|
for t in g.map.t.iter_mut() {
|
|
if t.kind == Kind::Earth {
|
|
t.reinforced = true;
|
|
}
|
|
}
|
|
g.map.t[idx(dx, dy)].reinforced = false;
|
|
g.map.t[idx(dx, dy)].marked = true;
|
|
let mut best = 0.0f32;
|
|
for _ in 0..200 {
|
|
g.update(0.05);
|
|
best = best.max(g.help_speed(idx(dx, dy)));
|
|
if g.map.t[idx(dx, dy)].kind != Kind::Earth {
|
|
break;
|
|
}
|
|
}
|
|
println!("best working speed {}", best);
|
|
assert!(best >= 1.75, "helpers join: {}", best);
|
|
assert!(best <= 2.0);
|
|
}
|
|
|
|
#[test]
|
|
fn flyers_cross_rivers_flee_and_heal() {
|
|
let (mut g, rx) = river_game(true);
|
|
let (cx, cy) = g.map.core;
|
|
// an aerie in the start area
|
|
g.gold = 5000.0;
|
|
g.build_rect(7, cx - 3, cy - 3, cx - 2, cy + 2);
|
|
assert!(g.counts[7] >= 8);
|
|
// a knight on the far side of the lava: an owl reaches it, a kobold does not
|
|
let far = (rx + 4, cy);
|
|
for x in rx + 3..rx + 7 {
|
|
let t = &mut g.map.t[idx(x, cy)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::None;
|
|
}
|
|
let k = g.spawn(EK::Knight, true, far.0 as f32 + 0.5, far.1 as f32 + 0.5, 1);
|
|
let owl = g.spawn(EK::Owl, false, (cx + 4) as f32 + 0.5, cy as f32 + 0.5, 3);
|
|
assert!(g.map.fly_path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_some());
|
|
assert!(g.map.path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_none());
|
|
let hp0 = g.ents.iter().find(|e| e.id == k).unwrap().hp;
|
|
for _ in 0..300 {
|
|
g.update(0.05);
|
|
}
|
|
let hit = g.ents.iter().find(|e| e.id == k).map_or(true, |e| e.hp < hp0);
|
|
assert!(hit, "the owl attacks across the lava");
|
|
// a badly hurt flyer flees to the aerie and heals up to 75%
|
|
g.ents.retain(|e| !e.hero);
|
|
let j = g.ents.iter().position(|e| e.id == owl).unwrap();
|
|
g.ents[j].hp = g.ents[j].max_hp * 0.2;
|
|
let mut rested = false;
|
|
for _ in 0..800 {
|
|
g.update(0.05);
|
|
let e = g.ents.iter().find(|e| e.id == owl).unwrap();
|
|
rested |= matches!(e.job, Job::Rest(_));
|
|
if e.hp >= e.max_hp * 0.75 {
|
|
break;
|
|
}
|
|
}
|
|
let e = g.ents.iter().find(|e| e.id == owl).unwrap();
|
|
assert!(rested && e.hp >= e.max_hp * 0.74, "fled and healed: {} / {}", e.hp, e.max_hp);
|
|
// lava does not burn flyers
|
|
let j = g.ents.iter().position(|e| e.id == owl).unwrap();
|
|
g.ents[j].x = (rx - 1) as f32 + 0.5;
|
|
g.ents[j].y = cy as f32 + 0.5;
|
|
g.knock(j, 1.0, 0.0);
|
|
assert!(g.ents[j].alive);
|
|
}
|
|
|
|
#[test]
|
|
fn aerie_attracts_flyers() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
g.counts = [16, 0, 9, 0, 6, 4, 0, 16];
|
|
let mut seen = std::collections::HashSet::new();
|
|
for _ in 0..200 {
|
|
g.try_portal_for_test();
|
|
for e in g.ents.iter().filter(|e| e.creature()) {
|
|
seen.insert(e.kind);
|
|
}
|
|
g.ents.retain(|e| !e.creature());
|
|
}
|
|
assert!(seen.contains(&EK::Bat) && seen.contains(&EK::Gargoyle) && seen.contains(&EK::Owl), "{:?}", seen);
|
|
assert!(seen.iter().all(|&k| flies(k)), "no lair: only flyers come ({:?})", seen);
|
|
}
|
|
|
|
#[test]
|
|
fn steel_walls_hold_heroes_back() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let (wx, wy) = (cx + 6, (cy - 3..=cy + 5).find(|&y| g.map.t[idx(cx + 6, y)].kind == Kind::Earth).unwrap());
|
|
g.gold = 1000.0;
|
|
g.build_steel(wx, wy, wx, wy);
|
|
assert_eq!(g.map.t[idx(wx, wy)].kind, Kind::Steel);
|
|
assert_eq!(g.gold, 1000.0 - STEEL_COST[0] as f32);
|
|
// far from own ground: nothing happens
|
|
g.build_steel(wx + 5, wy, wx + 5, wy);
|
|
assert_ne!(g.map.t[idx(wx + 5, wy)].kind, Kind::Steel);
|
|
// heroes avoid it and dig 20 times slower than through earth
|
|
assert_eq!(g.map.hero_cost()(idx(wx, wy)), Some(300));
|
|
// selling: a reinforced earth wall again, half of the plating back
|
|
let gold = g.gold;
|
|
g.sell_rect(wx, wy, wx, wy);
|
|
assert_eq!(g.map.t[idx(wx, wy)].kind, Kind::Earth);
|
|
assert!(g.map.t[idx(wx, wy)].reinforced);
|
|
assert!(g.gold > gold);
|
|
}
|
|
|
|
#[test]
|
|
fn imps_reinforce_walls() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
for _ in 0..(120 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let n = g.map.t.iter().filter(|t| t.reinforced).count();
|
|
println!("reinforced walls after 2 minutes: {}", n);
|
|
assert!(n >= 10);
|
|
// heroes avoid them: the hero path cost through reinforced earth is 10x
|
|
let (cx, cy) = g.map.core;
|
|
let w = (cx - 4, cy);
|
|
if g.map.t[idx(w.0, w.1)].reinforced {
|
|
assert_eq!(g.map.hero_cost()(idx(w.0, w.1)), Some(70));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn towers_shoot_and_get_attacked() {
|
|
for kind in [TowerKind::Arrow, TowerKind::Cannon, TowerKind::Magic] {
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.gold = 2000.0;
|
|
g.mana = 400.0;
|
|
let (cx, cy) = g.map.core;
|
|
let (tx, ty) = (cx - 2, cy - 2);
|
|
g.build_tower(kind, tx, ty);
|
|
assert_eq!(g.towers.len(), 1);
|
|
g.build_tower(kind, tx, ty); // upgrade
|
|
assert_eq!(g.towers[0].level, 2);
|
|
// a hero inside the start area, 4 tiles away
|
|
let id = g.spawn(EK::Knight, true, (cx + 3) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
|
|
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
|
|
let mut hurt = false;
|
|
let mut tower_hit = false;
|
|
for _ in 0..400 {
|
|
g.update(0.05);
|
|
if let Some(e) = g.ents.iter().find(|e| e.id == id) {
|
|
hurt |= e.hp < hp0;
|
|
} else {
|
|
hurt = true;
|
|
}
|
|
tower_hit |= g.towers.first().map_or(true, |t| t.hp < t.max_hp());
|
|
}
|
|
println!("{:?}: hero hurt {}, tower attacked {}", kind, hurt, tower_hit);
|
|
assert!(hurt, "{:?} did not damage the hero", kind);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn heroes_attack_towers() {
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.gold = 2000.0;
|
|
for e in g.ents.iter_mut() {
|
|
e.alive = false; // no imps: the tower is the only target
|
|
}
|
|
let (cx, cy) = g.map.core;
|
|
g.build_tower(TowerKind::Cannon, cx - 2, cy - 2);
|
|
g.spawn(EK::Ogre, true, (cx - 2) as f32 + 0.5, (cy + 4) as f32 + 0.5, 5);
|
|
for _ in 0..600 {
|
|
g.update(0.05);
|
|
}
|
|
let t = g.towers.first();
|
|
println!("tower after attack: {:?}", t.map(|t| t.hp));
|
|
assert!(t.map_or(true, |t| t.hp < t.max_hp()));
|
|
}
|
|
|
|
#[test]
|
|
fn prisoners_are_tortured_and_resolved() {
|
|
let mut converted = 0;
|
|
let mut died = 0;
|
|
for seed in 1..=40u32 {
|
|
let mut g = Game::new(seed, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1); // torture chamber 2x5 = 5 places... 10 tiles
|
|
assert!(g.prison_space() >= 2, "space {}", g.prison_space());
|
|
let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
g.kill(j);
|
|
assert_eq!(g.ents[j].captive, 1);
|
|
let mut reached = false;
|
|
for _ in 0..(200 * 20) {
|
|
g.update(0.05);
|
|
match g.ents.iter().find(|e| e.id == id) {
|
|
Some(e) if e.captive == 3 => reached = true,
|
|
Some(e) if !e.hero => {
|
|
converted += 1;
|
|
break;
|
|
}
|
|
None => {
|
|
died += 1;
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(reached, "seed {}: prisoner never reached the torture chamber", seed);
|
|
}
|
|
println!("40 prisoners: {} defected, {} died", converted, died);
|
|
assert_eq!(converted + died, 40);
|
|
assert!((8..=26).contains(&converted), "defect rate off: {}/40", converted);
|
|
}
|
|
|
|
#[test]
|
|
fn jailer_makes_prisoners_defect_more_often() {
|
|
let mut converted = 0;
|
|
let mut worked = 0;
|
|
for seed in 1..=40u32 {
|
|
let mut g = Game::new(seed, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1);
|
|
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 2); // a lair for the jailer
|
|
let jailer = g.spawn(EK::Jailer, false, (cx - 1) as f32 + 0.5, (cy - 3) as f32 + 0.5, 1);
|
|
let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
g.kill(j);
|
|
for _ in 0..(200 * 20) {
|
|
g.update(0.05);
|
|
if let Some(jl) = g.ents.iter_mut().find(|e| e.id == jailer) {
|
|
// keep the jailer fresh so that it works
|
|
jl.hunger = 0.0;
|
|
jl.energy = 1.0;
|
|
jl.happy = 1.0;
|
|
if jl.job == Job::Torment(id) {
|
|
worked += 1;
|
|
}
|
|
}
|
|
match g.ents.iter().find(|e| e.id == id) {
|
|
Some(e) if !e.hero => {
|
|
converted += 1;
|
|
break;
|
|
}
|
|
None => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
println!("with a jailer: {} of 40 defected (jailer worked {} ticks)", converted, worked);
|
|
assert!(worked > 0, "the jailer works on the prisoners");
|
|
assert!(converted >= 20, "defections with a jailer: {}/40", converted);
|
|
}
|
|
|
|
#[test]
|
|
fn temple_prayer_and_priestess_heals_core() {
|
|
let mut g = Game::new(9, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(6, cx - 3, cy - 3, cx - 2, cy + 1); // temple
|
|
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 1); // lair
|
|
g.build_rect(2, cx + 4, cy + 3, cx + 5, cy + 5); // farm
|
|
g.spawn(EK::Priestess, false, (cx + 4) as f32 + 0.5, (cy + 2) as f32 + 0.5, 1);
|
|
for k in 0..3 {
|
|
g.spawn(EK::Kobold, false, (cx - 1) as f32 + 0.5, (cy + k) as f32 + 0.5, 1);
|
|
}
|
|
g.core_hp = 1000.0;
|
|
let mut prayed = false;
|
|
for _ in 0..(120 * 20) {
|
|
g.update(0.05);
|
|
prayed |= g.ents.iter().any(|e| matches!(e.job, Job::Pray(_)) && e.working);
|
|
}
|
|
println!("core after 2 min: {:.0}, prayed: {}", g.core_hp, prayed);
|
|
assert!(prayed);
|
|
assert!(g.core_hp > 1300.0);
|
|
}
|
|
|
|
#[test]
|
|
fn waves_vary_their_entry_points() {
|
|
let mut g = Game::new(11, 96, 96);
|
|
let (cx, cy) = g.map.core;
|
|
// a bigger realm so there are several possible entry points
|
|
for y in cy - 8..=cy + 10 {
|
|
for x in cx - 6..=cx + 14 {
|
|
if inb(x, y) && g.map.t[idx(x, y)].kind != Kind::Core && g.map.t[idx(x, y)].kind != Kind::Rock {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
}
|
|
let mut seen = Vec::new();
|
|
for _ in 0..4 {
|
|
g.plan_entries();
|
|
let e = g.planned_entries[0];
|
|
println!("entry {:?} ({})", e, g.direction_key(e));
|
|
seen.push(e);
|
|
g.entry_history.push(e);
|
|
}
|
|
// consecutive waves use different places
|
|
for w in seen.windows(2) {
|
|
assert!((w[0].0 - w[1].0).pow(2) + (w[0].1 - w[1].1).pow(2) >= 16, "same entry twice: {:?}", w);
|
|
}
|
|
// spawning a big wave gives heroes two different waypoints
|
|
g.warned = true;
|
|
g.planned_entries.clear();
|
|
g.plan_entries();
|
|
assert_eq!(g.planned_entries.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn core_starts_with_treasury_ring() {
|
|
let g = Game::new(21, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let ring = (cy - 1..=cy + 3).flat_map(|y| (cx - 1..=cx + 3).map(move |x| (x, y))).filter(|&(x, y)| g.map.t[idx(x, y)].kind == Kind::Treasury).count();
|
|
assert_eq!(ring, 16);
|
|
assert_eq!(g.counts[0], 16);
|
|
}
|
|
|
|
#[test]
|
|
fn hand_rules() {
|
|
let mut g = Game::new(4, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
// backhand on an own imp: faster for 15 s, costs a little HP
|
|
let imp = g.ents.iter().position(|e| e.kind == EK::Worker).unwrap();
|
|
let hp = g.ents[imp].hp;
|
|
assert_eq!(g.slap(Some(imp)), 1);
|
|
assert_eq!(g.ents[imp].boost, 15.0);
|
|
assert!(g.ents[imp].hp < hp);
|
|
// fist: only on own ground
|
|
let far = g.spawn(EK::Knight, true, 40.5, 40.5, 1);
|
|
let jf = g.ents.iter().position(|e| e.id == far).unwrap();
|
|
if g.map.t[idx(40, 40)].owner != Owner::Player {
|
|
assert_eq!(g.slap(Some(jf)), 0);
|
|
}
|
|
let near = g.spawn(EK::Knight, true, (cx - 2) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
|
|
let jn = g.ents.iter().position(|e| e.id == near).unwrap();
|
|
let hp = g.ents[jn].hp;
|
|
assert_eq!(g.slap(Some(jn)), 2);
|
|
assert!(g.ents[jn].hp < hp && g.ents[jn].stun > 0.0);
|
|
assert_eq!(g.slap(Some(jn)), 0, "cooldown");
|
|
// prisoners: pick up on own ground, drop onto a torture place = locked up
|
|
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1);
|
|
let jn = g.ents.iter().position(|e| e.id == near).unwrap();
|
|
g.kill(jn);
|
|
assert_eq!(g.ents[jn].captive, 1);
|
|
assert!(g.pickup(Some(jn)));
|
|
let t = (cx - 3, cy + 1);
|
|
assert!(g.drop(t.0 as f32 + 0.5, t.1 as f32 + 0.5));
|
|
let e = g.ents.iter().find(|e| e.id == near).unwrap();
|
|
assert_eq!(e.captive, 3);
|
|
// own units cannot be dropped outside own ground
|
|
let imp = g.ents.iter().position(|e| e.kind == EK::Worker).unwrap();
|
|
assert!(g.pickup(Some(imp)));
|
|
assert!(!g.drop(40.5, 40.5) || g.map.t[idx(40, 40)].owner == Owner::Player);
|
|
}
|
|
|
|
#[test]
|
|
fn chainbreaker_visits_only_great_dungeons() {
|
|
let mut g = Game::new(8, 64, 64);
|
|
g.gold = 9000.0;
|
|
// conditions not met: no Chainbreaker among many portal visits
|
|
let (px, py) = g.map.portals[0];
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
g.map.t[idx(px + x, py + y)].owner = Owner::Player;
|
|
}
|
|
}
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy + 5); // lair 18
|
|
g.build_rect(2, cx - 3, cy - 3, cx - 2, cy + 5); // farm 18
|
|
g.recount();
|
|
for _ in 0..60 {
|
|
g.try_portal();
|
|
}
|
|
assert!(!g.ents.iter().any(|e| e.kind == EK::Chainbreaker));
|
|
// a veteran creature makes it possible (rooms 16 ring + 36 = 52 tiles, gold 9000)
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.creature()) {
|
|
e.level = 5;
|
|
}
|
|
for e in g.ents.iter_mut().filter(|e| e.creature()) {
|
|
e.alive = false; // free beds
|
|
}
|
|
g.ents.retain(|e| e.alive);
|
|
g.spawn(EK::Kobold, false, cx as f32, cy as f32, 6);
|
|
let mut seen = false;
|
|
for _ in 0..400 {
|
|
g.gold = 9000.0;
|
|
g.try_portal();
|
|
seen |= g.ents.iter().any(|e| e.kind == EK::Chainbreaker);
|
|
g.ents.retain(|e| !e.creature() || e.kind == EK::Chainbreaker || e.level >= 5);
|
|
}
|
|
assert!(seen);
|
|
assert!(g.ents.iter().filter(|e| e.kind == EK::Chainbreaker).count() <= 1);
|
|
}
|
|
}
|
|
|