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

+161 -2
View File
@@ -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<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],
@@ -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<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;
@@ -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);
}
}