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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
Reference in new issue
Block a user