From 1e92e190893f6767949387e8075ecb024499e14e Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 07:58:35 +0100 Subject: [PATCH] Hero wave AI: varying entry points facing the gate, split waves, scout warning with directions and minimap markers Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 16 +++++ lang/de.lang | 11 ++++ lang/en.lang | 11 ++++ src/game.rs | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++- src/ui.rs | 7 +++ 5 files changed, 206 insertions(+), 2 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 4951ed0..c6149a8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -61,6 +61,22 @@ An original dungeon management game (genre: dungeon building & defence) that run - **Spells** (mana): lightning, heal, summon Pit Imp - **Heroes**: knight, archer, priest, dwarf miner, paladin of light (they dig their way to the Soul Core) +## Walls, towers, torture, temple +- **Reinforced walls**: imps reinforce earth walls next to claimed ground automatically (free, lowest priority). Heroes avoid them (path cost ×10) and need ~10× as long to break through; the player's imps need twice as long to dig them out +- **Defence towers** on claimed floor (one tile, not walkable), three tools; clicking an own tower with the same tool upgrades it (3 levels). Gold for building/upgrading, the magic tower uses mana per shot + - Arrow tower: cheap, fast; arrows pierce up to two more heroes + - Cannon tower: slow, long range, splash damage with knockback, minimum range + - Magic tower: never misses, slows, jumps to a second hero + - Heroes attack towers; imps repair them; towers count with half their combat value to the army value +- **Torture chamber**: one prisoner per 2 tiles. Defeated heroes are knocked out (if there is room) and carried in by imps; torture drains their HP over ~100 s, ~40% defect (paladins ~25%) and become own creatures (needs, wage, lair). Attracts the **Jailer** (6+ tiles): tanky melee that pulls enemies in with a chain +- **Temple**: creatures pray for mana (more than studying) and mood. Attracts the **Flame Priestess** (6+ tiles): fireballs with splash damage, heals creatures, heals the Soul Core (~8 HP/s) when it is below 90% and no fight is nearby + +## Hero wave AI +- Each wave picks an **entry point** on the border of the realm facing the hero gate: few towers/creatures, no reinforced walls and a short way are attractive, entry points of recent waves are avoided, plus some randomness +- Waves of **6+ heroes split into two groups** with different entry points (the paladin goes with the bigger group) +- Heroes first tunnel to their entry point, then go for the core +- **Warning** 30 s before: scout message with the direction(s) and blinking markers on the minimap + ## Goal - **Victory**: destroy the Hero Gate - **Defeat**: the Soul Core is destroyed diff --git a/lang/de.lang b/lang/de.lang index 8ba8c6d..8f3c914 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -322,3 +322,14 @@ info.unconscious = bewusstlos info.carried = wird in die Folterkammer getragen info.tortured = wird gefoltert info.prison = Gefangene: {0}/{1} + +msg.wave_warn_dir = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden wollen von {1} einfallen. +msg.wave_warn_two = Späher melden: Welle {0} erreicht in 30 Sekunden das Tor! Die Helden teilen sich auf und fallen von {1} und von {2} ein. +dir.n = Norden +dir.ne = Nordosten +dir.e = Osten +dir.se = Südosten +dir.s = Süden +dir.sw = Südwesten +dir.w = Westen +dir.nw = Nordwesten diff --git a/lang/en.lang b/lang/en.lang index 66d8203..e755bcc 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -323,3 +323,14 @@ info.unconscious = unconscious info.carried = being carried to the torture chamber info.tortured = being tortured info.prison = Prisoners: {0}/{1} + +msg.wave_warn_dir = Scouts report: hero wave {0} reaches the gate in 30 seconds! They plan to break in from the {1}. +msg.wave_warn_two = Scouts report: hero wave {0} reaches the gate in 30 seconds! They split up and break in from the {1} and the {2}. +dir.n = north +dir.ne = north-east +dir.e = east +dir.se = south-east +dir.s = south +dir.sw = south-west +dir.w = west +dir.nw = north-west diff --git a/src/game.rs b/src/game.rs index b15410a..73686e8 100644 --- a/src/game.rs +++ b/src/game.rs @@ -186,6 +186,8 @@ pub struct Ent { 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 @@ -226,6 +228,7 @@ impl Ent { carrier: 0, torture_t: 0.0, skill_cd: 0.0, + waypoint: None, guard: false, home: None, } @@ -290,6 +293,10 @@ pub struct Game { pub torches: Vec, #[serde(default)] pub towers: Vec, + #[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], @@ -373,6 +380,8 @@ impl Game { 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], }; @@ -601,9 +610,23 @@ impl Game { self.wave_timer -= dt; if !self.warned && self.wave_timer < 30.0 { self.warned = true; - self.msg("msg.wave_warn", vec![(self.wave + 1).to_string()]); + self.plan_entries(); + if self.planned_entries.is_empty() { + self.msg("msg.wave_warn", vec![(self.wave + 1).to_string()]); + } else { + let dirs: Vec = 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; @@ -793,7 +816,27 @@ impl Game { 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()]); @@ -2159,15 +2202,98 @@ impl Game { } 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; - if let Some(p) = self.map.hero_path_to_core(cur) { + 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) { @@ -2956,4 +3082,37 @@ mod tests { 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); + } } diff --git a/src/ui.rs b/src/ui.rs index 652d482..5fca5e6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -401,6 +401,13 @@ impl Ui { let (fx, fy) = xy(f); draw_rectangle(mrx + fx as f32 * ms - 2.0, mry + fy as f32 * ms - 2.0, 5.0, 5.0, RED); } + // expected entry points of the hero wave (blinking) + if (now * 3.0) as i64 % 2 == 0 { + for &(ex, ey) in &g.planned_entries { + let (px, py) = (mrx + ex as f32 * ms, mry + ey as f32 * ms); + draw_rectangle_lines(px - 4.0, py - 4.0, 9.0, 9.0, 2.0, Color::new(0.6, 0.85, 1.0, 1.0)); + } + } // camera footprint on the ground let corners = [cam.ground_point(0.0, 0.0), cam.ground_point(sw, 0.0), cam.ground_point(sw, sh), cam.ground_point(0.0, sh)]; for k in 0..4 {