7086 lines
277 KiB
Rust
7086 lines
277 KiB
Rust
// Game logic: units, AI, economy, hero waves.
|
|
|
|
use crate::commands::Command;
|
|
use crate::map::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub const CORE_HP: f32 = 2000.0;
|
|
pub const GATE_HP: f32 = 3000.0;
|
|
pub const PAYDAY: f32 = 150.0;
|
|
pub const PAYDAY_WARN: f32 = 20.0; // the payday is announced this many seconds before
|
|
pub const TREASURY_TILE: f32 = 500.0; // value one treasury tile holds
|
|
pub const CORE_STORE: f32 = 2000.0; // value the soul core holds without any treasury
|
|
pub const TRAIN_COST: f32 = 1.5;
|
|
pub const HELPERS_MAX: usize = 4; // up to five imps at one task (one worker + four helpers) // gold per second of training (billed on payday)
|
|
pub const FIRST_WAVE: f32 = 300.0;
|
|
pub const WAVE_GAP: f32 = 180.0;
|
|
pub const MAX_SQUAD: usize = 18;
|
|
|
|
// Sound events. The game logic reports them, the sound module plays them.
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Sfx {
|
|
Dig,
|
|
GoldDig,
|
|
DigDone,
|
|
Claim,
|
|
Deposit,
|
|
Build,
|
|
Sell,
|
|
MeleeHit,
|
|
Shoot,
|
|
Death,
|
|
HeroDeath,
|
|
Lightning,
|
|
Heal,
|
|
Summon,
|
|
PortalArrive,
|
|
WaveAlarm,
|
|
WaveStart,
|
|
CoreHit,
|
|
GateHit,
|
|
GateDefenders,
|
|
LevelUp,
|
|
Pickup,
|
|
Drop,
|
|
Payday,
|
|
FlagSet,
|
|
Victory,
|
|
Defeat,
|
|
Click,
|
|
Error,
|
|
Reinforce,
|
|
Cannon,
|
|
Magic,
|
|
Capture,
|
|
Convert,
|
|
Slap,
|
|
Punch,
|
|
// Crookfinger's voice: squeaky syllables
|
|
Voice1,
|
|
Voice2,
|
|
Voice3,
|
|
Voice4,
|
|
Voice5,
|
|
Voice6,
|
|
// weather, rogues and groves
|
|
Thunder,
|
|
Steal,
|
|
Snare,
|
|
Sermon,
|
|
Rain,
|
|
Wind,
|
|
Spores,
|
|
}
|
|
|
|
pub const VOICE_SYLLABLES: [Sfx; 6] = [Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6];
|
|
|
|
pub const SFX_ALL: [Sfx; 49] = [
|
|
Sfx::Dig, Sfx::GoldDig, Sfx::DigDone, Sfx::Claim, Sfx::Deposit, Sfx::Build, Sfx::Sell, Sfx::MeleeHit,
|
|
Sfx::Shoot, Sfx::Death, Sfx::HeroDeath, Sfx::Lightning, Sfx::Heal, Sfx::Summon, Sfx::PortalArrive,
|
|
Sfx::WaveAlarm, Sfx::WaveStart, Sfx::CoreHit, Sfx::GateHit, Sfx::GateDefenders, Sfx::LevelUp,
|
|
Sfx::Pickup, Sfx::Drop, Sfx::Payday, Sfx::FlagSet, Sfx::Victory, Sfx::Defeat, Sfx::Click, Sfx::Error,
|
|
Sfx::Reinforce, Sfx::Cannon, Sfx::Magic, Sfx::Capture, Sfx::Convert, Sfx::Slap, Sfx::Punch,
|
|
Sfx::Voice1, Sfx::Voice2, Sfx::Voice3, Sfx::Voice4, Sfx::Voice5, Sfx::Voice6,
|
|
Sfx::Thunder, Sfx::Steal, Sfx::Snare, Sfx::Sermon, Sfx::Rain, Sfx::Wind, Sfx::Spores,
|
|
];
|
|
|
|
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 MAX_CREATURES: usize = 100; // creatures of one master (the beds decide below that)
|
|
pub const SPRING_MANA: f32 = 0.6; // mana per second of each claimed mana spring
|
|
pub const SPRING_MAX: f32 = 150.0; // more mana maximum per claimed spring
|
|
pub const CARRY_MAX: i32 = 300;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
|
|
pub enum EK {
|
|
Worker,
|
|
Kobold,
|
|
Spider,
|
|
Mage,
|
|
Ogre,
|
|
Knight,
|
|
Archer,
|
|
Priest,
|
|
Dwarf,
|
|
Paladin,
|
|
Jailer, // attracted by the torture chamber
|
|
Priestess, // attracted by the temple
|
|
Chainbreaker, // rare and very strong
|
|
// flying creatures, attracted by the aerie
|
|
Bat, // fast and weak
|
|
Gargoyle, // aerie + torture chamber: stone, tough
|
|
Owl, // aerie + library: arcane bolts from afar
|
|
Wyrm, // big aerie + treasury: rare, very strong, fire breath
|
|
Cogling, // forge: a sooty little tinkerer, builds traps and doors fastest
|
|
Revenant, // rises from the graveyard: undead, needs no food and no bed
|
|
// more heroes
|
|
Giant, // slow, huge, smashes doors and traps
|
|
Fairy, // flies, finds the dungeon's traps and heals heroes a little
|
|
BattleMage, // blasts reinforced walls from afar, throws fireballs
|
|
// siege engines (heroes bring them; a master can build his own, see siege.rs)
|
|
Ram, // smashes doors, walls and towers, ignores creatures
|
|
Catapult, // hurls stones at towers from afar
|
|
// wild beasts of the caverns (beasts.rs): neutral until tamed
|
|
CaveBear, // strong and shaggy, likes the mushroom garden
|
|
Beetle, // a giant armoured beetle, likes the lair
|
|
Trader, // a merchant visiting through the portal (traders.rs)
|
|
// heroes of the later waves (rogues.rs)
|
|
Thief, // fast, sneaks to a treasury, steals gold and runs off with it
|
|
Monk, // fists and faith: spells do not touch him
|
|
Ranger, // a bow from afar, lays snares in the dungeon's corridors
|
|
Inquisitor, // fragile; now and then turns one of the weakest creatures to the heroes' side
|
|
}
|
|
|
|
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 const SUMMON_TOOL: usize = 11; // index of summoning in ui::TOOLS
|
|
|
|
pub fn st(k: EK) -> St {
|
|
let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty };
|
|
match k {
|
|
EK::Worker => s("unit.worker", 45.0, 3.0, 1.0, 3.2, 1.0, 0, 0),
|
|
EK::Kobold => s("unit.kobold", 75.0, 8.0, 1.0, 2.6, 0.9, 40, 0),
|
|
EK::Spider => s("unit.spider", 95.0, 10.0, 1.0, 2.9, 1.0, 60, 0),
|
|
EK::Mage => s("unit.mage", 60.0, 13.0, 4.5, 2.0, 1.4, 110, 0),
|
|
EK::Ogre => s("unit.ogre", 230.0, 20.0, 1.0, 1.8, 1.4, 150, 0),
|
|
// heroes: the wage applies when they defected to the player
|
|
EK::Knight => s("unit.knight", 140.0, 11.0, 1.0, 1.9, 1.1, 70, 120),
|
|
EK::Archer => s("unit.archer", 75.0, 9.0, 4.5, 2.1, 1.3, 60, 100),
|
|
EK::Priest => s("unit.priest", 80.0, 5.0, 3.5, 1.9, 1.5, 60, 120),
|
|
EK::Dwarf => s("unit.dwarf", 110.0, 9.0, 1.0, 2.0, 1.0, 60, 100),
|
|
EK::Paladin => s("unit.paladin", 700.0, 26.0, 1.0, 1.7, 1.2, 220, 1000),
|
|
EK::Jailer => s("unit.jailer", 260.0, 16.0, 1.0, 1.6, 1.3, 140, 0),
|
|
EK::Priestess => s("unit.priestess", 70.0, 14.0, 5.0, 2.0, 1.6, 120, 0),
|
|
EK::Chainbreaker => s("unit.chainbreaker", 600.0, 36.0, 1.1, 2.3, 1.1, 400, 0),
|
|
EK::Bat => s("unit.bat", 55.0, 6.0, 1.0, 3.6, 0.7, 30, 0),
|
|
EK::Gargoyle => s("unit.gargoyle", 260.0, 15.0, 1.0, 2.2, 1.2, 120, 0),
|
|
EK::Owl => s("unit.owl", 70.0, 12.0, 4.5, 2.8, 1.3, 100, 0),
|
|
EK::Wyrm => s("unit.wyrm", 650.0, 30.0, 3.0, 2.5, 1.4, 380, 0),
|
|
EK::Cogling => s("unit.cogling", 90.0, 9.0, 1.0, 2.4, 0.9, 70, 0),
|
|
EK::Revenant => s("unit.revenant", 160.0, 14.0, 1.0, 1.9, 1.2, 30, 0),
|
|
EK::Giant => s("unit.giant", 900.0, 34.0, 1.1, 1.3, 1.6, 200, 600),
|
|
EK::Fairy => s("unit.fairy", 70.0, 5.0, 3.5, 3.2, 1.2, 60, 150),
|
|
EK::BattleMage => s("unit.battlemage", 120.0, 18.0, 5.0, 1.8, 1.8, 120, 250),
|
|
EK::Ram => s("unit.ram", 900.0, 45.0, 1.1, 1.0, 1.8, 0, 200),
|
|
EK::Catapult => s("unit.catapult", 320.0, 40.0, 7.5, 0.9, 3.5, 0, 200),
|
|
EK::CaveBear => s("unit.cavebear", 420.0, 24.0, 1.0, 1.9, 1.4, 110, 0),
|
|
EK::Beetle => s("unit.beetle", 300.0, 13.0, 1.0, 1.7, 1.1, 70, 0),
|
|
EK::Trader => s("unit.trader", 200.0, 1.0, 1.0, 1.5, 2.0, 0, 0),
|
|
EK::Thief => s("unit.thief", 60.0, 6.0, 1.0, 3.1, 0.9, 50, 120),
|
|
EK::Monk => s("unit.monk", 150.0, 12.0, 1.0, 2.3, 0.7, 80, 180),
|
|
EK::Ranger => s("unit.ranger", 90.0, 11.0, 5.0, 2.2, 1.4, 80, 160),
|
|
EK::Inquisitor => s("unit.inquisitor", 70.0, 7.0, 3.5, 1.8, 1.6, 90, 250),
|
|
}
|
|
}
|
|
|
|
// flying creatures cross water and lava and sleep in the aerie
|
|
pub fn flies(k: EK) -> bool {
|
|
matches!(k, EK::Bat | EK::Gargoyle | EK::Owl | EK::Wyrm | EK::Fairy)
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum Job {
|
|
None,
|
|
Dig(usize),
|
|
Claim(usize),
|
|
Deliver,
|
|
Wander,
|
|
Sleep(usize),
|
|
Eat(usize),
|
|
Train(usize),
|
|
Study(usize),
|
|
Attack(u32),
|
|
AttackCore,
|
|
Leave,
|
|
Rally,
|
|
Reinforce(usize), // wall tile
|
|
Repair(u32), // tower id
|
|
AttackTower(u32), // tower id
|
|
Carry(u32), // imp carries an unconscious hero (id) to the torture chamber
|
|
Pray(usize), // temple tile
|
|
HealCore, // flame priestess heals the soul core
|
|
TearBridge(usize), // knight tears down a bridge of the player
|
|
Rest(usize), // flying creature recovers in the aerie (flees when badly hurt)
|
|
Torment(u32), // the jailer works on a prisoner in the torture chamber
|
|
Register, // new creature: reports at the portal
|
|
MakeBed(usize), // new creature: sets up its bed
|
|
FetchWage(usize), // payday: walks to the treasury (or the core) for the wage
|
|
Help(usize), // imp with nothing else to do helps another imp at this tile
|
|
Forge(usize), // creature works in the forge (traps and doors)
|
|
Install(u32), // imp carries a finished trap or door (id) to its spot and installs it
|
|
Brawl(u32), // two creatures that cannot stand each other scuffle (never to the death)
|
|
Guard(usize), // holds a guard post
|
|
Spar(usize), // sparring in the arena
|
|
MendBridge(usize), // imp mends a worn bridge tile
|
|
RaiseWall(usize), // imp raises a planned wall on own floor
|
|
March(usize), // creature follows an attack marker (sword) 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)
|
|
#[serde(default)]
|
|
pub haste: f32, // spell: seconds of haste left
|
|
#[serde(default)]
|
|
pub shield: f32, // spell: seconds of stoneskin left (half damage)
|
|
#[serde(default)]
|
|
pub invis: f32, // spell: seconds of invisibility left (until it attacks)
|
|
#[serde(default)]
|
|
pub plague: f32, // spell: seconds of plague left (poison that spreads)
|
|
#[serde(default)]
|
|
pub name: u32, // seed of the creature's name (names.rs), 0: none (heroes, siege engines)
|
|
#[serde(default)]
|
|
pub kills: u32, // heroes this creature has slain (names.rs: the hall of the fallen)
|
|
#[serde(default)]
|
|
pub bane: u8, // what hurt it last: sprite index + 1 of a unit kind, or names::BANE_*
|
|
#[serde(default)]
|
|
pub ab_cd: f32, // cooldown of the learned ability
|
|
#[serde(default)]
|
|
pub mood_t: f32, // seconds until the next mood check (likes and hatreds)
|
|
pub guard: bool, // stays at its home (gate defenders, treasure guards)
|
|
#[serde(default)]
|
|
pub home: Option<(f32, f32)>, // guard post
|
|
#[serde(default)]
|
|
pub patrol: Vec<(i32, i32)>, // fortress patrol: route of tiles, walked in a loop
|
|
#[serde(default)]
|
|
pub patrol_k: usize, // next point of the route
|
|
#[serde(default = "yes")]
|
|
pub settled: bool, // creatures: reported at the portal and set up their bed
|
|
#[serde(default)]
|
|
pub pay_due: f32, // wage of the last payday not fetched yet
|
|
#[serde(default)]
|
|
pub pay_t: f32, // seconds until the creature goes for its wage
|
|
#[serde(default)]
|
|
pub side: u8, // 0 player, 2..=4 the other masters, 6 dwarves (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
|
|
#[serde(default)]
|
|
pub boss: u8, // a champion of the Order leading a wave (see bosses.rs), 0: none
|
|
#[serde(default)]
|
|
pub gear: u8, // equipment from the forge: 0 none, 1 iron, 2 steel, 3 rune (armory.rs)
|
|
#[serde(skip, default = "zero_f32")]
|
|
pub disp_x: f32, // smoothed screen position (not part of the saved or synced world state)
|
|
#[serde(skip, default = "zero_f32")]
|
|
pub disp_y: f32,
|
|
}
|
|
|
|
// units of the neutral dwarves count for nobody
|
|
fn e_is_dwarf_side(side: u8) -> bool {
|
|
side == crate::dwarves::DWARF_SIDE
|
|
}
|
|
|
|
fn yes() -> bool {
|
|
true
|
|
}
|
|
|
|
fn zero_f32() -> f32 {
|
|
0.0
|
|
}
|
|
|
|
impl Ent {
|
|
fn dummy() -> Ent {
|
|
Ent {
|
|
id: 0,
|
|
kind: EK::Worker,
|
|
hero: false,
|
|
x: 0.0,
|
|
y: 0.0,
|
|
hp: 0.0,
|
|
max_hp: 1.0,
|
|
level: 1,
|
|
xp: 0.0,
|
|
job: Job::None,
|
|
path: Vec::new(),
|
|
timer: 0.0,
|
|
cd: 0.0,
|
|
think: 0.0,
|
|
repath: 0.0,
|
|
carry: 0,
|
|
carry_res: 0,
|
|
hunger: 0.0,
|
|
energy: 1.0,
|
|
happy: 1.0,
|
|
bed: None,
|
|
alive: false,
|
|
held: false,
|
|
anim: 0.0,
|
|
flash: 0.0,
|
|
face: 1.0,
|
|
dir: std::f32::consts::FRAC_PI_2,
|
|
moving: false,
|
|
working: false,
|
|
slow: 0.0,
|
|
captive: 0,
|
|
carrier: 0,
|
|
torture_t: 0.0,
|
|
skill_cd: 0.0,
|
|
waypoint: None,
|
|
boost: 0.0,
|
|
stun: 0.0,
|
|
haste: 0.0,
|
|
shield: 0.0,
|
|
invis: 0.0,
|
|
plague: 0.0,
|
|
name: 0,
|
|
kills: 0,
|
|
bane: 0,
|
|
ab_cd: 0.0,
|
|
mood_t: 0.0,
|
|
guard: false,
|
|
home: None,
|
|
patrol: Vec::new(),
|
|
patrol_k: 0,
|
|
settled: true,
|
|
pay_due: 0.0,
|
|
pay_t: 0.0,
|
|
side: 0,
|
|
target: None,
|
|
chained: false,
|
|
boss: 0,
|
|
gear: 0,
|
|
disp_x: 0.0,
|
|
disp_y: 0.0,
|
|
}
|
|
}
|
|
pub fn tile(&self) -> usize {
|
|
idx((self.x as i32).clamp(0, mw() as i32 - 1), (self.y as i32).clamp(0, mh() as i32 - 1))
|
|
}
|
|
pub fn atk(&self) -> f32 {
|
|
st(self.kind).atk * (1.0 + 0.12 * (self.level - 1) as f32) * crate::armory::gear_factor(self.gear)
|
|
}
|
|
// the player's creatures (not imps, heroes or the rivals' units)
|
|
pub fn creature(&self) -> bool {
|
|
!self.hero && self.side == me_side() && self.kind != EK::Worker && self.kind != EK::Trader && !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 && (2..=4).contains(&self.side)
|
|
}
|
|
// a dwarf of a dwarf cave (neutral, never an opponent for victory)
|
|
pub fn dwarf(&self) -> bool {
|
|
!self.hero && self.side == crate::dwarves::DWARF_SIDE
|
|
}
|
|
// a hero that is still fighting (not a prisoner)
|
|
pub fn active_hero(&self) -> bool {
|
|
self.hero && self.captive == 0
|
|
}
|
|
}
|
|
|
|
// Goals of a campaign mission (all must be reached)
|
|
#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)]
|
|
pub enum Goal {
|
|
Shards, // take back all shards of the soul core
|
|
Heart, // destroy the gate of the order
|
|
KillAll, // defeat every knight (prisoners count as defeated)
|
|
Loot(u32), // take treasure worth this much from knight vaults
|
|
Towers, // raze all knight towers
|
|
Survive(u32), // beat this many waves
|
|
Rivals, // destroy the soul cores of all rival masters
|
|
Springs(u32), // claim this many mana springs
|
|
Freed(u32), // free this many chained creatures
|
|
}
|
|
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
|
pub struct MissionState {
|
|
pub index: usize, // number of the mission in the campaign
|
|
pub goals: Vec<Goal>,
|
|
pub tools: Vec<bool>, // allowed tools (ui::TOOLS order)
|
|
pub waves: bool,
|
|
pub wave_limit: u32, // 0 = endless
|
|
pub gate_defenders: bool,
|
|
pub heart: bool, // the map has a gate of the order
|
|
pub time_limit: f32, // seconds, 0 = none
|
|
pub shards: u32, // shards on the map
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct Fx {
|
|
pub kind: u8, // 0 projectile, 1 lightning, 2 hit, 3 smoke, 4 heal, 5 gold, 6 dust, 7 level up, 8 claim
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub x2: f32,
|
|
pub y2: f32,
|
|
pub t: f32,
|
|
pub max: f32,
|
|
pub col: u32,
|
|
pub h: f32, // height of the start point (3D)
|
|
pub h2: f32, // height of the end point (projectiles)
|
|
pub size: f32, // 1 = normal; attacks of higher levels look bigger
|
|
pub style: u8, // projectiles: arrow, cannonball, fireball, ... (see particles.rs)
|
|
}
|
|
|
|
thread_local! {
|
|
// the master whose turn it is in the simulation (see Game::switch_to)
|
|
static ME_SIDE: std::cell::Cell<u8> = std::cell::Cell::new(0);
|
|
}
|
|
|
|
pub fn me_side() -> u8 {
|
|
ME_SIDE.with(|s| s.get())
|
|
}
|
|
|
|
fn player_owner() -> Owner {
|
|
Owner::Player
|
|
}
|
|
|
|
// The state of a dungeon master that is not being processed right now. The Game's own fields
|
|
// (gold, mana, hand, ...) always belong to the master whose side is `me_side`; switching swaps
|
|
// them with the stored ones. A single player game has no stored masters at all.
|
|
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct MasterState {
|
|
pub side: u8,
|
|
pub owner: Option<Owner>,
|
|
pub core: (i32, i32),
|
|
pub core_hp: f32,
|
|
pub gold: f32,
|
|
pub wood: f32,
|
|
pub mana: f32,
|
|
pub payday: f32,
|
|
pub payday_warned: bool,
|
|
pub hand: Vec<u32>,
|
|
pub flag: Option<usize>,
|
|
pub reserve: f32,
|
|
pub incoming: Vec<(u8, f32, Option<usize>)>,
|
|
pub training_bill: f32,
|
|
pub counts: [usize; N_ROOMS],
|
|
pub room_level: Vec<u8>,
|
|
pub gear_stock: Vec<u16>,
|
|
pub gear_work: f32,
|
|
pub guard_marks: Vec<usize>,
|
|
pub attack_marks: Vec<usize>,
|
|
pub springs: usize,
|
|
pub known: Vec<bool>,
|
|
pub researchable: Vec<bool>,
|
|
pub research: f32,
|
|
pub forge_work: f32,
|
|
pub rune_free: u32,
|
|
pub rune_final: u32,
|
|
pub rune_next: u32,
|
|
pub portal_timer: f32,
|
|
pub punch_ready: f32,
|
|
#[serde(default)]
|
|
pub talents: crate::talents::Talents,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct Game {
|
|
pub map: Map,
|
|
pub ents: Vec<Ent>,
|
|
pub next_id: u32,
|
|
pub rng: Rng,
|
|
pub gold: f32,
|
|
pub mana: f32,
|
|
pub core_hp: f32,
|
|
pub time: f32,
|
|
pub wave: u32,
|
|
pub wave_timer: f32,
|
|
pub warned: bool,
|
|
pub portal_timer: f32,
|
|
pub payday: f32,
|
|
#[serde(skip)]
|
|
pub fx: Vec<Fx>,
|
|
#[serde(skip)]
|
|
pub msgs: Vec<Msg>,
|
|
pub state: u8, // 0 running, 1 victory, 2 defeat
|
|
pub paused: bool,
|
|
pub hand: Vec<u32>,
|
|
#[serde(skip)]
|
|
pub counts: [usize; N_ROOMS], // room tiles per room kind (ROOM_KINDS order, recounted)
|
|
#[serde(default)]
|
|
pub gear_stock: Vec<u16>, // finished equipment waiting for a creature, per tier (armory.rs)
|
|
#[serde(default)]
|
|
pub gear_work: f32, // forge work on the next piece
|
|
#[serde(default)]
|
|
pub room_level: Vec<u8>, // level of every room kind (ROOM_KINDS order; 1 when missing), researched
|
|
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 script: Option<crate::mscript::ScriptState>, // block scripts of an editor mission
|
|
#[serde(default)]
|
|
pub objective: Option<String>, // the objective a mission script set
|
|
#[serde(default)]
|
|
pub rivals: Vec<crate::rival::Rival>, // AI dungeon masters
|
|
#[serde(default = "yes")]
|
|
pub gate_on: bool, // free game: the humans' gate exists
|
|
#[serde(skip)]
|
|
pub springs: usize, // claimed mana springs (recounted)
|
|
#[serde(skip)]
|
|
pub parts: Vec<crate::particles::Part>, // visual particles (never saved)
|
|
#[serde(skip)]
|
|
pub stuck: Vec<crate::particles::Part>, // arrows stuck in walls and floors, scorch marks
|
|
#[serde(skip, default = "crate::particles::vrng")]
|
|
pub vrng: Rng, // random numbers for the particles only
|
|
#[serde(default)]
|
|
pub traps: Vec<crate::traps::Trap>, // traps and doors (ordered, in the forge, carried, armed)
|
|
#[serde(default)]
|
|
pub rollers: Vec<crate::traps::Roller>,
|
|
#[serde(default)]
|
|
pub clouds: Vec<crate::traps::Cloud>,
|
|
#[serde(default)]
|
|
pub known: Vec<bool>, // tools known (ui::TOOLS order; empty = old savegame: everything)
|
|
#[serde(default)]
|
|
pub researchable: Vec<bool>, // tools that can be researched in the library
|
|
#[serde(default)]
|
|
pub research: f32, // research points towards the current item
|
|
#[serde(default)]
|
|
pub reveals: Vec<(f32, f32, f32)>, // seer's eye: x, y, seconds left
|
|
#[serde(default)]
|
|
pub graves: Vec<crate::rooms::Grave>,
|
|
#[serde(default)]
|
|
pub altar_last: Option<EK>, // the last sacrifice that waits for its pair
|
|
#[serde(skip)]
|
|
pub fog_t: f32, // fog of war: seconds until the next update
|
|
#[serde(skip)]
|
|
pub forge_work: f32, // work done in the forge this frame
|
|
#[serde(skip)]
|
|
pub demo: Option<crate::demo::Demo>, // the endless bridge battle behind the title menu
|
|
#[serde(default)]
|
|
pub dwarf_caves: Vec<crate::dwarves::DwarfCave>,
|
|
#[serde(default)]
|
|
pub bridgework: crate::bridges::BridgeWork, // burning and worn bridges
|
|
#[serde(default)]
|
|
pub weather: crate::weather::Weather, // above ground (weather.rs)
|
|
#[serde(default)]
|
|
pub memorials: Vec<crate::names::Memorial>, // stones for fallen creatures (names.rs)
|
|
#[serde(default)]
|
|
pub hall: Vec<crate::names::Fallen>, // the local master's fallen of this game (names.rs)
|
|
#[serde(default)]
|
|
pub trading: crate::traders::Trading, // the visiting trader (traders.rs)
|
|
#[serde(default)]
|
|
pub happenings: Option<crate::events::Events>, // random events (free games and versus), see events.rs
|
|
#[serde(default)]
|
|
pub snares: Vec<usize>, // tiles with a ranger's snare (rogues.rs)
|
|
#[serde(default)]
|
|
pub talents: crate::talents::Talents, // of the master being processed (talents.rs)
|
|
#[serde(default)]
|
|
pub guard_marks: Vec<usize>, // guard markers (shields) of the master being processed (orders.rs)
|
|
#[serde(default)]
|
|
pub attack_marks: Vec<usize>, // attack markers (swords)
|
|
#[serde(default)]
|
|
pub marks_t: f32,
|
|
#[serde(default)]
|
|
pub stats: crate::stats::GameStats, // statistics of the master at this computer (see stats.rs)
|
|
#[serde(default)]
|
|
pub wall_plans: Vec<(usize, u8)>, // walls to raise: tile, master side (see walls.rs)
|
|
#[serde(default)]
|
|
pub wood: f32, // the second resource (see wood.rs)
|
|
#[serde(default)]
|
|
pub woodwork: crate::wood::WoodWork, // felled mushrooms growing back
|
|
#[serde(default)]
|
|
pub dwarf_t: f32,
|
|
#[serde(default)]
|
|
pub rune_free: u32, // rune forge towers that can be built for free (from chests)
|
|
#[serde(default)]
|
|
pub rune_final: u32, // the same, but given back for a sold tower: no second refund
|
|
#[serde(default)]
|
|
pub rune_next: u32, // sold rune forge towers: back as a chest in the next mission
|
|
#[serde(default)]
|
|
pub freed: u32, // chained creatures freed // tiles next to fighting heroes: knight ground there cannot be claimed
|
|
#[serde(skip, default = "player_owner")]
|
|
pub me: Owner, // owner of the ground of the master being processed
|
|
#[serde(skip)]
|
|
pub me_side: u8, // that master's side (0 = the first player)
|
|
#[serde(skip)]
|
|
pub local_side: u8, // the master who plays at this computer
|
|
#[serde(default)]
|
|
pub masters: Vec<MasterState>, // the other masters (multiplayer, AI masters), saved with the game
|
|
#[serde(skip)]
|
|
pub pending: Vec<Command>, // orders of this master, applied at the next step boundary
|
|
#[serde(skip)]
|
|
pub cmd_seq: u32, // number of the next command queued here
|
|
#[serde(skip)]
|
|
pub sim_steps: u64, // fixed simulation steps done so far (the lockstep clock)
|
|
#[serde(skip)]
|
|
step_acc: f32, // real time not yet paid for with a step
|
|
#[serde(skip)]
|
|
pub netplay: Option<crate::netplay::Netplay>, // a match: turns come in, the world steps per turn
|
|
#[serde(skip)]
|
|
pub ai_sides: Vec<u8>, // match sides whose human master left: AI keeps them playing
|
|
#[serde(skip)]
|
|
pub ai_difficulty: u8, // difficulty used when a left side becomes an AI rival
|
|
#[serde(skip)]
|
|
pub coop: bool, // co-op match: the playable master sides are on one side against the heroes
|
|
#[serde(skip)]
|
|
pub teams: Vec<(u8, u8)>, // (side, team) of a versus match; a side without an entry is its own team
|
|
#[serde(skip)]
|
|
pub out_sides: Vec<u8>, // masters of a match whose soul core fell (their creatures fight on)
|
|
#[serde(skip)]
|
|
pub marks: Vec<Mark>, // short-lived markers for commands given by the masters
|
|
#[serde(skip)]
|
|
pub hints: bool, // show hint messages (setting)
|
|
#[serde(skip)]
|
|
pub debug_markers: bool, // show debug-only command markers
|
|
#[serde(skip, default = "hint_times")]
|
|
hint_t: [f32; 48], // last time each hint slot was shown
|
|
}
|
|
|
|
pub const MARK_DIG: u8 = 0;
|
|
pub const MARK_ROOM: u8 = 1;
|
|
pub const MARK_SELL: u8 = 2;
|
|
pub const MARK_BRIDGE: u8 = 3;
|
|
pub const MARK_STEEL: u8 = 4;
|
|
pub const MARK_TOWER: u8 = 5;
|
|
pub const MARK_TRAP: u8 = 6;
|
|
pub const MARK_SPELL: u8 = 7;
|
|
pub const MARK_LIGHTNING: u8 = 8;
|
|
pub const MARK_HEAL: u8 = 9;
|
|
pub const MARK_SUMMON: u8 = 10;
|
|
pub const MARK_FLAG: u8 = 11;
|
|
pub const MARK_PICKUP: u8 = 12;
|
|
pub const MARK_DROP: u8 = 13;
|
|
pub const MARK_SLAP: u8 = 14;
|
|
pub const MARK_ATTACK: u8 = 15;
|
|
pub const MARK_GUARD: u8 = 16;
|
|
pub const MARK_CANCEL: u8 = 17;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
|
pub struct Mark {
|
|
pub x: f32,
|
|
pub y: f32,
|
|
pub kind: u8,
|
|
pub side: u8,
|
|
pub until: u64,
|
|
#[serde(default)]
|
|
pub fixed: bool, // guard/attack/gather order flags are shown without the debug setting
|
|
}
|
|
|
|
fn hint_times() -> [f32; 48] {
|
|
[-1000.0; 48]
|
|
}
|
|
|
|
// every simulation step has the same length: on every client of a match the same commands are
|
|
// applied and then the same number of steps runs, so the worlds stay identical
|
|
pub const STEP: f32 = 1.0 / 50.0; // 50 steps a second
|
|
// one frame never runs away into the future (a lagging tab catches up at most this much)
|
|
const MAX_STEPS_PER_FRAME: usize = 12;
|
|
|
|
// torch on a wall face: wall tile + direction (index into DIR4) towards the open side;
|
|
// or a torch on a little stand in the middle of a floor tile (stand: wall = that tile)
|
|
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
pub struct Torch {
|
|
pub wall: usize,
|
|
pub dir: usize,
|
|
#[serde(default)]
|
|
pub stand: bool,
|
|
}
|
|
|
|
// rooms where imps may set up a torch stand (no beds, hoards, mushrooms or crates in the way)
|
|
pub fn stand_ground(k: Kind) -> bool {
|
|
matches!(k, Kind::Floor | Kind::Training | Kind::Library | Kind::Torture | Kind::Temple | Kind::Prison | Kind::Graveyard | Kind::GuardPost | Kind::Arena | Kind::Altar)
|
|
}
|
|
|
|
// a torch stand goes where no torch shines closer than this (tiles)
|
|
pub const STAND_GAP: f32 = 4.5;
|
|
|
|
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);
|
|
if self.stand {
|
|
return (x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
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)
|
|
}
|
|
|
|
// height of the bottom of the flame
|
|
pub fn flame_h(&self) -> f32 {
|
|
if self.stand {
|
|
0.62
|
|
} else {
|
|
0.72
|
|
}
|
|
}
|
|
}
|
|
|
|
pub const N_ROOMS: usize = 14;
|
|
pub const ROOM_FORGE: usize = 8;
|
|
pub const ROOM_KINDS: [Kind; N_ROOMS] = [
|
|
Kind::Treasury,
|
|
Kind::Lair,
|
|
Kind::Farm,
|
|
Kind::Training,
|
|
Kind::Library,
|
|
Kind::Torture,
|
|
Kind::Temple,
|
|
Kind::Aerie,
|
|
Kind::Forge,
|
|
Kind::Prison,
|
|
Kind::Graveyard,
|
|
Kind::GuardPost,
|
|
Kind::Arena,
|
|
Kind::Altar,
|
|
];
|
|
pub const ROOM_COST: [i32; N_ROOMS] = [60, 80, 100, 140, 160, 200, 180, 120, 150, 160, 140, 90, 180, 250];
|
|
pub const ROOM_NAMES: [&str; N_ROOMS] = [
|
|
"room.treasury",
|
|
"room.lair",
|
|
"room.farm",
|
|
"room.training",
|
|
"room.library",
|
|
"room.torture",
|
|
"room.temple",
|
|
"room.aerie",
|
|
"room.forge",
|
|
"room.prison",
|
|
"room.graveyard",
|
|
"room.guardpost",
|
|
"room.arena",
|
|
"room.altar",
|
|
];
|
|
|
|
// bridge cost per tile: over water, over lava
|
|
pub const BRIDGE_COST: [i32; 2] = [10, 75];
|
|
// a drawbridge tile: over water (and wood, see wood.rs), over lava
|
|
pub const DRAWBRIDGE_COST: [i32; 2] = [120, 150];
|
|
// 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)
|
|
}
|
|
|
|
impl Game {
|
|
// the researched level of a room kind of the master being processed (1, 2 or 3)
|
|
pub fn room_level_of(&self, k: Kind) -> u8 {
|
|
room_index(k).and_then(|r| self.room_level.get(r).copied()).unwrap_or(1).max(1)
|
|
}
|
|
|
|
// how much better a room works at its level: +25% per level above the first
|
|
pub fn room_bonus(&self, k: Kind) -> f32 {
|
|
1.0 + 0.25 * (self.room_level_of(k) - 1) as f32
|
|
}
|
|
}
|
|
|
|
pub(crate) 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)
|
|
}
|
|
}
|
|
|
|
pub(crate) fn sgn(v: f32) -> f32 {
|
|
if v < 0.0 {
|
|
-1.0
|
|
} else {
|
|
1.0
|
|
}
|
|
}
|
|
|
|
impl Game {
|
|
// free game with the humans' gate only (tests; the menu uses new_free)
|
|
#[cfg_attr(not(test), allow(dead_code))]
|
|
pub fn new(seed: u32, w: usize, h: usize) -> Game {
|
|
let mut g = Game::from_map(Map::generate(seed, w, h), seed);
|
|
// heroes take longer to reach the core on big maps: longer grace period
|
|
let (cx, cy) = g.map.core;
|
|
let (gx, gy) = g.map.gate;
|
|
let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32;
|
|
g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0);
|
|
g.guard_treasures();
|
|
g.place_dwarf_caves(seed);
|
|
g.add_wilds(seed, true);
|
|
g.init_research(None);
|
|
g.init_fog();
|
|
g.msg("msg.welcome1", vec![]);
|
|
g.msg("msg.welcome2", vec![]);
|
|
g.msg("msg.welcome3", vec![]);
|
|
g.msg("msg.welcome4", vec![]);
|
|
g
|
|
}
|
|
|
|
// treasure caves are guarded by heroes
|
|
pub fn guard_treasures(&mut self) {
|
|
for k in 0..self.map.treasures.len() {
|
|
let (tx, ty) = self.map.treasures[k];
|
|
let squad = [EK::Knight, EK::Archer, EK::Dwarf, if k % 2 == 0 { EK::Priest } else { EK::Knight }];
|
|
for (j, &kind) in squad.iter().enumerate() {
|
|
let x = tx as f32 + 0.5 + (j as f32 - 1.5) * 0.6;
|
|
let id = self.spawn(kind, true, x, ty as f32 + 0.5, 2);
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.guard = true;
|
|
e.home = Some((tx as f32 + 0.5, ty as f32 + 0.5));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// game state around a generated map: treasury ring around the core, four imps, torches
|
|
pub fn from_map(map: Map, seed: u32) -> Game {
|
|
let mut g = Game {
|
|
map,
|
|
ents: Vec::new(),
|
|
next_id: 1,
|
|
rng: Rng(seed.wrapping_mul(2654435761) | 1),
|
|
gold: 1500.0,
|
|
mana: 100.0,
|
|
core_hp: CORE_HP,
|
|
time: 0.0,
|
|
wave: 0,
|
|
wave_timer: FIRST_WAVE,
|
|
warned: false,
|
|
portal_timer: 10.0,
|
|
payday: PAYDAY,
|
|
fx: Vec::new(),
|
|
msgs: Vec::new(),
|
|
state: 0,
|
|
paused: false,
|
|
hand: Vec::new(),
|
|
counts: [0; N_ROOMS],
|
|
room_level: Vec::new(),
|
|
gear_stock: Vec::new(),
|
|
gear_work: 0.0,
|
|
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,
|
|
debug_markers: false,
|
|
me: Owner::Player,
|
|
me_side: 0,
|
|
local_side: 0,
|
|
masters: Vec::new(),
|
|
pending: Vec::new(),
|
|
cmd_seq: 0,
|
|
sim_steps: 0,
|
|
step_acc: 0.0,
|
|
netplay: None,
|
|
ai_sides: Vec::new(),
|
|
ai_difficulty: 1,
|
|
coop: false,
|
|
teams: Vec::new(),
|
|
out_sides: Vec::new(),
|
|
marks: Vec::new(),
|
|
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,
|
|
script: None,
|
|
objective: None,
|
|
rivals: Vec::new(),
|
|
gate_on: true,
|
|
springs: 0,
|
|
freed: 0,
|
|
demo: None,
|
|
traps: Vec::new(),
|
|
rollers: Vec::new(),
|
|
clouds: Vec::new(),
|
|
forge_work: 0.0,
|
|
fog_t: 0.0,
|
|
graves: Vec::new(),
|
|
altar_last: None,
|
|
reveals: Vec::new(),
|
|
known: Vec::new(),
|
|
researchable: Vec::new(),
|
|
research: 0.0,
|
|
parts: Vec::new(),
|
|
stuck: Vec::new(),
|
|
vrng: crate::particles::vrng(),
|
|
dwarf_caves: Vec::new(),
|
|
bridgework: Default::default(),
|
|
weather: Default::default(),
|
|
memorials: Vec::new(),
|
|
hall: Vec::new(),
|
|
trading: Default::default(),
|
|
happenings: None,
|
|
snares: Vec::new(),
|
|
talents: Default::default(),
|
|
guard_marks: Vec::new(),
|
|
attack_marks: Vec::new(),
|
|
marks_t: 0.0,
|
|
stats: Default::default(),
|
|
wall_plans: Vec::new(),
|
|
wood: crate::wood::WOOD_START,
|
|
woodwork: Default::default(),
|
|
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| if t.stand { stand_ground(map.t[t.wall].kind) } else { map.t[t.wall].solid() });
|
|
for i in 0..n() {
|
|
if self.map.t[i].owner == self.me {
|
|
self.place_torches(i);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- campaign missions
|
|
|
|
// the rune forge tower only shows up in the build menu once there is one to build or upgrade
|
|
pub fn tool_visible(&self, tool: usize) -> bool {
|
|
tool != RUNE_TOOL || self.rune_available() > 0 || self.towers.iter().any(|t| !t.hero && t.kind == TowerKind::Rune)
|
|
}
|
|
|
|
pub fn tool_allowed(&self, tool: usize) -> bool {
|
|
if !self.known.is_empty() {
|
|
return self.known(tool);
|
|
}
|
|
self.mission.as_ref().map_or(true, |m| m.tools.get(tool).copied().unwrap_or(true))
|
|
}
|
|
|
|
pub fn goal_done(&self, goal: Goal) -> bool {
|
|
match goal {
|
|
Goal::Shards => self.shards_taken >= self.mission.as_ref().map_or(0, |m| m.shards),
|
|
Goal::Heart => self.gate_hp <= 0.0,
|
|
Goal::KillAll => !self.ents.iter().any(|e| e.alive && e.active_hero()),
|
|
Goal::Loot(n) => self.loot >= n as f32,
|
|
Goal::Towers => !self.towers.iter().any(|t| t.hero),
|
|
Goal::Survive(n) => self.wave >= n && !self.ents.iter().any(|e| e.alive && e.active_hero() && !e.guard && e.patrol.is_empty()),
|
|
Goal::Rivals => self.rivals.iter().all(|r| !r.alive),
|
|
Goal::Springs(n) => self.springs >= n as usize,
|
|
Goal::Freed(n) => self.freed >= n,
|
|
}
|
|
}
|
|
|
|
pub fn waves_on(&self) -> bool {
|
|
self.mission.as_ref().map_or(self.gate_on, |m| m.waves && (m.wave_limit == 0 || self.wave < m.wave_limit))
|
|
}
|
|
|
|
pub fn has_gate(&self) -> bool {
|
|
self.mission.as_ref().map_or(self.gate_on, |m| m.heart)
|
|
}
|
|
|
|
// messages, hints and sounds are meant for the master who plays at this computer: while
|
|
// another master is being simulated (his economy, his orders) nothing of that reaches the screen
|
|
fn speaking(&self) -> bool {
|
|
self.me_side == self.local_side
|
|
}
|
|
|
|
pub fn msg(&mut self, key: &'static str, args: Vec<String>) {
|
|
if !self.speaking() {
|
|
return; // a foreign master ordered something: only he hears about it
|
|
}
|
|
self.msgs.push(Msg { key, args });
|
|
if self.msgs.len() > 50 {
|
|
self.msgs.remove(0);
|
|
}
|
|
}
|
|
|
|
pub fn sfx(&mut self, sfx: Sfx, x: f32, y: f32) {
|
|
if !self.speaking() {
|
|
return;
|
|
}
|
|
if self.events.len() < 64 {
|
|
self.events.push(SfxEvent { sfx, pos: Some((x, y)) });
|
|
}
|
|
}
|
|
|
|
pub fn sfx_ui(&mut self, sfx: Sfx) {
|
|
if !self.speaking() {
|
|
return;
|
|
}
|
|
if self.events.len() < 64 {
|
|
self.events.push(SfxEvent { sfx, pos: None });
|
|
}
|
|
}
|
|
|
|
pub fn gate_center(&self) -> (f32, f32) {
|
|
(self.map.gate.0 as f32 + 1.5, self.map.gate.1 as f32 + 1.5)
|
|
}
|
|
|
|
// Army value: sqrt(sum of HP * sum of damage/s) of all own creatures.
|
|
pub fn army_value(&self) -> f32 {
|
|
let (mut h, mut d) = (0.0, 0.0);
|
|
for e in &self.ents {
|
|
if e.alive && e.creature() {
|
|
h += e.max_hp;
|
|
d += e.atk() / st(e.kind).interval;
|
|
}
|
|
}
|
|
// towers count with half their combat value
|
|
for t in self.towers.iter().filter(|t| !t.hero) {
|
|
h += 0.5 * t.max_hp();
|
|
d += 0.5 * t.dmg() / tower_stats(t.kind).interval;
|
|
}
|
|
(h * d).sqrt()
|
|
}
|
|
|
|
// imps hang torches on walls next to claimed floor (min. distance 3.5 tiles)
|
|
pub fn place_torches(&mut self, i: usize) {
|
|
let (x, y) = xy(i);
|
|
for (d, &(dx, dy)) in DIR4.iter().enumerate() {
|
|
let (wx, wy) = (x + dx, y + dy);
|
|
if !inb(wx, wy) || !self.map.t[idx(wx, wy)].solid() {
|
|
continue;
|
|
}
|
|
// the direction from the wall to the floor is the opposite one
|
|
let t = Torch { wall: idx(wx, wy), dir: (d + 2) % 4, stand: false };
|
|
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);
|
|
}
|
|
}
|
|
// a big room the wall torches do not light: a torch on a stand in the middle of it,
|
|
// on the master's own ground only (never neutral or foreign ground) and not next to a wall
|
|
let tile = self.map.t[i];
|
|
let open_around = (-1..=1).all(|dy| (-1..=1).all(|dx| self.map.get(x + dx, y + dy).map_or(false, |t| !t.solid())));
|
|
if tile.owner == self.me && stand_ground(tile.kind) && open_around {
|
|
let (px, pz) = (x as f32 + 0.5, y as f32 + 0.5);
|
|
let near = self.torches.iter().any(|o| {
|
|
let (ox, oz) = o.pos();
|
|
(ox - px).powi(2) + (oz - pz).powi(2) < STAND_GAP * STAND_GAP
|
|
});
|
|
if !near {
|
|
self.torches.push(Torch { wall: i, dir: 0, stand: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
// torch stands go when their ground is lost or becomes a room they do not fit in
|
|
fn tidy_torch_stands(&mut self) {
|
|
let map = &self.map;
|
|
self.torches.retain(|t| !t.stand || (stand_ground(map.t[t.wall].kind) && !matches!(map.t[t.wall].owner, Owner::None | Owner::Hero)));
|
|
}
|
|
|
|
pub(crate) fn hint(&mut self, slot: usize, key: &'static str) {
|
|
if !self.speaking() {
|
|
return; // another master ordered something: he keeps his hints for himself
|
|
}
|
|
if self.hints && self.time - self.hint_t[slot] > 30.0 {
|
|
self.hint_t[slot] = self.time;
|
|
self.msg(key, vec![]);
|
|
}
|
|
}
|
|
|
|
pub fn recount(&mut self) {
|
|
let mut c = [0usize; N_ROOMS];
|
|
self.springs = self.map.t.iter().filter(|t| t.kind == Kind::ManaSpring && t.owner == self.me).count();
|
|
for t in &self.map.t {
|
|
if t.owner == self.me {
|
|
if let Some(r) = room_index(t.kind) {
|
|
c[r] += 1;
|
|
}
|
|
}
|
|
}
|
|
self.counts = c;
|
|
}
|
|
|
|
pub fn mana_max(&self) -> f32 {
|
|
MANA_MAX + SPRING_MAX * self.springs as f32
|
|
}
|
|
|
|
pub fn gold_cap(&self) -> f32 {
|
|
CORE_STORE + TREASURY_TILE * self.counts[0] as f32 * self.room_bonus(Kind::Treasury)
|
|
}
|
|
|
|
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));
|
|
self.count(crate::stats::GOLD, acc);
|
|
}
|
|
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::<f32>() * self.talents.wages();
|
|
wages + self.training_bill
|
|
}
|
|
|
|
// The gold counter is the truth; the treasury tiles show where it lies. Added treasure fills
|
|
// tiles of the same kind first, then empty ones, the rest goes into the core. Spending takes
|
|
// from random treasury tiles first, then from the core.
|
|
pub fn sync_treasury(&mut self) {
|
|
let mut tiles = Vec::new();
|
|
let mut stored = 0.0;
|
|
for i in 0..self.map.t.len() {
|
|
let t = &mut self.map.t[i];
|
|
if t.kind == Kind::Treasury && t.owner == self.me {
|
|
tiles.push(i);
|
|
stored += t.gold as f32;
|
|
} else if t.gold != 0 && t.vein().is_none() && t.kind != Kind::Treasury {
|
|
t.gold = 0; // a sold treasury tile loses its content
|
|
t.res = 0;
|
|
}
|
|
}
|
|
let diff = self.gold - (self.reserve + stored);
|
|
let incoming = std::mem::take(&mut self.incoming);
|
|
if diff < -0.5 {
|
|
let mut need = -diff;
|
|
while need > 0.5 {
|
|
let full: Vec<usize> = tiles.iter().copied().filter(|&i| self.map.t[i].gold > 0).collect();
|
|
if full.is_empty() {
|
|
break;
|
|
}
|
|
let i = full[(self.rng.next() as usize) % full.len()];
|
|
let t = &mut self.map.t[i];
|
|
let take = (t.gold as f32).min(need.ceil());
|
|
t.gold -= take as i32;
|
|
need -= take;
|
|
}
|
|
self.reserve = (self.reserve - need.max(0.0)).max(0.0);
|
|
} else if diff > 0.5 {
|
|
let mut left = diff;
|
|
let mut parts: Vec<(u8, f32, Option<usize>)> = incoming;
|
|
let typed: f32 = parts.iter().map(|p| p.1).sum();
|
|
if typed < left {
|
|
parts.push((RES_GOLD, left - typed, None));
|
|
}
|
|
for (res, amount, at) in parts {
|
|
let mut a = amount.min(left);
|
|
left -= a;
|
|
// delivered treasure goes to the treasury tiles nearest to the place of delivery
|
|
let mut order = tiles.clone();
|
|
if let Some(at) = at {
|
|
let (ax, ay) = xy(at);
|
|
order.sort_by_key(|&i| {
|
|
let (x, y) = xy(i);
|
|
(x - ax).pow(2) + (y - ay).pow(2)
|
|
});
|
|
}
|
|
// without a place: tiles holding this treasure first, then empty tiles;
|
|
// with a place: strictly the nearest tiles that can take it
|
|
let passes: &[u8] = if at.is_some() { &[2] } else { &[0, 1] };
|
|
for &pass in passes {
|
|
for &i in &order {
|
|
if a <= 0.5 {
|
|
break;
|
|
}
|
|
let t = &mut self.map.t[i];
|
|
let fits = match pass {
|
|
0 => t.gold > 0 && t.res == res,
|
|
1 => t.gold <= 0,
|
|
_ => (t.gold > 0 && t.res == res && (t.gold as f32) < TREASURY_TILE) || t.gold <= 0,
|
|
};
|
|
if !fits {
|
|
continue;
|
|
}
|
|
let room = TREASURY_TILE - t.gold as f32;
|
|
let put = a.min(room).floor();
|
|
if put >= 1.0 {
|
|
t.gold += put as i32;
|
|
t.res = res;
|
|
a -= put;
|
|
}
|
|
}
|
|
}
|
|
self.reserve += a.max(0.0);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn portals_owned(&self) -> usize {
|
|
(0..self.map.portals.len()).filter(|&k| self.map.portal_owner(k) == self.me).count()
|
|
}
|
|
|
|
// gate defenders alive (not the treasure guards)
|
|
pub fn gate_defenders_alive(&self) -> bool {
|
|
let (gx, gy) = self.gate_center();
|
|
self.ents.iter().any(|e| e.alive && e.guard && e.captive == 0 && e.home.map_or(false, |(x, y)| (x - gx).abs() < 1.0 && (y - gy).abs() < 1.0))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn portal_owned(&self) -> bool {
|
|
let Some(&(px, py)) = self.map.portals.first() else { return false };
|
|
self.map.t[idx(px, py)].owner == self.me
|
|
}
|
|
|
|
pub fn creature_count(&self) -> usize {
|
|
self.ents.iter().filter(|e| e.alive && e.creature()).count()
|
|
}
|
|
pub fn worker_count(&self) -> usize {
|
|
self.ents.iter().filter(|e| e.alive && !e.hero && e.kind == EK::Worker && e.side == self.me_side && !e.chained).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.side = self.me_side;
|
|
e.x = x;
|
|
e.y = y;
|
|
e.disp_x = x;
|
|
e.disp_y = y;
|
|
e.level = level;
|
|
if !hero && !matches!(kind, EK::Ram | EK::Catapult | EK::Trader) {
|
|
e.name = crate::names::seed_for(id);
|
|
}
|
|
e.max_hp = s.hp * (1.0 + 0.15 * (level - 1) as f32);
|
|
e.hp = e.max_hp;
|
|
e.alive = true;
|
|
e.think = self.rng.f();
|
|
e.energy = 0.7 + self.rng.f() * 0.3;
|
|
e.hunger = self.rng.f() * 0.3;
|
|
e.anim = self.rng.f() * 10.0;
|
|
self.ents.push(e);
|
|
id
|
|
}
|
|
|
|
pub fn fx(&mut self, kind: u8, x: f32, y: f32, x2: f32, y2: f32, max: f32, col: u32) {
|
|
let h = if kind == 0 { 0.45 } else { 0.4 };
|
|
self.fx_particles(kind, x, y, h, col);
|
|
if self.fx.len() < 400 {
|
|
self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col, h, h2: 0.45, size: 1.0, style: 0 });
|
|
}
|
|
}
|
|
|
|
// projectile from (x, y) at height h to (x2, y2) at height h2
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn shot(&mut self, x: f32, y: f32, h: f32, x2: f32, y2: f32, h2: f32, max: f32, col: u32) {
|
|
if self.fx.len() < 400 {
|
|
self.fx.push(Fx { kind: 0, x, y, x2, y2, t: 0.0, max, col, h, h2, size: 1.0, style: 0 });
|
|
}
|
|
}
|
|
|
|
// the last effect gets bigger (attacks of experienced units)
|
|
fn grow_last_fx(&mut self, size: f32) {
|
|
if let Some(f) = self.fx.last_mut() {
|
|
f.size = size;
|
|
}
|
|
}
|
|
|
|
// effect at a given height
|
|
pub fn fx_at(&mut self, kind: u8, x: f32, y: f32, h: f32, max: f32, col: u32) {
|
|
self.fx_particles(kind, x, y, h, col);
|
|
if self.fx.len() < 400 {
|
|
self.fx.push(Fx { kind, x, y, x2: 0.0, y2: 0.0, t: 0.0, max, col, h, h2: h, size: 1.0, style: 0 });
|
|
}
|
|
}
|
|
|
|
pub fn find(&self, id: u32) -> Option<usize> {
|
|
self.ents.iter().position(|e| e.id == id && e.alive && !e.held)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- update
|
|
|
|
// smoothed positions for the screen only: the simulation stays fixed-step and deterministic
|
|
pub fn smooth_display(&mut self, dt: f32) {
|
|
if dt <= 0.0 {
|
|
return;
|
|
}
|
|
let k = (1.0 - (-22.0 * dt).exp()).clamp(0.0, 1.0);
|
|
for e in self.ents.iter_mut() {
|
|
if e.disp_x == 0.0 && e.disp_y == 0.0 {
|
|
e.disp_x = e.x;
|
|
e.disp_y = e.y;
|
|
continue;
|
|
}
|
|
let dx = e.x - e.disp_x;
|
|
let dy = e.y - e.disp_y;
|
|
if dx * dx + dy * dy > 4.0 {
|
|
e.disp_x = e.x;
|
|
e.disp_y = e.y;
|
|
} else {
|
|
e.disp_x += dx * k;
|
|
e.disp_y += dy * k;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn update(&mut self, dt: f32) {
|
|
let mut steps = 0usize;
|
|
let cmds = if self.netplay.is_some() {
|
|
// a match runs turn by turn (see netplay.rs): without the next turn the world stands still,
|
|
// and local orders are not applied here - they travel to the server first
|
|
Vec::new()
|
|
} else {
|
|
self.step_acc = (self.step_acc + dt).min(STEP * MAX_STEPS_PER_FRAME as f32);
|
|
while self.step_acc >= STEP {
|
|
self.step_acc -= STEP;
|
|
steps += 1;
|
|
}
|
|
std::mem::take(&mut self.pending)
|
|
};
|
|
self.apply_turn(&cmds, steps);
|
|
if !self.marks.is_empty() {
|
|
self.marks.retain(|m| self.sim_steps < m.until);
|
|
}
|
|
// what only the camera cares about runs in real time: it is not part of the shared state
|
|
self.update_particles(dt);
|
|
for f in self.fx.iter_mut() {
|
|
f.t += dt;
|
|
}
|
|
self.fx.retain(|f| f.t < f.max);
|
|
}
|
|
|
|
// one fixed step: the world of every master, then the shared bookkeeping
|
|
pub(crate) fn step_fixed(&mut self) {
|
|
self.sim_steps += 1;
|
|
if self.state == 0 && !self.paused {
|
|
self.step(STEP);
|
|
}
|
|
for side in self.master_sides() {
|
|
self.switch_to(side);
|
|
self.sync_treasury();
|
|
}
|
|
// mission scripts: always from the first player's side, the same on every computer
|
|
if self.script.is_some() {
|
|
self.switch_to(0);
|
|
self.run_scripts();
|
|
}
|
|
let local = self.local_side;
|
|
self.switch_to(local);
|
|
self.sample_stats(STEP);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- masters
|
|
|
|
pub fn master_sides(&self) -> Vec<u8> {
|
|
let mut v: Vec<u8> = std::iter::once(self.me_side).chain(self.masters.iter().map(|m| m.side)).collect();
|
|
v.sort_unstable();
|
|
v
|
|
}
|
|
|
|
pub fn is_master(&self, side: u8) -> bool {
|
|
side == self.me_side || self.masters.iter().any(|m| m.side == side)
|
|
}
|
|
|
|
pub(crate) fn is_hostile(&self, e: &Ent) -> bool {
|
|
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != self.me_side && !self.allied(self.me_side, e.side())
|
|
}
|
|
|
|
pub fn is_rival_side(&self, side: u8) -> bool {
|
|
self.rivals.iter().any(|r| r.alive && r.side == side)
|
|
}
|
|
|
|
fn take_active(&mut self) -> MasterState {
|
|
MasterState {
|
|
side: self.me_side,
|
|
owner: Some(self.me),
|
|
core: self.map.core,
|
|
core_hp: self.core_hp,
|
|
gold: self.gold,
|
|
wood: self.wood,
|
|
mana: self.mana,
|
|
payday: self.payday,
|
|
payday_warned: self.payday_warned,
|
|
hand: std::mem::take(&mut self.hand),
|
|
flag: self.flag,
|
|
reserve: self.reserve,
|
|
incoming: std::mem::take(&mut self.incoming),
|
|
training_bill: self.training_bill,
|
|
counts: self.counts,
|
|
room_level: self.room_level.clone(),
|
|
gear_stock: self.gear_stock.clone(),
|
|
gear_work: self.gear_work,
|
|
guard_marks: std::mem::take(&mut self.guard_marks),
|
|
attack_marks: std::mem::take(&mut self.attack_marks),
|
|
springs: self.springs,
|
|
known: std::mem::take(&mut self.known),
|
|
researchable: std::mem::take(&mut self.researchable),
|
|
research: self.research,
|
|
forge_work: self.forge_work,
|
|
rune_free: self.rune_free,
|
|
rune_final: self.rune_final,
|
|
rune_next: self.rune_next,
|
|
portal_timer: self.portal_timer,
|
|
punch_ready: self.punch_ready,
|
|
talents: self.talents.clone(),
|
|
}
|
|
}
|
|
|
|
fn put_active(&mut self, m: MasterState) {
|
|
self.me_side = m.side;
|
|
self.me = m.owner.unwrap_or(Owner::of_side(m.side));
|
|
ME_SIDE.with(|s| s.set(m.side));
|
|
self.map.core = m.core;
|
|
self.core_hp = m.core_hp;
|
|
self.gold = m.gold;
|
|
self.wood = m.wood;
|
|
self.mana = m.mana;
|
|
self.payday = m.payday;
|
|
self.payday_warned = m.payday_warned;
|
|
self.hand = m.hand;
|
|
self.flag = m.flag;
|
|
self.reserve = m.reserve;
|
|
self.incoming = m.incoming;
|
|
self.training_bill = m.training_bill;
|
|
self.counts = m.counts;
|
|
self.room_level = m.room_level;
|
|
self.gear_stock = m.gear_stock;
|
|
self.gear_work = m.gear_work;
|
|
self.guard_marks = m.guard_marks;
|
|
self.attack_marks = m.attack_marks;
|
|
self.springs = m.springs;
|
|
self.known = m.known;
|
|
self.researchable = m.researchable;
|
|
self.research = m.research;
|
|
self.forge_work = m.forge_work;
|
|
self.rune_free = m.rune_free;
|
|
self.rune_final = m.rune_final;
|
|
self.rune_next = m.rune_next;
|
|
self.portal_timer = m.portal_timer;
|
|
self.punch_ready = m.punch_ready;
|
|
self.talents = m.talents;
|
|
}
|
|
|
|
// make `side` the master being processed (no-op for sides that are no masters)
|
|
pub fn switch_to(&mut self, side: u8) {
|
|
if side == self.me_side {
|
|
return;
|
|
}
|
|
let Some(k) = self.masters.iter().position(|m| m.side == side) else { return };
|
|
let target = self.masters.swap_remove(k);
|
|
let cur = self.take_active();
|
|
self.masters.push(cur);
|
|
self.put_active(target);
|
|
}
|
|
|
|
// a new master on `side` with his core at (cx, cy) (top-left of the 3x3 core); single player
|
|
// games have no stored masters
|
|
#[allow(dead_code)] // used by the tests; the *Multiplayer* menu (step D) will call it
|
|
pub fn add_master(&mut self, side: u8, core: (i32, i32)) {
|
|
let mut m = self.take_active();
|
|
let me = std::mem::replace(&mut m, MasterState::default());
|
|
// the copy keeps the economy settings; the new one starts fresh
|
|
let fresh = MasterState {
|
|
side,
|
|
owner: Some(Owner::of_side(side)),
|
|
core,
|
|
core_hp: CORE_HP,
|
|
gold: 1500.0,
|
|
mana: 100.0,
|
|
payday: PAYDAY,
|
|
known: me.known.clone(),
|
|
researchable: me.researchable.clone(),
|
|
portal_timer: 10.0,
|
|
..MasterState::default()
|
|
};
|
|
self.put_active(me);
|
|
self.masters.push(fresh);
|
|
}
|
|
|
|
// A match: `sides` (out of 0, 2, 3, 4 - side 1 is the heroes') play against each other and
|
|
// `local` is the master who plays at this computer. Side 0 keeps the core of the generated map
|
|
// (that is where the hero gate path leads); the other masters get their cores at fixed places
|
|
// worked out from the map size alone, so every client of a match puts them in the same spots.
|
|
#[allow(dead_code)] // used by the tests; the *Multiplayer* menu (step D) will call it
|
|
pub fn start_masters(&mut self, sides: &[u8], local: u8) -> bool {
|
|
let sides: Vec<u8> = sides.iter().copied().filter(|s| Owner::MASTER_SIDES.contains(s)).collect();
|
|
if sides.is_empty() || !sides.contains(&0) || !sides.contains(&local) {
|
|
return false;
|
|
}
|
|
if sides.len() < 2 && !self.coop {
|
|
return false;
|
|
}
|
|
let (w, h) = (mw() as i32, mh() as i32);
|
|
if w < 72 || h < 72 {
|
|
return false; // the master areas need room
|
|
}
|
|
let cores: Vec<(i32, i32)> = sides.iter().map(|&s| match s { 0 => self.map.core, 2 => (w - 30, 12), 3 => (w - 30, h - 16), _ => (14, h - 16) }).collect();
|
|
for (k, side) in sides.iter().enumerate() {
|
|
if *side == self.me_side {
|
|
continue;
|
|
}
|
|
self.carve_master_area(*side, cores[k]);
|
|
// every master gets a portal of his own (side 0 has the map's portal)
|
|
self.add_master_portal(*side, cores[k]);
|
|
self.add_master(*side, cores[k]);
|
|
self.switch_to(*side);
|
|
let (cx, cy) = cores[k];
|
|
// a new master needs the same starting crew side 0 already has
|
|
for j in 0..4 {
|
|
self.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + j as f32 * 0.7 + 0.3, 1);
|
|
}
|
|
}
|
|
self.local_side = local;
|
|
self.switch_to(local);
|
|
true
|
|
}
|
|
|
|
// a master's own portal right beside his starting area (if there is room)
|
|
pub(crate) fn add_master_portal(&mut self, side: u8, core: (i32, i32)) {
|
|
let owner = Owner::of_side(side);
|
|
let (px, py) = (core.0 + 6, core.1);
|
|
if px + 3 >= mw() as i32 - 2 || py < 2 || py + 3 >= mh() as i32 - 2 {
|
|
return;
|
|
}
|
|
for y in py..py + 3 {
|
|
for x in px..px + 3 {
|
|
let t = &mut self.map.t[idx(x, y)];
|
|
if !matches!(t.kind, Kind::Core | Kind::Gate) {
|
|
t.kind = Kind::Portal;
|
|
t.owner = owner;
|
|
t.gold = 0;
|
|
t.marked = false;
|
|
t.reinforced = false;
|
|
}
|
|
}
|
|
}
|
|
if !self.map.portals.contains(&(px, py)) {
|
|
self.map.portals.push((px, py));
|
|
}
|
|
}
|
|
|
|
// the starting area of a master: a 9x9 room of his own floor and his 3x3 soul core
|
|
pub(crate) fn carve_master_area(&mut self, side: u8, core: (i32, i32)) {
|
|
let owner = Owner::of_side(side);
|
|
for y in core.1 - 3..core.1 + 6 {
|
|
for x in core.0 - 3..core.0 + 6 {
|
|
if x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
|
|
continue;
|
|
}
|
|
let t = &mut self.map.t[idx(x, y)];
|
|
if matches!(t.kind, Kind::Portal | Kind::Gate | Kind::Core) {
|
|
continue;
|
|
}
|
|
t.kind = Kind::Floor;
|
|
t.owner = owner;
|
|
t.gold = 0;
|
|
t.marked = false;
|
|
t.reinforced = false;
|
|
}
|
|
}
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
let t = &mut self.map.t[idx(core.0 + x, core.1 + y)];
|
|
t.kind = Kind::Core;
|
|
t.owner = owner;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn update_units(&mut self, dt: f32) {
|
|
for i in 0..self.ents.len() {
|
|
if !self.ents[i].alive || self.ents[i].held {
|
|
continue;
|
|
}
|
|
let side = self.ents[i].side;
|
|
if !self.ents[i].hero && side != self.me_side && !self.masters.is_empty() {
|
|
self.switch_to(side);
|
|
}
|
|
let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy());
|
|
let (x0, y0) = (e.x, e.y);
|
|
self.update_ent(&mut e, dt);
|
|
self.turn(&mut e, x0, y0);
|
|
self.ents[i] = e;
|
|
}
|
|
// units that wasted away (plague)
|
|
for j in 0..self.ents.len() {
|
|
if self.ents[j].alive && !self.ents[j].held && self.ents[j].hp <= 0.0 {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
self.ents.retain(|e| e.alive);
|
|
}
|
|
|
|
fn step(&mut self, dt: f32) {
|
|
if self.demo.is_some() {
|
|
self.demo_step(dt);
|
|
return;
|
|
}
|
|
self.time += dt;
|
|
// mushroom gardens grow faster at a higher level (of the master who owns them)
|
|
let farm = Kind::Farm;
|
|
let lv: Vec<(Owner, f32)> = self.master_sides().into_iter().map(|s| (Owner::of_side(s), {
|
|
let r = room_index(farm).unwrap_or(0);
|
|
let l = if s == self.me_side { self.room_level.get(r).copied() } else { self.masters.iter().find(|m| m.side == s).and_then(|m| m.room_level.get(r).copied()) };
|
|
1.0 + 0.25 * (l.unwrap_or(1).max(1) - 1) as f32
|
|
})).collect();
|
|
for t in self.map.t.iter_mut() {
|
|
if t.kind == Kind::Farm {
|
|
let f = lv.iter().find(|x| x.0 == t.owner).map_or(1.0, |x| x.1);
|
|
t.food = (t.food + dt * f / 18.0).min(3.0);
|
|
}
|
|
}
|
|
for side in self.master_sides() {
|
|
// a master whose heart fell has no household any more: no mana, no new creatures, no payday
|
|
if self.is_out(side) {
|
|
continue;
|
|
}
|
|
self.switch_to(side);
|
|
self.economy(dt);
|
|
}
|
|
self.after_economy(dt);
|
|
}
|
|
|
|
// a master's household: mana, core, portals, payday
|
|
fn economy(&mut self, dt: f32) {
|
|
self.recount();
|
|
self.mana = (self.mana + (1.0 + SPRING_MANA * self.springs as f32) * self.talents.mana_rate() * dt).min(self.mana_max());
|
|
self.core_hp = (self.core_hp + 0.5 * dt).min(CORE_HP);
|
|
|
|
let owned = self.portals_owned();
|
|
if owned > 0 {
|
|
self.portal_timer -= dt;
|
|
if self.portal_timer <= 0.0 {
|
|
self.portal_timer = (20.0 + self.rng.f() * 14.0) / (owned as f32).sqrt() * self.lair_factor();
|
|
self.try_portal();
|
|
}
|
|
}
|
|
|
|
self.payday -= dt;
|
|
if !self.payday_warned && self.payday <= PAYDAY_WARN {
|
|
self.payday_warned = true;
|
|
let cost = self.next_payday_cost();
|
|
if cost >= 1.0 {
|
|
self.msg("msg.payday_soon", vec![(self.payday.ceil() as i32).to_string(), (cost.ceil() as i32).to_string()]);
|
|
self.sfx_ui(Sfx::FlagSet);
|
|
if cost > self.gold {
|
|
self.hint(22, "hint.payday_short");
|
|
}
|
|
}
|
|
}
|
|
if self.payday <= 0.0 {
|
|
self.payday = PAYDAY;
|
|
self.payday_warned = false;
|
|
self.do_payday();
|
|
}
|
|
// every two seconds: corners between reinforced walls are closed
|
|
if (self.time / 2.0).floor() != ((self.time - dt) / 2.0).floor() {
|
|
self.reinforce_corners();
|
|
self.tidy_torch_stands();
|
|
self.tidy_memorials();
|
|
}
|
|
self.update_forge(dt);
|
|
self.update_research();
|
|
self.update_marks(dt);
|
|
}
|
|
|
|
fn after_economy(&mut self, dt: f32) {
|
|
// the shared world (waves, units, towers, rivals ...) runs from the first master's side: the
|
|
// same on every computer of a match, whichever master plays at this one. Single-player
|
|
// games only have that one side.
|
|
let local = self.local_side;
|
|
let first = self.master_sides().first().copied().unwrap_or(local);
|
|
self.switch_to(first);
|
|
if self.waves_on() {
|
|
self.wave_tick(dt);
|
|
}
|
|
if self.time - self.gate_hit_t > 10.0 && self.has_gate() {
|
|
self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP);
|
|
}
|
|
|
|
self.update_contested();
|
|
self.update_units(dt);
|
|
self.switch_to(first); // update_units worked master by master
|
|
self.update_snares();
|
|
self.update_towers(dt);
|
|
self.update_reveals(dt);
|
|
self.update_graves(dt);
|
|
if !self.rivals.is_empty() {
|
|
self.update_rivals(dt);
|
|
}
|
|
self.update_dwarves(dt);
|
|
self.update_weather(dt);
|
|
self.update_bridges(dt);
|
|
self.update_wood(dt);
|
|
self.update_events(dt);
|
|
self.update_traders(dt);
|
|
// fallen hearts of a match are shared state: every client marks them in the same step
|
|
self.update_fallen_masters();
|
|
// the end of the game and the fog are about the master at this computer
|
|
self.switch_to(local);
|
|
if self.netplay.is_some() {
|
|
self.check_match_end();
|
|
} else {
|
|
self.check_end();
|
|
}
|
|
self.update_fog(dt);
|
|
}
|
|
|
|
fn check_end(&mut self) {
|
|
if self.core_hp <= 0.0 {
|
|
self.core_hp = 0.0;
|
|
self.state = 2;
|
|
self.msg("msg.defeat", vec![]);
|
|
self.sfx_ui(Sfx::Defeat);
|
|
return;
|
|
}
|
|
if let Some(m) = &self.mission {
|
|
if m.time_limit > 0.0 && self.time > m.time_limit {
|
|
self.state = 2;
|
|
self.msg("msg.time_up", vec![]);
|
|
self.sfx_ui(Sfx::Defeat);
|
|
return;
|
|
}
|
|
let goals = m.goals.clone();
|
|
if !goals.is_empty() && goals.iter().all(|&g| self.goal_done(g)) {
|
|
self.state = 1;
|
|
self.msg("msg.mission_won", vec![]);
|
|
self.sfx_ui(Sfx::Victory);
|
|
}
|
|
return;
|
|
}
|
|
// free game: every opponent (rival masters and the humans' gate) must fall
|
|
let rivals_out = self.rivals.iter().all(|r| !r.alive);
|
|
let gate_out = !self.gate_on || self.gate_hp <= 0.0;
|
|
if rivals_out && gate_out {
|
|
self.gate_hp = self.gate_hp.max(0.0);
|
|
self.state = 1;
|
|
self.msg("msg.victory", vec![]);
|
|
self.sfx_ui(Sfx::Victory);
|
|
}
|
|
}
|
|
|
|
fn wave_tick(&mut self, dt: f32) {
|
|
self.wave_timer -= dt;
|
|
if !self.warned && self.wave_timer < 30.0 {
|
|
self.warned = true;
|
|
let goal = self.wave_goal();
|
|
if goal != self.me_side {
|
|
// the wave will march on another master: no entry markers in this dungeon
|
|
self.planned_entries.clear();
|
|
self.announce_wave("msg.wave_warn", "msg.wave_warn_on_master", vec![(self.wave + 1).to_string()], goal, Sfx::WaveAlarm);
|
|
return;
|
|
}
|
|
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)
|
|
pub(crate) 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.creature_cost(flies(e.kind)), |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;
|
|
// a bed per lair tile (aerie: per aerie tile), never more than MAX_CREATURES in all
|
|
let room = MAX_CREATURES.saturating_sub(walkers + flyers);
|
|
(self.counts[1].saturating_sub(walkers).min(room), self.counts[7].saturating_sub(flyers).min(room))
|
|
}
|
|
|
|
// how many creatures this master can house: his beds, at most MAX_CREATURES
|
|
pub fn creature_cap(&self) -> usize {
|
|
(self.counts[1] + self.counts[7]).min(MAX_CREATURES)
|
|
}
|
|
|
|
pub fn lair_factor(&self) -> f32 {
|
|
(8.0 / (self.free_beds() as f32 + 2.0)).clamp(0.35, 3.0)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn try_portal_for_test(&mut self) {
|
|
let (px, py) = self.map.portals[0];
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
self.map.t[idx(px + x, py + y)].owner = self.me;
|
|
}
|
|
}
|
|
self.try_portal();
|
|
}
|
|
|
|
fn try_portal(&mut self) {
|
|
let lair = self.counts[1];
|
|
let aerie = self.counts[7];
|
|
let farm = self.counts[2];
|
|
if lair == 0 && aerie == 0 {
|
|
self.hint(0, "hint.portal_no_lair");
|
|
return;
|
|
}
|
|
if farm == 0 {
|
|
self.hint(1, "hint.no_farm");
|
|
return;
|
|
}
|
|
if self.creature_count() >= MAX_CREATURES {
|
|
self.hint(35, "hint.creature_max");
|
|
return;
|
|
}
|
|
let (free_lair, free_aerie) = self.free_lair_aerie();
|
|
if free_lair == 0 && free_aerie == 0 {
|
|
self.hint(2, "hint.lair_full");
|
|
return;
|
|
}
|
|
let mut opts: Vec<(EK, u32)> = Vec::new();
|
|
// flying creatures need room in the aerie
|
|
if free_aerie > 0 {
|
|
if aerie >= 4 {
|
|
opts.push((EK::Bat, 4));
|
|
}
|
|
if aerie >= 6 && self.counts[5] >= 4 {
|
|
opts.push((EK::Gargoyle, 2));
|
|
}
|
|
if aerie >= 6 && self.counts[4] >= 6 {
|
|
opts.push((EK::Owl, 2));
|
|
}
|
|
let has_wyrm = self.ents.iter().any(|e| e.alive && e.kind == EK::Wyrm && !e.hero);
|
|
if aerie >= 16 && self.counts[0] >= 16 && !has_wyrm {
|
|
opts.push((EK::Wyrm, 1));
|
|
}
|
|
}
|
|
if free_lair == 0 {
|
|
if opts.is_empty() {
|
|
self.hint(2, "hint.lair_full");
|
|
return;
|
|
}
|
|
} else {
|
|
self.portal_walkers(&mut opts);
|
|
}
|
|
if opts.is_empty() {
|
|
return;
|
|
}
|
|
self.portal_arrival(opts);
|
|
}
|
|
|
|
// walking creatures that the rooms attract
|
|
fn portal_walkers(&mut self, opts: &mut Vec<(EK, u32)>) {
|
|
let farm = self.counts[2];
|
|
opts.push((EK::Kobold, 4));
|
|
if farm >= 6 {
|
|
opts.push((EK::Spider, 3));
|
|
}
|
|
if self.counts[4] >= 6 {
|
|
opts.push((EK::Mage, 3));
|
|
}
|
|
if self.counts[3] >= 9 {
|
|
opts.push((EK::Ogre, 2));
|
|
}
|
|
if self.counts[5] >= 6 {
|
|
opts.push((EK::Jailer, 2));
|
|
}
|
|
if self.counts[6] >= 6 {
|
|
opts.push((EK::Priestess, 2));
|
|
}
|
|
if self.counts[ROOM_FORGE] >= 6 {
|
|
opts.push((EK::Cogling, 3));
|
|
}
|
|
// the Chainbreaker only visits big dungeons with experienced creatures and a rich treasury
|
|
let room_tiles: usize = self.counts.iter().sum();
|
|
let veteran = self.ents.iter().any(|e| e.alive && e.creature() && e.level >= 5);
|
|
let has_one = self.ents.iter().any(|e| e.alive && e.kind == EK::Chainbreaker && !e.hero);
|
|
if room_tiles >= 40 && veteran && self.gold >= 4000.0 && !has_one {
|
|
opts.push((EK::Chainbreaker, 1));
|
|
}
|
|
}
|
|
|
|
fn portal_arrival(&mut self, opts: Vec<(EK, u32)>) {
|
|
let total: u32 = opts.iter().map(|o| o.1).sum();
|
|
let mut r = self.rng.next() % total;
|
|
let mut kind = EK::Kobold;
|
|
for (k, w) in opts {
|
|
if r < w {
|
|
kind = k;
|
|
break;
|
|
}
|
|
r -= w;
|
|
}
|
|
let owned: Vec<usize> = (0..self.map.portals.len()).filter(|&k| self.map.portal_owner(k) == self.me).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;
|
|
let merchant = self.talents.wages();
|
|
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) * merchant;
|
|
e.pay_due += w;
|
|
e.pay_t = r * 55.0;
|
|
n += 1;
|
|
total += w;
|
|
}
|
|
if n > 0 || bill > 0.0 {
|
|
self.sfx_ui(Sfx::Payday);
|
|
self.msg("msg.payday_walk", vec![n.to_string(), (total as i32).to_string()]);
|
|
}
|
|
}
|
|
|
|
// a creature goes for its wage: the nearest treasury tile holding treasure, else the core
|
|
fn start_fetch_wage(&mut self, e: &mut Ent) {
|
|
let cur = e.tile();
|
|
let goal = |i: usize| {
|
|
let t = &self.map.t[i];
|
|
(t.kind == Kind::Treasury && t.owner == self.me && t.gold > 0) || (self.counts[0] == 0 && self.map.adj_core(i))
|
|
};
|
|
let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), goal, true) } else { self.map.search(cur, self.map.walk_cost(), goal, true) };
|
|
match found {
|
|
Some(p) => {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
e.path = p;
|
|
e.job = Job::FetchWage(t);
|
|
}
|
|
// no way to a treasury: the wage is paid out anyway
|
|
None => self.take_wage(e, None),
|
|
}
|
|
}
|
|
|
|
// takes the wage out of the treasury tile (the rest from elsewhere); unpaid = angry
|
|
fn take_wage(&mut self, e: &mut Ent, at: Option<usize>) {
|
|
let mut need = e.pay_due;
|
|
if let Some(t) = at {
|
|
let tile = &mut self.map.t[t];
|
|
if tile.kind == Kind::Treasury {
|
|
let take = (tile.gold as f32).min(need).floor();
|
|
tile.gold -= take as i32;
|
|
self.gold -= take;
|
|
need -= take;
|
|
}
|
|
}
|
|
let rest = need.min(self.gold.max(0.0));
|
|
self.gold -= rest;
|
|
need -= rest;
|
|
e.pay_due = 0.0;
|
|
if need > 0.5 {
|
|
e.happy -= 0.45;
|
|
self.msg("msg.wage_unpaid", vec![format!("@{}", st(e.kind).name)]);
|
|
self.sfx(Sfx::Error, e.x, e.y);
|
|
} else {
|
|
e.happy = (e.happy + 0.2).min(1.0);
|
|
self.fx(5, e.x, e.y, 0.0, 0.0, 0.8, 0xff30d0ff);
|
|
self.sfx(Sfx::Deposit, e.x, e.y);
|
|
}
|
|
}
|
|
|
|
// a new creature: report at the portal, go to the lair (aerie), set up the bed
|
|
fn settle(&mut self, e: &mut Ent, dt: f32) {
|
|
let me = e.id + 1;
|
|
match e.job {
|
|
Job::Register => {
|
|
e.working = true;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
let cur = e.tile();
|
|
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
|
|
let free = |i: usize| self.map.t[i].kind == bed_kind && self.map.t[i].bed == 0 && self.map.t[i].owner == self.me;
|
|
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.
|
|
pub(crate) 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)]);
|
|
// the humans march on the strongest master; the wave is measured against him
|
|
let goal = self.wave_goal();
|
|
let target = (self.side_army(goal) * ratio).max(min_value);
|
|
let mut pool = vec![(Knight, 4), (Archer, 3)];
|
|
if w >= 2 {
|
|
pool.push((Dwarf, 2));
|
|
}
|
|
if w >= 3 {
|
|
pool.push((Priest, 2));
|
|
pool.push((Fairy, 1));
|
|
}
|
|
if w >= 4 {
|
|
pool.push((BattleMage, 1));
|
|
}
|
|
if w >= 6 {
|
|
pool.push((Giant, 1));
|
|
}
|
|
// the rogues of the later waves (in the campaign only from the later missions on)
|
|
for (k, from_wave, from_mission) in crate::rogues::ROGUES {
|
|
if w >= from_wave && self.mission.as_ref().map_or(true, |m| m.index >= from_mission) {
|
|
pool.push((k, 1));
|
|
}
|
|
}
|
|
// siege engines for the dungeon's doors, walls and towers
|
|
if w >= 4 && self.traps.iter().any(|t| t.kind.door()) {
|
|
pool.push((Ram, 1));
|
|
}
|
|
if w >= 6 && self.towers.iter().any(|t| !t.hero) {
|
|
pool.push((Catapult, 1));
|
|
}
|
|
let boss = if w % 5 == 0 { Some(Paladin) } else { None };
|
|
let base_level = 1 + (w - 1) / 3;
|
|
let list = self.build_squad(target, base_level, &pool, boss, 2);
|
|
self.last_wave_value = Self::squad_value(&list);
|
|
let before = self.ents.len();
|
|
self.spawn_squad(&list, false);
|
|
// the paladin of every fifth wave is one of the Order's champions
|
|
if boss.is_some() {
|
|
if let Some(j) = (before..self.ents.len()).find(|&j| self.ents[j].kind == Paladin) {
|
|
self.make_boss(j, 1 + ((w / 5 - 1) % 4) as u8);
|
|
}
|
|
}
|
|
if goal != self.me_side {
|
|
for e in self.ents[before..].iter_mut() {
|
|
e.target = Some(goal);
|
|
e.waypoint = None;
|
|
}
|
|
self.planned_entries.clear();
|
|
let args = vec![w.to_string(), list.len().to_string()];
|
|
if self.is_master(goal) {
|
|
self.announce_wave("msg.wave", "msg.wave_on_master", args, goal, Sfx::WaveStart);
|
|
} else {
|
|
self.sfx_ui(Sfx::WaveStart);
|
|
self.msg(if goal == 2 { "msg.wave_on_rival1" } else { "msg.wave_on_rival2" }, args);
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
let key = if boss.is_some() { "msg.wave_boss" } else { "msg.wave" };
|
|
let goal = self.me_side;
|
|
self.announce_wave(key, "msg.wave_on_master", vec![w.to_string(), list.len().to_string()], goal, Sfx::WaveStart);
|
|
}
|
|
|
|
// The side a hero wave marches on: the strongest master still standing (creatures, towers and
|
|
// gold), players and rival masters alike; ties go to the lower side.
|
|
pub fn wave_goal(&self) -> u8 {
|
|
let mut best = (self.me_side, f32::MIN);
|
|
let mut sides: Vec<u8> = self.master_sides().into_iter().filter(|&s| !self.is_out(s) && self.core_hp_of(s) > 0.0).collect();
|
|
sides.extend(self.rivals.iter().filter(|r| r.alive).map(|r| r.side));
|
|
sides.sort_unstable();
|
|
for s in sides {
|
|
let gold = if self.is_master(s) { self.gold_of(s) } else { 0.0 };
|
|
let score = self.side_army(s) + 0.05 * gold;
|
|
if score > best.1 {
|
|
best = (s, score);
|
|
}
|
|
}
|
|
best.0
|
|
}
|
|
|
|
// the fighting strength of one side, like `army_value`: its creatures and (half) its towers
|
|
pub fn side_army(&self, side: u8) -> f32 {
|
|
let owner = Owner::of_side(side);
|
|
let (mut h, mut d) = (0.0, 0.0);
|
|
for e in &self.ents {
|
|
if e.alive && !e.hero && !e.chained && e.kind != EK::Worker && e.side() == side {
|
|
h += e.max_hp;
|
|
d += e.atk() / st(e.kind).interval;
|
|
}
|
|
}
|
|
for t in self.towers.iter().filter(|t| !t.hero && self.map.t[t.tile].owner == owner) {
|
|
h += 0.5 * t.max_hp();
|
|
d += 0.5 * t.dmg() / tower_stats(t.kind).interval;
|
|
}
|
|
(h * d).sqrt()
|
|
}
|
|
|
|
// tells about a wave: the attacked master hears `key`; every other player of a match hears
|
|
// `other` (with the attacked master's colour as last argument). A wave on a rival master is
|
|
// told to the player like a normal warning.
|
|
fn announce_wave(&mut self, key: &'static str, other: &'static str, args: Vec<String>, goal: u8, sfx: Sfx) {
|
|
if !self.is_master(goal) || self.netplay.is_none() || self.local_side == goal {
|
|
let me = self.me_side;
|
|
self.switch_to(goal);
|
|
self.msg(key, args);
|
|
self.sfx_ui(sfx);
|
|
self.switch_to(me);
|
|
return;
|
|
}
|
|
let mut a = args;
|
|
a.push(format!("@net.side{goal}"));
|
|
self.tell_local(other, a, Some(sfx));
|
|
}
|
|
|
|
// 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()]);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn hit_gate(&mut self, e: &mut Ent) {
|
|
let s = st(e.kind);
|
|
e.cd = s.interval;
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
|
|
self.gate_hp -= dmg;
|
|
self.gate_hit_t = self.time;
|
|
e.xp += dmg * 0.3;
|
|
let (gx, gy) = self.gate_center();
|
|
if s.range > 1.5 {
|
|
let col = if e.kind == EK::Mage { 0xffff44cc } else { 0xff70b0d0 };
|
|
self.fx(0, e.x, e.y, gx, gy, 0.25, col);
|
|
} else {
|
|
let fx = (e.x + gx) * 0.5;
|
|
let fy = (e.y + gy) * 0.5;
|
|
self.fx(2, fx, fy, 0.0, 0.0, 0.25, 0xffffffff);
|
|
}
|
|
self.sfx(Sfx::GateHit, gx, gy);
|
|
self.hint(10, "hint.gate_attack");
|
|
if self.mission.as_ref().map_or(true, |m| m.gate_defenders) {
|
|
self.check_gate_stage();
|
|
}
|
|
}
|
|
|
|
fn update_ent(&mut self, e: &mut Ent, dt: f32) {
|
|
e.anim += dt;
|
|
e.flash = (e.flash - dt).max(0.0);
|
|
e.slow = (e.slow - dt).max(0.0);
|
|
e.skill_cd -= dt;
|
|
e.boost = (e.boost - dt).max(0.0);
|
|
if e.stun > 0.0 {
|
|
e.stun -= dt;
|
|
e.moving = false;
|
|
return;
|
|
}
|
|
self.tick_effects(e, dt);
|
|
// slapped units work and move faster, hasted ones even more
|
|
let dt = if e.boost > 0.0 { dt * 1.5 } else { dt } * if e.haste > 0.0 { 1.6 } else { 1.0 };
|
|
e.cd -= dt;
|
|
e.think -= dt;
|
|
e.repath -= dt;
|
|
e.moving = false;
|
|
e.working = false;
|
|
let s = st(e.kind);
|
|
while e.level < 10 && e.xp >= 100.0 * e.level as f32 {
|
|
e.xp -= 100.0 * e.level as f32;
|
|
e.level += 1;
|
|
let old = e.max_hp;
|
|
e.max_hp = s.hp * (1.0 + 0.15 * (e.level - 1) as f32) * crate::armory::gear_factor(e.gear);
|
|
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![crate::names::arg(e), e.level.to_string()]);
|
|
}
|
|
}
|
|
if e.chained {
|
|
// waits in chains until the player's imps claim its cave
|
|
e.moving = false;
|
|
let (x, y) = xy(e.tile());
|
|
let reached = std::iter::once((0, 0)).chain(DIR8.iter().copied()).any(|(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |t| t.owner == self.me));
|
|
if reached {
|
|
e.chained = false;
|
|
e.settled = false;
|
|
e.job = Job::Register;
|
|
e.timer = 1.0;
|
|
e.happy = 1.0;
|
|
self.freed += 1;
|
|
self.fx(7, e.x, e.y, 0.0, 0.0, 1.2, 0xff44ddff);
|
|
self.sfx(Sfx::Convert, e.x, e.y);
|
|
self.msg("msg.creature_freed", vec![format!("@{}", st(e.kind).name)]);
|
|
}
|
|
} else if self.demo.is_some() {
|
|
self.demo_ai(e, dt);
|
|
} else if e.hero && e.captive != 0 {
|
|
self.captive_update(e, dt);
|
|
} else if !self.is_master(e.side) && (self.is_rival_side(e.side) || e.rival()) {
|
|
self.rival_ai(e, dt);
|
|
} else if e.kind == EK::Trader {
|
|
self.trader_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) | Job::MendBridge(t) | Job::RaiseWall(t) => tile(t),
|
|
Job::Attack(id) | Job::Torment(id) | Job::Carry(id) | Job::Brawl(id) => self.find(id).map(|j| (self.ents[j].x, self.ents[j].y)),
|
|
Job::Repair(id) | Job::AttackTower(id) => self.towers.iter().find(|t| t.id == id).map(|t| t.pos()),
|
|
_ => None,
|
|
};
|
|
if let Some((tx, ty)) = target {
|
|
let (dx, dy) = (tx - e.x, ty - e.y);
|
|
if dx * dx + dy * dy > 0.01 {
|
|
e.dir = dy.atan2(dx);
|
|
}
|
|
}
|
|
}
|
|
|
|
// moves a unit along its path; true = still moving
|
|
pub(crate) fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool {
|
|
if e.path.is_empty() {
|
|
return false;
|
|
}
|
|
let next = e.path[0];
|
|
let tile = self.map.t[next];
|
|
if tile.kind == Kind::Door {
|
|
if e.side() != 0 {
|
|
// enemies smash doors on their way
|
|
if self.bash_door(e, next, dt) {
|
|
return true;
|
|
}
|
|
} else if tile.res & 1 == 1 && e.tile() != next {
|
|
// locked: our own creatures have to find another way
|
|
e.path.clear();
|
|
return false;
|
|
}
|
|
}
|
|
if !tile.walkable() && !(flies(e.kind) && tile.liquid()) {
|
|
if (e.hero || (!self.is_master(e.side) && (self.is_rival_side(e.side) || e.rival()))) && tile.diggable() {
|
|
// heroes dig their way through
|
|
e.working = true;
|
|
let (tx, ty) = xy(next);
|
|
e.face = sgn(tx as f32 + 0.5 - e.x);
|
|
e.timer += dt;
|
|
if e.timer >= 0.5 {
|
|
e.timer = 0.0;
|
|
let pow = match e.kind {
|
|
EK::Dwarf => 3.0,
|
|
EK::Paladin | EK::Giant => 2.0,
|
|
EK::Ram => 4.0,
|
|
_ => 1.0,
|
|
} * if e.kind == EK::BattleMage && (tile.reinforced || tile.kind == Kind::Steel) {
|
|
// the battle mage blasts reinforced walls (steel resists better)
|
|
if tile.kind == Kind::Steel {
|
|
0.4
|
|
} else {
|
|
1.6
|
|
}
|
|
} else if tile.kind == Kind::Steel {
|
|
0.05
|
|
} else if tile.reinforced {
|
|
0.2
|
|
} else {
|
|
1.0
|
|
};
|
|
if e.kind == EK::BattleMage {
|
|
let (sx, sy) = (e.x, e.y);
|
|
self.shot(sx, sy, 0.6, tx as f32 + 0.5, ty as f32 + 0.5, 0.5, 0.3, 0xff3080ff);
|
|
self.style_last_fx(crate::particles::ST_FIRE);
|
|
}
|
|
self.fx(6, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.4, 0xff3a5a7a);
|
|
self.sfx(Sfx::Dig, tx as f32 + 0.5, ty as f32 + 0.5);
|
|
let t = &mut self.map.t[next];
|
|
t.hp -= pow;
|
|
if t.hp <= 0.0 {
|
|
self.dig_out(next);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
e.path.clear();
|
|
return false;
|
|
}
|
|
let (tx, ty) = xy(next);
|
|
let (cx, cy) = (tx as f32 + 0.5, ty as f32 + 0.5);
|
|
let dx = cx - e.x;
|
|
let dy = cy - e.y;
|
|
let d = (dx * dx + dy * dy).sqrt();
|
|
let mut sp = st(e.kind).speed * dt * if e.slow > 0.0 { 0.55 } else { 1.0 };
|
|
if !e.hero && e.creature() && e.hunger > 2.0 {
|
|
sp *= 0.8;
|
|
}
|
|
if d <= sp {
|
|
e.x = cx;
|
|
e.y = cy;
|
|
e.path.remove(0);
|
|
} else {
|
|
e.x += dx / d * sp;
|
|
e.y += dy / d * sp;
|
|
}
|
|
if dx.abs() > 0.01 {
|
|
e.face = sgn(dx);
|
|
}
|
|
e.moving = true;
|
|
true
|
|
}
|
|
|
|
pub fn dig_out(&mut self, i: usize) {
|
|
self.torches.retain(|t| t.wall != i);
|
|
let t = &mut self.map.t[i];
|
|
// a grove's mushroom leaves its moss behind
|
|
let grove = t.kind == Kind::Mushroom && t.res & MUSH_GROVE != 0;
|
|
t.kind = if grove { Kind::Grove } else { Kind::Floor };
|
|
if grove {
|
|
t.res = 0;
|
|
}
|
|
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.count(crate::stats::DUG, 1.0);
|
|
self.fx(3, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff304a66);
|
|
self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
|
|
pub(crate) fn unreserve(&mut self, me: u32) {
|
|
for t in self.map.t.iter_mut() {
|
|
if t.reserved == me {
|
|
t.reserved = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn release_all(&mut self, id: u32) {
|
|
let me = id + 1;
|
|
for t in self.map.t.iter_mut() {
|
|
if t.reserved == me {
|
|
t.reserved = 0;
|
|
}
|
|
if t.bed == me {
|
|
t.bed = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn los(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> bool {
|
|
let dx = x1 - x0;
|
|
let dy = y1 - y0;
|
|
let d = (dx * dx + dy * dy).sqrt();
|
|
let n = (d * 4.0) as i32 + 1;
|
|
for k in 1..n {
|
|
let f = k as f32 / n as f32;
|
|
let x = (x0 + dx * f) as i32;
|
|
let y = (y0 + dy * f) as i32;
|
|
if let Some(t) = self.map.get(x, y) {
|
|
if t.solid() || t.kind == Kind::Core {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
pub(crate) fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option<u32> {
|
|
let mut best = None;
|
|
let mut bd = radius * radius;
|
|
for o in &self.ents {
|
|
// every other side is an enemy; co-op partners and team mates fight the heroes, not each other
|
|
let coop_ally = self.allied(e.side(), o.side());
|
|
if !o.alive || o.held || o.side() == e.side() || coop_ally || o.captive != 0 || o.chained || o.invis > 0.0 {
|
|
continue;
|
|
}
|
|
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
|
|
if d < bd {
|
|
bd = d;
|
|
best = Some(o.id);
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
// fight a specific unit; false = target invalid/unreachable
|
|
pub(crate) fn attack_job(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
|
|
let Some(j) = self.find(id) else { return false };
|
|
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
|
|
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
|
|
let s = st(e.kind);
|
|
if d > 12.0 {
|
|
return false;
|
|
}
|
|
if e.kind == EK::Jailer && e.skill_cd <= 0.0 && d > 1.4 && d < 4.5 && self.los(e.x, e.y, tx, ty) {
|
|
// jailer: pulls the enemy in with a chain
|
|
let (dx, dy) = norm(tx - e.x, ty - e.y);
|
|
let (nx, ny) = (e.x + dx * 0.9, e.y + dy * 0.9);
|
|
if self.map.walk(nx as i32, ny as i32) {
|
|
self.fx(0, tx, ty, e.x, e.y, 0.3, 0xff8090a0);
|
|
self.sfx(Sfx::Capture, e.x, e.y);
|
|
let t = &mut self.ents[j];
|
|
t.x = nx;
|
|
t.y = ny;
|
|
t.path.clear();
|
|
e.skill_cd = 6.0;
|
|
return true;
|
|
}
|
|
}
|
|
if d <= s.range + 0.45 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
|
|
e.path.clear();
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
if e.cd <= 0.0 && self.use_ability(e, j) {
|
|
return true;
|
|
}
|
|
if e.cd <= 0.0 {
|
|
e.cd = s.interval;
|
|
self.hit(e, j);
|
|
}
|
|
return true;
|
|
}
|
|
if e.path.is_empty() || e.repath <= 0.0 {
|
|
e.repath = 0.6;
|
|
let tt = idx(tx as i32, ty as i32);
|
|
let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), tt) } else { self.map.path_to(e.tile(), tt) };
|
|
match p {
|
|
Some(p) => e.path = p,
|
|
None => return false,
|
|
}
|
|
}
|
|
if !self.step_path(e, dt) && e.tile() == idx(tx as i32, ty as i32) {
|
|
// same tile: approach directly
|
|
let dx = tx - e.x;
|
|
let dy = ty - e.y;
|
|
let l = (dx * dx + dy * dy).sqrt().max(0.001);
|
|
let sp = s.speed * dt;
|
|
e.x += dx / l * sp.min(l);
|
|
e.y += dy / l * sp.min(l);
|
|
}
|
|
true
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn hit_for_test(&mut self, e: &mut Ent, j: usize) {
|
|
self.hit(e, j);
|
|
}
|
|
|
|
fn hit(&mut self, e: &mut Ent, j: usize) {
|
|
// the Warlord talent of the attacker's master
|
|
let boost = if !e.hero && Owner::MASTER_SIDES.contains(&e.side) { self.talents_of(e.side).damage() } else { 1.0 };
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3) * boost;
|
|
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,
|
|
EK::Inquisitor => 0xff40d0ff,
|
|
_ => 0xff70b0d0,
|
|
};
|
|
// flyers shoot from the air
|
|
let h = if flies(e.kind) { 0.8 } else { 0.45 };
|
|
self.shot(e.x, e.y, h, tx, ty, 0.45, 0.25, col);
|
|
self.grow_last_fx(lv);
|
|
use crate::particles::*;
|
|
self.style_last_fx(match e.kind {
|
|
EK::Archer | EK::Ranger => ST_ARROW,
|
|
EK::Inquisitor => ST_HOLY,
|
|
EK::Mage => ST_SHADOW,
|
|
EK::Priest => ST_HOLY,
|
|
EK::Priestess | EK::Wyrm | EK::BattleMage => ST_FIRE,
|
|
EK::Owl => ST_ARCANE,
|
|
_ => ST_PLAIN,
|
|
});
|
|
self.sfx(Sfx::Shoot, e.x, e.y);
|
|
} else {
|
|
self.fx(2, tx, ty, 0.0, 0.0, 0.25, if e.level >= 9 { 0xff60e0ff } else { 0xffffffff });
|
|
self.grow_last_fx(lv);
|
|
self.sfx(Sfx::MeleeHit, tx, ty);
|
|
}
|
|
e.xp += dmg * 0.6;
|
|
if e.kind == EK::Chainbreaker {
|
|
// twin axes: a second enemy next to the Chainbreaker takes half damage
|
|
let me = e.side();
|
|
let second = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - e.x).powi(2) + (x.y - e.y).powi(2) < 2.6)
|
|
.map(|(o, _)| o);
|
|
if let Some(o) = second {
|
|
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
|
|
self.fx(2, ox, oy, 0.0, 0.0, 0.25, 0xff40a0ff);
|
|
self.grow_last_fx(lv);
|
|
self.ents[o].hp -= dmg * 0.5;
|
|
self.ents[o].flash = 0.15;
|
|
if self.ents[o].hp <= 0.0 {
|
|
self.kill(o);
|
|
}
|
|
}
|
|
}
|
|
if e.kind == EK::Priestess || e.kind == EK::Wyrm || e.kind == EK::BattleMage {
|
|
// fire on a wooden bridge sets it alight now and then
|
|
if self.rng.f() < 0.25 {
|
|
self.ignite_at(tx, ty);
|
|
}
|
|
// fireball / fire breath: half damage to enemies around the target
|
|
let me = e.side();
|
|
let splash: Vec<usize> = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < if crate::personality::knows(e, "inferno") { 6.25 } else { 1.44 })
|
|
.map(|(o, _)| o)
|
|
.collect();
|
|
for o in splash {
|
|
if self.ents[o].alive {
|
|
self.ents[o].hp -= dmg * 0.5;
|
|
self.ents[o].flash = 0.15;
|
|
if self.ents[o].hp <= 0.0 {
|
|
self.kill(o);
|
|
}
|
|
}
|
|
}
|
|
if !self.ents[j].alive {
|
|
return;
|
|
}
|
|
}
|
|
// learned abilities that come with a blow
|
|
if crate::personality::knows(e, "drain") {
|
|
e.hp = (e.hp + dmg * 0.3).min(e.max_hp);
|
|
}
|
|
if crate::personality::knows(e, "web") && self.rng.f() < 0.3 {
|
|
self.ents[j].slow = self.ents[j].slow.max(3.0);
|
|
}
|
|
if crate::personality::knows(e, "shackle") && self.rng.f() < 0.2 {
|
|
self.ents[j].stun = self.ents[j].stun.max(2.0);
|
|
}
|
|
if crate::personality::knows(e, "volley") {
|
|
let me = e.side();
|
|
let second = self.ents.iter().enumerate().find(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 6.0).map(|(o, _)| o);
|
|
if let Some(o) = second {
|
|
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
|
|
self.shot(e.x, e.y, 0.8, ox, oy, 0.45, 0.25, 0xffffa060);
|
|
self.style_last_fx(crate::particles::ST_ARCANE);
|
|
self.ents[o].hp -= dmg * 0.6;
|
|
self.ents[o].flash = 0.15;
|
|
if self.ents[o].hp <= 0.0 {
|
|
self.kill(o);
|
|
}
|
|
}
|
|
}
|
|
let my_id = e.id;
|
|
e.invis = 0.0; // attacking breaks invisibility
|
|
let armour = {
|
|
let t = &self.ents[j];
|
|
if !t.hero && Owner::MASTER_SIDES.contains(&t.side) { self.talents_of(t.side).armour() } else { 1.0 }
|
|
};
|
|
let t = &mut self.ents[j];
|
|
t.hp -= if t.shield > 0.0 { dmg * 0.5 } else { dmg } * armour;
|
|
t.flash = 0.15;
|
|
t.bane = crate::gfx::sprite_index(e.kind) as u8 + 1;
|
|
if t.hp <= 0.0 && t.hero && t.captive == 0 {
|
|
e.kills += 1;
|
|
}
|
|
if t.creature() && !matches!(t.job, Job::Attack(_)) {
|
|
t.job = Job::Attack(my_id);
|
|
t.path.clear();
|
|
}
|
|
if t.hp <= 0.0 {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
|
|
// free places in the torture chamber (one prisoner per 2 tiles)
|
|
pub fn prison_space(&self) -> usize {
|
|
let cap = self.counts[5] / 2 + self.counts[crate::rooms::ROOM_PRISON];
|
|
let used = self.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count();
|
|
cap.saturating_sub(used)
|
|
}
|
|
|
|
pub(crate) fn kill(&mut self, j: usize) {
|
|
self.boss_loot(j);
|
|
if self.try_tame(j) {
|
|
return;
|
|
}
|
|
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.count_local(crate::stats::HEROES);
|
|
self.count_local(crate::stats::CAPTURED);
|
|
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);
|
|
// a named creature of some standing gets a memorial stone
|
|
let dead = self.ents[j].clone();
|
|
let remembered = self.raise_memorial(&dead);
|
|
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
|
|
if kind != EK::Worker && kind != EK::Revenant {
|
|
self.bury(x, y);
|
|
}
|
|
let s = st(kind);
|
|
if hero {
|
|
self.count_local(crate::stats::HEROES);
|
|
} else if side == self.local_side && !e_is_dwarf_side(side) {
|
|
let me = self.me_side;
|
|
let local = self.local_side;
|
|
self.switch_to(local);
|
|
self.count(crate::stats::LOST, 1.0);
|
|
self.switch_to(me);
|
|
}
|
|
if hero {
|
|
let b = s.bounty as f32 + if kind == EK::Thief { self.thief_loot(id) } else { 0.0 };
|
|
self.add_master_xp(s.bounty as f32 * crate::talents::HERO_XP);
|
|
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);
|
|
if !remembered {
|
|
self.msg("msg.creature_died", vec![crate::names::arg(&dead)]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
// a named creature lost in lava or water is remembered too
|
|
if !hero {
|
|
let mut dead = self.ents[j].clone();
|
|
let lava = self.map.get(x as i32, y as i32).map_or(false, |t| t.river() == Some(Kind::Lava));
|
|
dead.bane = if lava { crate::names::BANE_LAVA } else { crate::names::BANE_WATER };
|
|
self.raise_memorial(&dead);
|
|
}
|
|
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
|
|
pub(crate) fn ground_lost(&mut self, i: usize) {
|
|
let lava = self.map.t[i].river() == Some(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 e.hero {
|
|
self.count_local(crate::stats::DROWNED);
|
|
}
|
|
if lava {
|
|
self.burn(j);
|
|
continue;
|
|
}
|
|
// water: swim to the nearest bank, otherwise drown
|
|
let bank = DIR8.iter().map(|&(dx, dy)| (x + dx, y + dy)).find(|&(bx, by)| self.map.walk(bx, by));
|
|
match bank {
|
|
Some((bx, by)) => {
|
|
let e = &mut self.ents[j];
|
|
e.x = bx as f32 + 0.5;
|
|
e.y = by as f32 + 0.5;
|
|
e.path.clear();
|
|
}
|
|
None => self.burn(j),
|
|
}
|
|
}
|
|
}
|
|
|
|
// Bridges grow from own ground into the river: every pass adds the river tiles of the
|
|
// rectangle that touch own walkable ground (including bridges built in the pass before)
|
|
// Earth walls next to own ground become steel walls
|
|
pub fn build_steel(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
|
let (mut built, mut broke) = (0, false);
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) {
|
|
continue;
|
|
}
|
|
let t = self.map.t[idx(x, y)];
|
|
if t.kind != Kind::Earth || t.owner == Owner::Hero {
|
|
continue;
|
|
}
|
|
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me && n.walkable()));
|
|
if !own {
|
|
continue;
|
|
}
|
|
let cost = STEEL_COST[t.reinforced as usize] as f32;
|
|
if self.gold < cost {
|
|
broke = true;
|
|
continue;
|
|
}
|
|
self.gold -= cost;
|
|
let tl = &mut self.map.t[idx(x, y)];
|
|
tl.kind = Kind::Steel;
|
|
tl.reinforced = true;
|
|
tl.marked = false;
|
|
tl.hp = STEEL_HP;
|
|
built += 1;
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xffd0d0d0);
|
|
if built == 1 {
|
|
self.sfx(Sfx::Reinforce, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
}
|
|
}
|
|
if broke {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
} else if built == 0 {
|
|
self.hint(23, "hint.steel_where");
|
|
}
|
|
}
|
|
|
|
pub fn build_bridges(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
|
self.build_bridges_of(x0, y0, x1, y1, false);
|
|
}
|
|
|
|
pub fn build_bridges_of(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, draw: bool) {
|
|
let (mut built, mut broke, mut any_river, mut no_wood) = (0, false, false, false);
|
|
loop {
|
|
let mut add = Vec::new();
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) || !self.map.t[idx(x, y)].liquid() {
|
|
continue;
|
|
}
|
|
any_river = true;
|
|
let own = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me && n.walkable()));
|
|
if own {
|
|
add.push(idx(x, y));
|
|
}
|
|
}
|
|
}
|
|
if add.is_empty() {
|
|
break;
|
|
}
|
|
let mut progress = false;
|
|
for i in add {
|
|
let lava = self.map.t[i].kind == Kind::Lava;
|
|
let cost = if draw { DRAWBRIDGE_COST[lava as usize] } else { BRIDGE_COST[lava as usize] } as f32;
|
|
// wooden bridges also cost wood
|
|
let wood = if lava { 0.0 } else if draw { crate::wood::DRAWBRIDGE_WOOD } else { crate::wood::BRIDGE_WOOD };
|
|
if self.gold < cost {
|
|
broke = true;
|
|
continue;
|
|
}
|
|
if self.wood + 1e-3 < wood {
|
|
no_wood = true;
|
|
continue;
|
|
}
|
|
self.gold -= cost;
|
|
self.wood -= wood;
|
|
let t = &mut self.map.t[i];
|
|
t.kind = if draw {
|
|
Kind::Drawbridge
|
|
} else if lava {
|
|
Kind::LavaBridge
|
|
} else {
|
|
Kind::Bridge
|
|
};
|
|
t.res = if draw && lava { DB_LAVA } else { 0 };
|
|
t.owner = self.me;
|
|
built += 1;
|
|
progress = true;
|
|
let (x, y) = xy(i);
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff);
|
|
if built == 1 {
|
|
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
}
|
|
if !progress {
|
|
break;
|
|
}
|
|
}
|
|
if broke {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
} else if no_wood {
|
|
self.msg("msg.no_wood", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
} else if built == 0 {
|
|
self.hint(21, if any_river { "hint.bridge_where" } else { "hint.bridge_river" });
|
|
}
|
|
}
|
|
|
|
// An earth corner between two reinforced walls (and diagonal to own floor) cannot be
|
|
// reached by the imps: it is reinforced together with the walls. The floor condition keeps
|
|
// this from spreading into the earth behind the walls.
|
|
pub fn reinforce_corners(&mut self) {
|
|
let (w, h) = (mw() as i32, mh() as i32);
|
|
let wall = |m: &Map, x: i32, y: i32| m.get(x, y).map_or(false, |n| n.kind == Kind::Earth && n.reinforced && n.owner != Owner::Hero);
|
|
let floor = |m: &Map, x: i32, y: i32| m.get(x, y).map_or(false, |n| n.owner == self.me && n.walkable());
|
|
let mut add = Vec::new();
|
|
for y in 1..h - 1 {
|
|
for x in 1..w - 1 {
|
|
let t = &self.map.t[idx(x, y)];
|
|
if t.kind != Kind::Earth || t.reinforced || t.marked || t.owner == Owner::Hero {
|
|
continue;
|
|
}
|
|
for (ax, by) in [(1, 1), (1, -1), (-1, 1), (-1, -1)] {
|
|
if wall(&self.map, x + ax, y) && wall(&self.map, x, y + by) && floor(&self.map, x + ax, y + by) {
|
|
add.push(idx(x, y));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for i in add {
|
|
let t = &mut self.map.t[i];
|
|
t.reinforced = true;
|
|
t.hp = t.hp.max(12.0);
|
|
let (x, y) = xy(i);
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
|
|
}
|
|
}
|
|
|
|
// tiles within two of a fighting hero
|
|
fn update_contested(&mut self) {
|
|
let n = self.map.t.len();
|
|
if self.contested.len() != n {
|
|
self.contested = vec![false; n];
|
|
} else {
|
|
self.contested.iter_mut().for_each(|c| *c = false);
|
|
}
|
|
for e in &self.ents {
|
|
// any enemy of the player (heroes and the rivals' units) keeps ground from being claimed.
|
|
// In co-op matches, allied master sides do not block the player's own ground claims.
|
|
if !e.alive || e.held || e.captive != 0 || e.side() == 0 || (self.coop && Owner::MASTER_SIDES.contains(&e.side())) || self.allied(0, e.side()) {
|
|
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.raised() && t.owner == self.me {
|
|
return Some(idx(cx + dx, cy + dy));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
// a knight walks next to the bridge tile and tears it down
|
|
fn tear_bridge(&mut self, e: &mut Ent, i: usize, dt: f32) -> bool {
|
|
let t = self.map.t[i];
|
|
if !t.bridge() || t.raised() || t.owner != self.me {
|
|
return false;
|
|
}
|
|
let (bx, by) = xy(i);
|
|
let cur = e.tile();
|
|
let (cx, cy) = xy(cur);
|
|
if (cx - bx).abs() + (cy - by).abs() == 1 {
|
|
e.path.clear();
|
|
e.working = true;
|
|
e.face = sgn(bx as f32 + 0.5 - e.x);
|
|
// the bridge wears down step by step; imps can mend it while it stands
|
|
e.timer += dt;
|
|
if e.timer >= 1.0 {
|
|
e.timer -= 1.0;
|
|
self.sfx(Sfx::MeleeHit, bx as f32 + 0.5, by as f32 + 0.5);
|
|
self.fx(2, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.2, 0xff3a5a7a);
|
|
}
|
|
return self.wear_bridge(i, dt);
|
|
}
|
|
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)
|
|
|
|
// a neighbour of tile i that this imp may dig: marked by its own master (side)
|
|
fn dig_neighbor(&self, i: usize, me: u32, side: u8) -> 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.mark_by == side && t.diggable() {
|
|
if t.reserved == me {
|
|
return Some(idx(x + dx, y + dy));
|
|
}
|
|
if t.reserved == 0 && found.is_none() {
|
|
found = Some(idx(x + dx, y + dy));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
// a damaged tower next to walkable tile i
|
|
fn damaged_tower_near(&self, i: usize) -> Option<u32> {
|
|
let (x, y) = xy(i);
|
|
self.towers.iter().find(|t| {
|
|
let (tx, ty) = xy(t.tile);
|
|
!t.hero && (tx - x).abs() <= 1 && (ty - y).abs() <= 1 && t.hp < t.max_hp() - 1.0
|
|
}).map(|t| t.id)
|
|
}
|
|
|
|
// an earth wall next to own floor that can be reinforced from tile i
|
|
fn reinforce_neighbor(&self, i: usize, me: u32) -> Option<usize> {
|
|
if self.map.t[i].owner != self.me {
|
|
return None;
|
|
}
|
|
let (x, y) = xy(i);
|
|
for &(dx, dy) in &DIR4 {
|
|
if let Some(t) = self.map.get(x + dx, y + dy) {
|
|
if t.kind == Kind::Earth && !t.reinforced && !t.marked && (t.reserved == 0 || t.reserved == me) {
|
|
return Some(idx(x + dx, y + dy));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn claimable(&self, i: usize, me: u32) -> bool {
|
|
let t = &self.map.t[i];
|
|
let ok = if t.owner == self.me {
|
|
false
|
|
} else if t.owner == Owner::None {
|
|
matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest)
|
|
} else if t.owner != Owner::Hero && self.allied(self.me_side, t.owner.side()) {
|
|
false // an ally's ground is never taken
|
|
} else {
|
|
// knight and enemy masters' ground (also vaults and shard chambers), but not while enemies fight there
|
|
matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Lair | Kind::Training) && !self.contested.get(i).copied().unwrap_or(false)
|
|
};
|
|
if !ok {
|
|
return false;
|
|
}
|
|
if t.reserved != 0 && t.reserved != me {
|
|
return false;
|
|
}
|
|
let (x, y) = xy(i);
|
|
DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == self.me))
|
|
}
|
|
|
|
fn is_deposit(&self, i: usize) -> bool {
|
|
let t = &self.map.t[i];
|
|
(t.kind == Kind::Treasury && t.owner == self.me) || (self.counts[0] == 0 && self.map.adj_core(i))
|
|
}
|
|
|
|
fn start_deliver(&mut self, e: &mut Ent) -> bool {
|
|
let cur = e.tile();
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.is_deposit(i), true) {
|
|
e.path = p;
|
|
e.job = Job::Deliver;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
// flying creatures patrol far around (also over rivers and the land of the enemy)
|
|
fn patrol_flight(&mut self, e: &mut Ent) {
|
|
let (x, y) = xy(e.tile());
|
|
for _ in 0..8 {
|
|
let nx = x + self.rng.range(-14, 14);
|
|
let ny = y + self.rng.range(-14, 14);
|
|
let ok = self.map.get(nx, ny).map_or(false, |t| t.walkable() || t.liquid());
|
|
if ok {
|
|
if let Some(p) = self.map.fly_path_to(e.tile(), idx(nx, ny)) {
|
|
if p.len() <= 40 {
|
|
e.path = p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
e.job = Job::Wander;
|
|
e.timer = 2.0 + self.rng.f() * 3.0;
|
|
}
|
|
|
|
// badly hurt creatures flee to their bed (lair, flyers: aerie), or else next to the soul
|
|
// core, and rest there until they are healed to 75%
|
|
fn start_rest(&mut self, e: &mut Ent) -> bool {
|
|
let cur = e.tile();
|
|
let fly = flies(e.kind);
|
|
let bed_kind = if fly { Kind::Aerie } else { Kind::Lair };
|
|
let me = self.me;
|
|
let target = e
|
|
.bed
|
|
.filter(|&b| self.map.t[b].kind == bed_kind && self.map.t[b].owner == me)
|
|
.or_else(|| self.map.search(cur, self.map.creature_cost(fly), |i| self.map.t[i].kind == bed_kind && self.map.t[i].owner == me, true).map(|p| p.last().copied().unwrap_or(cur)))
|
|
.or_else(|| self.map.search(cur, self.map.creature_cost(fly), |i| self.map.adj_core_of(i, me), true).map(|p| p.last().copied().unwrap_or(cur)));
|
|
let Some(b) = target else { return false };
|
|
e.path = self.map.creature_path(fly, cur, b).unwrap_or_default();
|
|
e.job = Job::Rest(b);
|
|
true
|
|
}
|
|
|
|
fn wander(&mut self, e: &mut Ent, radius: i32) {
|
|
let (x, y) = xy(e.tile());
|
|
for _ in 0..6 {
|
|
let nx = x + self.rng.range(-radius, radius);
|
|
let ny = y + self.rng.range(-radius, radius);
|
|
if self.map.walk(nx, ny) && self.map.t[idx(nx, ny)].owner == self.me {
|
|
if let Some(p) = self.map.path_to(e.tile(), idx(nx, ny)) {
|
|
if p.len() as i32 <= radius * 2 + 2 {
|
|
e.path = p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
e.job = Job::Wander;
|
|
e.timer = 1.0 + self.rng.f() * 2.0;
|
|
}
|
|
|
|
fn worker_ai(&mut self, e: &mut Ent, dt: f32) {
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
let me = e.id + 1;
|
|
let cur = e.tile();
|
|
match e.job {
|
|
Job::Dig(t) => {
|
|
let tile = self.map.t[t];
|
|
if !tile.marked || tile.mark_by != e.side || !tile.diggable() || !adj4(cur, t) {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
// an imp carries one kind of treasure: other loads are delivered first
|
|
if tile.vein().map_or(false, |r| e.carry > 0 && r != e.carry_res) {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
self.start_deliver(e);
|
|
return;
|
|
}
|
|
let (tx, ty) = xy(t);
|
|
e.face = sgn(tx as f32 + 0.5 - e.x);
|
|
e.working = true;
|
|
let skilled = if crate::personality::knows(e, "dig") { 1.25 } else { 1.0 };
|
|
e.timer += dt * self.help_speed(t) * skilled * self.talents.dig_speed();
|
|
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 {
|
|
if self.map.t[t].plant() {
|
|
self.felled(t);
|
|
}
|
|
self.dig_out(t);
|
|
e.job = Job::None;
|
|
}
|
|
if e.carry >= CARRY_MAX {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
self.start_deliver(e);
|
|
}
|
|
}
|
|
}
|
|
Job::Carry(id) => {
|
|
self.carry_job(e, id, dt);
|
|
}
|
|
Job::Install(id) => {
|
|
self.install_job(e, id, dt);
|
|
}
|
|
Job::Repair(id) => {
|
|
let Some(k) = self.towers.iter().position(|t| t.id == id) else {
|
|
e.job = Job::None;
|
|
return;
|
|
};
|
|
let (tx, ty) = self.towers[k].pos();
|
|
if (tx - e.x).abs() > 1.6 || (ty - e.y).abs() > 1.6 {
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
let t = &mut self.towers[k];
|
|
t.hp = (t.hp + 30.0 * dt).min(t.max_hp());
|
|
if t.hp >= t.max_hp() {
|
|
e.job = Job::None;
|
|
}
|
|
if (e.anim * 3.0).fract() < dt * 3.0 {
|
|
self.sfx(Sfx::Reinforce, tx, ty);
|
|
}
|
|
}
|
|
Job::RaiseWall(w) => {
|
|
if !self.raise_wall_job(e, w, dt) {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::MendBridge(b) => {
|
|
let (bx, by) = xy(b);
|
|
let (tx, ty) = (bx as f32 + 0.5, by as f32 + 0.5);
|
|
if (tx - e.x).abs() > 1.6 || (ty - e.y).abs() > 1.6 || self.burning(b) {
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
if !self.mend_bridge(b, dt) {
|
|
e.job = Job::None;
|
|
}
|
|
if (e.anim * 3.0).fract() < dt * 3.0 {
|
|
self.sfx(Sfx::Reinforce, tx, ty);
|
|
}
|
|
}
|
|
Job::Help(t) => {
|
|
// the task is done or nobody works on it any more: back to thinking
|
|
if self.task_worker(t).is_none() || self.helpers(t) > HELPERS_MAX {
|
|
e.job = Job::None;
|
|
e.think = 0.2;
|
|
return;
|
|
}
|
|
let (tx, _) = xy(t);
|
|
e.face = sgn(tx as f32 + 0.5 - e.x);
|
|
e.working = true;
|
|
if e.think <= 0.0 {
|
|
// every 1.5 s: is there own work again? (if not, the imp comes back to help)
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Reinforce(w) => {
|
|
let t = self.map.t[w];
|
|
if !adj4(cur, w) || t.kind != Kind::Earth || t.reinforced || t.marked {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
let (wx, wy) = xy(w);
|
|
e.face = sgn(wx as f32 + 0.5 - e.x);
|
|
e.working = true;
|
|
e.timer += dt * self.help_speed(w);
|
|
if e.timer >= 2.5 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
let tl = &mut self.map.t[w];
|
|
tl.reinforced = true;
|
|
tl.hp = 12.0; // the player's imps need twice as long to dig it out again
|
|
self.fx(8, wx as f32 + 0.5, wy as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
|
|
self.sfx(Sfx::Reinforce, wx as f32 + 0.5, wy as f32 + 0.5);
|
|
}
|
|
}
|
|
Job::Claim(t) => {
|
|
if cur != t || !self.claimable(t, me) {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
e.timer += dt * self.help_speed(t);
|
|
// knight ground takes longer
|
|
let prev = self.map.t[t].owner;
|
|
let knight = prev == Owner::Hero;
|
|
if e.timer >= if prev != Owner::None { 3.0 } else { 1.0 } {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
let (tx, ty) = xy(t);
|
|
if knight {
|
|
self.alarm(tx as f32 + 0.5, ty as f32 + 0.5, e.id);
|
|
}
|
|
// vaults, shards and springs (of the knights, rivals or nobody)
|
|
self.take_knight_tile(t);
|
|
if let (Kind::Portal, Some(pk)) = (self.map.t[t].kind, self.map.portal_at(tx, ty)) {
|
|
let (px, py) = self.map.portals[pk];
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
self.map.t[idx(px + x, py + y)].owner = self.me;
|
|
}
|
|
}
|
|
self.msg("msg.portal_claimed", vec![]);
|
|
self.fx(7, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 1.0, 0xffff66cc);
|
|
} else {
|
|
self.map.t[t].owner = self.me;
|
|
}
|
|
self.place_torches(t);
|
|
let (x, y) = xy(t);
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
|
|
self.sfx(Sfx::Claim, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
}
|
|
Job::Deliver => {
|
|
if self.is_deposit(cur) {
|
|
let amount = e.carry as f32 * self.talents.treasure();
|
|
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, e.side).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(t) = self.dig_neighbor(end, me, e.side) {
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Dig(t);
|
|
return;
|
|
}
|
|
}
|
|
if e.carry > 0 && self.start_deliver(e) {
|
|
return;
|
|
}
|
|
// finished traps and doors are carried to their spots
|
|
if let Some((id, p)) = self.crate_to_carry(cur) {
|
|
if let Some(k) = self.traps.iter().position(|t| t.id == id) {
|
|
self.traps[k].carrier = me;
|
|
}
|
|
e.path = p;
|
|
e.job = Job::Install(id);
|
|
e.timer = 0.0;
|
|
return;
|
|
}
|
|
// damaged towers are repaired (free of charge) before more ground is claimed
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(id) = self.damaged_tower_near(end) {
|
|
e.path = p;
|
|
e.job = Job::Repair(id);
|
|
return;
|
|
}
|
|
}
|
|
// planned walls are raised
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.planned_wall_near(i, me).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(w) = self.planned_wall_near(end, me) {
|
|
self.map.t[w].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::RaiseWall(w);
|
|
e.timer = 0.0;
|
|
return;
|
|
}
|
|
}
|
|
// ... and so are bridges the knights worked on
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.worn_bridge_near(i).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(b) = self.worn_bridge_near(end) {
|
|
e.path = p;
|
|
e.job = Job::MendBridge(b);
|
|
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, e: &Ent, kind: Kind, me: u32, need_food: bool) -> Option<Vec<usize>> {
|
|
self.map.search(
|
|
e.tile(),
|
|
self.map.creature_cost(flies(e.kind)),
|
|
|i| {
|
|
let t = &self.map.t[i];
|
|
t.kind == kind && t.owner == self.me && (t.reserved == 0 || t.reserved == me) && (!need_food || t.food >= 1.0)
|
|
},
|
|
true,
|
|
)
|
|
}
|
|
|
|
fn creature_ai(&mut self, e: &mut Ent, dt: f32) {
|
|
if crate::siege::siege(e.kind) {
|
|
// a siege engine has no needs: it only follows the attack flag
|
|
e.hunger = 0.0;
|
|
e.energy = 1.0;
|
|
e.happy = 1.0;
|
|
self.siege_ai(e, dt);
|
|
return;
|
|
}
|
|
let me = e.id + 1;
|
|
if e.kind == EK::Revenant {
|
|
// the undead neither eat nor sleep
|
|
e.hunger = 0.0;
|
|
e.energy = 1.0;
|
|
} else {
|
|
e.hunger += dt / 75.0;
|
|
if !matches!(e.job, Job::Sleep(_)) {
|
|
e.energy -= dt / 110.0;
|
|
}
|
|
}
|
|
if e.hunger > 1.8 {
|
|
e.happy -= dt * 0.004;
|
|
}
|
|
if e.kind == EK::Priestess && e.skill_cd <= 0.0 {
|
|
// flame priestess: heals the most hurt own creature nearby (or herself)
|
|
let mut best: Option<usize> = None;
|
|
let mut worst = if e.hp < e.max_hp * 0.8 { e.hp / e.max_hp } else { 0.8 };
|
|
for (j, o) in self.ents.iter().enumerate() {
|
|
if o.alive && !o.held && o.creature() && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 4.5 * 4.5 && o.hp / o.max_hp < worst {
|
|
worst = o.hp / o.max_hp;
|
|
best = Some(j);
|
|
}
|
|
}
|
|
if let Some(j) = best {
|
|
let o = &mut self.ents[j];
|
|
o.hp = (o.hp + 20.0).min(o.max_hp);
|
|
let (ox, oy) = (o.x, o.y);
|
|
self.fx(4, ox, oy, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
self.sfx(Sfx::Heal, ox, oy);
|
|
e.skill_cd = 2.5;
|
|
} else if e.hp < e.max_hp * 0.8 {
|
|
e.hp = (e.hp + 20.0).min(e.max_hp);
|
|
self.fx(4, e.x, e.y, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
e.skill_cd = 2.5;
|
|
} else {
|
|
e.skill_cd = 1.0;
|
|
}
|
|
}
|
|
if e.think <= 0.0 {
|
|
e.think = 0.4 + self.rng.f() * 0.3;
|
|
// a new creature builds its own bed in the lair soon after its arrival
|
|
let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair };
|
|
let beds = if flies(e.kind) { self.counts[7] } else { self.counts[1] };
|
|
if e.bed.is_none() && beds > 0 && !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rally | Job::March(_)) && 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 && self.map.t[i].owner == self.me;
|
|
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);
|
|
}
|
|
}
|
|
// badly hurt creatures may flee before they die (not all of them, and not at once)
|
|
// and come back at 75%
|
|
if e.hp < e.max_hp * 0.25 && !matches!(e.job, Job::Rest(_) | Job::Leave) && self.rng.f() < 0.3 {
|
|
self.unreserve(me);
|
|
if self.start_rest(e) {
|
|
return;
|
|
}
|
|
}
|
|
if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_) | Job::Brawl(_)) {
|
|
let radius = if matches!(e.job, Job::Rally | Job::March(_)) { 8.5 } else if matches!(e.job, Job::Guard(_)) { 10.0 } else if flies(e.kind) { 9.0 } else { 7.0 };
|
|
if let Some(id) = self.nearest_enemy(e, radius) {
|
|
self.unreserve(me);
|
|
e.job = Job::Attack(id);
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
} else if !matches!(e.job, Job::AttackTower(_) | Job::Sleep(_) | Job::Eat(_)) {
|
|
// knight towers in reach are torn down
|
|
if let Some(id) = self.enemy_tower_near(e.x, e.y, radius - 1.0) {
|
|
self.unreserve(me);
|
|
e.job = Job::AttackTower(id);
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if let Job::AttackTower(id) = e.job {
|
|
if !self.attack_tower(e, id, dt) {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
return;
|
|
}
|
|
// arrival: report at the portal and set up the bed before anything else
|
|
if !e.settled {
|
|
self.settle(e, dt);
|
|
return;
|
|
}
|
|
// payday: the wage is fetched from a treasury
|
|
if e.pay_due > 0.0 {
|
|
e.pay_t -= dt;
|
|
if e.pay_t <= 0.0 && !matches!(e.job, Job::FetchWage(_) | Job::Leave | Job::Rest(_)) {
|
|
self.unreserve(me);
|
|
self.start_fetch_wage(e);
|
|
return;
|
|
}
|
|
}
|
|
if let Job::FetchWage(t) = e.job {
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
self.take_wage(e, Some(t));
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
if let Job::Rest(b) = e.job {
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
let (bx, by) = xy(b);
|
|
let there = (e.x - bx as f32 - 0.5).abs() < 1.5 && (e.y - by as f32 - 0.5).abs() < 1.5;
|
|
if !there {
|
|
// pushed away on the way: go on, or give up if there is no way
|
|
match self.map.creature_path(flies(e.kind), e.tile(), b) {
|
|
Some(p) if !p.is_empty() => e.path = p,
|
|
_ => e.job = Job::None,
|
|
}
|
|
return;
|
|
}
|
|
// resting heals: quickly in a bed room, slower next to the core; back to work at 75%
|
|
let bed_room = matches!(self.map.t[b].kind, Kind::Lair | Kind::Aerie);
|
|
let rest = if bed_room { self.room_bonus(self.map.t[b].kind) } else { 1.0 };
|
|
e.hp = (e.hp + e.max_hp * dt * rest / if bed_room { 12.0 } else { 24.0 }).min(e.max_hp);
|
|
e.energy = (e.energy + dt * rest / 14.0).min(1.0);
|
|
if e.hp >= e.max_hp * 0.75 {
|
|
e.job = Job::None;
|
|
}
|
|
return;
|
|
}
|
|
if e.kind == EK::Chainbreaker {
|
|
// restless: without a fight for two minutes its mood sinks (skill_cd counts down as idle time)
|
|
if matches!(e.job, Job::Attack(_)) {
|
|
e.skill_cd = 0.0;
|
|
} else if e.skill_cd < -120.0 {
|
|
e.happy -= 0.005 * dt;
|
|
self.hint(20, "hint.chainbreaker_restless");
|
|
}
|
|
}
|
|
self.mood_check(e, dt);
|
|
self.passive_abilities(e);
|
|
if let Job::Brawl(id) = e.job {
|
|
self.brawl(e, id, dt);
|
|
return;
|
|
}
|
|
if let Job::Attack(id) = e.job {
|
|
if !self.attack_job(e, id, dt) {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
return;
|
|
}
|
|
if e.job == Job::Rally {
|
|
self.rally(e, dt);
|
|
return;
|
|
}
|
|
if let Job::March(f) = e.job {
|
|
if self.attack_marks.contains(&f) {
|
|
self.rally_to(e, f, dt);
|
|
} else {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
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 * self.room_bonus(Kind::Temple)).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 * self.room_bonus(Kind::Training);
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Guard(t) => {
|
|
// a guard post, or a guard order in any room
|
|
e.timer -= dt;
|
|
if cur != t || !self.map.t[t].walkable() || self.map.t[t].owner != self.me || e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Spar(t) => {
|
|
if !self.spar(e, t, dt) {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Forge(t) => {
|
|
if cur != t || self.map.t[t].kind != Kind::Forge || !self.forge_orders() {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
let rate = if e.kind == EK::Cogling { 2.5 } else { 1.0 } * (1.0 + 0.1 * (e.level - 1) as f32);
|
|
self.forge_work += rate * dt * self.room_bonus(Kind::Forge);
|
|
e.xp += 2.5 * dt;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Study(t) => {
|
|
if cur != t || self.map.t[t].kind != Kind::Library {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
let rate = if e.kind == EK::Mage { 1.2 } else { 0.5 };
|
|
self.mana = (self.mana + rate * 0.5 * dt).min(self.mana_max());
|
|
// research: mages and owls are the scholars
|
|
let points = match e.kind {
|
|
EK::Mage => 1.6,
|
|
EK::Owl => 1.3,
|
|
_ => 0.5,
|
|
} * (1.0 + 0.12 * (e.level - 1) as f32);
|
|
self.add_research(points * dt * self.room_bonus(Kind::Library));
|
|
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;
|
|
};
|
|
self.rally_to(e, f, dt);
|
|
}
|
|
|
|
// march to tile f and fight there (the attack flag, or an attack marker)
|
|
pub(crate) fn rally_to(&mut self, e: &mut Ent, f: usize, dt: f32) {
|
|
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 && !self.allied(e.side(), r.side)).find(|r| {
|
|
let (cx, cy) = r.centre();
|
|
(fx - cx).powi(2) + (fy - cy).powi(2) <= 8.0 * 8.0
|
|
});
|
|
// in a match the heart of an enemy master counts as well
|
|
let master_near = self.enemy_master_near(e.side(), fx, fy);
|
|
if let Some(side) = rival_near.map(|r| r.side).or(master_near) {
|
|
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();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn add_mark(&mut self, x: f32, y: f32, kind: u8, side: u8, duration: f32, fixed: bool) {
|
|
// the shields, swords and the flag show themselves; these markers are for debugging only
|
|
let _ = fixed;
|
|
if !self.debug_markers || duration <= 0.0 || !x.is_finite() || !y.is_finite() {
|
|
return;
|
|
}
|
|
let until = self.sim_steps + (duration / STEP).ceil() as u64;
|
|
if let Some(m) = self.marks.iter_mut().find(|m| (m.x - x).abs() < 0.75 && (m.y - y).abs() < 0.75 && m.kind == kind && m.side == side && m.fixed == fixed) {
|
|
m.until = m.until.max(until);
|
|
return;
|
|
}
|
|
if self.marks.len() >= 80 {
|
|
self.marks.remove(0);
|
|
}
|
|
self.marks.push(Mark { x, y, kind, side, until, fixed });
|
|
}
|
|
|
|
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];
|
|
// a bed in somebody else's lair (an ally's, or ground that changed hands) is given up
|
|
if t.kind != bed_kind || t.bed != me || t.owner != self.me {
|
|
if t.bed == me {
|
|
self.map.t[b].bed = 0;
|
|
}
|
|
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 && t.owner == self.me
|
|
};
|
|
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(e, 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(e, Kind::Temple, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Pray(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// the jailer's favourite work: prisoners nobody is working on yet
|
|
if e.kind == EK::Jailer && self.rng.f() < 0.85 {
|
|
let busy: Vec<u32> = self.ents.iter().filter_map(|o| if let Job::Torment(p) = o.job { Some(p) } else { None }).collect();
|
|
let prisoner = self.ents.iter().filter(|o| o.alive && o.captive == 3 && !busy.contains(&o.id)).map(|o| (o.id, o.tile())).next();
|
|
if let Some((id, pt)) = prisoner {
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| i != pt && adj4(i, pt) || i == pt, true) {
|
|
e.path = p;
|
|
e.job = Job::Torment(id);
|
|
e.timer = 12.0 + self.rng.f() * 8.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
// guard posts and the arena
|
|
if self.counts[crate::rooms::ROOM_GUARD] > 0 && self.rng.f() < 0.2 {
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.free_post(i, me), true) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Guard(t);
|
|
e.timer = 35.0 + self.rng.f() * 20.0;
|
|
return;
|
|
}
|
|
}
|
|
if self.counts[crate::rooms::ROOM_ARENA] > 0 && e.hp > e.max_hp * 0.8 && self.rng.f() < 0.25 {
|
|
if let Some(p) = self.find_room_tile(e, Kind::Arena, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Spar(t);
|
|
e.timer = 14.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
// the forge: orders waiting (coglings love it, others lend a hand now and then)
|
|
if self.forge_orders() && self.counts[ROOM_FORGE] > 0 {
|
|
let r = self.rng.f();
|
|
if (e.kind == EK::Cogling && r < 0.9) || r < 0.25 {
|
|
if let Some(p) = self.find_room_tile(e, Kind::Forge, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Forge(t);
|
|
e.timer = 10.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
let r = self.rng.f();
|
|
if (e.kind == EK::Mage && r < 0.8) || r < 0.2 {
|
|
if let Some(p) = self.find_room_tile(e, 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(e, Kind::Training, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Train(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
if flies(e.kind) {
|
|
self.patrol_flight(e);
|
|
} else {
|
|
self.wander(e, 4);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- prisoners
|
|
|
|
fn captive_update(&mut self, e: &mut Ent, dt: f32) {
|
|
e.moving = false;
|
|
match e.captive {
|
|
1 => {
|
|
// unconscious: bleeds out if nobody carries the hero away
|
|
e.torture_t += dt;
|
|
if e.torture_t > 60.0 {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
|
}
|
|
}
|
|
2 => {
|
|
// carried: follows the imp
|
|
match self.find(e.carrier) {
|
|
Some(k) => {
|
|
e.x = self.ents[k].x;
|
|
e.y = self.ents[k].y;
|
|
}
|
|
None => {
|
|
e.captive = 1;
|
|
e.carrier = 0;
|
|
}
|
|
}
|
|
}
|
|
_ if self.map.t[e.tile()].kind == Kind::Prison => {
|
|
// in prison: no torture, but hunger wears the prisoner down
|
|
e.hp -= e.max_hp / 180.0 * dt;
|
|
if e.hp <= 0.0 {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
|
self.msg("msg.prisoner_starved", vec![format!("@{}", st(e.kind).name)]);
|
|
self.bury(e.x, e.y);
|
|
}
|
|
}
|
|
_ => {
|
|
let t = e.tile();
|
|
if self.map.t[t].kind != Kind::Torture {
|
|
// the chamber is gone: the prisoner breaks free
|
|
e.captive = 0;
|
|
e.hp = e.max_hp * 0.3;
|
|
self.release_all(e.id);
|
|
return;
|
|
}
|
|
e.working = true;
|
|
e.hp -= e.max_hp / 100.0 * dt;
|
|
e.torture_t += dt;
|
|
if e.torture_t >= 5.0 {
|
|
e.torture_t -= 5.0;
|
|
// ~40% defect over the whole torture (paladins ~25%); a jailer working on
|
|
// the prisoner makes it 2.5 times as likely
|
|
let tormented = self.ents.iter().any(|o| o.alive && o.kind == EK::Jailer && o.job == Job::Torment(e.id) && o.path.is_empty() && (o.x - e.x).abs() < 1.6 && (o.y - e.y).abs() < 1.6);
|
|
let p = if e.kind == EK::Paladin { 0.0143 } else { 0.0252 } * if tormented { 2.5 } else { 1.0 };
|
|
if tormented {
|
|
self.fx(2, e.x, e.y, 0.0, 0.0, 0.3, 0xff3040ff);
|
|
}
|
|
if self.rng.f() < p {
|
|
self.defect(e);
|
|
return;
|
|
}
|
|
}
|
|
if e.hp <= 0.0 {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
|
self.sfx(Sfx::HeroDeath, e.x, e.y);
|
|
self.msg("msg.prisoner_died", vec![format!("@{}", st(e.kind).name)]);
|
|
self.bury(e.x, e.y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a prisoner joins the player's side
|
|
fn defect(&mut self, e: &mut Ent) {
|
|
self.release_all(e.id);
|
|
e.hero = false;
|
|
e.captive = 0;
|
|
e.carrier = 0;
|
|
e.guard = false;
|
|
e.home = None;
|
|
e.hp = e.max_hp * 0.5;
|
|
e.happy = 0.6;
|
|
e.hunger = 0.5;
|
|
e.energy = 0.4;
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
e.bed = None;
|
|
self.fx(7, e.x, e.y, 0.0, 0.0, 1.0, 0xff4040e0);
|
|
self.sfx(Sfx::Convert, e.x, e.y);
|
|
self.msg("msg.defected", vec![format!("@{}", st(e.kind).name)]);
|
|
}
|
|
|
|
// unconscious hero lying next to tile i that no imp takes care of yet
|
|
fn captive_near(&self, i: usize) -> Option<u32> {
|
|
let (x, y) = xy(i);
|
|
self.ents.iter().find(|o| {
|
|
o.alive && o.hero && o.captive == 1 && o.carrier == 0 && (o.x as i32 - x).abs() <= 1 && (o.y as i32 - y).abs() <= 1
|
|
}).map(|o| o.id)
|
|
}
|
|
|
|
fn carry_job(&mut self, e: &mut Ent, id: u32, dt: f32) {
|
|
let Some(k) = self.find(id) else {
|
|
e.job = Job::None;
|
|
return;
|
|
};
|
|
let cur = e.tile();
|
|
match self.ents[k].captive {
|
|
1 => {
|
|
let (cx, cy) = (self.ents[k].x, self.ents[k].y);
|
|
if (cx - e.x).abs() < 1.2 && (cy - e.y).abs() < 1.2 {
|
|
// pick up and look for a free place in the torture chamber
|
|
match self.map.search(cur, self.map.walk_cost(), |i| self.free_cell(i), true) {
|
|
Some(p) => {
|
|
let target = p.last().copied().unwrap_or(cur);
|
|
self.map.t[target].bed = id + 1;
|
|
let c = &mut self.ents[k];
|
|
c.captive = 2;
|
|
c.carrier = e.id;
|
|
e.path = p;
|
|
}
|
|
None => {
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
} else if e.path.is_empty() {
|
|
match self.map.path_to(cur, idx(cx as i32, cy as i32)) {
|
|
Some(p) if !p.is_empty() => e.path = p,
|
|
_ => {
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
2 if self.ents[k].carrier == e.id => {
|
|
if !self.step_path(e, dt) {
|
|
// arrived (or blocked): put the prisoner down on the reserved torture tile
|
|
let target = self.map.t.iter().position(|t| t.bed == id + 1);
|
|
match target {
|
|
Some(t) if t == cur || adj4(t, cur) || t == e.tile() => {
|
|
let (tx, ty) = xy(t);
|
|
let c = &mut self.ents[k];
|
|
c.captive = 3;
|
|
c.carrier = 0;
|
|
c.hp = c.max_hp; // the torture drains it over ~100 s
|
|
c.x = tx as f32 + 0.5;
|
|
c.y = ty as f32 + 0.5;
|
|
c.torture_t = 0.0;
|
|
self.sfx(Sfx::Capture, tx as f32 + 0.5, ty as f32 + 0.5);
|
|
e.job = Job::None;
|
|
}
|
|
Some(t) => {
|
|
if let Some(p) = self.map.path_to(cur, t) {
|
|
e.path = p;
|
|
} else {
|
|
self.ents[k].captive = 1;
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
None => {
|
|
self.ents[k].captive = 1;
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
_ => {
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- heroes
|
|
|
|
fn hero_ai(&mut self, e: &mut Ent, dt: f32) {
|
|
if e.boss > 0 {
|
|
self.boss_ability(e, dt);
|
|
}
|
|
// the rogues' tricks: a thief who is busy stealing or running does nothing else
|
|
if self.rogue_ai(e, dt) {
|
|
return;
|
|
}
|
|
if e.kind == EK::Fairy {
|
|
// fairy dust: the heroes around are healed a little all the time
|
|
for o in self.ents.iter_mut() {
|
|
if o.alive && o.active_hero() && o.hp < o.max_hp && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 9.0 {
|
|
o.hp = (o.hp + 6.0 * dt).min(o.max_hp);
|
|
}
|
|
}
|
|
if self.vrng.f() < 5.0 * dt {
|
|
let vx = self.vrng.f() * 0.6 - 0.3;
|
|
self.part(crate::particles::EMBER, (e.x, e.y, 0.9), (vx, 0.0, 0.2), 1.0, crate::particles::abgr(255, 240, 180), 0.03);
|
|
}
|
|
}
|
|
if e.think <= 0.0 {
|
|
e.think = 0.5;
|
|
if e.kind == EK::Priest && e.cd <= 0.0 {
|
|
let mut best: Option<usize> = None;
|
|
let mut worst = 0.95;
|
|
for (j, o) in self.ents.iter().enumerate() {
|
|
if o.alive && o.hero {
|
|
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
|
|
let r = o.hp / o.max_hp;
|
|
if d < 16.0 && r < worst {
|
|
worst = r;
|
|
best = Some(j);
|
|
}
|
|
}
|
|
}
|
|
if e.hp / e.max_hp < worst {
|
|
e.hp = (e.hp + 25.0).min(e.max_hp);
|
|
self.fx(4, e.x, e.y, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
e.cd = 2.5;
|
|
} else if let Some(j) = best {
|
|
let o = &mut self.ents[j];
|
|
o.hp = (o.hp + 25.0).min(o.max_hp);
|
|
let (ox, oy) = (o.x, o.y);
|
|
self.fx(4, ox, oy, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
e.cd = 2.5;
|
|
}
|
|
}
|
|
let fort = e.guard || !e.patrol.is_empty();
|
|
// a catapult aims at towers first
|
|
if e.kind == EK::Catapult && !matches!(e.job, Job::AttackTower(_)) {
|
|
let reach = st(e.kind).range + 1.0;
|
|
let near = self.towers.iter().filter(|t| !t.hero).map(|t| (t.id, (t.pos().0 - e.x).powi(2) + (t.pos().1 - e.y).powi(2))).filter(|&(_, d2)| d2 < reach * reach).min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)).map(|t| t.0);
|
|
if let Some(id) = near {
|
|
e.job = Job::AttackTower(id);
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
}
|
|
}
|
|
if !matches!(e.job, Job::Attack(_)) && !matches!((e.kind, e.job), (EK::Catapult, Job::AttackTower(_))) {
|
|
let radius = if fort { 8.0 } else { 6.5 };
|
|
// a ram does not fight creatures
|
|
let foe = if e.kind == EK::Ram { None } else { self.nearest_enemy(e, radius) };
|
|
if let Some(id) = foe {
|
|
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;
|
|
// the core next to it is the first master's (whoever is being processed in a match)
|
|
let (cx, cy) = self.core_of_side(0);
|
|
e.face = sgn(cx as f32 + 1.5 - e.x);
|
|
if e.cd <= 0.0 {
|
|
e.cd = st(e.kind).interval;
|
|
self.damage_master_core(0, e.atk());
|
|
let fx = (e.x + cx as f32 + 1.5) * 0.5;
|
|
let fy = (e.y + cy as f32 + 1.5) * 0.5;
|
|
self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff);
|
|
self.sfx(Sfx::CoreHit, fx, fy);
|
|
self.hint(6, "hint.core_attacked");
|
|
}
|
|
return;
|
|
}
|
|
if let Some((wx, wy)) = e.waypoint {
|
|
if (e.x - wx as f32 - 0.5).abs() < 2.5 && (e.y - wy as f32 - 0.5).abs() < 2.5 {
|
|
e.waypoint = None; // broke into the realm: now for the core
|
|
e.path.clear();
|
|
}
|
|
}
|
|
if e.path.is_empty() && e.repath <= 0.0 {
|
|
e.repath = 1.0;
|
|
let path = match e.waypoint {
|
|
Some((wx, wy)) => {
|
|
let goal = idx(wx, wy);
|
|
let p = self.map.search(cur, self.map.hero_cost(), |i| i == goal, true);
|
|
if p.is_none() {
|
|
e.waypoint = None;
|
|
}
|
|
p
|
|
}
|
|
None => self.map.hero_path_to_core(cur),
|
|
};
|
|
if let Some(p) = path {
|
|
e.path = p;
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
}
|
|
|
|
// Wave AI: choose where the next wave breaks into the realm. Candidates are border tiles of
|
|
// the player's territory on the side facing the hero gate; towers, creatures and reinforced
|
|
// walls nearby make a place less attractive, as does a long way and recent use.
|
|
pub fn plan_entries(&mut self) {
|
|
self.planned_entries.clear();
|
|
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
|
|
let (gx, gy) = (self.map.gate.0 + 1, self.map.gate.1 + 1);
|
|
let dir = norm((gx - cx) as f32, (gy - cy) as f32);
|
|
let mut cands = Vec::new();
|
|
for i in 0..self.map.t.len() {
|
|
let t = &self.map.t[i];
|
|
if t.owner != self.me || !t.walkable() {
|
|
continue;
|
|
}
|
|
let (x, y) = xy(i);
|
|
let border = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != self.me));
|
|
if !border {
|
|
continue;
|
|
}
|
|
let rel = ((x - cx) as f32, (y - cy) as f32);
|
|
let len = (rel.0 * rel.0 + rel.1 * rel.1).sqrt();
|
|
if len < 2.0 || (rel.0 * dir.0 + rel.1 * dir.1) / len < 0.0 {
|
|
continue;
|
|
}
|
|
cands.push((x, y));
|
|
}
|
|
if cands.is_empty() {
|
|
return;
|
|
}
|
|
let step = (cands.len() / 300).max(1);
|
|
let mut scored: Vec<((i32, i32), f32)> = Vec::new();
|
|
for &(x, y) in cands.iter().step_by(step) {
|
|
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
|
let near = |px: f32, py: f32, r: f32| (px - fx).powi(2) + (py - fy).powi(2) < r * r;
|
|
let towers: u32 = self.towers.iter().filter(|t| near(t.pos().0, t.pos().1, 6.0)).map(|t| t.level).sum();
|
|
let creatures = self.ents.iter().filter(|e| e.alive && e.creature() && near(e.x, e.y, 6.0)).count();
|
|
let mut reinf = 0;
|
|
for dy in -2..=2 {
|
|
for dx in -2..=2 {
|
|
if self.map.get(x + dx, y + dy).map_or(false, |t| t.reinforced) {
|
|
reinf += 1;
|
|
}
|
|
}
|
|
}
|
|
let dist = (((x - gx).pow(2) + (y - gy).pow(2)) as f32).sqrt();
|
|
let recent: f32 = self.entry_history.iter().rev().take(2).filter(|&&(hx, hy)| ((hx - x).pow(2) + (hy - y).pow(2)) < 100).count() as f32 * -60.0;
|
|
let score = -dist * 0.8 - 25.0 * towers as f32 - 12.0 * creatures as f32 - 6.0 * reinf as f32 + recent + self.rng.f() * 25.0;
|
|
scored.push(((x, y), score));
|
|
}
|
|
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
let first = scored[0].0;
|
|
self.planned_entries.push(first);
|
|
// second entry point at least 10 tiles away (used by waves of 6+ heroes)
|
|
if let Some(&(second, _)) = scored.iter().find(|((x, y), _)| (x - first.0).pow(2) + (y - first.1).pow(2) >= 100) {
|
|
self.planned_entries.push(second);
|
|
}
|
|
}
|
|
|
|
// compass direction of a tile as seen from the core (language key)
|
|
pub fn direction_key(&self, (x, y): (i32, i32)) -> &'static str {
|
|
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
|
|
let a = ((y - cy) as f32).atan2((x - cx) as f32).to_degrees(); // 0 = east, 90 = south (map y grows downwards)
|
|
let k = (((a + 360.0 + 22.5) % 360.0) / 45.0) as usize;
|
|
["dir.e", "dir.se", "dir.s", "dir.sw", "dir.w", "dir.nw", "dir.n", "dir.ne"][k % 8]
|
|
}
|
|
|
|
// ---------------------------------------------------------------- player actions
|
|
|
|
pub fn mark_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, on: bool) {
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if inb(x, y) {
|
|
let t = &mut self.map.t[idx(x, y)];
|
|
// a master sets and clears only his own marks
|
|
let side = self.me.side().min(7);
|
|
if t.diggable() && (!t.marked || t.mark_by == side) {
|
|
t.marked = on;
|
|
t.mark_by = side;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 * self.talents.room_cost()).round();
|
|
let mut built = 0;
|
|
let mut broke = false;
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) {
|
|
continue;
|
|
}
|
|
let i = idx(x, y);
|
|
let t = self.map.t[i];
|
|
if t.kind == Kind::Floor && t.owner == self.me {
|
|
if self.gold < cost {
|
|
broke = true;
|
|
continue;
|
|
}
|
|
self.gold -= cost;
|
|
let t = &mut self.map.t[i];
|
|
t.kind = kind;
|
|
t.food = 0.0;
|
|
t.bed = 0;
|
|
built += 1;
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff);
|
|
if built == 1 {
|
|
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if broke {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
}
|
|
if built == 0 && !broke {
|
|
self.hint(7, "hint.build_where");
|
|
}
|
|
self.count(crate::stats::ROOMS, built as f32);
|
|
self.recount();
|
|
}
|
|
|
|
pub fn sell_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
|
let mut refund = 0.0;
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) {
|
|
continue;
|
|
}
|
|
let i = idx(x, y);
|
|
if self.remove_trap_at(i) {
|
|
continue;
|
|
}
|
|
if let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) {
|
|
refund += self.towers[k].invested() as f32 * 0.5;
|
|
if self.towers[k].kind == TowerKind::Rune && !self.towers[k].rune_final {
|
|
// the base tower comes back once: in the next mission (free play: at once)
|
|
if self.mission.is_some() {
|
|
self.rune_next += 1;
|
|
self.msg("msg.rune_next", vec![]);
|
|
} else {
|
|
self.rune_final += 1;
|
|
self.msg("msg.rune_back", vec![]);
|
|
}
|
|
}
|
|
self.towers.remove(k);
|
|
self.map.t[i].kind = Kind::Floor;
|
|
continue;
|
|
}
|
|
let t = &mut self.map.t[i];
|
|
if t.kind == Kind::Steel {
|
|
t.kind = Kind::Earth;
|
|
t.reinforced = true;
|
|
t.hp = 12.0;
|
|
refund += STEEL_COST[1] as f32 * 0.5;
|
|
continue;
|
|
}
|
|
if t.owner == self.me && t.bridge() {
|
|
let lava = t.river() == Some(Kind::Lava);
|
|
let cost = if t.kind == Kind::Drawbridge { DRAWBRIDGE_COST[lava as usize] } else { BRIDGE_COST[lava as usize] };
|
|
t.kind = if lava { Kind::Lava } else { Kind::Water };
|
|
t.owner = Owner::None;
|
|
t.reserved = 0;
|
|
t.res = 0;
|
|
refund += cost as f32 * 0.5;
|
|
self.ground_lost(i);
|
|
continue;
|
|
}
|
|
let t = &mut self.map.t[i];
|
|
if t.owner == self.me {
|
|
if let Some(r) = room_index(t.kind) {
|
|
t.kind = Kind::Floor;
|
|
t.bed = 0;
|
|
t.food = 0.0;
|
|
refund += ROOM_COST[r] as f32 * 0.5;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.recount();
|
|
if refund > 0.0 {
|
|
let got = self.add_gold(refund);
|
|
self.sfx_ui(Sfx::Sell);
|
|
self.msg("msg.sold", vec![(got as i32).to_string()]);
|
|
}
|
|
}
|
|
|
|
pub fn tower_at(&self, i: usize) -> Option<&Tower> {
|
|
self.towers.iter().find(|t| t.tile == i)
|
|
}
|
|
|
|
// Build a tower on own floor, or upgrade an own tower of the same kind
|
|
pub fn build_tower(&mut self, kind: TowerKind, x: i32, y: i32) {
|
|
if !inb(x, y) {
|
|
return;
|
|
}
|
|
let i = idx(x, y);
|
|
let s = tower_stats(kind);
|
|
if let Some(k) = self.towers.iter().position(|t| t.tile == i) {
|
|
let t = &self.towers[k];
|
|
if t.hero {
|
|
self.hint(16, "hint.tower_where");
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
if t.kind != kind {
|
|
self.hint(14, "hint.tower_other");
|
|
return;
|
|
}
|
|
if t.level >= 3 {
|
|
self.hint(15, "hint.tower_max");
|
|
return;
|
|
}
|
|
let cost = s.cost[t.level as usize] as f32;
|
|
if self.gold < cost {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.gold -= cost;
|
|
let t = &mut self.towers[k];
|
|
let old_max = t.max_hp();
|
|
t.level += 1;
|
|
t.hp += t.max_hp() - old_max;
|
|
let (px, py) = t.pos();
|
|
let lvl = t.level;
|
|
self.fx(7, px, py, 0.0, 0.0, 0.8, 0xff44ddff);
|
|
self.sfx(Sfx::LevelUp, px, py);
|
|
self.msg("msg.tower_upgraded", vec![format!("@{}", tower_name(kind)), lvl.to_string()]);
|
|
return;
|
|
}
|
|
let t = self.map.t[i];
|
|
if t.kind != Kind::Floor || t.owner != self.me {
|
|
self.hint(16, "hint.tower_where");
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
let cost = s.cost[0] as f32;
|
|
if self.gold < cost {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
// rune forge towers: one build per opened chest (fresh plans first)
|
|
let mut rune_final = false;
|
|
if kind == TowerKind::Rune {
|
|
if self.rune_free > 0 {
|
|
self.rune_free -= 1;
|
|
} else if self.rune_final > 0 {
|
|
self.rune_final -= 1;
|
|
rune_final = true;
|
|
} else {
|
|
self.hint(24, "hint.rune_none");
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
}
|
|
self.gold -= cost;
|
|
self.map.t[i].kind = Kind::Tower;
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false, rune_final };
|
|
let (px, py) = tower.pos();
|
|
self.towers.push(tower);
|
|
self.count(crate::stats::TOWERS, 1.0);
|
|
// units standing on the tile step aside
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && e.tile() == i {
|
|
for &(dx, dy) in &DIR8 {
|
|
if self.map.walk(x + dx, y + dy) {
|
|
e.x = (x + dx) as f32 + 0.5;
|
|
e.y = (y + dy) as f32 + 0.5;
|
|
e.path.clear();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.fx(8, px, py, 0.0, 0.0, 0.5, 0xff40d0ff);
|
|
self.sfx(Sfx::Build, px, py);
|
|
}
|
|
|
|
fn destroy_tower(&mut self, k: usize) {
|
|
let t = self.towers.remove(k);
|
|
self.map.t[t.tile].kind = Kind::Floor;
|
|
let (px, py) = t.pos();
|
|
self.fx(3, px, py, 0.0, 0.0, 1.0, 0xff505070);
|
|
self.sfx(Sfx::DigDone, px, py);
|
|
if t.hero {
|
|
self.towers_razed += 1;
|
|
self.msg("msg.enemy_tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
|
|
} else {
|
|
self.msg("msg.tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
|
|
}
|
|
}
|
|
|
|
// a knight tower near the unit (for the player's creatures)
|
|
fn enemy_tower_near(&self, x: f32, y: f32, radius: f32) -> Option<u32> {
|
|
self.towers
|
|
.iter()
|
|
.filter(|t| t.hero)
|
|
.map(|t| (t.id, (t.pos().0 - x).powi(2) + (t.pos().1 - y).powi(2)))
|
|
.filter(|&(_, d)| d < radius * radius)
|
|
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
|
|
.map(|(id, _)| id)
|
|
}
|
|
|
|
// damage dealt to a unit by a tower (no attacker unit involved)
|
|
fn tower_damage(&mut self, j: usize, dmg: f32) {
|
|
let e = &mut self.ents[j];
|
|
e.hp -= if e.shield > 0.0 { dmg * 0.5 } else { dmg };
|
|
e.flash = 0.15;
|
|
if e.hp <= 0.0 && e.alive {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
|
|
fn tower_target(&self, t: &Tower) -> Option<usize> {
|
|
let (px, py) = t.pos();
|
|
let r = t.range();
|
|
let minr = tower_stats(t.kind).min_range;
|
|
let mut best = None;
|
|
let mut bd = r * r;
|
|
for (j, e) in self.ents.iter().enumerate() {
|
|
// knight towers shoot the player's units, the player's towers shoot heroes
|
|
// knight towers shoot everybody but the humans, the player's towers everybody else
|
|
if !e.alive || e.held || !self.tower_enemy(t, e) || e.invis > 0.0 {
|
|
continue;
|
|
}
|
|
let d = (e.x - px).powi(2) + (e.y - py).powi(2);
|
|
if d < bd && d >= minr * minr && self.los(px, py, e.x, e.y) {
|
|
bd = d;
|
|
best = Some(j);
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
// an enemy tower or the gate of the order within reach: (x, y, height, tower id or None = gate)
|
|
fn tower_structure_target(&self, t: &Tower) -> Option<(f32, f32, f32, Option<u32>)> {
|
|
let (px, py) = t.pos();
|
|
let r = t.range();
|
|
let minr = tower_stats(t.kind).min_range;
|
|
let in_reach = |x: f32, y: f32| {
|
|
let d = (x - px).powi(2) + (y - py).powi(2);
|
|
d <= r * r && d >= minr * minr
|
|
};
|
|
let mut best: Option<(f32, (f32, f32, f32, Option<u32>))> = None;
|
|
for o in self.towers.iter().filter(|o| o.hero != t.hero) {
|
|
let (ox, oy) = o.pos();
|
|
if in_reach(ox, oy) && self.los_to_tile(px, py, o.tile) {
|
|
let d = (ox - px).powi(2) + (oy - py).powi(2);
|
|
if best.map_or(true, |b| d < b.0) {
|
|
best = Some((d, (ox, oy, o.top() * 0.6, Some(o.id))));
|
|
}
|
|
}
|
|
}
|
|
if best.is_none() && !t.hero && self.has_gate() && self.gate_hp > 0.0 {
|
|
// the nearest tile of the gate counts
|
|
let (gx, gy) = self.map.gate;
|
|
let (nx, ny) = ((px - 0.5).clamp(gx as f32, gx as f32 + 2.0) + 0.5, (py - 0.5).clamp(gy as f32, gy as f32 + 2.0) + 0.5);
|
|
if in_reach(nx, ny) && self.los_to_tile(px, py, idx(nx as i32, ny as i32)) {
|
|
best = Some((0.0, (nx, ny, 1.0, None)));
|
|
}
|
|
}
|
|
best.map(|b| b.1)
|
|
}
|
|
|
|
// line of sight from a tower to a target tile: walls and other towers block,
|
|
// the tower's own tile and the target tile do not
|
|
fn los_to_tile(&self, x0: f32, y0: f32, target: usize) -> bool {
|
|
let start = idx(x0 as i32, y0 as i32);
|
|
let (tx, ty) = xy(target);
|
|
let (x1, y1) = (tx as f32 + 0.5, ty as f32 + 0.5);
|
|
let n = (((x1 - x0).powi(2) + (y1 - y0).powi(2)).sqrt() * 3.0).ceil().max(1.0) as i32;
|
|
for k in 1..n {
|
|
let f = k as f32 / n as f32;
|
|
let i = idx((x0 + (x1 - x0) * f) as i32, (y0 + (y1 - y0) * f) as i32);
|
|
if i == start || i == target {
|
|
continue;
|
|
}
|
|
let tl = &self.map.t[i];
|
|
if tl.solid() || tl.kind == Kind::Tower {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn tower_fire_at_structure(&mut self, k: usize, (ex, ey, eh, target): (f32, f32, f32, Option<u32>)) {
|
|
let t = self.towers[k].clone();
|
|
let s = tower_stats(t.kind);
|
|
if s.mana > 0.0 && !t.hero && self.mana < s.mana {
|
|
self.towers[k].cd = 1.0;
|
|
return;
|
|
}
|
|
if !t.hero {
|
|
self.mana -= s.mana;
|
|
}
|
|
let (px, py) = t.pos();
|
|
let aim = (ey - py).atan2(ex - px);
|
|
let tw = &mut self.towers[k];
|
|
tw.cd = s.interval;
|
|
tw.flash = 0.15;
|
|
tw.aim = aim;
|
|
let (mx, my, mh) = match t.kind {
|
|
TowerKind::Arrow => (px, py, t.top() + 0.25),
|
|
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
|
|
TowerKind::Magic => (px, py, t.top() + 0.45),
|
|
TowerKind::Rune => (px, py, t.top() + 0.55),
|
|
};
|
|
let col = match t.kind {
|
|
TowerKind::Arrow => 0xffd0e0f0,
|
|
TowerKind::Cannon => 0xff303030,
|
|
TowerKind::Magic => 0xffff60d0,
|
|
TowerKind::Rune => 0xff40c0ff,
|
|
};
|
|
self.shot(mx, my, mh, ex, ey, eh, 0.3, col);
|
|
self.style_last_fx(match t.kind {
|
|
TowerKind::Arrow => crate::particles::ST_ARROW,
|
|
TowerKind::Cannon => crate::particles::ST_CANNON,
|
|
TowerKind::Magic => crate::particles::ST_MAGIC,
|
|
TowerKind::Rune => crate::particles::ST_RUNE,
|
|
});
|
|
self.sfx(match t.kind {
|
|
TowerKind::Arrow => Sfx::Shoot,
|
|
TowerKind::Cannon => Sfx::Cannon,
|
|
TowerKind::Magic | TowerKind::Rune => Sfx::Magic,
|
|
}, px, py);
|
|
let dmg = t.dmg();
|
|
match target {
|
|
Some(id) => {
|
|
if let Some(o) = self.towers.iter().position(|o| o.id == id) {
|
|
self.towers[o].hp -= dmg;
|
|
if self.towers[o].hp <= 0.0 {
|
|
self.destroy_tower(o);
|
|
}
|
|
}
|
|
}
|
|
None => {
|
|
self.gate_hp -= dmg;
|
|
self.gate_hit_t = self.time;
|
|
self.sfx(Sfx::GateHit, ex, ey);
|
|
if self.mission.as_ref().map_or(true, |m| m.gate_defenders) {
|
|
self.check_gate_stage();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn update_towers(&mut self, dt: f32) {
|
|
for k in 0..self.towers.len() {
|
|
if k >= self.towers.len() {
|
|
break;
|
|
}
|
|
let t = &mut self.towers[k];
|
|
t.cd -= dt;
|
|
t.flash = (t.flash - dt).max(0.0);
|
|
if t.cd > 0.0 {
|
|
continue;
|
|
}
|
|
let t = self.towers[k].clone();
|
|
let Some(j) = self.tower_target(&t) else {
|
|
// no unit in reach: enemy towers and the enemy gate are shot at
|
|
if let Some(target) = self.tower_structure_target(&t) {
|
|
self.tower_fire_at_structure(k, target);
|
|
}
|
|
continue;
|
|
};
|
|
let s = tower_stats(t.kind);
|
|
if s.mana > 0.0 && !t.hero && self.mana < s.mana {
|
|
self.hint(17, "hint.tower_mana");
|
|
self.towers[k].cd = 1.0;
|
|
continue;
|
|
}
|
|
if !t.hero {
|
|
self.mana -= s.mana;
|
|
}
|
|
self.towers[k].cd = s.interval;
|
|
self.towers[k].flash = 0.15;
|
|
let (px, py) = t.pos();
|
|
let (ex, ey) = (self.ents[j].x, self.ents[j].y);
|
|
let aim = (ey - py).atan2(ex - px);
|
|
self.towers[k].aim = aim;
|
|
// where the shot leaves the tower (see the tower models in render3d.rs)
|
|
let (mx, my, mh) = match t.kind {
|
|
TowerKind::Arrow => (px, py, t.top() + 0.25),
|
|
TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2),
|
|
TowerKind::Magic => (px, py, t.top() + 0.45),
|
|
TowerKind::Rune => (px, py, t.top() + 0.55),
|
|
};
|
|
let dmg = t.dmg();
|
|
match t.kind {
|
|
TowerKind::Arrow => {
|
|
// the arrow pierces up to two more heroes behind the target
|
|
let dir = norm(ex - px, ey - py);
|
|
// what the eye sees: past the target the spent arrow comes down at a random
|
|
// distance (not all on a circle at the end of the range); the damage below
|
|
// still reaches along the whole range
|
|
let to_target = ((ex - px).powi(2) + (ey - py).powi(2)).sqrt();
|
|
let reach = to_target + 0.8 + self.vrng.f() * (t.range() + 1.5 - to_target - 0.8).max(0.5);
|
|
let (endx, endy, wall) = self.arrow_end(px, py, dir.0, dir.1, reach);
|
|
self.shot(mx, my, mh, endx, endy, 0.45, 0.3, 0xffd0e0f0);
|
|
self.style_last_fx(if wall { crate::particles::ST_ARROW_WALL } else { crate::particles::ST_ARROW_FALL });
|
|
self.sfx(Sfx::Shoot, px, py);
|
|
let mut hits = vec![(j, dmg)];
|
|
for (o, e) in self.ents.iter().enumerate() {
|
|
if o == j || !e.alive || e.held || !e.active_hero() || hits.len() >= 3 {
|
|
continue;
|
|
}
|
|
let rel = (e.x - px, e.y - py);
|
|
let along = rel.0 * dir.0 + rel.1 * dir.1;
|
|
let side = (rel.0 * dir.1 - rel.1 * dir.0).abs();
|
|
if along > 0.0 && along <= t.range() && side < 0.45 {
|
|
hits.push((o, dmg * 0.6));
|
|
}
|
|
}
|
|
for (o, d) in hits {
|
|
if self.ents[o].alive {
|
|
self.tower_damage(o, d);
|
|
}
|
|
}
|
|
}
|
|
TowerKind::Cannon => {
|
|
// splash damage and knockback around the impact
|
|
self.shot(mx, my, mh, ex, ey, 0.4, 0.35, 0xff303030);
|
|
self.style_last_fx(crate::particles::ST_CANNON);
|
|
self.sfx(Sfx::Cannon, px, py);
|
|
let mut hits = Vec::new();
|
|
for (o, e) in self.ents.iter().enumerate() {
|
|
if !e.alive || e.held || !e.active_hero() {
|
|
continue;
|
|
}
|
|
let d = ((e.x - ex).powi(2) + (e.y - ey).powi(2)).sqrt();
|
|
if d < 1.6 {
|
|
hits.push((o, dmg * (1.0 - d / 2.0), d));
|
|
}
|
|
}
|
|
for (o, d, dist) in hits {
|
|
if !self.ents[o].alive {
|
|
continue;
|
|
}
|
|
// knockback away from the impact, only onto walkable ground
|
|
let e = &self.ents[o];
|
|
let away = if dist > 0.05 { norm(e.x - ex, e.y - ey) } else { norm(e.x - px, e.y - py) };
|
|
self.knock(o, away.0 * 0.7, away.1 * 0.7);
|
|
if self.ents[o].alive {
|
|
self.tower_damage(o, d);
|
|
}
|
|
}
|
|
}
|
|
TowerKind::Magic => {
|
|
// sure hit, slows, jumps to a second hero
|
|
self.shot(mx, my, mh, ex, ey, 0.45, 0.2, 0xffff60d0);
|
|
self.style_last_fx(crate::particles::ST_MAGIC);
|
|
self.sfx(Sfx::Magic, px, py);
|
|
self.ents[j].slow = 2.0;
|
|
let chain = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(o, e)| *o != j && e.alive && !e.held && e.active_hero() && (e.x - ex).powi(2) + (e.y - ey).powi(2) < 9.0)
|
|
.min_by(|a, b| {
|
|
let da = (a.1.x - ex).powi(2) + (a.1.y - ey).powi(2);
|
|
let db = (b.1.x - ex).powi(2) + (b.1.y - ey).powi(2);
|
|
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
|
})
|
|
.map(|(o, _)| o);
|
|
self.tower_damage(j, dmg);
|
|
if let Some(o) = chain {
|
|
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
|
|
self.fx(0, ex, ey, ox, oy, 0.2, 0xffff60d0);
|
|
self.ents[o].slow = 2.0;
|
|
if self.ents[o].alive {
|
|
self.tower_damage(o, dmg * 0.6);
|
|
}
|
|
}
|
|
}
|
|
TowerKind::Rune => {
|
|
// a rune bolt that jumps on to up to three more enemies nearby
|
|
self.shot(mx, my, mh, ex, ey, 0.45, 0.25, 0xff40c0ff);
|
|
self.style_last_fx(crate::particles::ST_RUNE);
|
|
self.fx(2, ex, ey, 0.0, 0.0, 0.35, 0xffffc040);
|
|
self.sfx(Sfx::Magic, px, py);
|
|
let mut hit = vec![j];
|
|
let (mut lx, mut ly) = (ex, ey);
|
|
self.tower_damage(j, dmg);
|
|
for n in 0..3 {
|
|
let next = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(o, e)| !hit.contains(o) && e.alive && !e.held && self.tower_enemy(&t, e) && (e.x - lx).powi(2) + (e.y - ly).powi(2) < 3.5 * 3.5)
|
|
.min_by(|a, b| {
|
|
let da = (a.1.x - lx).powi(2) + (a.1.y - ly).powi(2);
|
|
let db = (b.1.x - lx).powi(2) + (b.1.y - ly).powi(2);
|
|
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
|
})
|
|
.map(|(o, _)| o);
|
|
let Some(o) = next else { break };
|
|
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
|
|
self.fx(0, lx, ly, ox, oy, 0.25, 0xff40c0ff);
|
|
hit.push(o);
|
|
if self.ents[o].alive {
|
|
self.tower_damage(o, dmg * (0.75 - 0.1 * n as f32));
|
|
}
|
|
lx = ox;
|
|
ly = oy;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a unit the tower shoots at
|
|
fn tower_enemy(&self, t: &Tower, e: &Ent) -> bool {
|
|
let target = if t.hero {
|
|
e.side() != 1
|
|
} else if self.coop {
|
|
e.side() == 1
|
|
} else if !self.masters.is_empty() {
|
|
// a match: a tower belongs to the master whose ground it stands on and spares his allies
|
|
let owner = self.map.t.get(t.tile).map_or(0, |tl| crate::netplay::side_of_owner(tl.owner));
|
|
!self.allied(owner, e.side())
|
|
} else {
|
|
e.side() != 0
|
|
};
|
|
e.captive == 0 && !e.chained && target
|
|
}
|
|
|
|
// a hero attacks a tower; false = tower gone or unreachable
|
|
pub(crate) fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
|
|
let Some(k) = self.towers.iter().position(|t| t.id == id) else { return false };
|
|
let (tx, ty) = self.towers[k].pos();
|
|
let hit_h = self.towers[k].top() * 0.6;
|
|
let s = st(e.kind);
|
|
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
|
|
if d <= s.range + 0.75 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
|
|
e.path.clear();
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
if e.cd <= 0.0 {
|
|
e.cd = s.interval;
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3) * crate::siege::tower_breaker(e.kind);
|
|
if s.range > 1.5 {
|
|
self.shot(e.x, e.y, 0.45, tx, ty, hit_h, 0.25, 0xff70b0d0);
|
|
self.style_last_fx(if e.kind == EK::Archer { crate::particles::ST_ARROW } else { crate::particles::ST_PLAIN });
|
|
} else {
|
|
self.fx_at(2, tx, ty, hit_h, 0.25, 0xffffffff);
|
|
}
|
|
self.sfx(Sfx::MeleeHit, tx, ty);
|
|
let t = &mut self.towers[k];
|
|
t.hp -= dmg;
|
|
if t.hp <= 0.0 {
|
|
self.destroy_tower(k);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
if e.path.is_empty() || e.repath <= 0.0 {
|
|
e.repath = 1.0;
|
|
let tile = self.towers[k].tile;
|
|
let (x, y) = xy(tile);
|
|
match self.map.search(e.tile(), self.map.walk_cost(), |i| {
|
|
let (ix, iy) = xy(i);
|
|
(ix - x).abs() <= 1 && (iy - y).abs() <= 1 && i != tile
|
|
}, true) {
|
|
// already next to the tower: step to the middle of the tile (in reach)
|
|
Some(p) if p.is_empty() => e.path = vec![e.tile()],
|
|
Some(p) => e.path = p,
|
|
None => return false,
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
true
|
|
}
|
|
|
|
fn on_own_ground(&self, x: f32, y: f32) -> bool {
|
|
self.map.get(x as i32, y as i32).map_or(false, |t| t.owner == self.me)
|
|
}
|
|
|
|
// Left click with the hand: own units anywhere, unconscious heroes only on own ground
|
|
pub fn pickup(&mut self, target: Option<usize>) -> bool {
|
|
if self.hand.len() >= 8 {
|
|
self.hint(8, "hint.hand_full");
|
|
return false;
|
|
}
|
|
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return false };
|
|
if self.buy_from_trader(j) {
|
|
return false;
|
|
}
|
|
let e = &self.ents[j];
|
|
if e.rival() || e.dwarf() {
|
|
return false; // the rivals' creatures and the dwarves cannot be picked up
|
|
}
|
|
if e.hero {
|
|
if e.captive != 1 && e.captive != 3 {
|
|
return false;
|
|
}
|
|
if !self.on_own_ground(e.x, e.y) {
|
|
self.hint(18, "hint.pickup_prisoner_where");
|
|
self.sfx_ui(Sfx::Error);
|
|
return false;
|
|
}
|
|
}
|
|
let id = self.ents[j].id;
|
|
let me = id + 1;
|
|
self.unreserve(me);
|
|
if self.ents[j].captive == 3 {
|
|
// taken out of its cell
|
|
for t in self.map.t.iter_mut().filter(|t| t.bed == me && matches!(t.kind, Kind::Torture | Kind::Prison)) {
|
|
t.bed = 0;
|
|
}
|
|
}
|
|
for o in self.ents.iter_mut() {
|
|
if o.carrier == id && o.captive != 0 {
|
|
o.captive = 1;
|
|
o.carrier = 0;
|
|
}
|
|
}
|
|
let e = &mut self.ents[j];
|
|
e.held = true;
|
|
e.carrier = 0;
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
self.hand.push(id);
|
|
self.sfx_ui(Sfx::Pickup);
|
|
true
|
|
}
|
|
|
|
// Right click: drop the last picked unit on own walkable ground. Prisoners dropped on a
|
|
// free torture place are locked up at once.
|
|
pub fn drop(&mut self, wx: f32, wy: f32) -> bool {
|
|
let Some(&id) = self.hand.last() else { return false };
|
|
let (x, y) = (wx as i32, wy as i32);
|
|
if !inb(x, y) {
|
|
return false;
|
|
}
|
|
let i = idx(x, y);
|
|
let t = self.map.t[i];
|
|
if !t.walkable() || t.owner != self.me {
|
|
self.hint(9, "hint.drop_where");
|
|
self.sfx_ui(Sfx::Error);
|
|
return false;
|
|
}
|
|
self.hand.pop();
|
|
let imprison = matches!(t.kind, Kind::Torture | Kind::Prison) && t.bed == 0;
|
|
// the sacrifice altar takes creatures
|
|
if t.kind == Kind::Altar {
|
|
if let Some(k) = self.ents.iter().position(|e| e.id == id && !e.hero) {
|
|
self.ents[k].held = false;
|
|
self.ents[k].x = wx;
|
|
self.ents[k].y = wy;
|
|
self.sacrifice(k);
|
|
return true;
|
|
}
|
|
}
|
|
if let Some(k) = self.ents.iter().position(|e| e.id == id) {
|
|
let e = &mut self.ents[k];
|
|
e.held = false;
|
|
e.x = wx;
|
|
e.y = wy;
|
|
e.job = Job::None;
|
|
e.think = 0.0;
|
|
e.path.clear();
|
|
if e.hero {
|
|
e.carrier = 0;
|
|
e.torture_t = 0.0;
|
|
if imprison {
|
|
e.captive = 3;
|
|
e.hp = e.max_hp;
|
|
e.x = x as f32 + 0.5;
|
|
e.y = y as f32 + 0.5;
|
|
self.map.t[i].bed = id + 1;
|
|
self.sfx(Sfx::Capture, wx, wy);
|
|
} else {
|
|
e.captive = 1;
|
|
}
|
|
}
|
|
}
|
|
self.fx(3, wx, wy, 0.0, 0.0, 0.4, 0xff707090);
|
|
self.sfx(Sfx::Drop, wx, wy);
|
|
true
|
|
}
|
|
|
|
// Shift + left click: backhand for own units (speed-up), fist for heroes on own ground.
|
|
// Returns 1 = backhand, 2 = punch, 0 = nothing.
|
|
pub fn slap(&mut self, target: Option<usize>) -> u8 {
|
|
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return 0 };
|
|
let (x, y) = (self.ents[j].x, self.ents[j].y);
|
|
if self.ents[j].dwarf() {
|
|
return 0;
|
|
}
|
|
if !self.ents[j].hero && !self.ents[j].rival() {
|
|
let e = &mut self.ents[j];
|
|
e.boost = 15.0;
|
|
e.hp = (e.hp - e.max_hp * 0.05).max(1.0);
|
|
e.happy -= 0.04;
|
|
e.flash = 0.15;
|
|
if matches!(e.job, Job::Sleep(_)) {
|
|
e.job = Job::None; // woken up
|
|
}
|
|
self.fx(2, x, y, 0.0, 0.0, 0.3, 0xffffffff);
|
|
self.sfx(Sfx::Slap, x, y);
|
|
self.script_event(crate::mscript::ScriptEvent::Slap);
|
|
self.count(crate::stats::SLAPS, 1.0);
|
|
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) {
|
|
let cost = 50.0 * self.talents.spell_cost();
|
|
if self.mana < cost {
|
|
self.msg("msg.no_mana_bolt", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= cost;
|
|
self.count(crate::stats::SPELLS, 1.0);
|
|
self.sfx(Sfx::Lightning, wx, wy);
|
|
self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa);
|
|
self.ignite_at(wx, wy);
|
|
let mut kills = Vec::new();
|
|
for (j, e) in self.ents.iter_mut().enumerate() {
|
|
if e.alive && !e.held && e.active_hero() && !crate::rogues::spell_proof(e) && (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) {
|
|
let cost = 60.0 * self.talents.spell_cost();
|
|
if self.mana < cost {
|
|
self.msg("msg.no_mana_heal", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= cost;
|
|
self.count(crate::stats::SPELLS, 1.0);
|
|
self.sfx(Sfx::Heal, wx, wy);
|
|
let mut fxs = Vec::new();
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && !e.held && !e.hero && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 6.5 {
|
|
e.hp = (e.hp + e.max_hp * 0.5).min(e.max_hp);
|
|
fxs.push((e.x, e.y));
|
|
}
|
|
}
|
|
self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xff60ff80);
|
|
for (x, y) in fxs {
|
|
self.fx(4, x, y, 0.0, 0.0, 0.9, 0xff60ff80);
|
|
}
|
|
}
|
|
|
|
pub fn summon(&mut self, wx: f32, wy: f32) {
|
|
let cost = self.summon_cost();
|
|
let (x, y) = (wx as i32, wy as i32);
|
|
if !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != self.me {
|
|
self.hint(13, "hint.summon_where");
|
|
return;
|
|
}
|
|
if self.mana < cost {
|
|
self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= cost;
|
|
self.sfx(Sfx::Summon, wx, wy);
|
|
self.spawn(EK::Worker, false, wx, wy, 1);
|
|
self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xffff66cc);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ defence towers
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum TowerKind {
|
|
Arrow,
|
|
Cannon,
|
|
Magic,
|
|
Rune, // dwarven rune forge tower: from a chest of a dwarf cave, built for free
|
|
}
|
|
|
|
pub struct TowerStats {
|
|
pub cost: [i32; 3], // build, upgrade to level 2, upgrade to level 3
|
|
pub hp: f32,
|
|
pub dmg: f32,
|
|
pub range: f32,
|
|
pub min_range: f32,
|
|
pub interval: f32,
|
|
pub mana: f32, // per shot
|
|
}
|
|
|
|
pub fn tower_stats(k: TowerKind) -> TowerStats {
|
|
match k {
|
|
TowerKind::Arrow => TowerStats { cost: [250, 200, 300], hp: 300.0, dmg: 10.0, range: 6.0, min_range: 0.0, interval: 0.8, mana: 0.0 },
|
|
TowerKind::Cannon => TowerStats { cost: [450, 350, 500], hp: 450.0, dmg: 32.0, range: 8.5, min_range: 2.0, interval: 2.6, mana: 0.0 },
|
|
TowerKind::Magic => TowerStats { cost: [350, 300, 450], hp: 300.0, dmg: 24.0, range: 7.0, min_range: 0.0, interval: 1.5, mana: 8.0 },
|
|
TowerKind::Rune => TowerStats { cost: [0, 800, 1200], hp: 1400.0, dmg: 55.0, range: 10.0, min_range: 0.0, interval: 1.3, mana: 0.0 },
|
|
}
|
|
}
|
|
|
|
pub fn tower_name(k: TowerKind) -> &'static str {
|
|
match k {
|
|
TowerKind::Arrow => "tower.arrow",
|
|
TowerKind::Cannon => "tower.cannon",
|
|
TowerKind::Magic => "tower.magic",
|
|
TowerKind::Rune => "tower.rune",
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct Tower {
|
|
pub id: u32,
|
|
pub kind: TowerKind,
|
|
pub tile: usize,
|
|
pub level: u32,
|
|
pub hp: f32,
|
|
pub cd: f32,
|
|
pub aim: f32, // direction of the last shot (radians, for drawing)
|
|
pub flash: f32,
|
|
#[serde(default)]
|
|
pub hero: bool, // tower of a knight fortress: shoots the player's units
|
|
#[serde(default)]
|
|
pub rune_final: bool, // rune forge tower built from a refund: selling it gives nothing back
|
|
}
|
|
|
|
impl Tower {
|
|
pub fn max_hp(&self) -> f32 {
|
|
tower_stats(self.kind).hp * [1.0, 1.4, 1.9][(self.level as usize - 1).min(2)]
|
|
}
|
|
pub fn dmg(&self) -> f32 {
|
|
tower_stats(self.kind).dmg * [1.0, 1.5, 2.2][(self.level as usize - 1).min(2)]
|
|
}
|
|
pub fn range(&self) -> f32 {
|
|
tower_stats(self.kind).range + 0.75 * (self.level - 1) as f32
|
|
}
|
|
pub fn pos(&self) -> (f32, f32) {
|
|
let (x, y) = xy(self.tile);
|
|
(x as f32 + 0.5, y as f32 + 0.5)
|
|
}
|
|
|
|
// height of the tower body (3D units); grows with the level
|
|
pub fn top(&self) -> f32 {
|
|
match self.kind {
|
|
TowerKind::Arrow => 1.0 + 0.3 * (self.level - 1) as f32,
|
|
TowerKind::Cannon => 0.6 + 0.15 * (self.level - 1) as f32,
|
|
TowerKind::Magic => 1.2 + 0.3 * (self.level - 1) as f32,
|
|
TowerKind::Rune => 1.4 + 0.25 * (self.level - 1) as f32,
|
|
}
|
|
}
|
|
// gold spent on this tower so far
|
|
pub fn invested(&self) -> i32 {
|
|
tower_stats(self.kind).cost[..self.level as usize].iter().sum()
|
|
}
|
|
}
|
|
|
|
// Language key describing what a unit is doing
|
|
pub fn job_name(e: &Ent) -> &'static str {
|
|
match e.job {
|
|
Job::None if !e.patrol.is_empty() => "job.patrol",
|
|
Job::None if e.guard => "job.guard",
|
|
Job::None => "job.none",
|
|
Job::Dig(_) => "job.dig",
|
|
Job::Claim(_) => "job.claim",
|
|
Job::Deliver => "job.deliver",
|
|
Job::Wander => "job.wander",
|
|
Job::Sleep(_) => {
|
|
if e.moving {
|
|
"job.sleep_go"
|
|
} else {
|
|
"job.sleep"
|
|
}
|
|
}
|
|
Job::Eat(_) => "job.eat",
|
|
Job::Train(_) => "job.train",
|
|
Job::Forge(_) => "job.forge",
|
|
Job::Brawl(_) => "job.brawl",
|
|
Job::Guard(_) => "job.guard_post",
|
|
Job::Spar(_) => "job.spar",
|
|
Job::Install(_) => "job.install",
|
|
Job::Study(_) => "job.study",
|
|
Job::Attack(_) => "job.fight",
|
|
Job::TearBridge(_) => "job.tear_bridge",
|
|
Job::Rest(_) => "job.rest",
|
|
Job::Torment(_) => "job.torment",
|
|
Job::Register => "job.register",
|
|
Job::MakeBed(_) => "job.make_bed",
|
|
Job::FetchWage(_) => "job.fetch_wage",
|
|
Job::Help(_) => "job.help",
|
|
Job::AttackCore => {
|
|
if e.working {
|
|
"job.core_attack"
|
|
} else {
|
|
"job.core_march"
|
|
}
|
|
}
|
|
Job::Leave => "job.leave",
|
|
Job::Reinforce(_) => "job.reinforce",
|
|
Job::Repair(_) => "job.repair",
|
|
Job::MendBridge(_) => "job.mend_bridge",
|
|
Job::RaiseWall(_) => "job.raise_wall",
|
|
Job::March(_) => "job.march",
|
|
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 a_match_gives_every_master_his_core_and_his_ground() {
|
|
let mut g = Game::new(7, 96, 128);
|
|
g.init_fog();
|
|
assert!(g.start_masters(&[0, 2, 3, 4], 2), "four masters, and I am the green one");
|
|
assert_eq!(g.local_side, 2);
|
|
assert_eq!(g.masters.len(), 3);
|
|
for side in Owner::MASTER_SIDES {
|
|
let owner = Owner::of_side(side);
|
|
assert!(g.is_master(side));
|
|
let cores = g.map.t.iter().filter(|t| t.kind == Kind::Core && t.owner == owner).count();
|
|
assert_eq!(cores, 9, "master {side} has his soul core");
|
|
let ground = g.map.t.iter().filter(|t| t.kind == Kind::Floor && t.owner == owner).count();
|
|
assert!(ground >= 40, "master {side} has a starting area of his own");
|
|
g.switch_to(side);
|
|
assert_eq!(g.core_hp, CORE_HP, "every core starts intact");
|
|
assert_eq!(g.gold, 1500.0, "and every master with the same gold");
|
|
}
|
|
g.switch_to(2);
|
|
let green = g.map.core;
|
|
g.switch_to(4);
|
|
assert_ne!(g.map.core, green, "the masters live apart");
|
|
for _ in 0..50 {
|
|
g.update(STEP);
|
|
}
|
|
assert_eq!(g.sim_steps, 50, "every master is simulated");
|
|
assert_eq!(g.me_side, 2, "the saddle goes back to the master who plays here");
|
|
}
|
|
|
|
#[test]
|
|
fn lava_burns_and_needs_stone_bridges() {
|
|
let (mut g, rx) = river_game(true);
|
|
let (_, cy) = g.map.core;
|
|
g.gold = 1000.0;
|
|
g.build_bridges(rx - 1, cy, rx, cy);
|
|
assert_eq!(g.map.t[idx(rx, cy)].kind, Kind::LavaBridge);
|
|
assert_eq!(g.gold, 1000.0 - BRIDGE_COST[1] as f32);
|
|
// a hero pushed off the bridge into the lava burns, the bounty is paid
|
|
let id = g.spawn(EK::Knight, true, rx as f32 + 0.5, cy as f32 + 0.5, 1);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
let gold = g.gold;
|
|
g.knock(j, 0.0, 1.0);
|
|
assert!(!g.ents[j].alive);
|
|
assert!(g.gold > gold);
|
|
// removing the stone bridge under a unit burns it as well
|
|
let id = g.spawn(EK::Kobold, false, rx as f32 + 0.5, cy as f32 + 0.5, 1);
|
|
g.sell_rect(rx, cy, rx, cy);
|
|
assert!(!g.ents.iter().any(|e| e.id == id && e.alive));
|
|
// heroes cannot path through lava
|
|
assert_eq!(g.map.hero_cost()(idx(rx + 1, cy)), None);
|
|
}
|
|
|
|
fn treasury_total(g: &Game) -> f32 {
|
|
g.reserve + g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player).map(|t| t.gold as f32).sum::<f32>()
|
|
}
|
|
|
|
#[test]
|
|
fn treasury_shows_its_treasures() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
g.update(STEP);
|
|
// the starting gold lies in the treasury ring
|
|
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
|
|
assert_eq!(g.reserve, 0.0);
|
|
// rubies fill their own tiles
|
|
g.add_res(1200.0, 2);
|
|
g.update(STEP);
|
|
let rubies: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res == 2).map(|t| t.gold).sum();
|
|
assert_eq!(rubies, 1200);
|
|
assert!(g.map.t.iter().all(|t| t.gold <= TREASURY_TILE as i32 || t.vein().is_some()));
|
|
// spending empties tiles (randomly), the total always matches the gold counter
|
|
g.gold -= 1700.0;
|
|
g.update(STEP);
|
|
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
|
|
// more than the treasury holds: the core keeps the rest
|
|
g.gold = g.gold_cap();
|
|
g.update(STEP);
|
|
assert!((treasury_total(&g) - g.gold).abs() < 2.0);
|
|
// (tiles partly filled with rubies take no gold, so the core may hold a little more)
|
|
let gem_room: f32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res != 0).map(|t| TREASURY_TILE - t.gold as f32).sum();
|
|
assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + gem_room + 1.0, "reserve {} gold {} cap {}", g.reserve, g.gold, g.gold_cap());
|
|
}
|
|
|
|
#[test]
|
|
fn treasure_lands_in_the_nearest_treasury() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
// a second treasury far from the core ring
|
|
for y in cy - 9..=cy - 7 {
|
|
for x in cx + 10..=cx + 12 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Treasury;
|
|
t.owner = Owner::Player;
|
|
t.gold = 0;
|
|
}
|
|
}
|
|
g.recount();
|
|
g.update(STEP);
|
|
let far = |g: &Game| -> i32 { (cy - 9..=cy - 7).flat_map(|y| (cx + 10..=cx + 12).map(move |x| (x, y))).map(|(x, y)| g.map.t[idx(x, y)].gold).sum() };
|
|
let before = far(&g);
|
|
let at = idx(cx + 11, cy - 8);
|
|
g.add_res_at(900.0, 2, Some(at));
|
|
g.update(STEP);
|
|
assert_eq!(far(&g) - before, 900, "the rubies lie where they were delivered");
|
|
}
|
|
|
|
#[test]
|
|
fn training_is_billed_on_payday() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
g.spawn(EK::Kobold, false, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
g.training_bill = 50.0;
|
|
let cost = g.next_payday_cost();
|
|
assert_eq!(cost, 50.0 + st(EK::Kobold).wage as f32);
|
|
let gold = g.gold;
|
|
g.payday = PAYDAY_WARN + 0.05;
|
|
g.update(0.1);
|
|
assert!(g.payday_warned);
|
|
g.payday = 0.01;
|
|
g.update(0.1);
|
|
// the training bill is paid at once, the wage when the kobold fetches it
|
|
assert_eq!(g.training_bill, 0.0);
|
|
assert!((gold - g.gold - 50.0).abs() < 1.0);
|
|
let mut fetched = false;
|
|
for _ in 0..(70 * 20) {
|
|
g.update(0.05);
|
|
fetched |= g.ents.iter().any(|e| matches!(e.job, Job::FetchWage(_)));
|
|
}
|
|
assert!(fetched, "the kobold walks to the treasury");
|
|
assert!((gold - g.gold - cost).abs() < 1.0, "paid {}", gold - g.gold);
|
|
assert!(g.ents.iter().all(|e| e.pay_due == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn new_creatures_report_and_set_up_their_bed() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
g.gold = 3000.0;
|
|
g.build_rect(1, cx - 3, cy - 3, cx - 2, cy + 1);
|
|
g.build_rect(2, cx + 4, cy - 3, cx + 5, cy + 1);
|
|
// arrives like from a portal (the test map's portal is not connected yet)
|
|
let id = g.spawn(EK::Kobold, false, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
|
e.settled = false;
|
|
e.job = Job::Register;
|
|
e.timer = 2.0;
|
|
}
|
|
let mut made_bed = false;
|
|
for _ in 0..(90 * 20) {
|
|
g.update(0.05);
|
|
let e = g.ents.iter().find(|e| e.id == id).unwrap();
|
|
made_bed |= matches!(e.job, Job::MakeBed(_)) && e.path.is_empty();
|
|
if e.settled {
|
|
break;
|
|
}
|
|
}
|
|
let e = g.ents.iter().find(|e| e.id == id).unwrap();
|
|
assert!(made_bed && e.settled);
|
|
assert_eq!(g.map.t[e.bed.unwrap()].kind, Kind::Lair);
|
|
}
|
|
|
|
// a fortress east of the start area, joined to it by a claimed corridor
|
|
fn fort_game(spec: impl Fn(i32, i32) -> crate::fortress::FortSpec) -> (Game, crate::fortress::FortSpec) {
|
|
let mut g = Game::new(11, 96, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let f = spec(cx + 14, cy - 6);
|
|
g.build_fortress(&f);
|
|
for x in cx + 5..f.x {
|
|
for y in cy..=cy + 2 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
(g, f)
|
|
}
|
|
|
|
fn spec(x: i32, y: i32) -> crate::fortress::FortSpec {
|
|
crate::fortress::FortSpec { x, y, w: 15, h: 15, doors: vec![3], towers: vec![], tower_level: 1, guards: vec![], patrols: vec![], vaults: 0, vault_value: 0, shard: false, heart: false }
|
|
}
|
|
|
|
#[test]
|
|
fn knight_towers_and_creatures_fight() {
|
|
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) });
|
|
assert_eq!(g.towers.len(), 1);
|
|
assert!(g.towers[0].hero);
|
|
let (tx, ty) = g.towers[0].pos();
|
|
let id = g.spawn(EK::Ogre, false, tx + 3.0, ty + 2.0, 4);
|
|
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
|
|
let thp0 = g.towers[0].hp;
|
|
for _ in 0..200 {
|
|
g.update(0.05);
|
|
}
|
|
let hurt = g.ents.iter().find(|e| e.id == id).map_or(true, |e| e.hp < hp0);
|
|
let tower_hurt = g.towers.first().map_or(true, |t| t.hp < thp0);
|
|
assert!(hurt, "the knight tower shoots the creature");
|
|
assert!(tower_hurt, "the creature attacks the knight tower");
|
|
let _ = f;
|
|
}
|
|
|
|
#[test]
|
|
fn towers_fight_towers_and_the_gate() {
|
|
// a tower of the player in the courtyard of a fortress, no units around
|
|
let (mut g, _) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) });
|
|
g.ents.clear();
|
|
let (kx, ky) = xy(g.towers[0].tile);
|
|
let (px, py) = (kx + 3, ky + 2);
|
|
g.map.t[idx(px, py)].owner = Owner::Player;
|
|
g.gold = 5000.0;
|
|
g.build_tower(TowerKind::Arrow, px, py);
|
|
assert_eq!(g.towers.len(), 2);
|
|
let hp: Vec<f32> = g.towers.iter().map(|t| t.hp).collect();
|
|
for _ in 0..100 {
|
|
g.update(0.05);
|
|
}
|
|
assert!(g.towers.iter().zip(&hp).all(|(t, h)| t.hp < *h), "both towers are hit");
|
|
// free game: a tower in reach of the hero gate shoots at the gate
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.ents.clear();
|
|
let (gx, gy) = g.map.gate;
|
|
let (tx, ty) = (gx - 2, gy + 1);
|
|
g.map.t[idx(tx, ty)].kind = Kind::Floor;
|
|
g.map.t[idx(tx, ty)].owner = Owner::Player;
|
|
g.gold = 5000.0;
|
|
g.build_tower(TowerKind::Arrow, tx, ty);
|
|
for _ in 0..60 {
|
|
g.update(0.05);
|
|
}
|
|
assert!(g.gate_hp < GATE_HP, "the gate is shot at");
|
|
}
|
|
|
|
#[test]
|
|
fn more_lair_room_brings_creatures_faster() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
g.counts[1] = 4;
|
|
let tight = g.lair_factor();
|
|
g.counts[1] = 12;
|
|
let normal = g.lair_factor();
|
|
g.counts[1] = 30;
|
|
let roomy = g.lair_factor();
|
|
assert!(tight > normal && normal > roomy, "{} {} {}", tight, normal, roomy);
|
|
// creatures fill the lair: arrivals slow down again
|
|
for _ in 0..20 {
|
|
g.spawn(EK::Kobold, false, 10.0, 10.0, 1);
|
|
}
|
|
assert!(g.lair_factor() > roomy);
|
|
}
|
|
|
|
#[test]
|
|
fn vaults_are_claimed_and_looted() {
|
|
let (mut g, _) = fort_game(|x, y| crate::fortress::FortSpec { vaults: 2, vault_value: 1800, ..spec(x, y) });
|
|
let gold0 = g.gold;
|
|
for _ in 0..(240 * 20) {
|
|
g.update(0.05);
|
|
if g.loot >= 3000.0 {
|
|
break;
|
|
}
|
|
}
|
|
println!("loot after claiming: {}", g.loot);
|
|
assert!(g.loot > 1000.0);
|
|
assert!(g.gold > gold0);
|
|
}
|
|
|
|
#[test]
|
|
fn patrols_walk_and_guards_raise_the_alarm() {
|
|
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec {
|
|
guards: vec![(EK::Knight, 1); 4],
|
|
patrols: vec![vec![(EK::Archer, 1)]],
|
|
..spec(x, y)
|
|
});
|
|
// no imps around (the patrol would shoot them through the door)
|
|
g.ents.retain(|e| e.hero);
|
|
let pj = g.ents.iter().position(|e| !e.patrol.is_empty()).unwrap();
|
|
let pid = g.ents[pj].id;
|
|
let start = (g.ents[pj].x, g.ents[pj].y);
|
|
let mut far = 0.0f32;
|
|
for _ in 0..200 {
|
|
g.update(0.05);
|
|
let p = g.ents.iter().find(|e| e.id == pid).unwrap();
|
|
far = far.max((p.x - start.0).abs() + (p.y - start.1).abs());
|
|
}
|
|
assert!(far > 6.0, "the patrol walks its round");
|
|
// an intruder next to one guard: the others join
|
|
let (hx, hy) = g.ents.iter().find(|e| e.guard).unwrap().home.unwrap();
|
|
g.spawn(EK::Ogre, false, hx + 1.0, hy, 8);
|
|
for _ in 0..20 {
|
|
g.update(0.05);
|
|
}
|
|
let fighting = g.ents.iter().filter(|e| e.guard && matches!(e.job, Job::Attack(_))).count();
|
|
assert!(fighting >= 3, "alarm: {} guards fight", fighting);
|
|
let _ = f;
|
|
}
|
|
|
|
#[test]
|
|
fn knights_tear_down_bridges() {
|
|
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { guards: vec![(EK::Knight, 1)], ..spec(x, y) });
|
|
// a bridge of the player in the courtyard, on a small pond
|
|
let (bx, by) = (f.x + f.w / 2 + 2, f.y + f.h / 2 + 2);
|
|
for y in by - 1..=by + 1 {
|
|
g.map.t[idx(bx, y)].kind = Kind::Water;
|
|
g.map.t[idx(bx, y)].owner = Owner::None;
|
|
}
|
|
g.map.t[idx(bx, by)].kind = Kind::Bridge;
|
|
g.map.t[idx(bx, by)].owner = Owner::Player;
|
|
// no imps around to mend it (and to be attacked instead)
|
|
g.ents.retain(|e| e.hero);
|
|
let mut torn = false;
|
|
for _ in 0..(90 * 20) {
|
|
g.update(0.05);
|
|
if g.map.t[idx(bx, by)].kind == Kind::Water {
|
|
torn = true;
|
|
break;
|
|
}
|
|
}
|
|
assert!(torn);
|
|
}
|
|
|
|
#[test]
|
|
fn some_imps_always_claim() {
|
|
// lots of digging work: still at least 10% (min. one) of the imps claim the dug-out floor
|
|
let mut g = Game::new(11, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
for _ in 0..8 {
|
|
let (x, y) = (g.ents[0].x, g.ents[0].y);
|
|
g.spawn(EK::Worker, false, x, y, 1);
|
|
}
|
|
g.mark_rect(cx - 12, cy - 12, cx + 12, cy + 12, true);
|
|
let (mut samples, mut ok) = (0, 0);
|
|
for step in 0..(150 * 20) {
|
|
g.update(0.05);
|
|
if step % 20 != 0 || step < 200 {
|
|
continue;
|
|
}
|
|
let digging = g.map.t.iter().any(|t| t.marked);
|
|
let claimable = (0..g.map.t.len()).any(|i| g.claimable(i, u32::MAX));
|
|
if digging && claimable {
|
|
let imps = g.worker_count();
|
|
let quota = ((imps as f32 * 0.1).ceil() as usize).max(1);
|
|
let claiming = g.ents.iter().filter(|e| e.alive && e.kind == EK::Worker && matches!(e.job, Job::Claim(_))).count();
|
|
samples += 1;
|
|
if claiming >= quota {
|
|
ok += 1;
|
|
}
|
|
}
|
|
}
|
|
println!("claim quota met in {}/{} samples", ok, samples);
|
|
assert!(samples > 10);
|
|
assert!(ok * 10 >= samples * 8);
|
|
}
|
|
|
|
#[test]
|
|
fn wall_corners_are_reinforced_too() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
// the start area spans cx-3..cx+5 / cy-3..cy+5: its walls are at cx-4 and cy-4
|
|
let (x0, y0) = (cx - 4, cy - 4);
|
|
for k in 1..4 {
|
|
g.map.t[idx(x0 + k, y0)].reinforced = true;
|
|
g.map.t[idx(x0, y0 + k)].reinforced = true;
|
|
}
|
|
assert!(g.map.t[idx(x0, y0)].kind == Kind::Earth && !g.map.t[idx(x0, y0)].reinforced);
|
|
g.reinforce_corners();
|
|
assert!(g.map.t[idx(x0, y0)].reinforced, "the corner is reinforced");
|
|
// nothing spreads into the earth behind the walls
|
|
g.reinforce_corners();
|
|
assert!(!g.map.t[idx(x0 - 1, y0)].reinforced && !g.map.t[idx(x0, y0 - 1)].reinforced && !g.map.t[idx(x0 - 1, y0 - 1)].reinforced);
|
|
}
|
|
|
|
#[test]
|
|
fn idle_imps_help_and_speed_up_work() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
for _ in 0..6 {
|
|
g.spawn(EK::Worker, false, cx as f32 + 4.5, cy as f32 + 1.5, 1);
|
|
}
|
|
// a single earth tile to dig, next to the start area: one digs, the others help
|
|
let (dx, dy) = (cx + 6, (cy - 3..=cy + 5).find(|&y| g.map.t[idx(cx + 6, y)].kind == Kind::Earth).expect("earth next to the start area"));
|
|
// no other work: everything around is claimed already, no walls to reinforce
|
|
for t in g.map.t.iter_mut() {
|
|
if t.kind == Kind::Earth {
|
|
t.reinforced = true;
|
|
}
|
|
}
|
|
g.map.t[idx(dx, dy)].reinforced = false;
|
|
g.map.t[idx(dx, dy)].marked = true;
|
|
let mut best = 0.0f32;
|
|
for _ in 0..200 {
|
|
g.update(0.05);
|
|
best = best.max(g.help_speed(idx(dx, dy)));
|
|
if g.map.t[idx(dx, dy)].kind != Kind::Earth {
|
|
break;
|
|
}
|
|
}
|
|
println!("best working speed {}", best);
|
|
assert!(best >= 1.75, "helpers join: {}", best);
|
|
assert!(best <= 2.0);
|
|
}
|
|
|
|
#[test]
|
|
fn imps_set_up_torch_stands_in_big_rooms_on_their_own_ground() {
|
|
let mut g = Game::new(5, 96, 96);
|
|
let (cx, cy) = g.map.core;
|
|
// a big hall of own ground east of the core, and as big a neutral cave south of it
|
|
let hall = (cx + 8, cy - 7, cx + 22, cy + 7);
|
|
let cave = (cx - 7, cy + 12, cx + 7, cy + 26);
|
|
for (x0, y0, x1, y1, own) in [(hall.0, hall.1, hall.2, hall.3, true), (cave.0, cave.1, cave.2, cave.3, false)] {
|
|
for y in y0..=y1 {
|
|
for x in x0..=x1 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = if own { g.me } else { Owner::None };
|
|
}
|
|
}
|
|
}
|
|
g.torches.clear();
|
|
g.place_all_torches();
|
|
let inside = |t: &Torch, r: (i32, i32, i32, i32)| {
|
|
let (x, y) = xy(t.wall);
|
|
x >= r.0 && x <= r.2 && y >= r.1 && y <= r.3
|
|
};
|
|
let stands: Vec<Torch> = g.torches.iter().copied().filter(|t| t.stand).collect();
|
|
assert!(stands.iter().any(|t| inside(t, hall)), "the big hall gets torch stands");
|
|
assert!(!stands.iter().any(|t| inside(t, cave)), "never on neutral ground");
|
|
for t in &stands {
|
|
assert_eq!(g.map.t[t.wall].owner, g.me, "only on own ground");
|
|
let (x, y) = xy(t.wall);
|
|
assert!((-1..=1).all(|dy| (-1..=1).all(|dx| !g.map.t[idx(x + dx, y + dy)].solid())), "not next to a wall");
|
|
}
|
|
// every tile of the hall's middle is within reach of some torch
|
|
for y in hall.1 + 2..=hall.3 - 2 {
|
|
for x in hall.0 + 2..=hall.2 - 2 {
|
|
let lit = g.torches.iter().any(|t| {
|
|
let (px, pz) = t.pos();
|
|
(px - x as f32 - 0.5).powi(2) + (pz - y as f32 - 0.5).powi(2) <= (STAND_GAP + 1.0).powi(2)
|
|
});
|
|
assert!(lit, "({}, {}) lies in the dark", x, y);
|
|
}
|
|
}
|
|
// ground lost: the stand goes
|
|
let s0 = stands.iter().find(|t| inside(t, hall)).unwrap().wall;
|
|
g.map.t[s0].owner = Owner::Hero;
|
|
for _ in 0..60 {
|
|
g.update(0.05);
|
|
}
|
|
assert!(!g.torches.iter().any(|t| t.stand && t.wall == s0), "a stand on lost ground is removed");
|
|
}
|
|
|
|
#[test]
|
|
fn badly_hurt_creatures_flee_to_the_lair_and_heal() {
|
|
let mut g = Game::new(5, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
g.gold = 5000.0;
|
|
g.build_rect(1, cx - 3, cy - 3, cx - 2, cy + 2);
|
|
assert!(g.counts[1] >= 8, "a lair");
|
|
let id = g.spawn(EK::Kobold, false, cx as f32 + 5.5, cy as f32 + 0.5, 2);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
g.ents[j].hp = g.ents[j].max_hp * 0.2;
|
|
let mut fled = false;
|
|
for _ in 0..(20 * 6) {
|
|
g.update(0.05);
|
|
fled |= matches!(g.ents[j].job, Job::Rest(_));
|
|
}
|
|
assert!(fled, "it fled to rest");
|
|
for _ in 0..(20 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
assert!(g.ents[j].hp > g.ents[j].max_hp * 0.5, "and healed: {} of {}", g.ents[j].hp, g.ents[j].max_hp);
|
|
}
|
|
|
|
#[test]
|
|
fn flyers_take_every_way_across_rivers() {
|
|
let (mut g, rx) = river_game(false);
|
|
let (cx, cy) = g.map.core;
|
|
// open floor on the far bank with one training tile of the player
|
|
for y in cy - 2..=cy + 2 {
|
|
for x in rx + 3..rx + 7 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::None;
|
|
}
|
|
}
|
|
let room = idx(rx + 4, cy);
|
|
g.map.t[room].kind = Kind::Training;
|
|
g.map.t[room].owner = g.me;
|
|
let owl = g.spawn(EK::Owl, false, (cx + 4) as f32 + 0.5, cy as f32 + 0.5, 1);
|
|
let kobold = g.spawn(EK::Kobold, false, (cx + 4) as f32 + 0.5, cy as f32 + 0.5, 1);
|
|
let owl = g.ents.iter().find(|e| e.id == owl).unwrap().clone();
|
|
let kobold = g.ents.iter().find(|e| e.id == kobold).unwrap().clone();
|
|
// room searches: the owl flies over the river to the training room, the kobold cannot get there
|
|
let p = g.find_room_tile(&owl, Kind::Training, owl.id + 1, false).expect("the owl finds the room across the river");
|
|
assert_eq!(p.last().copied(), Some(room));
|
|
assert!(g.find_room_tile(&kobold, Kind::Training, kobold.id + 1, false).map_or(true, |p| p.last() != Some(&room)));
|
|
// flyers cut corners over water: a diagonal crossing is as short as on open ground
|
|
let (a, b) = (idx(rx - 1, cy - 1), idx(rx + 3, cy + 1));
|
|
let p = g.map.creature_path(true, a, b).expect("flight path");
|
|
assert!(p.len() <= 5, "diagonal flight over the river: {} steps", p.len());
|
|
assert!(p.iter().any(|&i| g.map.t[i].liquid()));
|
|
// attacking flyers (the fairy spy) also fly over rivers instead of searching for a bridge
|
|
assert!(g.map.hero_cost_as(true)(idx(rx + 1, cy)).is_some());
|
|
assert_eq!(g.map.hero_cost()(idx(rx + 1, cy)), None);
|
|
// a locked door stops flyers too
|
|
g.map.t[idx(rx + 5, cy)].kind = Kind::Door;
|
|
g.map.t[idx(rx + 5, cy)].res = 1;
|
|
assert_eq!(g.map.fly_cost()(idx(rx + 5, cy)), None);
|
|
}
|
|
|
|
#[test]
|
|
fn flyers_cross_rivers_flee_and_heal() {
|
|
let (mut g, rx) = river_game(true);
|
|
let (cx, cy) = g.map.core;
|
|
// an aerie in the start area
|
|
g.gold = 5000.0;
|
|
g.build_rect(7, cx - 3, cy - 3, cx - 2, cy + 2);
|
|
assert!(g.counts[7] >= 8);
|
|
// a knight on the far side of the lava: an owl reaches it, a kobold does not
|
|
let far = (rx + 4, cy);
|
|
for x in rx + 3..rx + 7 {
|
|
let t = &mut g.map.t[idx(x, cy)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::None;
|
|
}
|
|
let k = g.spawn(EK::Knight, true, far.0 as f32 + 0.5, far.1 as f32 + 0.5, 1);
|
|
let owl = g.spawn(EK::Owl, false, (cx + 4) as f32 + 0.5, cy as f32 + 0.5, 3);
|
|
assert!(g.map.fly_path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_some());
|
|
assert!(g.map.path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_none());
|
|
let hp0 = g.ents.iter().find(|e| e.id == k).unwrap().hp;
|
|
for _ in 0..300 {
|
|
g.update(0.05);
|
|
}
|
|
let hit = g.ents.iter().find(|e| e.id == k).map_or(true, |e| e.hp < hp0);
|
|
assert!(hit, "the owl attacks across the lava");
|
|
// a badly hurt flyer flees to the aerie and heals up to 75%
|
|
g.ents.retain(|e| !e.hero);
|
|
let j = g.ents.iter().position(|e| e.id == owl).unwrap();
|
|
g.ents[j].hp = g.ents[j].max_hp * 0.2;
|
|
let mut rested = false;
|
|
for _ in 0..800 {
|
|
g.update(0.05);
|
|
let e = g.ents.iter().find(|e| e.id == owl).unwrap();
|
|
rested |= matches!(e.job, Job::Rest(_));
|
|
if e.hp >= e.max_hp * 0.75 {
|
|
break;
|
|
}
|
|
}
|
|
let e = g.ents.iter().find(|e| e.id == owl).unwrap();
|
|
assert!(rested && e.hp >= e.max_hp * 0.74, "fled and healed: {} / {}", e.hp, e.max_hp);
|
|
// lava does not burn flyers
|
|
let j = g.ents.iter().position(|e| e.id == owl).unwrap();
|
|
g.ents[j].x = (rx - 1) as f32 + 0.5;
|
|
g.ents[j].y = cy as f32 + 0.5;
|
|
g.knock(j, 1.0, 0.0);
|
|
assert!(g.ents[j].alive);
|
|
}
|
|
|
|
#[test]
|
|
fn aerie_attracts_flyers() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
g.counts = [16, 0, 9, 0, 6, 4, 0, 16, 0, 0, 0, 0, 0, 0];
|
|
let mut seen = std::collections::HashSet::new();
|
|
for _ in 0..200 {
|
|
g.try_portal_for_test();
|
|
for e in g.ents.iter().filter(|e| e.creature()) {
|
|
seen.insert(e.kind);
|
|
}
|
|
g.ents.retain(|e| !e.creature());
|
|
}
|
|
assert!(seen.contains(&EK::Bat) && seen.contains(&EK::Gargoyle) && seen.contains(&EK::Owl), "{:?}", seen);
|
|
assert!(seen.iter().all(|&k| flies(k)), "no lair: only flyers come ({:?})", seen);
|
|
}
|
|
|
|
#[test]
|
|
fn steel_walls_hold_heroes_back() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let (wx, wy) = (cx + 6, (cy - 3..=cy + 5).find(|&y| g.map.t[idx(cx + 6, y)].kind == Kind::Earth).unwrap());
|
|
g.gold = 1000.0;
|
|
g.build_steel(wx, wy, wx, wy);
|
|
assert_eq!(g.map.t[idx(wx, wy)].kind, Kind::Steel);
|
|
assert_eq!(g.gold, 1000.0 - STEEL_COST[0] as f32);
|
|
// far from own ground: nothing happens
|
|
g.build_steel(wx + 5, wy, wx + 5, wy);
|
|
assert_ne!(g.map.t[idx(wx + 5, wy)].kind, Kind::Steel);
|
|
// heroes avoid it and dig 20 times slower than through earth
|
|
assert_eq!(g.map.hero_cost()(idx(wx, wy)), Some(300));
|
|
// selling: a reinforced earth wall again, half of the plating back
|
|
let gold = g.gold;
|
|
g.sell_rect(wx, wy, wx, wy);
|
|
assert_eq!(g.map.t[idx(wx, wy)].kind, Kind::Earth);
|
|
assert!(g.map.t[idx(wx, wy)].reinforced);
|
|
assert!(g.gold > gold);
|
|
}
|
|
|
|
#[test]
|
|
fn imps_reinforce_walls() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
for _ in 0..(120 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let n = g.map.t.iter().filter(|t| t.reinforced).count();
|
|
println!("reinforced walls after 2 minutes: {}", n);
|
|
assert!(n >= 10);
|
|
// heroes avoid them: the hero path cost through reinforced earth is 10x
|
|
let (cx, cy) = g.map.core;
|
|
let w = (cx - 4, cy);
|
|
if g.map.t[idx(w.0, w.1)].reinforced {
|
|
assert_eq!(g.map.hero_cost()(idx(w.0, w.1)), Some(70));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn towers_shoot_and_get_attacked() {
|
|
for kind in [TowerKind::Arrow, TowerKind::Cannon, TowerKind::Magic] {
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.gold = 2000.0;
|
|
g.mana = 400.0;
|
|
let (cx, cy) = g.map.core;
|
|
let (tx, ty) = (cx - 2, cy - 2);
|
|
g.build_tower(kind, tx, ty);
|
|
assert_eq!(g.towers.len(), 1);
|
|
g.build_tower(kind, tx, ty); // upgrade
|
|
assert_eq!(g.towers[0].level, 2);
|
|
// a hero inside the start area, 4 tiles away
|
|
let id = g.spawn(EK::Knight, true, (cx + 3) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
|
|
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
|
|
let mut hurt = false;
|
|
let mut tower_hit = false;
|
|
for _ in 0..400 {
|
|
g.update(0.05);
|
|
if let Some(e) = g.ents.iter().find(|e| e.id == id) {
|
|
hurt |= e.hp < hp0;
|
|
} else {
|
|
hurt = true;
|
|
}
|
|
tower_hit |= g.towers.first().map_or(true, |t| t.hp < t.max_hp());
|
|
}
|
|
println!("{:?}: hero hurt {}, tower attacked {}", kind, hurt, tower_hit);
|
|
assert!(hurt, "{:?} did not damage the hero", kind);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn heroes_attack_towers() {
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.gold = 2000.0;
|
|
for e in g.ents.iter_mut() {
|
|
e.alive = false; // no imps: the tower is the only target
|
|
}
|
|
let (cx, cy) = g.map.core;
|
|
g.build_tower(TowerKind::Cannon, cx - 2, cy - 2);
|
|
g.spawn(EK::Ogre, true, (cx - 2) as f32 + 0.5, (cy + 4) as f32 + 0.5, 5);
|
|
for _ in 0..600 {
|
|
g.update(0.05);
|
|
}
|
|
let t = g.towers.first();
|
|
println!("tower after attack: {:?}", t.map(|t| t.hp));
|
|
assert!(t.map_or(true, |t| t.hp < t.max_hp()));
|
|
}
|
|
|
|
#[test]
|
|
fn prisoners_are_tortured_and_resolved() {
|
|
let mut converted = 0;
|
|
let mut died = 0;
|
|
for seed in 1..=40u32 {
|
|
let mut g = Game::new(seed, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1); // torture chamber 2x5 = 5 places... 10 tiles
|
|
assert!(g.prison_space() >= 2, "space {}", g.prison_space());
|
|
let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
g.kill(j);
|
|
assert_eq!(g.ents[j].captive, 1);
|
|
let mut reached = false;
|
|
for _ in 0..(200 * 20) {
|
|
g.update(0.05);
|
|
match g.ents.iter().find(|e| e.id == id) {
|
|
Some(e) if e.captive == 3 => reached = true,
|
|
Some(e) if !e.hero => {
|
|
converted += 1;
|
|
break;
|
|
}
|
|
None => {
|
|
died += 1;
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(reached, "seed {}: prisoner never reached the torture chamber", seed);
|
|
}
|
|
println!("40 prisoners: {} defected, {} died", converted, died);
|
|
assert_eq!(converted + died, 40);
|
|
assert!((8..=26).contains(&converted), "defect rate off: {}/40", converted);
|
|
}
|
|
|
|
#[test]
|
|
fn jailer_makes_prisoners_defect_more_often() {
|
|
let mut converted = 0;
|
|
let mut worked = 0;
|
|
for seed in 1..=40u32 {
|
|
let mut g = Game::new(seed, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1);
|
|
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 2); // a lair for the jailer
|
|
let jailer = g.spawn(EK::Jailer, false, (cx - 1) as f32 + 0.5, (cy - 3) as f32 + 0.5, 1);
|
|
let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
g.kill(j);
|
|
for _ in 0..(200 * 20) {
|
|
g.update(0.05);
|
|
if let Some(jl) = g.ents.iter_mut().find(|e| e.id == jailer) {
|
|
// keep the jailer fresh so that it works
|
|
jl.hunger = 0.0;
|
|
jl.energy = 1.0;
|
|
jl.happy = 1.0;
|
|
if jl.job == Job::Torment(id) {
|
|
worked += 1;
|
|
}
|
|
}
|
|
match g.ents.iter().find(|e| e.id == id) {
|
|
Some(e) if !e.hero => {
|
|
converted += 1;
|
|
break;
|
|
}
|
|
None => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
println!("with a jailer: {} of 40 defected (jailer worked {} ticks)", converted, worked);
|
|
assert!(worked > 0, "the jailer works on the prisoners");
|
|
assert!(converted >= 20, "defections with a jailer: {}/40", converted);
|
|
}
|
|
|
|
#[test]
|
|
fn temple_prayer_and_priestess_heals_core() {
|
|
let mut g = Game::new(9, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(6, cx - 3, cy - 3, cx - 2, cy + 1); // temple
|
|
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 1); // lair
|
|
g.build_rect(2, cx + 4, cy + 3, cx + 5, cy + 5); // farm
|
|
g.spawn(EK::Priestess, false, (cx + 4) as f32 + 0.5, (cy + 2) as f32 + 0.5, 1);
|
|
for k in 0..3 {
|
|
g.spawn(EK::Kobold, false, (cx - 1) as f32 + 0.5, (cy + k) as f32 + 0.5, 1);
|
|
}
|
|
g.core_hp = 1000.0;
|
|
let mut prayed = false;
|
|
for _ in 0..(120 * 20) {
|
|
g.update(0.05);
|
|
prayed |= g.ents.iter().any(|e| matches!(e.job, Job::Pray(_)) && e.working);
|
|
}
|
|
println!("core after 2 min: {:.0}, prayed: {}", g.core_hp, prayed);
|
|
assert!(prayed);
|
|
assert!(g.core_hp > 1300.0);
|
|
}
|
|
|
|
#[test]
|
|
fn waves_vary_their_entry_points() {
|
|
let mut g = Game::new(11, 96, 96);
|
|
let (cx, cy) = g.map.core;
|
|
// a bigger realm so there are several possible entry points
|
|
for y in cy - 8..=cy + 10 {
|
|
for x in cx - 6..=cx + 14 {
|
|
if inb(x, y) && g.map.t[idx(x, y)].kind != Kind::Core && g.map.t[idx(x, y)].kind != Kind::Rock {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
}
|
|
let mut seen = Vec::new();
|
|
for _ in 0..4 {
|
|
g.plan_entries();
|
|
let e = g.planned_entries[0];
|
|
println!("entry {:?} ({})", e, g.direction_key(e));
|
|
seen.push(e);
|
|
g.entry_history.push(e);
|
|
}
|
|
// consecutive waves use different places
|
|
for w in seen.windows(2) {
|
|
assert!((w[0].0 - w[1].0).pow(2) + (w[0].1 - w[1].1).pow(2) >= 16, "same entry twice: {:?}", w);
|
|
}
|
|
// spawning a big wave gives heroes two different waypoints
|
|
g.warned = true;
|
|
g.planned_entries.clear();
|
|
g.plan_entries();
|
|
assert_eq!(g.planned_entries.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn core_starts_with_treasury_ring() {
|
|
let g = Game::new(21, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let ring = (cy - 1..=cy + 3).flat_map(|y| (cx - 1..=cx + 3).map(move |x| (x, y))).filter(|&(x, y)| g.map.t[idx(x, y)].kind == Kind::Treasury).count();
|
|
assert_eq!(ring, 16);
|
|
assert_eq!(g.counts[0], 16);
|
|
}
|
|
|
|
#[test]
|
|
fn hand_rules() {
|
|
let mut g = Game::new(4, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
// backhand on an own imp: faster for 15 s, costs a little HP
|
|
let imp = g.ents.iter().position(|e| e.kind == EK::Worker).unwrap();
|
|
let hp = g.ents[imp].hp;
|
|
assert_eq!(g.slap(Some(imp)), 1);
|
|
assert_eq!(g.ents[imp].boost, 15.0);
|
|
assert!(g.ents[imp].hp < hp);
|
|
// fist: only on own ground
|
|
let far = g.spawn(EK::Knight, true, 40.5, 40.5, 1);
|
|
let jf = g.ents.iter().position(|e| e.id == far).unwrap();
|
|
if g.map.t[idx(40, 40)].owner != Owner::Player {
|
|
assert_eq!(g.slap(Some(jf)), 0);
|
|
}
|
|
let near = g.spawn(EK::Knight, true, (cx - 2) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
|
|
let jn = g.ents.iter().position(|e| e.id == near).unwrap();
|
|
let hp = g.ents[jn].hp;
|
|
assert_eq!(g.slap(Some(jn)), 2);
|
|
assert!(g.ents[jn].hp < hp && g.ents[jn].stun > 0.0);
|
|
assert_eq!(g.slap(Some(jn)), 0, "cooldown");
|
|
// prisoners: pick up on own ground, drop onto a torture place = locked up
|
|
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1);
|
|
let jn = g.ents.iter().position(|e| e.id == near).unwrap();
|
|
g.kill(jn);
|
|
assert_eq!(g.ents[jn].captive, 1);
|
|
assert!(g.pickup(Some(jn)));
|
|
let t = (cx - 3, cy + 1);
|
|
assert!(g.drop(t.0 as f32 + 0.5, t.1 as f32 + 0.5));
|
|
let e = g.ents.iter().find(|e| e.id == near).unwrap();
|
|
assert_eq!(e.captive, 3);
|
|
// own units cannot be dropped outside own ground
|
|
let imp = g.ents.iter().position(|e| e.kind == EK::Worker).unwrap();
|
|
assert!(g.pickup(Some(imp)));
|
|
assert!(!g.drop(40.5, 40.5) || g.map.t[idx(40, 40)].owner == Owner::Player);
|
|
}
|
|
|
|
#[test]
|
|
fn chainbreaker_visits_only_great_dungeons() {
|
|
let mut g = Game::new(8, 64, 64);
|
|
g.gold = 9000.0;
|
|
// conditions not met: no Chainbreaker among many portal visits
|
|
let (px, py) = g.map.portals[0];
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
g.map.t[idx(px + x, py + y)].owner = Owner::Player;
|
|
}
|
|
}
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy + 5); // lair 18
|
|
g.build_rect(2, cx - 3, cy - 3, cx - 2, cy + 5); // farm 18
|
|
g.recount();
|
|
for _ in 0..60 {
|
|
g.try_portal();
|
|
}
|
|
assert!(!g.ents.iter().any(|e| e.kind == EK::Chainbreaker));
|
|
// a veteran creature makes it possible (rooms 16 ring + 36 = 52 tiles, gold 9000)
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.creature()) {
|
|
e.level = 5;
|
|
}
|
|
for e in g.ents.iter_mut().filter(|e| e.creature()) {
|
|
e.alive = false; // free beds
|
|
}
|
|
g.ents.retain(|e| e.alive);
|
|
g.spawn(EK::Kobold, false, cx as f32, cy as f32, 6);
|
|
let mut seen = false;
|
|
for _ in 0..400 {
|
|
g.gold = 9000.0;
|
|
g.try_portal();
|
|
seen |= g.ents.iter().any(|e| e.kind == EK::Chainbreaker);
|
|
g.ents.retain(|e| !e.creature() || e.kind == EK::Chainbreaker || e.level >= 5);
|
|
}
|
|
assert!(seen);
|
|
assert!(g.ents.iter().filter(|e| e.kind == EK::Chainbreaker).count() <= 1);
|
|
}
|
|
|
|
#[test]
|
|
fn the_beds_decide_how_many_creatures_come_but_never_more_than_100() {
|
|
let mut g = Game::new(9, 96, 96);
|
|
let (cx, cy) = g.map.core;
|
|
// a lair of 40 beds: room for 40
|
|
let mut n = 0;
|
|
for y in cy + 4..cy + 20 {
|
|
for x in cx - 6..cx + 8 {
|
|
if n < 40 && inb(x, y) {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Lair;
|
|
t.owner = Owner::Player;
|
|
n += 1;
|
|
}
|
|
}
|
|
}
|
|
g.recount();
|
|
assert_eq!(g.creature_cap(), 40);
|
|
assert_eq!(g.free_lair_aerie().0, 40, "more than the old 30");
|
|
// a huge lair: the cap is 100
|
|
for y in cy + 4..cy + 20 {
|
|
for x in cx - 10..cx + 12 {
|
|
if inb(x, y) && g.map.t[idx(x, y)].kind != Kind::Core {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Lair;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
}
|
|
g.recount();
|
|
assert!(g.counts[1] > 100);
|
|
assert_eq!(g.creature_cap(), MAX_CREATURES);
|
|
for k in 0..100 {
|
|
g.spawn(EK::Kobold, false, cx as f32 + 0.5 + (k % 10) as f32, cy as f32 + 5.5 + (k / 10) as f32, 1);
|
|
}
|
|
assert_eq!(g.free_lair_aerie(), (0, 0), "no room for a 101st");
|
|
}
|
|
|
|
#[test]
|
|
fn creatures_never_make_their_bed_in_an_allys_lair() {
|
|
let mut g = Game::new(11, 96, 128);
|
|
g.coop = true;
|
|
assert!(g.start_masters(&[0, 2], 0));
|
|
let (cx, cy) = g.map.core;
|
|
for y in cy + 4..cy + 7 {
|
|
for x in cx - 1..cx + 4 {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Lair;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
g.recount();
|
|
let id = g.spawn(EK::Kobold, false, cx as f32 + 1.5, cy as f32 + 5.5, 1);
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
|
e.side = 2;
|
|
e.energy = 0.1;
|
|
}
|
|
for _ in 0..(40 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let e = g.ents.iter().find(|e| e.id == id).expect("still there");
|
|
assert!(e.bed.map_or(true, |b| g.map.t[b].owner != Owner::Player), "no bed in the ally's lair");
|
|
assert!(!g.map.t.iter().any(|t| t.kind == Kind::Lair && t.bed == id + 1));
|
|
}
|
|
|
|
#[test]
|
|
fn every_master_of_a_coop_mission_has_an_own_portal() {
|
|
let m = crate::campaign::coop_mission_indexes()[2];
|
|
let mut g = crate::campaign::start_mission(m, None);
|
|
g.coop = true;
|
|
assert!(g.start_masters(&[0, 2, 3], 0));
|
|
for side in [2u8, 3] {
|
|
let owner = Owner::of_side(side);
|
|
let core = g.masters.iter().find(|m| m.side == side).unwrap().core;
|
|
let k = (0..g.map.portals.len()).find(|&k| g.map.portal_owner(k) == owner && g.map.portals[k] == (core.0 + 6, core.1)).expect("a portal of his own");
|
|
let (px, py) = g.map.portals[k];
|
|
let from = idx(core.0 + 1, core.1 + 4);
|
|
assert!(g.map.creature_path(false, from, idx(px, py + 1)).is_some(), "reachable from his dungeon");
|
|
}
|
|
for n in crate::lobby::MATCH_SIZES {
|
|
let seats = crate::lobby::seats_for(n);
|
|
let mut m = Game::new(3, n, n);
|
|
assert!(m.start_masters(seats, 0), "a {}x{} match with {} seats", n, n, seats.len());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_coop_ai_seat_fights_beside_the_host_not_against_him() {
|
|
let mut g = Game::new(11, 96, 128);
|
|
g.init_fog();
|
|
g.coop = true;
|
|
assert!(g.start_masters(&[0, 2, 3], 2), "a co-op match may keep an empty side as AI");
|
|
g.sync_ai_sides(&[3], 0);
|
|
assert!(g.allied(g.me_side, 3), "side 3 is an allied master seat, not a rival");
|
|
|
|
g.ents.clear();
|
|
let (cx, cy) = g.map.core;
|
|
let local = g.spawn(EK::Kobold, false, cx as f32 + 3.5, cy as f32 + 3.5, 1);
|
|
let ally = g.spawn(EK::Kobold, false, cx as f32 + 4.5, cy as f32 + 3.5, 1);
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == ally) {
|
|
e.side = 3;
|
|
}
|
|
|
|
let local = g.ents.iter().find(|e| e.id == local).unwrap().clone();
|
|
let ally = g.ents.iter().find(|e| e.id == ally).unwrap().clone();
|
|
assert!(!g.is_hostile(&ally));
|
|
assert_eq!(g.nearest_enemy(&local, 5.0), None, "the allied AI creature is no target for the local master");
|
|
}
|
|
}
|
|
|