3119 lines
110 KiB
Rust
3119 lines
110 KiB
Rust
// Game logic: units, AI, economy, hero waves.
|
|
|
|
use crate::map::*;
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
pub const CORE_HP: f32 = 2000.0;
|
|
pub const GATE_HP: f32 = 3000.0;
|
|
pub const PAYDAY: f32 = 150.0;
|
|
pub const 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,
|
|
}
|
|
|
|
pub const SFX_ALL: [Sfx; 34] = [
|
|
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,
|
|
];
|
|
|
|
impl Sfx {
|
|
pub fn index(self) -> usize {
|
|
self as usize
|
|
}
|
|
}
|
|
|
|
// A game message: language key + arguments (arguments starting with '@' are keys too)
|
|
#[derive(Clone, Debug)]
|
|
pub struct Msg {
|
|
pub key: &'static str,
|
|
pub args: Vec<String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct SfxEvent {
|
|
pub sfx: Sfx,
|
|
pub pos: Option<(f32, f32)>, // world position (for distance-based volume), None = UI
|
|
}
|
|
pub const MANA_MAX: f32 = 400.0;
|
|
pub const CARRY_MAX: i32 = 300;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum EK {
|
|
Worker,
|
|
Kobold,
|
|
Spider,
|
|
Mage,
|
|
Ogre,
|
|
Knight,
|
|
Archer,
|
|
Priest,
|
|
Dwarf,
|
|
Paladin,
|
|
Jailer, // attracted by the torture chamber
|
|
Priestess, // attracted by the temple
|
|
}
|
|
|
|
pub struct St {
|
|
pub name: &'static str,
|
|
pub hp: f32,
|
|
pub atk: f32,
|
|
pub range: f32,
|
|
pub speed: f32,
|
|
pub interval: f32,
|
|
pub wage: i32,
|
|
pub bounty: i32,
|
|
}
|
|
|
|
pub fn st(k: EK) -> St {
|
|
let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty };
|
|
match k {
|
|
EK::Worker => s("unit.worker", 45.0, 3.0, 1.0, 3.2, 1.0, 0, 0),
|
|
EK::Kobold => s("unit.kobold", 75.0, 8.0, 1.0, 2.6, 0.9, 40, 0),
|
|
EK::Spider => s("unit.spider", 95.0, 10.0, 1.0, 2.9, 1.0, 60, 0),
|
|
EK::Mage => s("unit.mage", 60.0, 13.0, 4.5, 2.0, 1.4, 110, 0),
|
|
EK::Ogre => s("unit.ogre", 230.0, 20.0, 1.0, 1.8, 1.4, 150, 0),
|
|
// heroes: the wage applies when they defected to the player
|
|
EK::Knight => s("unit.knight", 140.0, 11.0, 1.0, 1.9, 1.1, 70, 120),
|
|
EK::Archer => s("unit.archer", 75.0, 9.0, 4.5, 2.1, 1.3, 60, 100),
|
|
EK::Priest => s("unit.priest", 80.0, 5.0, 3.5, 1.9, 1.5, 60, 120),
|
|
EK::Dwarf => s("unit.dwarf", 110.0, 9.0, 1.0, 2.0, 1.0, 60, 100),
|
|
EK::Paladin => s("unit.paladin", 700.0, 26.0, 1.0, 1.7, 1.2, 220, 1000),
|
|
EK::Jailer => s("unit.jailer", 260.0, 16.0, 1.0, 1.6, 1.3, 140, 0),
|
|
EK::Priestess => s("unit.priestess", 70.0, 14.0, 5.0, 2.0, 1.6, 120, 0),
|
|
}
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
#[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,
|
|
pub hunger: f32,
|
|
pub energy: f32,
|
|
pub happy: f32,
|
|
pub bed: Option<usize>,
|
|
pub alive: bool,
|
|
pub held: bool,
|
|
pub anim: f32,
|
|
pub flash: f32,
|
|
pub face: f32,
|
|
pub moving: bool,
|
|
pub working: bool,
|
|
#[serde(default)]
|
|
pub slow: f32, // seconds of slowdown left (magic tower)
|
|
#[serde(default)]
|
|
pub captive: u8, // heroes: 0 free, 1 unconscious, 2 carried, 3 in the torture chamber
|
|
#[serde(default)]
|
|
pub carrier: u32, // imp that has taken care of this captive
|
|
#[serde(default)]
|
|
pub torture_t: f32,
|
|
#[serde(default)]
|
|
pub skill_cd: f32, // special abilities (jailer pull, priestess heal)
|
|
#[serde(default)]
|
|
pub waypoint: Option<(i32, i32)>, // heroes: entry point into the player's realm
|
|
pub guard: bool, // stays at its home (gate defenders, treasure guards)
|
|
#[serde(default)]
|
|
pub home: Option<(f32, f32)>, // guard post
|
|
}
|
|
|
|
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,
|
|
hunger: 0.0,
|
|
energy: 1.0,
|
|
happy: 1.0,
|
|
bed: None,
|
|
alive: false,
|
|
held: false,
|
|
anim: 0.0,
|
|
flash: 0.0,
|
|
face: 1.0,
|
|
moving: false,
|
|
working: false,
|
|
slow: 0.0,
|
|
captive: 0,
|
|
carrier: 0,
|
|
torture_t: 0.0,
|
|
skill_cd: 0.0,
|
|
waypoint: None,
|
|
guard: false,
|
|
home: None,
|
|
}
|
|
}
|
|
pub fn tile(&self) -> usize {
|
|
idx((self.x as i32).clamp(0, mw() as i32 - 1), (self.y as i32).clamp(0, mh() as i32 - 1))
|
|
}
|
|
pub fn atk(&self) -> f32 {
|
|
st(self.kind).atk * (1.0 + 0.12 * (self.level - 1) as f32)
|
|
}
|
|
pub fn creature(&self) -> bool {
|
|
!self.hero && self.kind != EK::Worker
|
|
}
|
|
// a hero that is still fighting (not a prisoner)
|
|
pub fn active_hero(&self) -> bool {
|
|
self.hero && self.captive == 0
|
|
}
|
|
}
|
|
|
|
#[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,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct Game {
|
|
pub map: Map,
|
|
pub ents: Vec<Ent>,
|
|
pub next_id: u32,
|
|
pub rng: Rng,
|
|
pub gold: f32,
|
|
pub mana: f32,
|
|
pub core_hp: f32,
|
|
pub time: f32,
|
|
pub wave: u32,
|
|
pub wave_timer: f32,
|
|
pub warned: bool,
|
|
pub portal_timer: f32,
|
|
pub payday: f32,
|
|
#[serde(skip)]
|
|
pub fx: Vec<Fx>,
|
|
#[serde(skip)]
|
|
pub msgs: Vec<Msg>,
|
|
pub state: u8, // 0 running, 1 victory, 2 defeat
|
|
pub paused: bool,
|
|
pub hand: Vec<u32>,
|
|
pub counts: [usize; 7], // treasury, lair, farm, training hall, library, torture chamber, temple
|
|
pub gate_hp: f32,
|
|
pub gate_hit_t: f32,
|
|
pub gate_stage: u32, // defender thresholds triggered (0..3)
|
|
pub flag: Option<usize>,
|
|
#[serde(skip)]
|
|
pub events: Vec<SfxEvent>,
|
|
pub last_wave_value: f32,
|
|
pub torches: Vec<Torch>,
|
|
#[serde(default)]
|
|
pub towers: Vec<Tower>,
|
|
#[serde(default)]
|
|
pub 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(skip)]
|
|
pub hints: bool, // show hint messages (setting)
|
|
hint_t: [f32; 16],
|
|
}
|
|
|
|
// torch on a wall face: wall tile + direction (index into DIR4) towards the open side
|
|
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
|
|
pub struct Torch {
|
|
pub wall: usize,
|
|
pub dir: usize,
|
|
}
|
|
|
|
impl Torch {
|
|
// world position of the flame (x, z) on the ground plane
|
|
pub fn pos(&self) -> (f32, f32) {
|
|
let (x, y) = xy(self.wall);
|
|
let (dx, dy) = DIR4[self.dir];
|
|
(x as f32 + 0.5 + dx as f32 * 0.58, y as f32 + 0.5 + dy as f32 * 0.58)
|
|
}
|
|
}
|
|
|
|
pub const ROOM_KINDS: [Kind; 7] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple];
|
|
pub const ROOM_COST: [i32; 7] = [60, 80, 100, 140, 160, 200, 180];
|
|
pub const ROOM_NAMES: [&str; 7] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple"];
|
|
|
|
pub fn room_index(k: Kind) -> Option<usize> {
|
|
ROOM_KINDS.iter().position(|&r| r == k)
|
|
}
|
|
|
|
fn adj4(a: usize, b: usize) -> bool {
|
|
let (ax, ay) = xy(a);
|
|
let (bx, by) = xy(b);
|
|
(ax - bx).abs() + (ay - by).abs() == 1
|
|
}
|
|
|
|
fn norm(x: f32, y: f32) -> (f32, f32) {
|
|
let l = (x * x + y * y).sqrt();
|
|
if l < 1e-6 {
|
|
(0.0, 0.0)
|
|
} else {
|
|
(x / l, y / l)
|
|
}
|
|
}
|
|
|
|
fn sgn(v: f32) -> f32 {
|
|
if v < 0.0 {
|
|
-1.0
|
|
} else {
|
|
1.0
|
|
}
|
|
}
|
|
|
|
impl Game {
|
|
pub fn new(seed: u32, w: usize, h: usize) -> Game {
|
|
let map = Map::generate(seed, w, h);
|
|
let mut g = Game {
|
|
map,
|
|
ents: Vec::new(),
|
|
next_id: 1,
|
|
rng: Rng(seed.wrapping_mul(2654435761) | 1),
|
|
gold: 1500.0,
|
|
mana: 100.0,
|
|
core_hp: CORE_HP,
|
|
time: 0.0,
|
|
wave: 0,
|
|
wave_timer: FIRST_WAVE,
|
|
warned: false,
|
|
portal_timer: 10.0,
|
|
payday: PAYDAY,
|
|
fx: Vec::new(),
|
|
msgs: Vec::new(),
|
|
state: 0,
|
|
paused: false,
|
|
hand: Vec::new(),
|
|
counts: [0; 7],
|
|
gate_hp: GATE_HP,
|
|
gate_hit_t: -100.0,
|
|
gate_stage: 0,
|
|
flag: None,
|
|
events: Vec::new(),
|
|
last_wave_value: 0.0,
|
|
torches: Vec::new(),
|
|
towers: Vec::new(),
|
|
entry_history: Vec::new(),
|
|
planned_entries: Vec::new(),
|
|
hints: true,
|
|
hint_t: [-1000.0; 16],
|
|
};
|
|
let (cx, cy) = g.map.core;
|
|
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();
|
|
for i in 0..n() {
|
|
if g.map.t[i].owner == Owner::Player {
|
|
g.place_torches(i);
|
|
}
|
|
}
|
|
// heroes take longer to reach the core on big maps: longer grace period
|
|
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);
|
|
// treasure caves are guarded by heroes
|
|
for k in 0..g.map.treasures.len() {
|
|
let (tx, ty) = g.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 = g.spawn(kind, true, x, ty as f32 + 0.5, 2);
|
|
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
|
|
e.guard = true;
|
|
e.home = Some((tx as f32 + 0.5, ty as f32 + 0.5));
|
|
}
|
|
}
|
|
}
|
|
g.msg("msg.welcome1", vec![]);
|
|
g.msg("msg.welcome2", vec![]);
|
|
g.msg("msg.welcome3", vec![]);
|
|
g.msg("msg.welcome4", vec![]);
|
|
g
|
|
}
|
|
|
|
pub fn msg(&mut self, key: &'static str, args: Vec<String>) {
|
|
self.msgs.push(Msg { key, args });
|
|
if self.msgs.len() > 50 {
|
|
self.msgs.remove(0);
|
|
}
|
|
}
|
|
|
|
pub fn sfx(&mut self, sfx: Sfx, x: f32, y: f32) {
|
|
if self.events.len() < 64 {
|
|
self.events.push(SfxEvent { sfx, pos: Some((x, y)) });
|
|
}
|
|
}
|
|
|
|
pub fn sfx_ui(&mut self, sfx: Sfx) {
|
|
if self.events.len() < 64 {
|
|
self.events.push(SfxEvent { sfx, pos: None });
|
|
}
|
|
}
|
|
|
|
pub fn gate_center(&self) -> (f32, f32) {
|
|
(self.map.gate.0 as f32 + 1.5, self.map.gate.1 as f32 + 1.5)
|
|
}
|
|
|
|
// Army value: sqrt(sum of HP * sum of damage/s) of all own creatures.
|
|
pub fn army_value(&self) -> f32 {
|
|
let (mut h, mut d) = (0.0, 0.0);
|
|
for e in &self.ents {
|
|
if e.alive && e.creature() {
|
|
h += e.max_hp;
|
|
d += e.atk() / st(e.kind).interval;
|
|
}
|
|
}
|
|
// towers count with half their combat value
|
|
for t in &self.towers {
|
|
h += 0.5 * t.max_hp();
|
|
d += 0.5 * t.dmg() / tower_stats(t.kind).interval;
|
|
}
|
|
(h * d).sqrt()
|
|
}
|
|
|
|
// imps hang torches on walls next to claimed floor (min. distance 3.5 tiles)
|
|
pub fn place_torches(&mut self, i: usize) {
|
|
let (x, y) = xy(i);
|
|
for (d, &(dx, dy)) in DIR4.iter().enumerate() {
|
|
let (wx, wy) = (x + dx, y + dy);
|
|
if !inb(wx, wy) || !self.map.t[idx(wx, wy)].solid() {
|
|
continue;
|
|
}
|
|
// the direction from the wall to the floor is the opposite one
|
|
let t = Torch { wall: idx(wx, wy), dir: (d + 2) % 4 };
|
|
let (px, pz) = t.pos();
|
|
let near = self.torches.iter().any(|o| {
|
|
let (ox, oz) = o.pos();
|
|
(ox - px).powi(2) + (oz - pz).powi(2) < 3.5 * 3.5
|
|
});
|
|
if !near {
|
|
self.torches.push(t);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hint(&mut self, slot: usize, key: &'static str) {
|
|
if self.hints && self.time - self.hint_t[slot] > 30.0 {
|
|
self.hint_t[slot] = self.time;
|
|
self.msg(key, vec![]);
|
|
}
|
|
}
|
|
|
|
pub fn recount(&mut self) {
|
|
let mut c = [0usize; 7];
|
|
for t in &self.map.t {
|
|
if t.owner == Owner::Player {
|
|
if let Some(r) = room_index(t.kind) {
|
|
c[r] += 1;
|
|
}
|
|
}
|
|
}
|
|
self.counts = c;
|
|
}
|
|
|
|
pub fn gold_cap(&self) -> f32 {
|
|
2000.0 + 500.0 * self.counts[0] as f32
|
|
}
|
|
|
|
pub fn add_gold(&mut self, amount: f32) -> f32 {
|
|
let cap = self.gold_cap();
|
|
let acc = amount.min((cap - self.gold).max(0.0));
|
|
self.gold += acc;
|
|
acc
|
|
}
|
|
|
|
pub fn portals_owned(&self) -> usize {
|
|
(0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).count()
|
|
}
|
|
|
|
// gate defenders alive (not the treasure guards)
|
|
pub fn gate_defenders_alive(&self) -> bool {
|
|
let (gx, gy) = self.gate_center();
|
|
self.ents.iter().any(|e| e.alive && e.guard && e.captive == 0 && e.home.map_or(false, |(x, y)| (x - gx).abs() < 1.0 && (y - gy).abs() < 1.0))
|
|
}
|
|
|
|
#[allow(dead_code)]
|
|
pub fn portal_owned(&self) -> bool {
|
|
let Some(&(px, py)) = self.map.portals.first() else { return false };
|
|
self.map.t[idx(px, py)].owner == Owner::Player
|
|
}
|
|
|
|
pub fn creature_count(&self) -> usize {
|
|
self.ents.iter().filter(|e| e.alive && e.creature()).count()
|
|
}
|
|
pub fn worker_count(&self) -> usize {
|
|
self.ents.iter().filter(|e| e.alive && !e.hero && e.kind == EK::Worker).count()
|
|
}
|
|
pub fn summon_cost(&self) -> f32 {
|
|
80.0 + 20.0 * (self.worker_count() as f32 - 4.0).max(0.0)
|
|
}
|
|
|
|
pub fn spawn(&mut self, kind: EK, hero: bool, x: f32, y: f32, level: u32) -> u32 {
|
|
let s = st(kind);
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let mut e = Ent::dummy();
|
|
e.id = id;
|
|
e.kind = kind;
|
|
e.hero = hero;
|
|
e.x = x;
|
|
e.y = y;
|
|
e.level = level;
|
|
e.max_hp = s.hp * (1.0 + 0.15 * (level - 1) as f32);
|
|
e.hp = e.max_hp;
|
|
e.alive = true;
|
|
e.think = self.rng.f();
|
|
e.energy = 0.7 + self.rng.f() * 0.3;
|
|
e.hunger = self.rng.f() * 0.3;
|
|
e.anim = self.rng.f() * 10.0;
|
|
self.ents.push(e);
|
|
id
|
|
}
|
|
|
|
pub fn fx(&mut self, kind: u8, x: f32, y: f32, x2: f32, y2: f32, max: f32, col: u32) {
|
|
if self.fx.len() < 400 {
|
|
self.fx.push(Fx { kind, x, y, x2, y2, t: 0.0, max, col });
|
|
}
|
|
}
|
|
|
|
pub fn find(&self, id: u32) -> Option<usize> {
|
|
self.ents.iter().position(|e| e.id == id && e.alive && !e.held)
|
|
}
|
|
|
|
// ---------------------------------------------------------------- update
|
|
|
|
pub fn update(&mut self, dt: f32) {
|
|
let dt = dt.min(0.1);
|
|
if self.state == 0 && !self.paused {
|
|
self.step(dt);
|
|
}
|
|
for f in self.fx.iter_mut() {
|
|
f.t += dt;
|
|
}
|
|
self.fx.retain(|f| f.t < f.max);
|
|
}
|
|
|
|
fn step(&mut self, dt: f32) {
|
|
self.time += dt;
|
|
self.recount();
|
|
self.mana = (self.mana + 1.0 * dt).min(MANA_MAX);
|
|
self.core_hp = (self.core_hp + 0.5 * dt).min(CORE_HP);
|
|
for t in self.map.t.iter_mut() {
|
|
if t.kind == Kind::Farm {
|
|
t.food = (t.food + dt / 18.0).min(3.0);
|
|
}
|
|
}
|
|
|
|
let owned = self.portals_owned();
|
|
if owned > 0 {
|
|
self.portal_timer -= dt;
|
|
if self.portal_timer <= 0.0 {
|
|
self.portal_timer = (20.0 + self.rng.f() * 14.0) / (owned as f32).sqrt();
|
|
self.try_portal();
|
|
}
|
|
}
|
|
|
|
self.payday -= dt;
|
|
if self.payday <= 0.0 {
|
|
self.payday = PAYDAY;
|
|
self.do_payday();
|
|
}
|
|
|
|
self.wave_timer -= dt;
|
|
if !self.warned && self.wave_timer < 30.0 {
|
|
self.warned = true;
|
|
self.plan_entries();
|
|
if self.planned_entries.is_empty() {
|
|
self.msg("msg.wave_warn", vec![(self.wave + 1).to_string()]);
|
|
} else {
|
|
let dirs: Vec<String> = self.planned_entries.iter().map(|&p| format!("@{}", self.direction_key(p))).collect();
|
|
if dirs.len() >= 2 {
|
|
self.msg("msg.wave_warn_two", vec![(self.wave + 1).to_string(), dirs[0].clone(), dirs[1].clone()]);
|
|
} else {
|
|
self.msg("msg.wave_warn_dir", vec![(self.wave + 1).to_string(), dirs[0].clone()]);
|
|
}
|
|
}
|
|
self.sfx_ui(Sfx::WaveAlarm);
|
|
}
|
|
// markers disappear when no hero is on the way to an entry point any more
|
|
if !self.planned_entries.is_empty() && self.wave_timer > 30.0 && !self.ents.iter().any(|e| e.alive && e.waypoint.is_some()) {
|
|
self.planned_entries.clear();
|
|
}
|
|
if self.wave_timer <= 0.0 {
|
|
self.spawn_wave();
|
|
self.wave += 1;
|
|
self.wave_timer = WAVE_GAP + self.rng.f() * 40.0;
|
|
self.warned = false;
|
|
}
|
|
if self.time - self.gate_hit_t > 10.0 {
|
|
self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP);
|
|
}
|
|
|
|
for i in 0..self.ents.len() {
|
|
if !self.ents[i].alive || self.ents[i].held {
|
|
continue;
|
|
}
|
|
let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy());
|
|
self.update_ent(&mut e, dt);
|
|
self.ents[i] = e;
|
|
}
|
|
self.ents.retain(|e| e.alive);
|
|
self.update_towers(dt);
|
|
|
|
if self.core_hp <= 0.0 {
|
|
self.core_hp = 0.0;
|
|
self.state = 2;
|
|
self.msg("msg.defeat", vec![]);
|
|
self.sfx_ui(Sfx::Defeat);
|
|
} else if self.gate_hp <= 0.0 {
|
|
self.gate_hp = 0.0;
|
|
self.state = 1;
|
|
self.msg("msg.victory", vec![]);
|
|
self.sfx_ui(Sfx::Victory);
|
|
}
|
|
}
|
|
|
|
fn try_portal(&mut self) {
|
|
let creatures = self.creature_count();
|
|
let lair = self.counts[1];
|
|
let farm = self.counts[2];
|
|
let cap = lair.min(30);
|
|
if lair == 0 {
|
|
self.hint(0, "hint.portal_no_lair");
|
|
return;
|
|
}
|
|
if farm == 0 {
|
|
self.hint(1, "hint.no_farm");
|
|
return;
|
|
}
|
|
if creatures >= cap {
|
|
self.hint(2, "hint.lair_full");
|
|
return;
|
|
}
|
|
let mut opts: Vec<(EK, u32)> = vec![(EK::Kobold, 4)];
|
|
if farm >= 6 {
|
|
opts.push((EK::Spider, 3));
|
|
}
|
|
if self.counts[4] >= 6 {
|
|
opts.push((EK::Mage, 3));
|
|
}
|
|
if self.counts[3] >= 9 {
|
|
opts.push((EK::Ogre, 2));
|
|
}
|
|
if self.counts[5] >= 6 {
|
|
opts.push((EK::Jailer, 2));
|
|
}
|
|
if self.counts[6] >= 6 {
|
|
opts.push((EK::Priestess, 2));
|
|
}
|
|
let total: u32 = opts.iter().map(|o| o.1).sum();
|
|
let mut r = self.rng.next() % total;
|
|
let mut kind = EK::Kobold;
|
|
for (k, w) in opts {
|
|
if r < w {
|
|
kind = k;
|
|
break;
|
|
}
|
|
r -= w;
|
|
}
|
|
let owned: Vec<usize> = (0..self.map.portals.len()).filter(|&k| self.map.portal_owned(k)).collect();
|
|
let pk = owned[(self.rng.next() as usize) % owned.len()];
|
|
let (px, py) = self.map.portals[pk];
|
|
self.spawn(kind, false, px as f32 + 1.5, py as f32 + 1.5, 1);
|
|
self.fx(3, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 0.8, 0xffcc44ff);
|
|
self.sfx(Sfx::PortalArrive, px as f32 + 1.5, py as f32 + 1.5);
|
|
self.msg("msg.creature_arrived", vec![format!("@{}", st(kind).name)]);
|
|
}
|
|
|
|
fn do_payday(&mut self) {
|
|
let mut paid = 0;
|
|
let mut unpaid = 0;
|
|
let mut total = 0.0;
|
|
for i in 0..self.ents.len() {
|
|
if !self.ents[i].alive || !self.ents[i].creature() {
|
|
continue;
|
|
}
|
|
let e = &self.ents[i];
|
|
let w = st(e.kind).wage as f32 * (1.0 + 0.1 * (e.level - 1) as f32);
|
|
if self.gold >= w {
|
|
self.gold -= w;
|
|
total += w;
|
|
paid += 1;
|
|
let e = &mut self.ents[i];
|
|
e.happy = (e.happy + 0.2).min(1.0);
|
|
} else {
|
|
unpaid += 1;
|
|
self.ents[i].happy -= 0.45;
|
|
}
|
|
}
|
|
if paid + unpaid > 0 {
|
|
self.sfx_ui(Sfx::Payday);
|
|
if unpaid > 0 {
|
|
self.msg("msg.payday_partial", vec![paid.to_string(), (total as i32).to_string(), unpaid.to_string()]);
|
|
} else {
|
|
self.msg("msg.payday", vec![paid.to_string(), (total as i32).to_string()]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// strength of a squad (same formula as the army value)
|
|
fn squad_value(list: &[(EK, u32)]) -> f32 {
|
|
let (mut h, mut d) = (0.0, 0.0);
|
|
for &(k, l) in list {
|
|
let s = st(k);
|
|
h += s.hp * (1.0 + 0.15 * (l - 1) as f32);
|
|
d += s.atk * (1.0 + 0.12 * (l - 1) as f32) / s.interval;
|
|
}
|
|
(h * d).sqrt()
|
|
}
|
|
|
|
// builds a hero squad with at least the target strength
|
|
fn build_squad(&mut self, target: f32, base_level: u32, pool: &[(EK, u32)], boss: Option<EK>, min: usize) -> Vec<(EK, u32)> {
|
|
let total: u32 = pool.iter().map(|p| p.1).sum();
|
|
let mut list = Vec::new();
|
|
for level in base_level.min(10)..=10 {
|
|
list.clear();
|
|
if let Some(b) = boss {
|
|
list.push((b, level));
|
|
}
|
|
while list.len() < MAX_SQUAD && (list.len() < min || Self::squad_value(&list) < target) {
|
|
let mut r = self.rng.next() % total;
|
|
for &(k, w) in pool {
|
|
if r < w {
|
|
list.push((k, level));
|
|
break;
|
|
}
|
|
r -= w;
|
|
}
|
|
}
|
|
if Self::squad_value(&list) >= target {
|
|
break;
|
|
}
|
|
}
|
|
list
|
|
}
|
|
|
|
fn spawn_squad(&mut self, list: &[(EK, u32)], guard: bool) {
|
|
let (gx, gy) = self.map.gate;
|
|
for &(k, l) in list {
|
|
let x = gx as f32 + 0.5 + self.rng.f() * 2.0;
|
|
let y = gy as f32 + 0.5 + self.rng.f() * 2.0;
|
|
let id = self.spawn(k, true, x, y, l);
|
|
let home = (gx as f32 + 1.5, gy as f32 + 1.5);
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.guard = guard;
|
|
if guard {
|
|
e.home = Some(home);
|
|
}
|
|
}
|
|
}
|
|
self.fx(3, gx as f32 + 1.5, gy as f32 + 1.5, 0.0, 0.0, 1.0, 0xffffeecc);
|
|
}
|
|
|
|
// Endless waves: strength 60% -> 100% of the army value, with some randomness, at least 2 knights.
|
|
fn spawn_wave(&mut self) {
|
|
use EK::*;
|
|
let w = self.wave + 1;
|
|
let ratio = (0.6 + 0.05 * (w - 1) as f32).min(1.0) * (0.88 + self.rng.f() * 0.24);
|
|
let min_value = Self::squad_value(&[(Knight, 1), (Knight, 1)]);
|
|
let target = (self.army_value() * ratio).max(min_value);
|
|
let mut pool = vec![(Knight, 4), (Archer, 3)];
|
|
if w >= 2 {
|
|
pool.push((Dwarf, 2));
|
|
}
|
|
if w >= 3 {
|
|
pool.push((Priest, 2));
|
|
}
|
|
let boss = if w % 5 == 0 { Some(Paladin) } else { None };
|
|
let base_level = 1 + (w - 1) / 3;
|
|
let list = self.build_squad(target, base_level, &pool, boss, 2);
|
|
self.last_wave_value = Self::squad_value(&list);
|
|
let before = self.ents.len();
|
|
self.spawn_squad(&list, false);
|
|
if self.planned_entries.is_empty() {
|
|
self.plan_entries();
|
|
}
|
|
let entries = self.planned_entries.clone();
|
|
if !entries.is_empty() {
|
|
let split = list.len() >= 6 && entries.len() >= 2;
|
|
// the paladin (if any) is spawned first and joins group A, which gets the extra hero
|
|
for (k, e) in self.ents[before..].iter_mut().enumerate() {
|
|
e.waypoint = Some(if split && k % 2 == 1 { entries[1] } else { entries[0] });
|
|
}
|
|
if !split {
|
|
self.planned_entries.truncate(1);
|
|
}
|
|
self.entry_history.extend(self.planned_entries.iter().copied());
|
|
let n = self.entry_history.len();
|
|
if n > 4 {
|
|
self.entry_history.drain(0..n - 4);
|
|
}
|
|
}
|
|
self.sfx_ui(Sfx::WaveStart);
|
|
if boss.is_some() {
|
|
self.msg("msg.wave_boss", vec![w.to_string(), list.len().to_string()]);
|
|
} else {
|
|
self.msg("msg.wave", vec![w.to_string(), list.len().to_string()]);
|
|
}
|
|
}
|
|
|
|
// defenders at 75 / 50 / 25 % gate HP
|
|
fn check_gate_stage(&mut self) {
|
|
use EK::*;
|
|
let frac = self.gate_hp / GATE_HP;
|
|
let want = if frac <= 0.25 { 3 } else if frac <= 0.5 { 2 } else if frac <= 0.75 { 1 } else { 0 };
|
|
while self.gate_stage < want {
|
|
self.gate_stage += 1;
|
|
let st_i = self.gate_stage;
|
|
let ratio = [0.5, 0.65, 0.85][(st_i - 1) as usize] * (0.9 + self.rng.f() * 0.2);
|
|
let min_value = Self::squad_value(&[(Knight, 2), (Knight, 2), (Archer, 2)]);
|
|
let target = (self.army_value() * ratio).max(min_value);
|
|
let pool = [(Knight, 4), (Archer, 3), (Priest, 2), (Dwarf, 1)];
|
|
let boss = if st_i == 3 { Some(Paladin) } else { None };
|
|
let base_level = 2 + self.wave / 3 + st_i;
|
|
let list = self.build_squad(target, base_level, &pool, boss, 3);
|
|
self.spawn_squad(&list, true);
|
|
self.sfx_ui(Sfx::GateDefenders);
|
|
if st_i == 3 {
|
|
self.msg("msg.gate_boss", vec![(list.len() - 1).to_string()]);
|
|
} else {
|
|
self.msg("msg.gate_defenders", vec![list.len().to_string()]);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn hit_gate(&mut self, e: &mut Ent) {
|
|
let s = st(e.kind);
|
|
e.cd = s.interval;
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
|
|
self.gate_hp -= dmg;
|
|
self.gate_hit_t = self.time;
|
|
e.xp += dmg * 0.3;
|
|
let (gx, gy) = self.gate_center();
|
|
if s.range > 1.5 {
|
|
let col = if e.kind == EK::Mage { 0xffff44cc } else { 0xff70b0d0 };
|
|
self.fx(0, e.x, e.y - 0.3, 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");
|
|
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.cd -= dt;
|
|
e.think -= dt;
|
|
e.repath -= dt;
|
|
e.moving = false;
|
|
e.working = false;
|
|
let s = st(e.kind);
|
|
while e.level < 10 && e.xp >= 100.0 * e.level as f32 {
|
|
e.xp -= 100.0 * e.level as f32;
|
|
e.level += 1;
|
|
let old = e.max_hp;
|
|
e.max_hp = s.hp * (1.0 + 0.15 * (e.level - 1) as f32);
|
|
e.hp += e.max_hp - old;
|
|
self.fx(7, e.x, e.y, 0.0, 0.0, 0.8, 0xff44ddff);
|
|
if !e.hero {
|
|
self.sfx(Sfx::LevelUp, e.x, e.y);
|
|
}
|
|
if !e.hero && e.level >= 3 {
|
|
self.msg("msg.level_up", vec![format!("@{}", s.name), e.level.to_string()]);
|
|
}
|
|
}
|
|
if e.hero && e.captive != 0 {
|
|
self.captive_update(e, dt);
|
|
} else if e.hero {
|
|
self.hero_ai(e, dt);
|
|
} else if e.kind == EK::Worker {
|
|
self.worker_ai(e, dt);
|
|
} else {
|
|
self.creature_ai(e, dt);
|
|
}
|
|
}
|
|
|
|
// moves a unit along its path; true = still moving
|
|
fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool {
|
|
if e.path.is_empty() {
|
|
return false;
|
|
}
|
|
let next = e.path[0];
|
|
let tile = self.map.t[next];
|
|
if !tile.walkable() {
|
|
if e.hero && tile.diggable() {
|
|
// heroes dig their way through
|
|
e.working = true;
|
|
let (tx, ty) = xy(next);
|
|
e.face = sgn(tx as f32 + 0.5 - e.x);
|
|
e.timer += dt;
|
|
if e.timer >= 0.5 {
|
|
e.timer = 0.0;
|
|
let pow = match e.kind {
|
|
EK::Dwarf => 3.0,
|
|
EK::Paladin => 2.0,
|
|
_ => 1.0,
|
|
} * if tile.reinforced { 0.2 } else { 1.0 };
|
|
self.fx(6, tx as f32 + 0.5, ty as f32 + 0.5, 0.0, 0.0, 0.4, 0xff3a5a7a);
|
|
self.sfx(Sfx::Dig, tx as f32 + 0.5, ty as f32 + 0.5);
|
|
let t = &mut self.map.t[next];
|
|
t.hp -= pow;
|
|
if t.hp <= 0.0 {
|
|
self.dig_out(next);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
e.path.clear();
|
|
return false;
|
|
}
|
|
let (tx, ty) = xy(next);
|
|
let (cx, cy) = (tx as f32 + 0.5, ty as f32 + 0.5);
|
|
let dx = cx - e.x;
|
|
let dy = cy - e.y;
|
|
let d = (dx * dx + dy * dy).sqrt();
|
|
let mut sp = st(e.kind).speed * dt * if e.slow > 0.0 { 0.55 } else { 1.0 };
|
|
if !e.hero && e.creature() && e.hunger > 2.0 {
|
|
sp *= 0.8;
|
|
}
|
|
if d <= sp {
|
|
e.x = cx;
|
|
e.y = cy;
|
|
e.path.remove(0);
|
|
} else {
|
|
e.x += dx / d * sp;
|
|
e.y += dy / d * sp;
|
|
}
|
|
if dx.abs() > 0.01 {
|
|
e.face = sgn(dx);
|
|
}
|
|
e.moving = true;
|
|
true
|
|
}
|
|
|
|
pub fn dig_out(&mut self, i: usize) {
|
|
self.torches.retain(|t| t.wall != i);
|
|
let t = &mut self.map.t[i];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::None;
|
|
t.marked = false;
|
|
t.reinforced = false;
|
|
t.reserved = 0;
|
|
t.hp = 0.0;
|
|
t.gold = 0;
|
|
let (x, y) = xy(i);
|
|
self.fx(3, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff304a66);
|
|
self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
|
|
fn unreserve(&mut self, me: u32) {
|
|
for t in self.map.t.iter_mut() {
|
|
if t.reserved == me {
|
|
t.reserved = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn release_all(&mut self, id: u32) {
|
|
let me = id + 1;
|
|
for t in self.map.t.iter_mut() {
|
|
if t.reserved == me {
|
|
t.reserved = 0;
|
|
}
|
|
if t.bed == me {
|
|
t.bed = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn los(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> bool {
|
|
let dx = x1 - x0;
|
|
let dy = y1 - y0;
|
|
let d = (dx * dx + dy * dy).sqrt();
|
|
let n = (d * 4.0) as i32 + 1;
|
|
for k in 1..n {
|
|
let f = k as f32 / n as f32;
|
|
let x = (x0 + dx * f) as i32;
|
|
let y = (y0 + dy * f) as i32;
|
|
if let Some(t) = self.map.get(x, y) {
|
|
if t.solid() || t.kind == Kind::Core {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
true
|
|
}
|
|
|
|
fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option<u32> {
|
|
let mut best = None;
|
|
let mut bd = radius * radius;
|
|
for o in &self.ents {
|
|
if !o.alive || o.held || o.hero == e.hero || o.captive != 0 {
|
|
continue;
|
|
}
|
|
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
|
|
if d < bd {
|
|
bd = d;
|
|
best = Some(o.id);
|
|
}
|
|
}
|
|
best
|
|
}
|
|
|
|
// fight a specific unit; false = target invalid/unreachable
|
|
fn attack_job(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
|
|
let Some(j) = self.find(id) else { return false };
|
|
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
|
|
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
|
|
let s = st(e.kind);
|
|
if d > 12.0 {
|
|
return false;
|
|
}
|
|
if e.kind == EK::Jailer && e.skill_cd <= 0.0 && d > 1.4 && d < 4.5 && self.los(e.x, e.y, tx, ty) {
|
|
// jailer: pulls the enemy in with a chain
|
|
let (dx, dy) = norm(tx - e.x, ty - e.y);
|
|
let (nx, ny) = (e.x + dx * 0.9, e.y + dy * 0.9);
|
|
if self.map.walk(nx as i32, ny as i32) {
|
|
self.fx(0, tx, ty - 0.3, e.x, e.y - 0.3, 0.3, 0xff8090a0);
|
|
self.sfx(Sfx::Capture, e.x, e.y);
|
|
let t = &mut self.ents[j];
|
|
t.x = nx;
|
|
t.y = ny;
|
|
t.path.clear();
|
|
e.skill_cd = 6.0;
|
|
return true;
|
|
}
|
|
}
|
|
if d <= s.range + 0.45 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
|
|
e.path.clear();
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
if e.cd <= 0.0 {
|
|
e.cd = s.interval;
|
|
self.hit(e, j);
|
|
}
|
|
return true;
|
|
}
|
|
if e.path.is_empty() || e.repath <= 0.0 {
|
|
e.repath = 0.6;
|
|
let tt = idx(tx as i32, ty as i32);
|
|
match self.map.path_to(e.tile(), tt) {
|
|
Some(p) => e.path = p,
|
|
None => return false,
|
|
}
|
|
}
|
|
if !self.step_path(e, dt) && e.tile() == idx(tx as i32, ty as i32) {
|
|
// same tile: approach directly
|
|
let dx = tx - e.x;
|
|
let dy = ty - e.y;
|
|
let l = (dx * dx + dy * dy).sqrt().max(0.001);
|
|
let sp = s.speed * dt;
|
|
e.x += dx / l * sp.min(l);
|
|
e.y += dy / l * sp.min(l);
|
|
}
|
|
true
|
|
}
|
|
|
|
fn hit(&mut self, e: &mut Ent, j: usize) {
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
|
|
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
|
|
let s = st(e.kind);
|
|
if s.range > 1.5 {
|
|
let col = match e.kind {
|
|
EK::Mage => 0xffff44cc,
|
|
EK::Priest => 0xff88ffff,
|
|
EK::Priestess => 0xff3080ff,
|
|
_ => 0xff70b0d0,
|
|
};
|
|
self.fx(0, e.x, e.y - 0.3, tx, ty - 0.3, 0.25, col);
|
|
self.sfx(Sfx::Shoot, e.x, e.y);
|
|
} else {
|
|
self.fx(2, tx, ty - 0.2, 0.0, 0.0, 0.25, 0xffffffff);
|
|
self.sfx(Sfx::MeleeHit, tx, ty);
|
|
}
|
|
e.xp += dmg * 0.6;
|
|
if e.kind == EK::Priestess {
|
|
// fireball: half damage to enemies around the target
|
|
self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff);
|
|
let side = !e.hero;
|
|
let splash: Vec<usize> = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(o, x)| *o != j && x.alive && !x.held && x.hero == side && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 1.44)
|
|
.map(|(o, _)| o)
|
|
.collect();
|
|
for o in splash {
|
|
if self.ents[o].alive {
|
|
self.ents[o].hp -= dmg * 0.5;
|
|
self.ents[o].flash = 0.15;
|
|
if self.ents[o].hp <= 0.0 {
|
|
self.kill(o);
|
|
}
|
|
}
|
|
}
|
|
if !self.ents[j].alive {
|
|
return;
|
|
}
|
|
}
|
|
let my_id = e.id;
|
|
let t = &mut self.ents[j];
|
|
t.hp -= dmg;
|
|
t.flash = 0.15;
|
|
if t.creature() && !matches!(t.job, Job::Attack(_)) {
|
|
t.job = Job::Attack(my_id);
|
|
t.path.clear();
|
|
}
|
|
if t.hp <= 0.0 {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
|
|
// free places in the torture chamber (one prisoner per 2 tiles)
|
|
pub fn prison_space(&self) -> usize {
|
|
let cap = self.counts[5] / 2;
|
|
let used = self.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count();
|
|
cap.saturating_sub(used)
|
|
}
|
|
|
|
fn kill(&mut self, j: usize) {
|
|
let e = &self.ents[j];
|
|
if e.hero && e.captive == 0 && self.prison_space() > 0 {
|
|
// knocked out instead of killed: imps carry the hero to the torture chamber
|
|
let (kind, x, y) = (e.kind, e.x, e.y);
|
|
let id = e.id;
|
|
self.release_all(id);
|
|
let e = &mut self.ents[j];
|
|
e.captive = 1;
|
|
e.hp = 1.0;
|
|
e.carrier = 0;
|
|
e.torture_t = 0.0;
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
e.guard = false;
|
|
e.home = None;
|
|
let b = st(kind).bounty as f32;
|
|
let got = self.add_gold(b);
|
|
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
|
|
self.sfx(Sfx::Capture, x, y);
|
|
self.msg("msg.hero_captured", vec![format!("@{}", st(kind).name), (got as i32).to_string()]);
|
|
return;
|
|
}
|
|
let e = &mut self.ents[j];
|
|
e.alive = false;
|
|
let (id, kind, hero, x, y) = (e.id, e.kind, e.hero, e.x, e.y);
|
|
// prisoners carried by this unit are dropped
|
|
for o in self.ents.iter_mut() {
|
|
if o.captive == 2 && o.carrier == id {
|
|
o.captive = 1;
|
|
o.carrier = 0;
|
|
}
|
|
}
|
|
self.release_all(id);
|
|
self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070);
|
|
let s = st(kind);
|
|
if hero {
|
|
let b = s.bounty as f32;
|
|
let got = self.add_gold(b);
|
|
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
|
|
self.sfx(Sfx::HeroDeath, x, y);
|
|
self.msg("msg.hero_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
|
|
} else {
|
|
self.sfx(Sfx::Death, x, y);
|
|
self.msg("msg.creature_died", vec![format!("@{}", s.name)]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- pit imps (workers)
|
|
|
|
fn dig_neighbor(&self, i: usize, me: u32) -> Option<usize> {
|
|
let (x, y) = xy(i);
|
|
let mut found = None;
|
|
for &(dx, dy) in &DIR4 {
|
|
if let Some(t) = self.map.get(x + dx, y + dy) {
|
|
if t.marked && t.diggable() {
|
|
if t.reserved == me {
|
|
return Some(idx(x + dx, y + dy));
|
|
}
|
|
if t.reserved == 0 && found.is_none() {
|
|
found = Some(idx(x + dx, y + dy));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
found
|
|
}
|
|
|
|
// a damaged tower next to walkable tile i
|
|
fn damaged_tower_near(&self, i: usize) -> Option<u32> {
|
|
let (x, y) = xy(i);
|
|
self.towers.iter().find(|t| {
|
|
let (tx, ty) = xy(t.tile);
|
|
(tx - x).abs() <= 1 && (ty - y).abs() <= 1 && t.hp < t.max_hp() - 1.0
|
|
}).map(|t| t.id)
|
|
}
|
|
|
|
// an earth wall next to own floor that can be reinforced from tile i
|
|
fn reinforce_neighbor(&self, i: usize, me: u32) -> Option<usize> {
|
|
if self.map.t[i].owner != Owner::Player {
|
|
return None;
|
|
}
|
|
let (x, y) = xy(i);
|
|
for &(dx, dy) in &DIR4 {
|
|
if let Some(t) = self.map.get(x + dx, y + dy) {
|
|
if t.kind == Kind::Earth && !t.reinforced && !t.marked && (t.reserved == 0 || t.reserved == me) {
|
|
return Some(idx(x + dx, y + dy));
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn claimable(&self, i: usize, me: u32) -> bool {
|
|
let t = &self.map.t[i];
|
|
if t.owner != Owner::None || !matches!(t.kind, Kind::Floor | Kind::Portal) {
|
|
return false;
|
|
}
|
|
if t.reserved != 0 && t.reserved != me {
|
|
return false;
|
|
}
|
|
let (x, y) = xy(i);
|
|
DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == Owner::Player))
|
|
}
|
|
|
|
fn is_deposit(&self, i: usize) -> bool {
|
|
let t = &self.map.t[i];
|
|
(t.kind == Kind::Treasury && t.owner == Owner::Player) || (self.counts[0] == 0 && self.map.adj_core(i))
|
|
}
|
|
|
|
fn start_deliver(&mut self, e: &mut Ent) -> bool {
|
|
let cur = e.tile();
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.is_deposit(i), true) {
|
|
e.path = p;
|
|
e.job = Job::Deliver;
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn wander(&mut self, e: &mut Ent, radius: i32) {
|
|
let (x, y) = xy(e.tile());
|
|
for _ in 0..6 {
|
|
let nx = x + self.rng.range(-radius, radius);
|
|
let ny = y + self.rng.range(-radius, radius);
|
|
if self.map.walk(nx, ny) && self.map.t[idx(nx, ny)].owner == Owner::Player {
|
|
if let Some(p) = self.map.path_to(e.tile(), idx(nx, ny)) {
|
|
if p.len() as i32 <= radius * 2 + 2 {
|
|
e.path = p;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
e.job = Job::Wander;
|
|
e.timer = 1.0 + self.rng.f() * 2.0;
|
|
}
|
|
|
|
fn worker_ai(&mut self, e: &mut Ent, dt: f32) {
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
let me = e.id + 1;
|
|
let cur = e.tile();
|
|
match e.job {
|
|
Job::Dig(t) => {
|
|
let tile = self.map.t[t];
|
|
if !tile.marked || !tile.diggable() || !adj4(cur, t) {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
let (tx, ty) = xy(t);
|
|
e.face = sgn(tx as f32 + 0.5 - e.x);
|
|
e.working = true;
|
|
e.timer += dt;
|
|
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 tl.kind == Kind::Gold {
|
|
let take = tl.gold.min(25);
|
|
tl.gold -= take;
|
|
e.carry += take;
|
|
done = tl.gold <= 0;
|
|
self.fx(5, fxx, fxy, 0.0, 0.0, 0.5, 0xff30d0ff);
|
|
self.sfx(Sfx::GoldDig, fxx, fxy);
|
|
} else {
|
|
tl.hp -= 1.0;
|
|
done = tl.hp <= 0.0;
|
|
self.fx(6, fxx, fxy, 0.0, 0.0, 0.4, 0xff3a5a7a);
|
|
self.sfx(Sfx::Dig, fxx, fxy);
|
|
}
|
|
if done {
|
|
self.dig_out(t);
|
|
e.job = Job::None;
|
|
}
|
|
if e.carry >= CARRY_MAX {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
self.start_deliver(e);
|
|
}
|
|
}
|
|
}
|
|
Job::Carry(id) => {
|
|
self.carry_job(e, id, dt);
|
|
}
|
|
Job::Repair(id) => {
|
|
let Some(k) = self.towers.iter().position(|t| t.id == id) else {
|
|
e.job = Job::None;
|
|
return;
|
|
};
|
|
let (tx, ty) = self.towers[k].pos();
|
|
if (tx - e.x).abs() > 1.6 || (ty - e.y).abs() > 1.6 {
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
let t = &mut self.towers[k];
|
|
t.hp = (t.hp + 30.0 * dt).min(t.max_hp());
|
|
if t.hp >= t.max_hp() {
|
|
e.job = Job::None;
|
|
}
|
|
if (e.anim * 3.0).fract() < dt * 3.0 {
|
|
self.sfx(Sfx::Reinforce, tx, ty);
|
|
}
|
|
}
|
|
Job::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;
|
|
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;
|
|
if e.timer >= 1.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
let (tx, ty) = xy(t);
|
|
if let (Kind::Portal, Some(pk)) = (self.map.t[t].kind, self.map.portal_at(tx, ty)) {
|
|
let (px, py) = self.map.portals[pk];
|
|
for y in 0..3 {
|
|
for x in 0..3 {
|
|
self.map.t[idx(px + x, py + y)].owner = Owner::Player;
|
|
}
|
|
}
|
|
self.msg("msg.portal_claimed", vec![]);
|
|
self.fx(7, px as f32 + 1.5, py as f32 + 1.5, 0.0, 0.0, 1.0, 0xffff66cc);
|
|
} else {
|
|
self.map.t[t].owner = Owner::Player;
|
|
}
|
|
self.place_torches(t);
|
|
let (x, y) = xy(t);
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff4040e0);
|
|
self.sfx(Sfx::Claim, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
}
|
|
Job::Deliver => {
|
|
if self.is_deposit(cur) {
|
|
let amount = e.carry as f32;
|
|
let got = self.add_gold(amount);
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.dig_neighbor(i, me).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(t) = self.dig_neighbor(end, me) {
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Dig(t);
|
|
return;
|
|
}
|
|
}
|
|
if e.carry > 0 && self.start_deliver(e) {
|
|
return;
|
|
}
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
self.map.t[end].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Claim(end);
|
|
return;
|
|
}
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(id) = self.damaged_tower_near(end) {
|
|
e.path = p;
|
|
e.job = Job::Repair(id);
|
|
return;
|
|
}
|
|
}
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.reinforce_neighbor(i, me).is_some(), true) {
|
|
let end = p.last().copied().unwrap_or(cur);
|
|
if let Some(w) = self.reinforce_neighbor(end, me) {
|
|
self.map.t[w].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Reinforce(w);
|
|
e.timer = 0.0;
|
|
return;
|
|
}
|
|
}
|
|
e.think = 1.0;
|
|
self.wander(e, 3);
|
|
}
|
|
_ => e.job = Job::None,
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- creatures
|
|
|
|
fn find_room_tile(&self, cur: usize, kind: Kind, me: u32, need_food: bool) -> Option<Vec<usize>> {
|
|
self.map.search(
|
|
cur,
|
|
self.map.walk_cost(),
|
|
|i| {
|
|
let t = &self.map.t[i];
|
|
t.kind == kind && t.owner == Owner::Player && (t.reserved == 0 || t.reserved == me) && (!need_food || t.food >= 1.0)
|
|
},
|
|
true,
|
|
)
|
|
}
|
|
|
|
fn creature_ai(&mut self, e: &mut Ent, dt: f32) {
|
|
let me = e.id + 1;
|
|
e.hunger += dt / 75.0;
|
|
if !matches!(e.job, Job::Sleep(_)) {
|
|
e.energy -= dt / 110.0;
|
|
}
|
|
if e.hunger > 1.8 {
|
|
e.happy -= dt * 0.004;
|
|
}
|
|
if e.kind == EK::Priestess && e.skill_cd <= 0.0 {
|
|
// flame priestess: heals the most hurt own creature nearby (or herself)
|
|
let mut best: Option<usize> = None;
|
|
let mut worst = if e.hp < e.max_hp * 0.8 { e.hp / e.max_hp } else { 0.8 };
|
|
for (j, o) in self.ents.iter().enumerate() {
|
|
if o.alive && !o.held && o.creature() && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 4.5 * 4.5 && o.hp / o.max_hp < worst {
|
|
worst = o.hp / o.max_hp;
|
|
best = Some(j);
|
|
}
|
|
}
|
|
if let Some(j) = best {
|
|
let o = &mut self.ents[j];
|
|
o.hp = (o.hp + 20.0).min(o.max_hp);
|
|
let (ox, oy) = (o.x, o.y);
|
|
self.fx(4, ox, oy, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
self.sfx(Sfx::Heal, ox, oy);
|
|
e.skill_cd = 2.5;
|
|
} else if e.hp < e.max_hp * 0.8 {
|
|
e.hp = (e.hp + 20.0).min(e.max_hp);
|
|
self.fx(4, e.x, e.y, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
e.skill_cd = 2.5;
|
|
} else {
|
|
e.skill_cd = 1.0;
|
|
}
|
|
}
|
|
if e.think <= 0.0 {
|
|
e.think = 0.4 + self.rng.f() * 0.3;
|
|
if !matches!(e.job, Job::Attack(_) | Job::Leave) {
|
|
let radius = if e.job == Job::Rally { 8.5 } else { 7.0 };
|
|
if let Some(id) = self.nearest_enemy(e, radius) {
|
|
self.unreserve(me);
|
|
e.job = Job::Attack(id);
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
}
|
|
}
|
|
}
|
|
if let Job::Attack(id) = e.job {
|
|
if !self.attack_job(e, id, dt) {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
return;
|
|
}
|
|
if e.job == Job::Rally {
|
|
self.rally(e, dt);
|
|
return;
|
|
}
|
|
if self.step_path(e, dt) {
|
|
return;
|
|
}
|
|
let cur = e.tile();
|
|
match e.job {
|
|
Job::Sleep(_) => {
|
|
e.energy += dt / 14.0;
|
|
e.hp = (e.hp + e.max_hp * dt / 25.0).min(e.max_hp);
|
|
if e.energy >= 1.0 {
|
|
e.energy = 1.0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Eat(t) => {
|
|
if cur != t {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
e.timer += dt;
|
|
if e.timer >= 2.0 {
|
|
let tl = &mut self.map.t[t];
|
|
if tl.food >= 1.0 {
|
|
tl.food -= 1.0;
|
|
e.hunger = 0.0;
|
|
e.hp = (e.hp + e.max_hp * 0.2).min(e.max_hp);
|
|
}
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Train(t) => {
|
|
if cur != t || self.map.t[t].kind != Kind::Training || self.gold < 1.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
self.gold -= 1.5 * dt;
|
|
e.xp += 7.0 * dt;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Study(t) => {
|
|
if cur != t || self.map.t[t].kind != Kind::Library {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
let rate = if e.kind == EK::Mage { 1.2 } else { 0.5 };
|
|
self.mana = (self.mana + rate * dt).min(MANA_MAX);
|
|
e.xp += 3.0 * dt;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Pray(t) => {
|
|
if cur != t || self.map.t[t].kind != Kind::Temple {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
let rate = if e.kind == EK::Priestess { 1.6 } else { 0.9 };
|
|
self.mana = (self.mana + rate * dt).min(MANA_MAX);
|
|
e.happy = (e.happy + 0.01 * dt).min(1.0);
|
|
e.xp += 1.0 * dt;
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
self.unreserve(me);
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::HealCore => {
|
|
if !self.map.adj_core(cur) || self.core_hp >= CORE_HP {
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
e.working = true;
|
|
let (cx, cy) = self.map.core;
|
|
e.face = sgn(cx as f32 + 1.5 - e.x);
|
|
self.core_hp = (self.core_hp + 8.0 * dt).min(CORE_HP);
|
|
if (e.anim * 1.5).fract() < dt * 1.5 {
|
|
self.fx(4, cx as f32 + 1.5, cy as f32 + 1.5, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
}
|
|
}
|
|
Job::Wander => {
|
|
e.timer -= dt;
|
|
if e.timer <= 0.0 {
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
Job::Leave => {
|
|
if self.map.t[cur].kind == Kind::Portal {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.8, 0xffcc44ff);
|
|
} else {
|
|
self.start_leave(e);
|
|
}
|
|
}
|
|
Job::None => self.creature_choose(e),
|
|
_ => e.job = Job::None,
|
|
}
|
|
}
|
|
|
|
fn start_leave(&mut self, e: &mut Ent) {
|
|
let cur = e.tile();
|
|
e.job = Job::Leave;
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.t[i].kind == Kind::Portal, true) {
|
|
e.path = p;
|
|
} else {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.8, 0xff505070);
|
|
}
|
|
}
|
|
|
|
fn wants_rally(&self, e: &Ent) -> bool {
|
|
self.flag.is_some() && e.hp > e.max_hp * 0.5 && e.energy > 0.15 && e.hunger < 1.8 && e.happy > 0.0
|
|
}
|
|
|
|
// Attack flag: march to the flag; if the gate is nearby, attack it.
|
|
fn rally(&mut self, e: &mut Ent, dt: f32) {
|
|
let Some(f) = self.flag else {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
return;
|
|
};
|
|
if e.hp < e.max_hp * 0.3 || e.energy < 0.1 || e.hunger > 2.2 {
|
|
// exhausted or badly hurt: retreat
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
return;
|
|
}
|
|
let (fx, fy) = xy(f);
|
|
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
|
|
let (gx, gy) = self.gate_center();
|
|
let gate_near = self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0;
|
|
let s = st(e.kind);
|
|
if gate_near {
|
|
let dg = ((e.x - gx).powi(2) + (e.y - gy).powi(2)).sqrt();
|
|
if dg <= s.range + 1.6 && (s.range < 1.5 || self.los(e.x, e.y, gx, gy)) {
|
|
e.path.clear();
|
|
e.face = sgn(gx - e.x);
|
|
e.working = true;
|
|
if e.cd <= 0.0 {
|
|
self.hit_gate(e);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
let (target, tx, ty, near) = if gate_near {
|
|
(idx(gx as i32, gy as i32), gx, gy, s.range + 1.2)
|
|
} else {
|
|
(f, fx, fy, 1.5)
|
|
};
|
|
let d = ((e.x - tx).powi(2) + (e.y - ty).powi(2)).sqrt();
|
|
if d <= near && e.path.is_empty() {
|
|
return; // wait at the flag
|
|
}
|
|
if e.path.is_empty() || e.repath <= 0.0 {
|
|
e.repath = 1.5;
|
|
match self.map.path_to(e.tile(), target) {
|
|
Some(p) => e.path = p,
|
|
None => {
|
|
self.hint(11, "hint.flag_unreachable");
|
|
e.job = Job::Wander;
|
|
e.timer = 3.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
}
|
|
|
|
pub fn set_flag(&mut self, x: i32, y: i32) {
|
|
if !inb(x, y) {
|
|
return;
|
|
}
|
|
let i = idx(x, y);
|
|
if self.flag == Some(i) {
|
|
self.flag = None;
|
|
self.msg("msg.flag_removed", vec![]);
|
|
self.sfx_ui(Sfx::Click);
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && e.creature() && e.job == Job::Rally {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if !self.map.t[i].walkable() {
|
|
self.hint(12, "hint.flag_ground");
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.flag = Some(i);
|
|
self.sfx(Sfx::FlagSet, x as f32 + 0.5, y as f32 + 0.5);
|
|
self.msg("msg.flag_set", vec![]);
|
|
let mut ids = Vec::new();
|
|
for e in self.ents.iter() {
|
|
if e.alive && !e.held && e.creature() && matches!(e.job, Job::None | Job::Wander | Job::Train(_) | Job::Study(_) | Job::Rally) {
|
|
ids.push(e.id);
|
|
}
|
|
}
|
|
for id in ids {
|
|
self.unreserve(id + 1);
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn creature_choose(&mut self, e: &mut Ent) {
|
|
let me = e.id + 1;
|
|
let cur = e.tile();
|
|
let name = st(e.kind).name;
|
|
if e.happy <= 0.0 {
|
|
self.msg("msg.creature_leaves", vec![format!("@{}", name)]);
|
|
self.start_leave(e);
|
|
return;
|
|
}
|
|
if e.energy < 0.25 {
|
|
if let Some(b) = e.bed {
|
|
let t = &self.map.t[b];
|
|
if t.kind != Kind::Lair || t.bed != me {
|
|
e.bed = None;
|
|
}
|
|
}
|
|
if e.bed.is_none() {
|
|
let found = self.map.search(
|
|
cur,
|
|
self.map.walk_cost(),
|
|
|i| {
|
|
let t = &self.map.t[i];
|
|
t.kind == Kind::Lair && t.bed == 0
|
|
},
|
|
true,
|
|
);
|
|
if let Some(p) = found {
|
|
let b = p.last().copied().unwrap_or(cur);
|
|
self.map.t[b].bed = me;
|
|
e.bed = Some(b);
|
|
}
|
|
}
|
|
if let Some(b) = e.bed {
|
|
if let Some(p) = self.map.path_to(cur, b) {
|
|
e.path = p;
|
|
e.job = Job::Sleep(b);
|
|
return;
|
|
}
|
|
}
|
|
e.happy -= 0.1;
|
|
self.hint(4, "hint.no_beds");
|
|
e.job = Job::Sleep(cur);
|
|
return;
|
|
}
|
|
if e.hunger > 1.0 && !(self.flag.is_some() && e.hunger < 1.5) {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Farm, me, true) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Eat(t);
|
|
e.timer = 0.0;
|
|
return;
|
|
}
|
|
if e.hunger > 1.8 {
|
|
self.hint(5, "hint.hungry");
|
|
}
|
|
}
|
|
if self.wants_rally(e) {
|
|
e.job = Job::Rally;
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
return;
|
|
}
|
|
if e.hp < e.max_hp * 0.5 && e.energy >= 0.25 && self.counts[1] > 0 {
|
|
// hurt: rest in the lair
|
|
e.energy = e.energy.min(0.2);
|
|
self.creature_choose(e);
|
|
return;
|
|
}
|
|
if e.kind == EK::Priestess && self.core_hp < CORE_HP * 0.9 && self.nearest_enemy(e, 8.0).is_none() {
|
|
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.adj_core(i), true) {
|
|
e.path = p;
|
|
e.job = Job::HealCore;
|
|
return;
|
|
}
|
|
}
|
|
if self.counts[6] > 0 {
|
|
let r2 = self.rng.f();
|
|
if r2 < 0.3 || (e.kind == EK::Priestess && r2 < 0.7) {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Temple, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Pray(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
let r = self.rng.f();
|
|
if (e.kind == EK::Mage && r < 0.8) || r < 0.2 {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Library, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Study(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
if r < 0.8 && self.gold >= 5.0 {
|
|
if let Some(p) = self.find_room_tile(cur, Kind::Training, me, false) {
|
|
let t = p.last().copied().unwrap_or(cur);
|
|
self.map.t[t].reserved = me;
|
|
e.path = p;
|
|
e.job = Job::Train(t);
|
|
e.timer = 8.0 + self.rng.f() * 6.0;
|
|
return;
|
|
}
|
|
}
|
|
self.wander(e, 4);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- prisoners
|
|
|
|
fn captive_update(&mut self, e: &mut Ent, dt: f32) {
|
|
e.moving = false;
|
|
match e.captive {
|
|
1 => {
|
|
// unconscious: bleeds out if nobody carries the hero away
|
|
e.torture_t += dt;
|
|
if e.torture_t > 60.0 {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
|
}
|
|
}
|
|
2 => {
|
|
// carried: follows the imp
|
|
match self.find(e.carrier) {
|
|
Some(k) => {
|
|
e.x = self.ents[k].x;
|
|
e.y = self.ents[k].y;
|
|
}
|
|
None => {
|
|
e.captive = 1;
|
|
e.carrier = 0;
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
let t = e.tile();
|
|
if self.map.t[t].kind != Kind::Torture {
|
|
// the chamber is gone: the prisoner breaks free
|
|
e.captive = 0;
|
|
e.hp = e.max_hp * 0.3;
|
|
self.release_all(e.id);
|
|
return;
|
|
}
|
|
e.working = true;
|
|
e.hp -= e.max_hp / 100.0 * dt;
|
|
e.torture_t += dt;
|
|
if e.torture_t >= 5.0 {
|
|
e.torture_t -= 5.0;
|
|
// ~40% defect over the whole torture (paladins ~25%)
|
|
let p = if e.kind == EK::Paladin { 0.0143 } else { 0.0252 };
|
|
if self.rng.f() < p {
|
|
self.defect(e);
|
|
return;
|
|
}
|
|
}
|
|
if e.hp <= 0.0 {
|
|
e.alive = false;
|
|
self.release_all(e.id);
|
|
self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070);
|
|
self.sfx(Sfx::HeroDeath, e.x, e.y);
|
|
self.msg("msg.prisoner_died", vec![format!("@{}", st(e.kind).name)]);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a prisoner joins the player's side
|
|
fn defect(&mut self, e: &mut Ent) {
|
|
self.release_all(e.id);
|
|
e.hero = false;
|
|
e.captive = 0;
|
|
e.carrier = 0;
|
|
e.guard = false;
|
|
e.home = None;
|
|
e.hp = e.max_hp * 0.5;
|
|
e.happy = 0.6;
|
|
e.hunger = 0.5;
|
|
e.energy = 0.4;
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
e.bed = None;
|
|
self.fx(7, e.x, e.y, 0.0, 0.0, 1.0, 0xff4040e0);
|
|
self.sfx(Sfx::Convert, e.x, e.y);
|
|
self.msg("msg.defected", vec![format!("@{}", st(e.kind).name)]);
|
|
}
|
|
|
|
// unconscious hero lying next to tile i that no imp takes care of yet
|
|
fn captive_near(&self, i: usize) -> Option<u32> {
|
|
let (x, y) = xy(i);
|
|
self.ents.iter().find(|o| {
|
|
o.alive && o.hero && o.captive == 1 && o.carrier == 0 && (o.x as i32 - x).abs() <= 1 && (o.y as i32 - y).abs() <= 1
|
|
}).map(|o| o.id)
|
|
}
|
|
|
|
fn free_torture_tile(&self, i: usize) -> bool {
|
|
let t = &self.map.t[i];
|
|
t.kind == Kind::Torture && t.owner == Owner::Player && t.bed == 0
|
|
}
|
|
|
|
fn carry_job(&mut self, e: &mut Ent, id: u32, dt: f32) {
|
|
let Some(k) = self.find(id) else {
|
|
e.job = Job::None;
|
|
return;
|
|
};
|
|
let cur = e.tile();
|
|
match self.ents[k].captive {
|
|
1 => {
|
|
let (cx, cy) = (self.ents[k].x, self.ents[k].y);
|
|
if (cx - e.x).abs() < 1.2 && (cy - e.y).abs() < 1.2 {
|
|
// pick up and look for a free place in the torture chamber
|
|
match self.map.search(cur, self.map.walk_cost(), |i| self.free_torture_tile(i), true) {
|
|
Some(p) => {
|
|
let target = p.last().copied().unwrap_or(cur);
|
|
self.map.t[target].bed = id + 1;
|
|
let c = &mut self.ents[k];
|
|
c.captive = 2;
|
|
c.carrier = e.id;
|
|
e.path = p;
|
|
}
|
|
None => {
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
} else if e.path.is_empty() {
|
|
match self.map.path_to(cur, idx(cx as i32, cy as i32)) {
|
|
Some(p) if !p.is_empty() => e.path = p,
|
|
_ => {
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
2 if self.ents[k].carrier == e.id => {
|
|
if !self.step_path(e, dt) {
|
|
// arrived (or blocked): put the prisoner down on the reserved torture tile
|
|
let target = self.map.t.iter().position(|t| t.bed == id + 1);
|
|
match target {
|
|
Some(t) if t == cur || adj4(t, cur) || t == e.tile() => {
|
|
let (tx, ty) = xy(t);
|
|
let c = &mut self.ents[k];
|
|
c.captive = 3;
|
|
c.carrier = 0;
|
|
c.hp = c.max_hp; // the torture drains it over ~100 s
|
|
c.x = tx as f32 + 0.5;
|
|
c.y = ty as f32 + 0.5;
|
|
c.torture_t = 0.0;
|
|
self.sfx(Sfx::Capture, tx as f32 + 0.5, ty as f32 + 0.5);
|
|
e.job = Job::None;
|
|
}
|
|
Some(t) => {
|
|
if let Some(p) = self.map.path_to(cur, t) {
|
|
e.path = p;
|
|
} else {
|
|
self.ents[k].captive = 1;
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
None => {
|
|
self.ents[k].captive = 1;
|
|
self.ents[k].carrier = 0;
|
|
e.job = Job::None;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
_ => {
|
|
e.job = Job::None;
|
|
return;
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- heroes
|
|
|
|
fn hero_ai(&mut self, e: &mut Ent, dt: f32) {
|
|
if e.think <= 0.0 {
|
|
e.think = 0.5;
|
|
if e.kind == EK::Priest && e.cd <= 0.0 {
|
|
let mut best: Option<usize> = None;
|
|
let mut worst = 0.95;
|
|
for (j, o) in self.ents.iter().enumerate() {
|
|
if o.alive && o.hero {
|
|
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
|
|
let r = o.hp / o.max_hp;
|
|
if d < 16.0 && r < worst {
|
|
worst = r;
|
|
best = Some(j);
|
|
}
|
|
}
|
|
}
|
|
if e.hp / e.max_hp < worst {
|
|
e.hp = (e.hp + 25.0).min(e.max_hp);
|
|
self.fx(4, e.x, e.y, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
e.cd = 2.5;
|
|
} else if let Some(j) = best {
|
|
let o = &mut self.ents[j];
|
|
o.hp = (o.hp + 25.0).min(o.max_hp);
|
|
let (ox, oy) = (o.x, o.y);
|
|
self.fx(4, ox, oy, 0.0, 0.0, 0.8, 0xff60ff80);
|
|
e.cd = 2.5;
|
|
}
|
|
}
|
|
if !matches!(e.job, Job::Attack(_)) {
|
|
let radius = if e.guard { 8.0 } else { 6.5 };
|
|
if let Some(id) = self.nearest_enemy(e, radius) {
|
|
e.job = Job::Attack(id);
|
|
e.path.clear();
|
|
e.repath = 0.0;
|
|
} else if !matches!(e.job, Job::AttackTower(_)) {
|
|
// no unit around: towers within reach are attacked
|
|
let near = self.towers.iter().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::Attack(id) = e.job {
|
|
let (gx, gy) = e.home.unwrap_or_else(|| self.gate_center());
|
|
let far = e.guard && (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.guard {
|
|
// guards stay at their post (gate or treasure cave)
|
|
let (gx, gy) = e.home.unwrap_or_else(|| self.gate_center());
|
|
let d2 = (e.x - gx).powi(2) + (e.y - gy).powi(2);
|
|
if d2 > 2.5 * 2.5 && e.path.is_empty() && e.repath <= 0.0 {
|
|
e.repath = 1.0;
|
|
let target = idx(gx as i32, gy as i32);
|
|
if let Some(p) = self.map.path_to(e.tile(), target) {
|
|
e.path = p;
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
return;
|
|
}
|
|
let cur = e.tile();
|
|
if e.path.is_empty() && self.map.adj_core(cur) {
|
|
e.working = true;
|
|
let (cx, cy) = self.map.core;
|
|
e.face = sgn(cx as f32 + 1.5 - e.x);
|
|
if e.cd <= 0.0 {
|
|
e.cd = st(e.kind).interval;
|
|
self.core_hp -= e.atk();
|
|
let fx = (e.x + cx as f32 + 1.5) * 0.5;
|
|
let fy = (e.y + cy as f32 + 1.5) * 0.5;
|
|
self.fx(2, fx, fy, 0.0, 0.0, 0.3, 0xff4040ff);
|
|
self.sfx(Sfx::CoreHit, fx, fy);
|
|
self.hint(6, "hint.core_attacked");
|
|
}
|
|
return;
|
|
}
|
|
if let Some((wx, wy)) = e.waypoint {
|
|
if (e.x - wx as f32 - 0.5).abs() < 2.5 && (e.y - wy as f32 - 0.5).abs() < 2.5 {
|
|
e.waypoint = None; // broke into the realm: now for the core
|
|
e.path.clear();
|
|
}
|
|
}
|
|
if e.path.is_empty() && e.repath <= 0.0 {
|
|
e.repath = 1.0;
|
|
let path = match e.waypoint {
|
|
Some((wx, wy)) => {
|
|
let goal = idx(wx, wy);
|
|
let p = self.map.search(cur, self.map.hero_cost(), |i| i == goal, true);
|
|
if p.is_none() {
|
|
e.waypoint = None;
|
|
}
|
|
p
|
|
}
|
|
None => self.map.hero_path_to_core(cur),
|
|
};
|
|
if let Some(p) = path {
|
|
e.path = p;
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
}
|
|
|
|
// Wave AI: choose where the next wave breaks into the realm. Candidates are border tiles of
|
|
// the player's territory on the side facing the hero gate; towers, creatures and reinforced
|
|
// walls nearby make a place less attractive, as does a long way and recent use.
|
|
pub fn plan_entries(&mut self) {
|
|
self.planned_entries.clear();
|
|
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
|
|
let (gx, gy) = (self.map.gate.0 + 1, self.map.gate.1 + 1);
|
|
let dir = norm((gx - cx) as f32, (gy - cy) as f32);
|
|
let mut cands = Vec::new();
|
|
for i in 0..self.map.t.len() {
|
|
let t = &self.map.t[i];
|
|
if t.owner != Owner::Player || !t.walkable() {
|
|
continue;
|
|
}
|
|
let (x, y) = xy(i);
|
|
let border = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner != Owner::Player));
|
|
if !border {
|
|
continue;
|
|
}
|
|
let rel = ((x - cx) as f32, (y - cy) as f32);
|
|
let len = (rel.0 * rel.0 + rel.1 * rel.1).sqrt();
|
|
if len < 2.0 || (rel.0 * dir.0 + rel.1 * dir.1) / len < 0.0 {
|
|
continue;
|
|
}
|
|
cands.push((x, y));
|
|
}
|
|
if cands.is_empty() {
|
|
return;
|
|
}
|
|
let step = (cands.len() / 300).max(1);
|
|
let mut scored: Vec<((i32, i32), f32)> = Vec::new();
|
|
for &(x, y) in cands.iter().step_by(step) {
|
|
let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5);
|
|
let near = |px: f32, py: f32, r: f32| (px - fx).powi(2) + (py - fy).powi(2) < r * r;
|
|
let towers: u32 = self.towers.iter().filter(|t| near(t.pos().0, t.pos().1, 6.0)).map(|t| t.level).sum();
|
|
let creatures = self.ents.iter().filter(|e| e.alive && e.creature() && near(e.x, e.y, 6.0)).count();
|
|
let mut reinf = 0;
|
|
for dy in -2..=2 {
|
|
for dx in -2..=2 {
|
|
if self.map.get(x + dx, y + dy).map_or(false, |t| t.reinforced) {
|
|
reinf += 1;
|
|
}
|
|
}
|
|
}
|
|
let dist = (((x - gx).pow(2) + (y - gy).pow(2)) as f32).sqrt();
|
|
let recent: f32 = self.entry_history.iter().rev().take(2).filter(|&&(hx, hy)| ((hx - x).pow(2) + (hy - y).pow(2)) < 100).count() as f32 * -60.0;
|
|
let score = -dist * 0.8 - 25.0 * towers as f32 - 12.0 * creatures as f32 - 6.0 * reinf as f32 + recent + self.rng.f() * 25.0;
|
|
scored.push(((x, y), score));
|
|
}
|
|
scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
|
|
let first = scored[0].0;
|
|
self.planned_entries.push(first);
|
|
// second entry point at least 10 tiles away (used by waves of 6+ heroes)
|
|
if let Some(&(second, _)) = scored.iter().find(|((x, y), _)| (x - first.0).pow(2) + (y - first.1).pow(2) >= 100) {
|
|
self.planned_entries.push(second);
|
|
}
|
|
}
|
|
|
|
// compass direction of a tile as seen from the core (language key)
|
|
pub fn direction_key(&self, (x, y): (i32, i32)) -> &'static str {
|
|
let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1);
|
|
let a = ((y - cy) as f32).atan2((x - cx) as f32).to_degrees(); // 0 = east, 90 = south (map y grows downwards)
|
|
let k = (((a + 360.0 + 22.5) % 360.0) / 45.0) as usize;
|
|
["dir.e", "dir.se", "dir.s", "dir.sw", "dir.w", "dir.nw", "dir.n", "dir.ne"][k % 8]
|
|
}
|
|
|
|
// ---------------------------------------------------------------- player actions
|
|
|
|
pub fn mark_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, on: bool) {
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if inb(x, y) {
|
|
let t = &mut self.map.t[idx(x, y)];
|
|
if t.diggable() {
|
|
t.marked = on;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn build_rect(&mut self, room: usize, x0: i32, y0: i32, x1: i32, y1: i32) {
|
|
let kind = ROOM_KINDS[room];
|
|
let cost = ROOM_COST[room] as f32;
|
|
let mut built = 0;
|
|
let mut broke = false;
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) {
|
|
continue;
|
|
}
|
|
let i = idx(x, y);
|
|
let t = self.map.t[i];
|
|
if t.kind == Kind::Floor && t.owner == Owner::Player {
|
|
if self.gold < cost {
|
|
broke = true;
|
|
continue;
|
|
}
|
|
self.gold -= cost;
|
|
let t = &mut self.map.t[i];
|
|
t.kind = kind;
|
|
t.food = 0.0;
|
|
t.bed = 0;
|
|
built += 1;
|
|
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.5, 0xff40d0ff);
|
|
if built == 1 {
|
|
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if broke {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
}
|
|
if built == 0 && !broke {
|
|
self.hint(7, "hint.build_where");
|
|
}
|
|
self.recount();
|
|
}
|
|
|
|
pub fn sell_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32) {
|
|
let mut refund = 0.0;
|
|
for y in y0.min(y1)..=y0.max(y1) {
|
|
for x in x0.min(x1)..=x0.max(x1) {
|
|
if !inb(x, y) {
|
|
continue;
|
|
}
|
|
let i = idx(x, y);
|
|
if let Some(k) = self.towers.iter().position(|t| t.tile == i) {
|
|
refund += self.towers[k].invested() as f32 * 0.5;
|
|
self.towers.remove(k);
|
|
self.map.t[i].kind = Kind::Floor;
|
|
continue;
|
|
}
|
|
let t = &mut self.map.t[i];
|
|
if t.owner == Owner::Player {
|
|
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.kind != kind {
|
|
self.hint(14, "hint.tower_other");
|
|
return;
|
|
}
|
|
if t.level >= 3 {
|
|
self.hint(15, "hint.tower_max");
|
|
return;
|
|
}
|
|
let cost = s.cost[t.level as usize] as f32;
|
|
if self.gold < cost {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.gold -= cost;
|
|
let t = &mut self.towers[k];
|
|
let old_max = t.max_hp();
|
|
t.level += 1;
|
|
t.hp += t.max_hp() - old_max;
|
|
let (px, py) = t.pos();
|
|
let lvl = t.level;
|
|
self.fx(7, px, py, 0.0, 0.0, 0.8, 0xff44ddff);
|
|
self.sfx(Sfx::LevelUp, px, py);
|
|
self.msg("msg.tower_upgraded", vec![format!("@{}", tower_name(kind)), lvl.to_string()]);
|
|
return;
|
|
}
|
|
let t = self.map.t[i];
|
|
if t.kind != Kind::Floor || t.owner != Owner::Player {
|
|
self.hint(16, "hint.tower_where");
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
let cost = s.cost[0] as f32;
|
|
if self.gold < cost {
|
|
self.msg("msg.no_gold", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.gold -= cost;
|
|
self.map.t[i].kind = Kind::Tower;
|
|
let id = self.next_id;
|
|
self.next_id += 1;
|
|
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0 };
|
|
let (px, py) = tower.pos();
|
|
self.towers.push(tower);
|
|
// units standing on the tile step aside
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && e.tile() == i {
|
|
for &(dx, dy) in &DIR8 {
|
|
if self.map.walk(x + dx, y + dy) {
|
|
e.x = (x + dx) as f32 + 0.5;
|
|
e.y = (y + dy) as f32 + 0.5;
|
|
e.path.clear();
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.fx(8, px, py, 0.0, 0.0, 0.5, 0xff40d0ff);
|
|
self.sfx(Sfx::Build, px, py);
|
|
}
|
|
|
|
fn destroy_tower(&mut self, k: usize) {
|
|
let t = self.towers.remove(k);
|
|
self.map.t[t.tile].kind = Kind::Floor;
|
|
let (px, py) = t.pos();
|
|
self.fx(3, px, py, 0.0, 0.0, 1.0, 0xff505070);
|
|
self.sfx(Sfx::DigDone, px, py);
|
|
self.msg("msg.tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
|
|
}
|
|
|
|
// damage dealt to a unit by a tower (no attacker unit involved)
|
|
fn tower_damage(&mut self, j: usize, dmg: f32) {
|
|
let e = &mut self.ents[j];
|
|
e.hp -= dmg;
|
|
e.flash = 0.15;
|
|
if e.hp <= 0.0 && e.alive {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
|
|
fn tower_target(&self, t: &Tower) -> Option<usize> {
|
|
let (px, py) = t.pos();
|
|
let r = t.range();
|
|
let minr = tower_stats(t.kind).min_range;
|
|
let mut best = None;
|
|
let mut bd = r * r;
|
|
for (j, e) in self.ents.iter().enumerate() {
|
|
if !e.alive || e.held || !e.active_hero() {
|
|
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
|
|
}
|
|
|
|
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 { continue };
|
|
let s = tower_stats(t.kind);
|
|
if s.mana > 0.0 && self.mana < s.mana {
|
|
self.hint(17, "hint.tower_mana");
|
|
self.towers[k].cd = 1.0;
|
|
continue;
|
|
}
|
|
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);
|
|
self.towers[k].aim = (ey - py).atan2(ex - px);
|
|
let dmg = t.dmg();
|
|
match t.kind {
|
|
TowerKind::Arrow => {
|
|
// the arrow pierces up to two more heroes behind the target
|
|
let dir = norm(ex - px, ey - py);
|
|
let end = (px + dir.0 * t.range(), py + dir.1 * t.range());
|
|
self.fx(0, px, py - 0.6, end.0, end.1, 0.3, 0xffd0e0f0);
|
|
self.sfx(Sfx::Shoot, px, py);
|
|
let mut hits = vec![(j, dmg)];
|
|
for (o, e) in self.ents.iter().enumerate() {
|
|
if o == j || !e.alive || e.held || !e.active_hero() || hits.len() >= 3 {
|
|
continue;
|
|
}
|
|
let rel = (e.x - px, e.y - py);
|
|
let along = rel.0 * dir.0 + rel.1 * dir.1;
|
|
let side = (rel.0 * dir.1 - rel.1 * dir.0).abs();
|
|
if along > 0.0 && along <= t.range() && side < 0.45 {
|
|
hits.push((o, dmg * 0.6));
|
|
}
|
|
}
|
|
for (o, d) in hits {
|
|
if self.ents[o].alive {
|
|
self.tower_damage(o, d);
|
|
}
|
|
}
|
|
}
|
|
TowerKind::Cannon => {
|
|
// splash damage and knockback around the impact
|
|
self.fx(0, px, py - 0.8, ex, ey, 0.35, 0xff303030);
|
|
self.fx(3, ex, ey, 0.0, 0.0, 0.6, 0xff4070a0);
|
|
self.fx(2, ex, ey - 0.2, 0.0, 0.0, 0.3, 0xff60c0ff);
|
|
self.sfx(Sfx::Cannon, px, py);
|
|
let mut hits = Vec::new();
|
|
for (o, e) in self.ents.iter().enumerate() {
|
|
if !e.alive || e.held || !e.active_hero() {
|
|
continue;
|
|
}
|
|
let d = ((e.x - ex).powi(2) + (e.y - ey).powi(2)).sqrt();
|
|
if d < 1.6 {
|
|
hits.push((o, dmg * (1.0 - d / 2.0), d));
|
|
}
|
|
}
|
|
for (o, d, dist) in hits {
|
|
if !self.ents[o].alive {
|
|
continue;
|
|
}
|
|
// knockback away from the impact, only onto walkable ground
|
|
let e = &self.ents[o];
|
|
let away = if dist > 0.05 { norm(e.x - ex, e.y - ey) } else { norm(e.x - px, e.y - py) };
|
|
let (nx, ny) = (e.x + away.0 * 0.7, e.y + away.1 * 0.7);
|
|
if self.map.walk(nx as i32, ny as i32) {
|
|
let e = &mut self.ents[o];
|
|
e.x = nx;
|
|
e.y = ny;
|
|
e.path.clear();
|
|
}
|
|
self.tower_damage(o, d);
|
|
}
|
|
}
|
|
TowerKind::Magic => {
|
|
// sure hit, slows, jumps to a second hero
|
|
self.fx(0, px, py - 1.0, ex, ey, 0.2, 0xffff60d0);
|
|
self.sfx(Sfx::Magic, px, py);
|
|
self.ents[j].slow = 2.0;
|
|
let chain = self
|
|
.ents
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(o, e)| *o != j && e.alive && !e.held && e.active_hero() && (e.x - ex).powi(2) + (e.y - ey).powi(2) < 9.0)
|
|
.min_by(|a, b| {
|
|
let da = (a.1.x - ex).powi(2) + (a.1.y - ey).powi(2);
|
|
let db = (b.1.x - ex).powi(2) + (b.1.y - ey).powi(2);
|
|
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
|
|
})
|
|
.map(|(o, _)| o);
|
|
self.tower_damage(j, dmg);
|
|
if let Some(o) = chain {
|
|
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
|
|
self.fx(0, ex, ey, ox, oy, 0.2, 0xffff60d0);
|
|
self.ents[o].slow = 2.0;
|
|
if self.ents[o].alive {
|
|
self.tower_damage(o, dmg * 0.6);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// a hero attacks a tower; false = tower gone or unreachable
|
|
fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
|
|
let Some(k) = self.towers.iter().position(|t| t.id == id) else { return false };
|
|
let (tx, ty) = self.towers[k].pos();
|
|
let s = st(e.kind);
|
|
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
|
|
if d <= s.range + 0.75 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
|
|
e.path.clear();
|
|
e.face = sgn(tx - e.x);
|
|
e.working = true;
|
|
if e.cd <= 0.0 {
|
|
e.cd = s.interval;
|
|
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
|
|
if s.range > 1.5 {
|
|
self.fx(0, e.x, e.y - 0.3, tx, ty - 0.5, 0.25, 0xff70b0d0);
|
|
} else {
|
|
self.fx(2, tx, ty - 0.4, 0.0, 0.0, 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) {
|
|
Some(p) => e.path = p,
|
|
None => return false,
|
|
}
|
|
}
|
|
self.step_path(e, dt);
|
|
true
|
|
}
|
|
|
|
pub fn pickup(&mut self, target: Option<usize>) -> bool {
|
|
if self.hand.len() >= 8 {
|
|
self.hint(8, "hint.hand_full");
|
|
return false;
|
|
}
|
|
if let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held && !self.ents[j].hero) {
|
|
let id = self.ents[j].id;
|
|
let me = id + 1;
|
|
self.unreserve(me);
|
|
for o in self.ents.iter_mut() {
|
|
if o.carrier == id && o.captive != 0 {
|
|
o.captive = 1;
|
|
o.carrier = 0;
|
|
}
|
|
}
|
|
let e = &mut self.ents[j];
|
|
e.held = true;
|
|
e.job = Job::None;
|
|
e.path.clear();
|
|
self.hand.push(id);
|
|
self.sfx_ui(Sfx::Pickup);
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
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 t = self.map.t[idx(x, y)];
|
|
if !t.walkable() || t.owner != Owner::Player {
|
|
self.hint(9, "hint.drop_where");
|
|
return false;
|
|
}
|
|
self.hand.pop();
|
|
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
|
|
e.held = false;
|
|
e.x = wx;
|
|
e.y = wy;
|
|
e.job = Job::None;
|
|
e.think = 0.0;
|
|
e.path.clear();
|
|
}
|
|
self.fx(3, wx, wy, 0.0, 0.0, 0.4, 0xff707090);
|
|
self.sfx(Sfx::Drop, wx, wy);
|
|
true
|
|
}
|
|
|
|
pub fn lightning(&mut self, wx: f32, wy: f32) {
|
|
const COST: f32 = 50.0;
|
|
if self.mana < COST {
|
|
self.msg("msg.no_mana_bolt", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= COST;
|
|
self.sfx(Sfx::Lightning, wx, wy);
|
|
self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa);
|
|
let mut kills = Vec::new();
|
|
for (j, e) in self.ents.iter_mut().enumerate() {
|
|
if e.alive && !e.held && e.active_hero() && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 2.6 {
|
|
e.hp -= 55.0;
|
|
e.flash = 0.2;
|
|
if e.hp <= 0.0 {
|
|
kills.push(j);
|
|
}
|
|
}
|
|
}
|
|
for j in kills {
|
|
self.kill(j);
|
|
}
|
|
}
|
|
|
|
pub fn heal(&mut self, wx: f32, wy: f32) {
|
|
const COST: f32 = 60.0;
|
|
if self.mana < COST {
|
|
self.msg("msg.no_mana_heal", vec![]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= COST;
|
|
self.sfx(Sfx::Heal, wx, wy);
|
|
let mut fxs = Vec::new();
|
|
for e in self.ents.iter_mut() {
|
|
if e.alive && !e.held && !e.hero && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 6.5 {
|
|
e.hp = (e.hp + e.max_hp * 0.5).min(e.max_hp);
|
|
fxs.push((e.x, e.y));
|
|
}
|
|
}
|
|
self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xff60ff80);
|
|
for (x, y) in fxs {
|
|
self.fx(4, x, y, 0.0, 0.0, 0.9, 0xff60ff80);
|
|
}
|
|
}
|
|
|
|
pub fn summon(&mut self, wx: f32, wy: f32) {
|
|
let cost = self.summon_cost();
|
|
let (x, y) = (wx as i32, wy as i32);
|
|
if !self.map.walk(x, y) || self.map.t[idx(x, y)].owner != Owner::Player {
|
|
self.hint(13, "hint.summon_where");
|
|
return;
|
|
}
|
|
if self.mana < cost {
|
|
self.msg("msg.no_mana_cost", vec![(cost as i32).to_string()]);
|
|
self.sfx_ui(Sfx::Error);
|
|
return;
|
|
}
|
|
self.mana -= cost;
|
|
self.sfx(Sfx::Summon, wx, wy);
|
|
self.spawn(EK::Worker, false, wx, wy, 1);
|
|
self.fx(7, wx, wy, 0.0, 0.0, 0.6, 0xffff66cc);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ defence towers
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
|
|
pub enum TowerKind {
|
|
Arrow,
|
|
Cannon,
|
|
Magic,
|
|
}
|
|
|
|
pub struct TowerStats {
|
|
pub cost: [i32; 3], // build, upgrade to level 2, upgrade to level 3
|
|
pub hp: f32,
|
|
pub dmg: f32,
|
|
pub range: f32,
|
|
pub min_range: f32,
|
|
pub interval: f32,
|
|
pub mana: f32, // per shot
|
|
}
|
|
|
|
pub fn tower_stats(k: TowerKind) -> TowerStats {
|
|
match k {
|
|
TowerKind::Arrow => TowerStats { cost: [250, 200, 300], hp: 300.0, dmg: 10.0, range: 6.0, min_range: 0.0, interval: 0.8, mana: 0.0 },
|
|
TowerKind::Cannon => TowerStats { cost: [450, 350, 500], hp: 450.0, dmg: 32.0, range: 8.5, min_range: 2.0, interval: 2.6, mana: 0.0 },
|
|
TowerKind::Magic => TowerStats { cost: [350, 300, 450], hp: 300.0, dmg: 24.0, range: 7.0, min_range: 0.0, interval: 1.5, mana: 8.0 },
|
|
}
|
|
}
|
|
|
|
pub fn tower_name(k: TowerKind) -> &'static str {
|
|
match k {
|
|
TowerKind::Arrow => "tower.arrow",
|
|
TowerKind::Cannon => "tower.cannon",
|
|
TowerKind::Magic => "tower.magic",
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct Tower {
|
|
pub id: u32,
|
|
pub kind: TowerKind,
|
|
pub tile: usize,
|
|
pub level: u32,
|
|
pub hp: f32,
|
|
pub cd: f32,
|
|
pub aim: f32, // direction of the last shot (radians, for drawing)
|
|
pub flash: f32,
|
|
}
|
|
|
|
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)
|
|
}
|
|
// 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 => "job.none",
|
|
Job::Dig(_) => "job.dig",
|
|
Job::Claim(_) => "job.claim",
|
|
Job::Deliver => "job.deliver",
|
|
Job::Wander => "job.wander",
|
|
Job::Sleep(_) => {
|
|
if e.moving {
|
|
"job.sleep_go"
|
|
} else {
|
|
"job.sleep"
|
|
}
|
|
}
|
|
Job::Eat(_) => "job.eat",
|
|
Job::Train(_) => "job.train",
|
|
Job::Study(_) => "job.study",
|
|
Job::Attack(_) => "job.fight",
|
|
Job::AttackCore => {
|
|
if e.working {
|
|
"job.core_attack"
|
|
} else {
|
|
"job.core_march"
|
|
}
|
|
}
|
|
Job::Leave => "job.leave",
|
|
Job::Reinforce(_) => "job.reinforce",
|
|
Job::Repair(_) => "job.repair",
|
|
Job::Carry(_) => "job.carry",
|
|
Job::Pray(_) => "job.pray",
|
|
Job::HealCore => "job.heal_core",
|
|
Job::AttackTower(_) => "job.attack_tower",
|
|
Job::Rally => {
|
|
if e.working {
|
|
"job.gate_attack"
|
|
} else {
|
|
"job.rally"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// Simulates games with a simple bot player and checks that nothing crashes.
|
|
#[test]
|
|
fn simulate_long_game() {
|
|
for seed in [1u32, 7, 12345] {
|
|
let mut g = Game::new(seed, 64, 64);
|
|
let (cx, cy) = g.map.core;
|
|
let (px, py) = g.map.portals[0];
|
|
// dig a way to the portal (through earth/gold, around rock)
|
|
let start = idx(cx - 1, cy + 1);
|
|
let goal = idx(px + 1, py + 1);
|
|
let path = g
|
|
.map
|
|
.search(start, |i| if g.map.t[i].kind == Kind::Rock { None } else { Some(1) }, |i| i == goal, false)
|
|
.expect("Portal erreichbar");
|
|
for i in path {
|
|
if g.map.t[i].diggable() {
|
|
g.map.t[i].marked = true;
|
|
}
|
|
}
|
|
g.mark_rect(cx - 6, cy - 6, cx + 9, cy + 9, true);
|
|
let (ox, oy) = (cx - 3, cy - 3);
|
|
g.build_rect(1, ox, oy, ox + 2, oy + 1); // lair 3x2
|
|
g.build_rect(2, ox + 5, oy, ox + 7, oy + 1); // farm 3x2
|
|
let dt = 0.05;
|
|
let mut built = false;
|
|
let mut portal_logged = false;
|
|
for step in 0..(40 * 60 * 20) {
|
|
g.update(dt);
|
|
g.events.clear();
|
|
g.msgs.clear();
|
|
if step % 200 == 0 && !built && g.gold > 1900.0 {
|
|
g.build_rect(0, ox, oy + 6, ox + 1, oy + 8); // treasury
|
|
g.build_rect(3, ox + 6, oy + 6, ox + 8, oy + 8); // training hall
|
|
g.build_rect(1, ox, oy + 2, ox + 2, oy + 2); // enlarge the lair
|
|
built = true;
|
|
}
|
|
// the bot uses lightning against heroes at the core
|
|
if step % 20 == 0 && g.mana >= 50.0 {
|
|
let target = g.ents.iter().find(|e| e.alive && e.hero && (e.x - cx as f32).abs() < 4.0 && (e.y - cy as f32).abs() < 4.0).map(|e| (e.x, e.y));
|
|
if let Some((x, y)) = target {
|
|
g.lightning(x, y);
|
|
}
|
|
}
|
|
if step == 20 * 60 * 20 {
|
|
let (gx, gy) = g.map.gate;
|
|
g.set_flag(gx + 1, gy + 1);
|
|
}
|
|
if step % 2400 == 0 {
|
|
println!(
|
|
"seed {} t={:>5.0}s gold={:>5.0} creatures={} workers={} wave={} core={:.0} gate={:.0} heroes={} army={:.0}",
|
|
seed,
|
|
g.time,
|
|
g.gold,
|
|
g.creature_count(),
|
|
g.worker_count(),
|
|
g.wave,
|
|
g.core_hp,
|
|
g.gate_hp,
|
|
g.ents.iter().filter(|e| e.alive && e.hero).count(),
|
|
g.army_value()
|
|
);
|
|
}
|
|
if g.portals_owned() > 0 && step % 20 == 0 && !portal_logged {
|
|
portal_logged = true;
|
|
println!("seed {} portal claimed after {:.0}s", seed, g.time);
|
|
}
|
|
if g.state != 0 {
|
|
println!("seed {} game over after {:.0}s, state={}", seed, g.time, g.state);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn imps_reinforce_walls() {
|
|
let mut g = Game::new(3, 64, 64);
|
|
for _ in 0..(120 * 20) {
|
|
g.update(0.05);
|
|
}
|
|
let n = g.map.t.iter().filter(|t| t.reinforced).count();
|
|
println!("reinforced walls after 2 minutes: {}", n);
|
|
assert!(n >= 10);
|
|
// heroes avoid them: the hero path cost through reinforced earth is 10x
|
|
let (cx, cy) = g.map.core;
|
|
let w = (cx - 4, cy);
|
|
if g.map.t[idx(w.0, w.1)].reinforced {
|
|
assert_eq!(g.map.hero_cost()(idx(w.0, w.1)), Some(70));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn towers_shoot_and_get_attacked() {
|
|
for kind in [TowerKind::Arrow, TowerKind::Cannon, TowerKind::Magic] {
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.gold = 2000.0;
|
|
g.mana = 400.0;
|
|
let (cx, cy) = g.map.core;
|
|
let (tx, ty) = (cx - 2, cy - 2);
|
|
g.build_tower(kind, tx, ty);
|
|
assert_eq!(g.towers.len(), 1);
|
|
g.build_tower(kind, tx, ty); // upgrade
|
|
assert_eq!(g.towers[0].level, 2);
|
|
// a hero inside the start area, 4 tiles away
|
|
let id = g.spawn(EK::Knight, true, (cx + 3) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
|
|
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
|
|
let mut hurt = false;
|
|
let mut tower_hit = false;
|
|
for _ in 0..400 {
|
|
g.update(0.05);
|
|
if let Some(e) = g.ents.iter().find(|e| e.id == id) {
|
|
hurt |= e.hp < hp0;
|
|
} else {
|
|
hurt = true;
|
|
}
|
|
tower_hit |= g.towers.first().map_or(true, |t| t.hp < t.max_hp());
|
|
}
|
|
println!("{:?}: hero hurt {}, tower attacked {}", kind, hurt, tower_hit);
|
|
assert!(hurt, "{:?} did not damage the hero", kind);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn heroes_attack_towers() {
|
|
let mut g = Game::new(5, 64, 64);
|
|
g.gold = 2000.0;
|
|
for e in g.ents.iter_mut() {
|
|
e.alive = false; // no imps: the tower is the only target
|
|
}
|
|
let (cx, cy) = g.map.core;
|
|
g.build_tower(TowerKind::Cannon, cx - 2, cy - 2);
|
|
g.spawn(EK::Ogre, true, (cx - 2) as f32 + 0.5, (cy + 4) as f32 + 0.5, 5);
|
|
for _ in 0..600 {
|
|
g.update(0.05);
|
|
}
|
|
let t = g.towers.first();
|
|
println!("tower after attack: {:?}", t.map(|t| t.hp));
|
|
assert!(t.map_or(true, |t| t.hp < t.max_hp()));
|
|
}
|
|
|
|
#[test]
|
|
fn prisoners_are_tortured_and_resolved() {
|
|
let mut converted = 0;
|
|
let mut died = 0;
|
|
for seed in 1..=40u32 {
|
|
let mut g = Game::new(seed, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1); // torture chamber 2x5 = 5 places... 10 tiles
|
|
assert!(g.prison_space() >= 2, "space {}", g.prison_space());
|
|
let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1);
|
|
let j = g.ents.iter().position(|e| e.id == id).unwrap();
|
|
g.kill(j);
|
|
assert_eq!(g.ents[j].captive, 1);
|
|
let mut reached = false;
|
|
for _ in 0..(200 * 20) {
|
|
g.update(0.05);
|
|
match g.ents.iter().find(|e| e.id == id) {
|
|
Some(e) if e.captive == 3 => reached = true,
|
|
Some(e) if !e.hero => {
|
|
converted += 1;
|
|
break;
|
|
}
|
|
None => {
|
|
died += 1;
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
assert!(reached, "seed {}: prisoner never reached the torture chamber", seed);
|
|
}
|
|
println!("40 prisoners: {} defected, {} died", converted, died);
|
|
assert_eq!(converted + died, 40);
|
|
assert!((8..=26).contains(&converted), "defect rate off: {}/40", converted);
|
|
}
|
|
|
|
#[test]
|
|
fn temple_prayer_and_priestess_heals_core() {
|
|
let mut g = Game::new(9, 64, 64);
|
|
g.gold = 5000.0;
|
|
let (cx, cy) = g.map.core;
|
|
g.build_rect(6, cx - 3, cy - 3, cx - 2, cy + 1); // temple
|
|
g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 1); // lair
|
|
g.build_rect(2, cx + 4, cy + 3, cx + 5, cy + 5); // farm
|
|
g.spawn(EK::Priestess, false, (cx + 4) as f32 + 0.5, (cy + 2) as f32 + 0.5, 1);
|
|
for k in 0..3 {
|
|
g.spawn(EK::Kobold, false, (cx - 1) as f32 + 0.5, (cy + k) as f32 + 0.5, 1);
|
|
}
|
|
g.core_hp = 1000.0;
|
|
let mut prayed = false;
|
|
for _ in 0..(120 * 20) {
|
|
g.update(0.05);
|
|
prayed |= g.ents.iter().any(|e| matches!(e.job, Job::Pray(_)) && e.working);
|
|
}
|
|
println!("core after 2 min: {:.0}, prayed: {}", g.core_hp, prayed);
|
|
assert!(prayed);
|
|
assert!(g.core_hp > 1300.0);
|
|
}
|
|
|
|
#[test]
|
|
fn waves_vary_their_entry_points() {
|
|
let mut g = Game::new(11, 96, 96);
|
|
let (cx, cy) = g.map.core;
|
|
// a bigger realm so there are several possible entry points
|
|
for y in cy - 8..=cy + 10 {
|
|
for x in cx - 6..=cx + 14 {
|
|
if inb(x, y) && g.map.t[idx(x, y)].kind != Kind::Core && g.map.t[idx(x, y)].kind != Kind::Rock {
|
|
let t = &mut g.map.t[idx(x, y)];
|
|
t.kind = Kind::Floor;
|
|
t.owner = Owner::Player;
|
|
}
|
|
}
|
|
}
|
|
let mut seen = Vec::new();
|
|
for _ in 0..4 {
|
|
g.plan_entries();
|
|
let e = g.planned_entries[0];
|
|
println!("entry {:?} ({})", e, g.direction_key(e));
|
|
seen.push(e);
|
|
g.entry_history.push(e);
|
|
}
|
|
// consecutive waves use different places
|
|
for w in seen.windows(2) {
|
|
assert!((w[0].0 - w[1].0).pow(2) + (w[0].1 - w[1].1).pow(2) >= 16, "same entry twice: {:?}", w);
|
|
}
|
|
// spawning a big wave gives heroes two different waypoints
|
|
g.warned = true;
|
|
g.planned_entries.clear();
|
|
g.plan_entries();
|
|
assert_eq!(g.planned_entries.len(), 2);
|
|
}
|
|
}
|