diff --git a/lang/de.lang b/lang/de.lang index a656711..61b36ab 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -414,3 +414,12 @@ treasury.content = Enthält {0} in {1} hud.payday = Zahltag msg.payday_soon = Zahltag in {0} Sekunden: Deine Kreaturen wollen {1} Gold. hint.payday_short = Für den nächsten Zahltag reicht das Gold nicht. Unbezahlte Kreaturen werden wütend! + +# knight fortresses +msg.enemy_tower_destroyed = Ein Ritter-{0} ist gefallen! +msg.vault_taken = Ritterschatz erobert: Schätze im Wert von {0} gehören dir. +msg.shard_taken = Ein Splitter deines Seelenkerns ist wieder in deiner Hand! +msg.bridge_torn = Die Ritter haben eine deiner Brücken eingerissen! +job.tear_bridge = Reißt eine Brücke ein +job.patrol = Auf Patrouille +job.guard = Hält Wache diff --git a/lang/en.lang b/lang/en.lang index bab6857..191d577 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -415,3 +415,12 @@ treasury.content = Holds {0} worth of {1} hud.payday = Payday msg.payday_soon = Payday in {0} seconds: your creatures want {1} gold. hint.payday_short = There is not enough gold for the coming payday. Unpaid creatures get angry! + +# knight fortresses +msg.enemy_tower_destroyed = A knight {0} has fallen! +msg.vault_taken = Knight vault taken: {0} worth of treasure is yours. +msg.shard_taken = A shard of your soul core is back in your hands! +msg.bridge_torn = The knights have torn down one of your bridges! +job.tear_bridge = Tearing down a bridge +job.patrol = On patrol +job.guard = On guard diff --git a/src/fortress.rs b/src/fortress.rs new file mode 100644 index 0000000..de7082f --- /dev/null +++ b/src/fortress.rs @@ -0,0 +1,176 @@ +// Knight fortresses (campaign): an outer wall of fortress masonry with doors, a courtyard of +// knight ground, corner towers, vault chambers full of treasure, optionally a shard chamber or +// the gate of the order (the fortress heart), guards at their posts and patrols on their rounds. + +use crate::game::*; +use crate::map::*; + +#[derive(Clone, Debug)] +pub struct FortSpec { + pub x: i32, // top-left corner of the outer wall + pub y: i32, + pub w: i32, // outer size (at least 13 x 13) + pub h: i32, + pub doors: Vec, // sides with a door: index into DIR4 (0 north, 1 east, 2 south, 3 west) + pub towers: Vec, // placed in the inner corners, then along the walls + pub tower_level: u32, + pub guards: Vec<(EK, u32)>, // kind, level + pub patrols: Vec>, // groups walking around the courtyard + pub vaults: u32, // number of vault chambers + pub vault_value: i32, // treasure per vault chamber + pub shard: bool, // shard chamber in the middle + pub heart: bool, // the gate of the order in the middle (map.gate) +} + +pub const FORT_WALL_HP: f32 = 30.0; + +impl Game { + fn fort_tile(&mut self, x: i32, y: i32, kind: Kind) { + if !inb(x, y) || x < 1 || y < 1 || x >= mw() as i32 - 1 || y >= mh() as i32 - 1 { + return; + } + let t = &mut self.map.t[idx(x, y)]; + t.kind = kind; + t.owner = Owner::Hero; + t.gold = 0; + t.marked = false; + t.reinforced = kind == Kind::Earth; + t.hp = if kind == Kind::Earth { FORT_WALL_HP } else { 0.0 }; + } + + // walls around a rectangle (inclusive), floor inside + fn fort_box(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, inside: Kind) { + for y in y0..=y1 { + for x in x0..=x1 { + let edge = x == x0 || x == x1 || y == y0 || y == y1; + self.fort_tile(x, y, if edge { Kind::Earth } else { inside }); + } + } + } + + fn fort_hero(&mut self, kind: EK, level: u32, x: f32, y: f32) -> usize { + let id = self.spawn(kind, true, x, y, level.max(1)); + self.ents.iter().position(|e| e.id == id).unwrap() + } + + pub fn build_fortress(&mut self, f: &FortSpec) { + let (x0, y0, x1, y1) = (f.x, f.y, f.x + f.w - 1, f.y + f.h - 1); + let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2); + self.fort_box(x0, y0, x1, y1, Kind::Floor); + // doors: three tiles wide in the middle of a side + for &d in &f.doors { + for k in -1..=1 { + let (x, y) = match d { + 0 => (cx + k, y0), + 1 => (x1, cy + k), + 2 => (cx + k, y1), + _ => (x0, cy + k), + }; + self.fort_tile(x, y, Kind::Floor); + // the way out of the door is dug free + let (dx, dy) = DIR4[d]; + for s in 1..=2 { + let (ox, oy) = (x + dx * s, y + dy * s); + if let Some(t) = self.map.get(ox, oy) { + if t.solid() && t.kind != Kind::Rock { + let t = &mut self.map.t[idx(ox, oy)]; + t.kind = Kind::Floor; + t.owner = Owner::None; + t.gold = 0; + } + } + } + } + } + // centre: shard chamber and/or gate of the order + if f.heart { + for y in cy - 1..=cy + 1 { + for x in cx - 1..=cx + 1 { + self.fort_tile(x, y, Kind::Gate); + } + } + self.map.gate = (cx - 1, cy - 1); + } + if f.shard { + // a walled chamber with one door (below the gate if both exist) + let sy = if f.heart { cy + 3 } else { cy }; + self.fort_box(cx - 2, sy - 2, cx + 2, sy + 2, Kind::Floor); + self.fort_tile(cx, sy + 2, Kind::Floor); + self.fort_tile(cx, sy, Kind::Shard); + } + // vault chambers in the corners: 3 x 3 treasury behind a wall with a door + let corners = [(x0 + 1, y0 + 1, 1), (x1 - 5, y0 + 1, -1), (x0 + 1, y1 - 5, 1), (x1 - 5, y1 - 5, -1)]; + for &(vx, vy, sx) in corners.iter().take(f.vaults as usize) { + self.fort_box(vx, vy, vx + 4, vy + 4, Kind::Treasury); + // door towards the middle of the courtyard + self.fort_tile(if sx > 0 { vx + 4 } else { vx }, vy + 2, Kind::Floor); + // treasure: mostly gold, some silver, rubies, now and then diamonds + let per = (f.vault_value / 9).max(1); + for y in vy + 1..=vy + 3 { + for x in vx + 1..=vx + 3 { + let r = self.rng.f(); + let res = if r < 0.08 { 3 } else if r < 0.25 { 2 } else if r < 0.45 { 1 } else { 0 }; + let t = &mut self.map.t[idx(x, y)]; + t.gold = per; + t.res = res; + } + } + } + // towers: inner corners of the courtyard first, then along the walls + let mut spots = vec![(x0 + 2, y0 + 2), (x1 - 2, y0 + 2), (x0 + 2, y1 - 2), (x1 - 2, y1 - 2), (cx, y0 + 2), (cx, y1 - 2), (x0 + 2, cy), (x1 - 2, cy)]; + if f.vaults > 0 { + // vault chambers take the corners: towers stand in front of them + for s in spots.iter_mut().take(4) { + s.0 += if s.0 < cx { 5 } else { -5 }; + } + } + for (k, &kind) in f.towers.iter().enumerate() { + let Some(&(tx, ty)) = spots.get(k) else { break }; + if self.map.get(tx, ty).map_or(true, |t| t.kind != Kind::Floor) { + continue; + } + let i = idx(tx, ty); + self.map.t[i].kind = Kind::Tower; + let id = self.next_id; + self.next_id += 1; + let s = tower_stats(kind); + let mut tw = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: true }; + while tw.level < f.tower_level.min(3) { + let old = tw.max_hp(); + tw.level += 1; + tw.hp += tw.max_hp() - old; + } + self.towers.push(tw); + } + // guards: posts spread over the courtyard + let n = f.guards.len().max(1) as f32; + for (k, &(kind, level)) in f.guards.iter().enumerate() { + let a = k as f32 / n * std::f32::consts::TAU; + let (rx, ry) = ((f.w as f32 * 0.5 - 3.5).max(1.0), (f.h as f32 * 0.5 - 3.5).max(1.0)); + let (mut hx, mut hy) = (cx as f32 + 0.5 + a.cos() * rx * 0.6, cy as f32 + 0.5 + a.sin() * ry * 0.6); + if !self.map.walk(hx as i32, hy as i32) { + (hx, hy) = (cx as f32 + 0.5, (y0 + 2) as f32 + 0.5); + } + let j = self.fort_hero(kind, level, hx, hy); + let e = &mut self.ents[j]; + e.guard = true; + e.home = Some((hx, hy)); + } + // patrols: round through the courtyard along the inside of the walls + let route = vec![(x0 + 2, cy), (cx, y0 + 2), (x1 - 2, cy), (cx, y1 - 2)]; + let route: Vec<(i32, i32)> = route.into_iter().filter(|&(x, y)| self.map.walk(x, y)).collect(); + for (g, group) in f.patrols.iter().enumerate() { + if route.is_empty() { + break; + } + let start = (g * 2) % route.len(); + let (sx, sy) = route[start]; + for (k, &(kind, level)) in group.iter().enumerate() { + let j = self.fort_hero(kind, level, sx as f32 + 0.5 + (k as f32 - 0.5) * 0.4, sy as f32 + 0.5); + let e = &mut self.ents[j]; + e.patrol = route.clone(); + e.patrol_k = (start + 1) % route.len(); + } + } + } +} diff --git a/src/game.rs b/src/game.rs index 4a67c19..491d624 100644 --- a/src/game.rs +++ b/src/game.rs @@ -153,6 +153,7 @@ pub enum Job { 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 } #[derive(Clone, Serialize, Deserialize)] @@ -205,6 +206,10 @@ pub struct Ent { pub guard: bool, // stays at its home (gate defenders, treasure guards) #[serde(default)] pub home: Option<(f32, f32)>, // guard post + #[serde(default)] + pub patrol: Vec<(i32, i32)>, // fortress patrol: route of tiles, walked in a loop + #[serde(default)] + pub patrol_k: usize, // next point of the route } impl Ent { @@ -248,6 +253,8 @@ impl Ent { stun: 0.0, guard: false, home: None, + patrol: Vec::new(), + patrol_k: 0, } } pub fn tile(&self) -> usize { @@ -326,6 +333,14 @@ pub struct Game { 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, // tiles next to fighting heroes: knight ground there cannot be claimed #[serde(skip)] pub hints: bool, // show hint messages (setting) #[serde(skip, default = "hint_times")] @@ -426,6 +441,10 @@ impl Game { incoming: Vec::new(), training_bill: 0.0, payday_warned: false, + loot: 0.0, + shards_taken: 0, + towers_razed: 0, + contested: Vec::new(), }; let (cx, cy) = g.map.core; // the soul core starts surrounded by a one tile wide ring of treasury @@ -504,7 +523,7 @@ impl Game { } } // towers count with half their combat value - for t in &self.towers { + 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; } @@ -799,6 +818,7 @@ impl Game { self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP); } + self.update_contested(); for i in 0..self.ents.len() { if !self.ents[i].alive || self.ents[i].held { continue; @@ -1387,6 +1407,7 @@ impl Game { e.path.clear(); e.guard = false; e.home = None; + e.patrol.clear(); let b = st(kind).bounty as f32; let got = self.add_gold(b); self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff); @@ -1546,6 +1567,126 @@ impl Game { } } + // tiles within two of a fighting hero + fn update_contested(&mut self) { + let n = self.map.t.len(); + if self.contested.len() != n { + self.contested = vec![false; n]; + } else { + self.contested.iter_mut().for_each(|c| *c = false); + } + for e in &self.ents { + if !e.alive || e.held || !e.active_hero() { + continue; + } + let (x, y) = (e.x as i32, e.y as i32); + for dy in -2..=2 { + for dx in -2..=2 { + if inb(x + dx, y + dy) { + self.contested[idx(x + dx, y + dy)] = true; + } + } + } + } + } + + // a knight tile was claimed: vaults hand over their treasure, shards come back + fn take_knight_tile(&mut self, i: usize) { + let t = self.map.t[i]; + let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5); + if t.kind == Kind::Treasury && t.gold > 0 { + // the content stays in the (now own) treasury tile and counts as own gold + self.gold += t.gold as f32; + self.loot += t.gold as f32; + self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff); + self.sfx(Sfx::Payday, x, y); + self.msg("msg.vault_taken", vec![t.gold.to_string()]); + } + if t.kind == Kind::Shard { + self.shards_taken += 1; + self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff6060ff); + self.sfx(Sfx::LevelUp, x, y); + self.msg("msg.shard_taken", vec![]); + } + } + + // a fortress unit spotted an enemy: guards and patrols nearby join the fight + fn alarm(&mut self, x: f32, y: f32, target: u32) { + for o in self.ents.iter_mut() { + if !o.alive || o.held || !o.active_hero() || !(o.guard || !o.patrol.is_empty()) { + continue; + } + if matches!(o.job, Job::Attack(_)) || (o.x - x).powi(2) + (o.y - y).powi(2) > 13.0 * 13.0 { + continue; + } + o.job = Job::Attack(target); + o.path.clear(); + o.repath = 0.0; + } + } + + // fortress units: a bridge of the player near the post (None if none) + fn bridge_to_tear(&self, e: &Ent) -> Option { + let (hx, hy) = e.home.unwrap_or((e.x, e.y)); + let (cx, cy) = (hx as i32, hy as i32); + for r in 0..=9i32 { + for dy in -r..=r { + for dx in -r..=r { + if dx.abs() != r && dy.abs() != r { + continue; + } + if let Some(t) = self.map.get(cx + dx, cy + dy) { + if t.bridge() && t.owner == Owner::Player { + return Some(idx(cx + dx, cy + dy)); + } + } + } + } + } + None + } + + // a knight walks next to the bridge tile and tears it down + fn tear_bridge(&mut self, e: &mut Ent, i: usize, dt: f32) -> bool { + let t = self.map.t[i]; + if !t.bridge() || t.owner != Owner::Player { + return false; + } + let (bx, by) = xy(i); + let cur = e.tile(); + let (cx, cy) = xy(cur); + if (cx - bx).abs() + (cy - by).abs() == 1 { + e.path.clear(); + e.working = true; + e.face = sgn(bx as f32 + 0.5 - e.x); + e.timer += dt; + if e.timer >= 4.0 { + e.timer = 0.0; + let lava = t.kind == Kind::LavaBridge; + let tl = &mut self.map.t[i]; + tl.kind = if lava { Kind::Lava } else { Kind::Water }; + tl.owner = Owner::None; + tl.reserved = 0; + self.ground_lost(i); + self.fx(6, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff3a5a7a); + self.sfx(Sfx::DigDone, bx as f32 + 0.5, by as f32 + 0.5); + self.msg("msg.bridge_torn", vec![]); + return false; + } + return true; + } + if e.path.is_empty() && e.repath <= 0.0 { + e.repath = 1.5; + e.timer = 0.0; + match self.map.search(cur, self.map.walk_cost(), |j| j != i && adj4(i, j), true) { + Some(p) => e.path = p, + None => return false, + } + } + self.step_path(e, dt); + true + } + // ---------------------------------------------------------------- pit imps (workers) fn dig_neighbor(&self, i: usize, me: u32) -> Option { @@ -1571,7 +1712,7 @@ impl Game { 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 + !t.hero && (tx - x).abs() <= 1 && (ty - y).abs() <= 1 && t.hp < t.max_hp() - 1.0 }).map(|t| t.id) } @@ -1593,7 +1734,13 @@ impl Game { 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 | Kind::Bridge | Kind::LavaBridge | Kind::Shard) { + let ok = match t.owner { + Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard), + // knight ground (also vaults and shard chambers), but not while heroes fight there + Owner::Hero => matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard) && !self.contested.get(i).copied().unwrap_or(false), + Owner::Player => false, + }; + if !ok { return false; } if t.reserved != 0 && t.reserved != me { @@ -1745,10 +1892,16 @@ impl Game { } e.working = true; e.timer += dt; - if e.timer >= 1.0 { + // knight ground takes longer + let knight = self.map.t[t].owner == Owner::Hero; + if e.timer >= if knight { 3.0 } else { 1.0 } { self.unreserve(me); e.job = Job::None; let (tx, ty) = xy(t); + if knight { + self.take_knight_tile(t); + self.alarm(tx as f32 + 0.5, ty as f32 + 0.5, e.id); + } if let (Kind::Portal, Some(pk)) = (self.map.t[t].kind, self.map.portal_at(tx, ty)) { let (px, py) = self.map.portals[pk]; for y in 0..3 { @@ -1929,9 +2082,24 @@ impl Game { e.job = Job::Attack(id); e.path.clear(); e.repath = 0.0; + } else if !matches!(e.job, Job::AttackTower(_) | Job::Sleep(_) | Job::Eat(_)) { + // knight towers in reach are torn down + if let Some(id) = self.enemy_tower_near(e.x, e.y, radius - 1.0) { + self.unreserve(me); + e.job = Job::AttackTower(id); + e.path.clear(); + e.repath = 0.0; + } } } } + if let Job::AttackTower(id) = e.job { + if !self.attack_tower(e, id, dt) { + e.job = Job::None; + e.path.clear(); + } + return; + } if e.kind == EK::Chainbreaker { // restless: without a fight for two minutes its mood sinks (skill_cd counts down as idle time) if matches!(e.job, Job::Attack(_)) { @@ -2489,15 +2657,27 @@ impl Game { e.cd = 2.5; } } + let fort = e.guard || !e.patrol.is_empty(); if !matches!(e.job, Job::Attack(_)) { - let radius = if e.guard { 8.0 } else { 6.5 }; + let radius = if fort { 8.0 } else { 6.5 }; if let Some(id) = self.nearest_enemy(e, radius) { e.job = Job::Attack(id); e.path.clear(); e.repath = 0.0; - } else if !matches!(e.job, Job::AttackTower(_)) { + 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| { + 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| { @@ -2521,9 +2701,21 @@ impl Game { 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 { - 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; + // 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; } @@ -2531,6 +2723,25 @@ impl Game { 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()); @@ -2716,7 +2927,7 @@ impl Game { continue; } let i = idx(x, y); - if let Some(k) = self.towers.iter().position(|t| t.tile == i) { + if let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) { refund += self.towers[k].invested() as f32 * 0.5; self.towers.remove(k); self.map.t[i].kind = Kind::Floor; @@ -2764,6 +2975,11 @@ impl Game { 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; @@ -2806,7 +3022,7 @@ impl Game { 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 tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false }; let (px, py) = tower.pos(); self.towers.push(tower); // units standing on the tile step aside @@ -2832,7 +3048,23 @@ impl Game { 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))]); + 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 { + 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) @@ -2852,7 +3084,9 @@ impl Game { 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() { + // knight towers shoot the player's units, the player's towers shoot heroes + let enemy = if t.hero { !e.hero } else { e.active_hero() }; + if !e.alive || e.held || !enemy { continue; } let d = (e.x - px).powi(2) + (e.y - py).powi(2); @@ -2878,12 +3112,14 @@ impl Game { 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 { + if s.mana > 0.0 && !t.hero && self.mana < s.mana { self.hint(17, "hint.tower_mana"); self.towers[k].cd = 1.0; continue; } - self.mana -= s.mana; + if !t.hero { + self.mana -= s.mana; + } self.towers[k].cd = s.interval; self.towers[k].flash = 0.15; let (px, py) = t.pos(); @@ -3017,6 +3253,8 @@ impl Game { 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, } @@ -3268,6 +3506,8 @@ pub struct Tower { pub cd: f32, pub aim: f32, // direction of the last shot (radians, for drawing) pub flash: f32, + #[serde(default)] + pub hero: bool, // tower of a knight fortress: shoots the player's units } impl Tower { @@ -3302,6 +3542,8 @@ impl Tower { // 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", @@ -3318,6 +3560,7 @@ pub fn job_name(e: &Ent) -> &'static str { Job::Train(_) => "job.train", Job::Study(_) => "job.study", Job::Attack(_) => "job.fight", + Job::TearBridge(_) => "job.tear_bridge", Job::AttackCore => { if e.working { "job.core_attack" @@ -3531,6 +3774,108 @@ mod tests { assert!((gold - g.gold - cost).abs() < 1.0); } + // a fortress east of the start area, joined to it by a claimed corridor + fn fort_game(spec: impl Fn(i32, i32) -> crate::fortress::FortSpec) -> (Game, crate::fortress::FortSpec) { + let mut g = Game::new(11, 96, 64); + let (cx, cy) = g.map.core; + let f = spec(cx + 14, cy - 6); + g.build_fortress(&f); + for x in cx + 5..f.x { + for y in cy..=cy + 2 { + let t = &mut g.map.t[idx(x, y)]; + t.kind = Kind::Floor; + t.owner = Owner::Player; + } + } + (g, f) + } + + fn spec(x: i32, y: i32) -> crate::fortress::FortSpec { + crate::fortress::FortSpec { x, y, w: 15, h: 15, doors: vec![3], towers: vec![], tower_level: 1, guards: vec![], patrols: vec![], vaults: 0, vault_value: 0, shard: false, heart: false } + } + + #[test] + fn knight_towers_and_creatures_fight() { + let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) }); + assert_eq!(g.towers.len(), 1); + assert!(g.towers[0].hero); + let (tx, ty) = g.towers[0].pos(); + let id = g.spawn(EK::Ogre, false, tx + 3.0, ty + 2.0, 4); + let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp; + let thp0 = g.towers[0].hp; + for _ in 0..200 { + g.update(0.05); + } + let hurt = g.ents.iter().find(|e| e.id == id).map_or(true, |e| e.hp < hp0); + let tower_hurt = g.towers.first().map_or(true, |t| t.hp < thp0); + assert!(hurt, "the knight tower shoots the creature"); + assert!(tower_hurt, "the creature attacks the knight tower"); + let _ = f; + } + + #[test] + fn 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) + }); + 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); + for _ in 0..200 { + g.update(0.05); + } + let p = g.ents.iter().find(|e| e.id == pid).unwrap(); + assert!((p.x - start.0).abs() + (p.y - start.1).abs() > 3.0, "the patrol moves"); + // an intruder next to one guard: the others join + let (hx, hy) = g.ents.iter().find(|e| e.guard).unwrap().home.unwrap(); + g.spawn(EK::Ogre, false, hx + 1.0, hy, 8); + for _ in 0..20 { + g.update(0.05); + } + let fighting = g.ents.iter().filter(|e| e.guard && matches!(e.job, Job::Attack(_))).count(); + assert!(fighting >= 3, "alarm: {} guards fight", fighting); + let _ = f; + } + + #[test] + fn knights_tear_down_bridges() { + let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { guards: vec![(EK::Knight, 1)], ..spec(x, y) }); + // a bridge of the player in the courtyard, on a small pond + let (bx, by) = (f.x + f.w / 2 + 2, f.y + f.h / 2 + 2); + for y in by - 1..=by + 1 { + g.map.t[idx(bx, y)].kind = Kind::Water; + g.map.t[idx(bx, y)].owner = Owner::None; + } + g.map.t[idx(bx, by)].kind = Kind::Bridge; + g.map.t[idx(bx, by)].owner = Owner::Player; + let mut torn = false; + for _ in 0..(90 * 20) { + g.update(0.05); + if g.map.t[idx(bx, by)].kind == Kind::Water { + torn = true; + break; + } + } + assert!(torn); + } + #[test] fn some_imps_always_claim() { // lots of digging work: still at least 10% (min. one) of the imps claim the dug-out floor diff --git a/src/main.rs b/src/main.rs index 08a3b8e..8ee4f55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod audio; mod cloud; +mod fortress; mod game; mod gfx; mod i18n; diff --git a/src/render3d.rs b/src/render3d.rs index 1d11bb1..fdb408f 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1200,6 +1200,8 @@ fn draw_towers_solid(g: &Game) { let (x, z) = tw.pos(); let h = tower_height(tw); let f = 0.75 + tw.flash * 1.5; // brightens briefly when firing + // knight towers: pale stone with blue tones + let shade_c = |r: u8, g: u8, b: u8, f: f32| if tw.hero { shade_c(r / 3 + 110, g / 3 + 115, b / 3 + 135, f) } else { shade_c(r, g, b, f) }; match tw.kind { TowerKind::Arrow => { draw_cube(vec3(x, h * 0.5, z), vec3(0.7, h, 0.7), None, shade_c(110, 75, 45, f)); diff --git a/src/ui.rs b/src/ui.rs index 257e514..8abd24b 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -419,15 +419,15 @@ impl Ui { let cap = g.counts[1].min(30); let wave_col = if g.wave_timer < 30.0 { RED } else { TXT }; let vol = if settings.muted { tr("hud.off") } else { format!("{}%", (settings.volume * 100.0).round() as i32) }; + let mut gold_seg = vec![txt(tr("hud.gold"), GOLD), Part::Gap, txt(format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT)]; + let due = g.next_payday_cost().ceil() as i32; + if due > 0 { + // what the next payday costs, in red + gold_seg.push(Part::Gap); + gold_seg.push(txt(format!("-{}", due), RED)); + } let mut segs: Vec> = vec![ - vec![ - txt(tr("hud.gold"), GOLD), - Part::Gap, - txt(format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT), - Part::Gap, - // what the next payday costs, in red - txt(format!("-{}", g.next_payday_cost().ceil() as i32), RED), - ], + gold_seg, vec![txt(tr("hud.mana"), MANA), Part::Gap, Part::Bar(g.mana / MANA_MAX, MANA), Part::Gap, txt(format!("{:<3}", g.mana as i32), TXT)], vec![txt(tr("hud.core"), RED), Part::Gap, Part::Bar(g.core_hp / CORE_HP, RED)], vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)],