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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 07:58:35 +01:00
1 parent a1ad5ad141
commit 1e92e19089
5 files changed
+206 -2

No files matched your search

+16
View File
@@ -61,6 +61,22 @@ An original dungeon management game (genre: dungeon building & defence) that run
- **Spells** (mana): lightning, heal, summon Pit Imp - **Spells** (mana): lightning, heal, summon Pit Imp
- **Heroes**: knight, archer, priest, dwarf miner, paladin of light (they dig their way to the Soul Core) - **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 ## Goal
- **Victory**: destroy the Hero Gate - **Victory**: destroy the Hero Gate
- **Defeat**: the Soul Core is destroyed - **Defeat**: the Soul Core is destroyed
+11
View File
@@ -322,3 +322,14 @@ info.unconscious = bewusstlos
info.carried = wird in die Folterkammer getragen info.carried = wird in die Folterkammer getragen
info.tortured = wird gefoltert info.tortured = wird gefoltert
info.prison = Gefangene: {0}/{1} 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
+11
View File
@@ -323,3 +323,14 @@ info.unconscious = unconscious
info.carried = being carried to the torture chamber info.carried = being carried to the torture chamber
info.tortured = being tortured info.tortured = being tortured
info.prison = Prisoners: {0}/{1} 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
+161 -2
View File
@@ -186,6 +186,8 @@ pub struct Ent {
pub torture_t: f32, pub torture_t: f32,
#[serde(default)] #[serde(default)]
pub skill_cd: f32, // special abilities (jailer pull, priestess heal) 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) pub guard: bool, // stays at its home (gate defenders, treasure guards)
#[serde(default)] #[serde(default)]
pub home: Option<(f32, f32)>, // guard post pub home: Option<(f32, f32)>, // guard post
@@ -226,6 +228,7 @@ impl Ent {
carrier: 0, carrier: 0,
torture_t: 0.0, torture_t: 0.0,
skill_cd: 0.0, skill_cd: 0.0,
waypoint: None,
guard: false, guard: false,
home: None, home: None,
} }
@@ -290,6 +293,10 @@ pub struct Game {
pub torches: Vec<Torch>, pub torches: Vec<Torch>,
#[serde(default)] #[serde(default)]
pub towers: Vec<Tower>, 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)] #[serde(skip)]
pub hints: bool, // show hint messages (setting) pub hints: bool, // show hint messages (setting)
hint_t: [f32; 16], hint_t: [f32; 16],
@@ -373,6 +380,8 @@ impl Game {
last_wave_value: 0.0, last_wave_value: 0.0,
torches: Vec::new(), torches: Vec::new(),
towers: Vec::new(), towers: Vec::new(),
entry_history: Vec::new(),
planned_entries: Vec::new(),
hints: true, hints: true,
hint_t: [-1000.0; 16], hint_t: [-1000.0; 16],
}; };
@@ -601,9 +610,23 @@ impl Game {
self.wave_timer -= dt; self.wave_timer -= dt;
if !self.warned && self.wave_timer < 30.0 { if !self.warned && self.wave_timer < 30.0 {
self.warned = true; 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<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); 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 { if self.wave_timer <= 0.0 {
self.spawn_wave(); self.spawn_wave();
self.wave += 1; self.wave += 1;
@@ -793,7 +816,27 @@ impl Game {
let base_level = 1 + (w - 1) / 3; let base_level = 1 + (w - 1) / 3;
let list = self.build_squad(target, base_level, &pool, boss, 2); let list = self.build_squad(target, base_level, &pool, boss, 2);
self.last_wave_value = Self::squad_value(&list); self.last_wave_value = Self::squad_value(&list);
let before = self.ents.len();
self.spawn_squad(&list, false); 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); self.sfx_ui(Sfx::WaveStart);
if boss.is_some() { if boss.is_some() {
self.msg("msg.wave_boss", vec![w.to_string(), list.len().to_string()]); self.msg("msg.wave_boss", vec![w.to_string(), list.len().to_string()]);
@@ -2159,15 +2202,98 @@ impl Game {
} }
return; 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 { if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.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; e.path = p;
} }
} }
self.step_path(e, dt); 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 // ---------------------------------------------------------------- player actions
pub fn mark_rect(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, on: bool) { 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!(prayed);
assert!(g.core_hp > 1300.0); 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);
}
} }
+7
View File
@@ -401,6 +401,13 @@ impl Ui {
let (fx, fy) = xy(f); 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); 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 // 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)]; 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 { for k in 0..4 {