Free game against rival dungeon masters (green, blue) and/or the humans

- One opponent on maps up to 128x128, two on bigger maps; with a chance of one in two one of
  them is the humans' gate instead of a rival master
- Simplified rival masters (rival.rs): own soul core and dungeon (lair, treasury to loot) that
  grows ring by ring, creatures appear and get stronger over time, guard the dungeon and attack
  in groups - the player, the other master or the humans' gate; a fallen core puts the master
  out and frees its ground
- The humans attack everybody: waves now and then march on a rival master
- Sides for units: all other sides are enemies; towers and contested ground follow them; the
  hand cannot pick up rival creatures; the flag next to a rival core attacks it
- Difficulty (easy / medium / hard) in the New Game dialog
- Colours: green and blue floors, cores, unit rings, minimap, HUD bars of the rival cores
- Victory in free games: every rival core and the gate (if any) destroyed
- Tests for setups, growth and attacks, elimination and victory, humans against rivals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 14:11:23 +01:00
1 parent 52c4d1caf8
commit dd16c684e5
11 files changed
+771 -38

No files matched your search

+101 -21
View File
@@ -237,6 +237,10 @@ pub struct Ent {
pub pay_due: f32, // wage of the last payday not fetched yet
#[serde(default)]
pub pay_t: f32, // seconds until the creature goes for its wage
#[serde(default)]
pub side: u8, // 0 player, 2 and 3 rival masters (heroes: see side())
#[serde(default)]
pub target: Option<u8>, // attackers: side whose core (or gate) they march on
}
fn yes() -> bool {
@@ -289,6 +293,8 @@ impl Ent {
settled: true,
pay_due: 0.0,
pay_t: 0.0,
side: 0,
target: None,
}
}
pub fn tile(&self) -> usize {
@@ -297,8 +303,21 @@ impl Ent {
pub fn atk(&self) -> f32 {
st(self.kind).atk * (1.0 + 0.12 * (self.level - 1) as f32)
}
// the player's creatures (not imps, heroes or the rivals' units)
pub fn creature(&self) -> bool {
!self.hero && self.kind != EK::Worker
!self.hero && self.side == 0 && self.kind != EK::Worker
}
// 0 player, 1 humans, 2 and 3 rival masters
pub fn side(&self) -> u8 {
if self.hero {
1
} else {
self.side
}
}
// a unit of a rival master
pub fn rival(&self) -> bool {
!self.hero && self.side >= 2
}
// a hero that is still fighting (not a prisoner)
pub fn active_hero(&self) -> bool {
@@ -401,7 +420,11 @@ pub struct Game {
#[serde(skip)]
pub contested: Vec<bool>,
#[serde(default)]
pub mission: Option<MissionState>, // campaign mission (None: free game) // tiles next to fighting heroes: knight ground there cannot be claimed
pub mission: Option<MissionState>, // campaign mission (None: free game)
#[serde(default)]
pub rivals: Vec<crate::rival::Rival>, // AI dungeon masters
#[serde(default = "yes")]
pub gate_on: bool, // free game: the humans' gate exists // tiles next to fighting heroes: knight ground there cannot be claimed
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
#[serde(skip, default = "hint_times")]
@@ -466,6 +489,8 @@ fn sgn(v: f32) -> f32 {
}
impl Game {
// free game with the humans' gate only (tests; the menu uses new_free)
#[cfg_attr(not(test), allow(dead_code))]
pub fn new(seed: u32, w: usize, h: usize) -> Game {
let mut g = Game::from_map(Map::generate(seed, w, h), seed);
// heroes take longer to reach the core on big maps: longer grace period
@@ -541,6 +566,8 @@ impl Game {
towers_razed: 0,
contested: Vec::new(),
mission: None,
rivals: Vec::new(),
gate_on: true,
};
let (cx, cy) = g.map.core;
// the soul core starts surrounded by a one tile wide ring of treasury
@@ -590,11 +617,11 @@ impl Game {
}
pub fn waves_on(&self) -> bool {
self.mission.as_ref().map_or(true, |m| m.waves && (m.wave_limit == 0 || self.wave < m.wave_limit))
self.mission.as_ref().map_or(self.gate_on, |m| m.waves && (m.wave_limit == 0 || self.wave < m.wave_limit))
}
pub fn has_gate(&self) -> bool {
self.mission.as_ref().map_or(true, |m| m.heart)
self.mission.as_ref().map_or(self.gate_on, |m| m.heart)
}
pub fn msg(&mut self, key: &'static str, args: Vec<String>) {
@@ -658,7 +685,7 @@ impl Game {
}
}
fn hint(&mut self, slot: usize, key: &'static str) {
pub(crate) fn hint(&mut self, slot: usize, key: &'static str) {
if self.hints && self.time - self.hint_t[slot] > 30.0 {
self.hint_t[slot] = self.time;
self.msg(key, vec![]);
@@ -937,6 +964,9 @@ impl Game {
}
self.ents.retain(|e| e.alive);
self.update_towers(dt);
if !self.rivals.is_empty() {
self.update_rivals(dt);
}
self.check_end();
}
@@ -963,8 +993,11 @@ impl Game {
}
return;
}
if self.gate_hp <= 0.0 {
self.gate_hp = 0.0;
// free game: every opponent (rival masters and the humans' gate) must fall
let rivals_out = self.rivals.iter().all(|r| !r.alive);
let gate_out = !self.gate_on || self.gate_hp <= 0.0;
if rivals_out && gate_out {
self.gate_hp = self.gate_hp.max(0.0);
self.state = 1;
self.msg("msg.victory", vec![]);
self.sfx_ui(Sfx::Victory);
@@ -1375,6 +1408,22 @@ impl Game {
self.last_wave_value = Self::squad_value(&list);
let before = self.ents.len();
self.spawn_squad(&list, false);
// the humans attack everybody: now and then a rival master instead of the player
let alive: Vec<u8> = self.rivals.iter().filter(|r| r.alive).map(|r| r.side).collect();
if !alive.is_empty() {
let r = (self.rng.next() as usize) % (alive.len() + 2);
if r >= 2 {
let side = alive[r - 2];
for e in self.ents[before..].iter_mut() {
e.target = Some(side);
e.waypoint = None;
}
self.planned_entries.clear();
self.sfx_ui(Sfx::WaveStart);
self.msg(if side == 2 { "msg.wave_on_rival1" } else { "msg.wave_on_rival2" }, vec![w.to_string(), list.len().to_string()]);
return;
}
}
if self.planned_entries.is_empty() {
self.plan_entries();
}
@@ -1485,6 +1534,8 @@ impl Game {
}
if e.hero && e.captive != 0 {
self.captive_update(e, dt);
} else if e.rival() {
self.rival_ai(e, dt);
} else if e.hero {
self.hero_ai(e, dt);
} else if e.kind == EK::Worker {
@@ -1495,14 +1546,14 @@ impl Game {
}
// moves a unit along its path; true = still moving
fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool {
pub(crate) fn step_path(&mut self, e: &mut Ent, dt: f32) -> bool {
if e.path.is_empty() {
return false;
}
let next = e.path[0];
let tile = self.map.t[next];
if !tile.walkable() && !(flies(e.kind) && tile.liquid()) {
if e.hero && tile.diggable() {
if (e.hero || e.rival()) && tile.diggable() {
// heroes dig their way through
e.working = true;
let (tx, ty) = xy(next);
@@ -1611,11 +1662,12 @@ impl Game {
true
}
fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option<u32> {
pub(crate) fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option<u32> {
let mut best = None;
let mut bd = radius * radius;
for o in &self.ents {
if !o.alive || o.held || o.hero == e.hero || o.captive != 0 {
// every other side is an enemy
if !o.alive || o.held || o.side() == e.side() || o.captive != 0 {
continue;
}
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2);
@@ -1628,7 +1680,7 @@ impl Game {
}
// fight a specific unit; false = target invalid/unreachable
fn attack_job(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
pub(crate) fn attack_job(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
let Some(j) = self.find(id) else { return false };
let (tx, ty) = (self.ents[j].x, self.ents[j].y);
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
@@ -1706,12 +1758,12 @@ impl Game {
e.xp += dmg * 0.6;
if e.kind == EK::Chainbreaker {
// twin axes: a second enemy next to the Chainbreaker takes half damage
let side = !e.hero;
let me = e.side();
let second = self
.ents
.iter()
.enumerate()
.find(|(o, x)| *o != j && x.alive && !x.held && x.hero == side && x.captive == 0 && (x.x - e.x).powi(2) + (x.y - e.y).powi(2) < 2.6)
.find(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - e.x).powi(2) + (x.y - e.y).powi(2) < 2.6)
.map(|(o, _)| o);
if let Some(o) = second {
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
@@ -1726,12 +1778,12 @@ impl Game {
if e.kind == EK::Priestess || e.kind == EK::Wyrm {
// fireball / fire breath: half damage to enemies around the target
self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff);
let side = !e.hero;
let me = e.side();
let splash: Vec<usize> = self
.ents
.iter()
.enumerate()
.filter(|(o, x)| *o != j && x.alive && !x.held && x.hero == side && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 1.44)
.filter(|(o, x)| *o != j && x.alive && !x.held && x.side() != me && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 1.44)
.map(|(o, _)| o)
.collect();
for o in splash {
@@ -1793,7 +1845,7 @@ impl Game {
}
let e = &mut self.ents[j];
e.alive = false;
let (id, kind, hero, x, y) = (e.id, e.kind, e.hero, e.x, e.y);
let (id, kind, hero, x, y, side) = (e.id, e.kind, e.hero, e.x, e.y, e.side);
// prisoners carried by this unit are dropped
for o in self.ents.iter_mut() {
if o.captive == 2 && o.carrier == id {
@@ -1810,6 +1862,11 @@ impl Game {
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.hero_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
} else if side >= 2 {
// a creature of a rival master: half its wage as booty
let got = self.add_gold(s.wage as f32 * 0.5);
self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.rival_unit_slain", vec![format!("@{}", s.name), (got as i32).to_string()]);
} else {
self.sfx(Sfx::Death, x, y);
self.msg("msg.creature_died", vec![format!("@{}", s.name)]);
@@ -2034,7 +2091,8 @@ impl Game {
self.contested.iter_mut().for_each(|c| *c = false);
}
for e in &self.ents {
if !e.alive || e.held || !e.active_hero() {
// any enemy of the player (heroes and the rivals' units) keeps ground from being claimed
if !e.alive || e.held || e.captive != 0 || e.side() == 0 {
continue;
}
let (x, y) = (e.x as i32, e.y as i32);
@@ -2195,7 +2253,10 @@ impl Game {
let ok = match t.owner {
Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard),
// knight ground (also vaults and shard chambers), but not while heroes fight there
Owner::Hero => matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard) && !self.contested.get(i).copied().unwrap_or(false),
// knight and rival ground (also vaults and shard chambers), but not while enemies fight there
Owner::Hero | Owner::Rival1 | Owner::Rival2 => {
matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Lair | Kind::Training) && !self.contested.get(i).copied().unwrap_or(false)
}
Owner::Player => false,
};
if !ok {
@@ -2855,6 +2916,17 @@ impl Game {
}
let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5);
// the flag next to a rival master's core: attack the core
let rival_near = self.rivals.iter().filter(|r| r.alive).find(|r| {
let (cx, cy) = r.centre();
(fx - cx).powi(2) + (fy - cy).powi(2) <= 8.0 * 8.0
});
if let Some(side) = rival_near.map(|r| r.side) {
e.target = Some(side);
if self.march_on(e, dt) {
return;
}
}
let (gx, gy) = self.gate_center();
let gate_near = self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0;
let s = st(e.kind);
@@ -3368,6 +3440,10 @@ impl Game {
self.step_path(e, dt);
return;
}
// a wave sent against a rival master
if e.target.map_or(false, |t| t >= 2) && self.march_on(e, dt) {
return;
}
let cur = e.tile();
if e.path.is_empty() && self.map.adj_core(cur) {
e.working = true;
@@ -3704,7 +3780,8 @@ impl Game {
let mut bd = r * r;
for (j, e) in self.ents.iter().enumerate() {
// knight towers shoot the player's units, the player's towers shoot heroes
let enemy = if t.hero { !e.hero } else { e.active_hero() };
// knight towers shoot everybody but the humans, the player's towers everybody else
let enemy = e.captive == 0 && if t.hero { e.side() != 1 } else { e.side() != 0 };
if !e.alive || e.held || !enemy {
continue;
}
@@ -4005,6 +4082,9 @@ impl Game {
}
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return false };
let e = &self.ents[j];
if e.rival() {
return false; // the rivals' creatures cannot be picked up
}
if e.hero {
if e.captive != 1 {
return false;
@@ -4084,7 +4164,7 @@ impl Game {
pub fn slap(&mut self, target: Option<usize>) -> u8 {
let Some(j) = target.filter(|&j| j < self.ents.len() && self.ents[j].alive && !self.ents[j].held) else { return 0 };
let (x, y) = (self.ents[j].x, self.ents[j].y);
if !self.ents[j].hero {
if !self.ents[j].hero && !self.ents[j].rival() {
let e = &mut self.ents[j];
e.boost = 15.0;
e.hp = (e.hp - e.max_hp * 0.05).max(1.0);