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:
1 parent
a1ad5ad141
commit
1e92e19089
5 files changed
+206
-2
No files matched your search
+161
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user