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

+22
View File
@@ -576,3 +576,25 @@ job.make_bed = Richtet sein Bett ein
job.fetch_wage = Holt sich seinen Lohn job.fetch_wage = Holt sich seinen Lohn
msg.payday_walk = Zahltag! {0} Kreaturen holen sich ihren Lohn aus der Schatzkammer ({1} Gold inkl. Training). msg.payday_walk = Zahltag! {0} Kreaturen holen sich ihren Lohn aus der Schatzkammer ({1} Gold inkl. Training).
msg.wage_unpaid = Dein {0} hat kein Gold für seinen Lohn gefunden und ist wütend! msg.wage_unpaid = Dein {0} hat kein Gold für seinen Lohn gefunden und ist wütend!
owner.rival1 = gehört dem grünen Meister
owner.rival2 = gehört dem blauen Meister
# rival masters (free game)
newgame.difficulty = Gegner
diff.0 = Leicht
diff.1 = Mittel
diff.2 = Schwer
newgame.opponents1 = Ein Gegner: ein rivalisierender Dungeon-Meister oder - zufällig - das Tor der Menschen.
newgame.opponents2 = Zwei Gegner: zwei rivalisierende Meister oder - zufällig - ein Meister und das Tor der Menschen.
msg.welcome_rivals = Ein rivalisierender Dungeon-Meister ist nah ({0} insgesamt): Zerstöre jeden gegnerischen Seelenkern.
msg.welcome_both = Ein Meister und das Tor der Menschen stellen sich dir entgegen: Zerstöre den gegnerischen Kern und das Tor.
msg.welcome_humans = Das Tor der Menschen stellt sich dir entgegen: Zerstöre es.
msg.rival1_attack = Der grüne Meister schickt {0} Kreaturen gegen dich!
msg.rival2_attack = Der blaue Meister schickt {0} Kreaturen gegen dich!
msg.rival1_out = Der grüne Meister ist besiegt - sein Seelenkern ist zersprungen!
msg.rival2_out = Der blaue Meister ist besiegt - sein Seelenkern ist zersprungen!
msg.rival_unit_slain = Ein {0} eines Rivalen fällt! +{1} Gold.
msg.wave_on_rival1 = Welle {0}: {1} Helden ziehen gegen den grünen Meister.
msg.wave_on_rival2 = Welle {0}: {1} Helden ziehen gegen den blauen Meister.
hud.rival1 = Grün
hud.rival2 = Blau
+22
View File
@@ -577,3 +577,25 @@ job.make_bed = Setting up its bed
job.fetch_wage = Fetching its wage job.fetch_wage = Fetching its wage
msg.payday_walk = Payday! {0} creatures fetch their wages from the treasury ({1} gold incl. training). msg.payday_walk = Payday! {0} creatures fetch their wages from the treasury ({1} gold incl. training).
msg.wage_unpaid = Your {0} found no gold for its wage and is furious! msg.wage_unpaid = Your {0} found no gold for its wage and is furious!
owner.rival1 = held by the green master
owner.rival2 = held by the blue master
# rival masters (free game)
newgame.difficulty = Opponents
diff.0 = Easy
diff.1 = Medium
diff.2 = Hard
newgame.opponents1 = One opponent: a rival dungeon master or - by chance - the humans' gate.
newgame.opponents2 = Two opponents: two rival masters, or - by chance - one master and the humans' gate.
msg.welcome_rivals = A rival dungeon master is near ({0} in all): destroy every rival soul core to win.
msg.welcome_both = A rival master and the humans' gate oppose you: destroy the rival soul core and the gate.
msg.welcome_humans = The humans' gate opposes you: destroy it.
msg.rival1_attack = The green master sends {0} creatures against you!
msg.rival2_attack = The blue master sends {0} creatures against you!
msg.rival1_out = The green master is defeated - his soul core has shattered!
msg.rival2_out = The blue master is defeated - his soul core has shattered!
msg.rival_unit_slain = A {0} of a rival master falls! +{1} gold.
msg.wave_on_rival1 = Wave {0}: {1} heroes march on the green master.
msg.wave_on_rival2 = Wave {0}: {1} heroes march on the blue master.
hud.rival1 = Green
hud.rival2 = Blue
+101 -21
View File
@@ -237,6 +237,10 @@ pub struct Ent {
pub pay_due: f32, // wage of the last payday not fetched yet pub pay_due: f32, // wage of the last payday not fetched yet
#[serde(default)] #[serde(default)]
pub pay_t: f32, // seconds until the creature goes for its wage 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 { fn yes() -> bool {
@@ -289,6 +293,8 @@ impl Ent {
settled: true, settled: true,
pay_due: 0.0, pay_due: 0.0,
pay_t: 0.0, pay_t: 0.0,
side: 0,
target: None,
} }
} }
pub fn tile(&self) -> usize { pub fn tile(&self) -> usize {
@@ -297,8 +303,21 @@ impl Ent {
pub fn atk(&self) -> f32 { pub fn atk(&self) -> f32 {
st(self.kind).atk * (1.0 + 0.12 * (self.level - 1) as 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 { 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) // a hero that is still fighting (not a prisoner)
pub fn active_hero(&self) -> bool { pub fn active_hero(&self) -> bool {
@@ -401,7 +420,11 @@ pub struct Game {
#[serde(skip)] #[serde(skip)]
pub contested: Vec<bool>, pub contested: Vec<bool>,
#[serde(default)] #[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)] #[serde(skip)]
pub hints: bool, // show hint messages (setting) pub hints: bool, // show hint messages (setting)
#[serde(skip, default = "hint_times")] #[serde(skip, default = "hint_times")]
@@ -466,6 +489,8 @@ fn sgn(v: f32) -> f32 {
} }
impl Game { 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 { pub fn new(seed: u32, w: usize, h: usize) -> Game {
let mut g = Game::from_map(Map::generate(seed, w, h), seed); 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 // heroes take longer to reach the core on big maps: longer grace period
@@ -541,6 +566,8 @@ impl Game {
towers_razed: 0, towers_razed: 0,
contested: Vec::new(), contested: Vec::new(),
mission: None, mission: None,
rivals: Vec::new(),
gate_on: true,
}; };
let (cx, cy) = g.map.core; let (cx, cy) = g.map.core;
// the soul core starts surrounded by a one tile wide ring of treasury // 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 { 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 { 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>) { 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 { if self.hints && self.time - self.hint_t[slot] > 30.0 {
self.hint_t[slot] = self.time; self.hint_t[slot] = self.time;
self.msg(key, vec![]); self.msg(key, vec![]);
@@ -937,6 +964,9 @@ impl Game {
} }
self.ents.retain(|e| e.alive); self.ents.retain(|e| e.alive);
self.update_towers(dt); self.update_towers(dt);
if !self.rivals.is_empty() {
self.update_rivals(dt);
}
self.check_end(); self.check_end();
} }
@@ -963,8 +993,11 @@ impl Game {
} }
return; return;
} }
if self.gate_hp <= 0.0 { // free game: every opponent (rival masters and the humans' gate) must fall
self.gate_hp = 0.0; 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.state = 1;
self.msg("msg.victory", vec![]); self.msg("msg.victory", vec![]);
self.sfx_ui(Sfx::Victory); self.sfx_ui(Sfx::Victory);
@@ -1375,6 +1408,22 @@ impl Game {
self.last_wave_value = Self::squad_value(&list); self.last_wave_value = Self::squad_value(&list);
let before = self.ents.len(); let before = self.ents.len();
self.spawn_squad(&list, false); 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() { if self.planned_entries.is_empty() {
self.plan_entries(); self.plan_entries();
} }
@@ -1485,6 +1534,8 @@ impl Game {
} }
if e.hero && e.captive != 0 { if e.hero && e.captive != 0 {
self.captive_update(e, dt); self.captive_update(e, dt);
} else if e.rival() {
self.rival_ai(e, dt);
} else if e.hero { } else if e.hero {
self.hero_ai(e, dt); self.hero_ai(e, dt);
} else if e.kind == EK::Worker { } else if e.kind == EK::Worker {
@@ -1495,14 +1546,14 @@ impl Game {
} }
// moves a unit along its path; true = still moving // 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() { if e.path.is_empty() {
return false; return false;
} }
let next = e.path[0]; let next = e.path[0];
let tile = self.map.t[next]; let tile = self.map.t[next];
if !tile.walkable() && !(flies(e.kind) && tile.liquid()) { 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 // heroes dig their way through
e.working = true; e.working = true;
let (tx, ty) = xy(next); let (tx, ty) = xy(next);
@@ -1611,11 +1662,12 @@ impl Game {
true 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 best = None;
let mut bd = radius * radius; let mut bd = radius * radius;
for o in &self.ents { 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; continue;
} }
let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2); 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 // 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 Some(j) = self.find(id) else { return false };
let (tx, ty) = (self.ents[j].x, self.ents[j].y); let (tx, ty) = (self.ents[j].x, self.ents[j].y);
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt(); let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
@@ -1706,12 +1758,12 @@ impl Game {
e.xp += dmg * 0.6; e.xp += dmg * 0.6;
if e.kind == EK::Chainbreaker { if e.kind == EK::Chainbreaker {
// twin axes: a second enemy next to the Chainbreaker takes half damage // twin axes: a second enemy next to the Chainbreaker takes half damage
let side = !e.hero; let me = e.side();
let second = self let second = self
.ents .ents
.iter() .iter()
.enumerate() .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); .map(|(o, _)| o);
if let Some(o) = second { if let Some(o) = second {
let (ox, oy) = (self.ents[o].x, self.ents[o].y); 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 { if e.kind == EK::Priestess || e.kind == EK::Wyrm {
// fireball / fire breath: half damage to enemies around the target // fireball / fire breath: half damage to enemies around the target
self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff); 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 let splash: Vec<usize> = self
.ents .ents
.iter() .iter()
.enumerate() .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) .map(|(o, _)| o)
.collect(); .collect();
for o in splash { for o in splash {
@@ -1793,7 +1845,7 @@ impl Game {
} }
let e = &mut self.ents[j]; let e = &mut self.ents[j];
e.alive = false; 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 // prisoners carried by this unit are dropped
for o in self.ents.iter_mut() { for o in self.ents.iter_mut() {
if o.captive == 2 && o.carrier == id { 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.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::HeroDeath, x, y); self.sfx(Sfx::HeroDeath, x, y);
self.msg("msg.hero_slain", vec![format!("@{}", s.name), (got as i32).to_string()]); 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 { } else {
self.sfx(Sfx::Death, x, y); self.sfx(Sfx::Death, x, y);
self.msg("msg.creature_died", vec![format!("@{}", s.name)]); self.msg("msg.creature_died", vec![format!("@{}", s.name)]);
@@ -2034,7 +2091,8 @@ impl Game {
self.contested.iter_mut().for_each(|c| *c = false); self.contested.iter_mut().for_each(|c| *c = false);
} }
for e in &self.ents { 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; continue;
} }
let (x, y) = (e.x as i32, e.y as i32); let (x, y) = (e.x as i32, e.y as i32);
@@ -2195,7 +2253,10 @@ impl Game {
let ok = match t.owner { let ok = match t.owner {
Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard), 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 // 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, Owner::Player => false,
}; };
if !ok { if !ok {
@@ -2855,6 +2916,17 @@ impl Game {
} }
let (fx, fy) = xy(f); let (fx, fy) = xy(f);
let (fx, fy) = (fx as f32 + 0.5, fy as f32 + 0.5); 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 (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 gate_near = self.gate_hp > 0.0 && (fx - gx).powi(2) + (fy - gy).powi(2) <= 8.0 * 8.0;
let s = st(e.kind); let s = st(e.kind);
@@ -3368,6 +3440,10 @@ impl Game {
self.step_path(e, dt); self.step_path(e, dt);
return; 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(); let cur = e.tile();
if e.path.is_empty() && self.map.adj_core(cur) { if e.path.is_empty() && self.map.adj_core(cur) {
e.working = true; e.working = true;
@@ -3704,7 +3780,8 @@ impl Game {
let mut bd = r * r; let mut bd = r * r;
for (j, e) in self.ents.iter().enumerate() { for (j, e) in self.ents.iter().enumerate() {
// knight towers shoot the player's units, the player's towers shoot heroes // 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 { if !e.alive || e.held || !enemy {
continue; 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 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]; let e = &self.ents[j];
if e.rival() {
return false; // the rivals' creatures cannot be picked up
}
if e.hero { if e.hero {
if e.captive != 1 { if e.captive != 1 {
return false; return false;
@@ -4084,7 +4164,7 @@ impl Game {
pub fn slap(&mut self, target: Option<usize>) -> u8 { 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 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); 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]; let e = &mut self.ents[j];
e.boost = 15.0; e.boost = 15.0;
e.hp = (e.hp - e.max_hp * 0.05).max(1.0); e.hp = (e.hp - e.max_hp * 0.05).max(1.0);
+23 -2
View File
@@ -1,7 +1,7 @@
// Procedurally generated pixel art: tile atlas, unit sprites and the bitmap font. // Procedurally generated pixel art: tile atlas, unit sprites and the bitmap font.
use crate::game::EK; use crate::game::EK;
use crate::map::Kind; use crate::map::{Kind, Owner};
use macroquad::prelude::*; use macroquad::prelude::*;
pub const CELL: f32 = 16.0; pub const CELL: f32 = 16.0;
@@ -67,6 +67,8 @@ pub const D_CRACK: u32 = 125; // a long crack through several stones
pub const T_AERIE: u32 = 126; // 4 variants: straw, twigs and feathers pub const T_AERIE: u32 = 126; // 4 variants: straw, twigs and feathers
pub const T_STEEL: u32 = 130; // 4 variants: riveted iron plates pub const T_STEEL: u32 = 130; // 4 variants: riveted iron plates
pub const I_STEEL: u32 = 134; pub const I_STEEL: u32 = 134;
pub const T_RIVAL1: u32 = 135; // 4 variants: floor of the green master
pub const T_RIVAL2: u32 = 139; // 4 variants: floor of the blue master
pub struct Gfx { pub struct Gfx {
pub atlas: Texture2D, pub atlas: Texture2D,
@@ -746,6 +748,19 @@ fn gen_atlas() -> Image {
} }
}); });
} }
// floors of the rival masters: like the player's claimed floor, in their colours
for v in 0..4u32 {
for (id, base, grout, mark) in [(T_RIVAL1, (44, 74, 50), (20, 36, 24), (90, 170, 100)), (T_RIVAL2, (44, 54, 86), (20, 24, 44), (90, 120, 200))] {
cv.cell(id + v, |x, y| {
let col = stones(x, y, v + 250, base, grout, 10);
if (x % 8 == 4 && y % 8 == 4) && hash(x / 8, y / 8, v + 251) % 2 == 0 {
Some(c(mark.0, mark.1, mark.2))
} else {
Some(col)
}
});
}
}
// steel walls: riveted plates with a little rust // steel walls: riveted plates with a little rust
for v in 0..4u32 { for v in 0..4u32 {
cv.cell(T_STEEL + v, |x, y| { cv.cell(T_STEEL + v, |x, y| {
@@ -1323,7 +1338,9 @@ impl Gfx {
Rect::new((i * SPR_CELL) as f32, (row * SPR_CELL) as f32, SPR_CELL as f32, SPR_CELL as f32) Rect::new((i * SPR_CELL) as f32, (row * SPR_CELL) as f32, SPR_CELL as f32, SPR_CELL as f32)
} }
pub fn tile_tex(kind: Kind, claimed: bool, hero: bool, var: u8, reinforced: bool) -> u32 { pub fn tile_tex(kind: Kind, owner: Owner, var: u8, reinforced: bool) -> u32 {
let claimed = owner == Owner::Player;
let hero = owner == Owner::Hero;
let v = var as u32 & 3; let v = var as u32 & 3;
match kind { match kind {
Kind::Rock => T_ROCK + v, Kind::Rock => T_ROCK + v,
@@ -1337,6 +1354,10 @@ impl Gfx {
Kind::Floor => { Kind::Floor => {
if claimed { if claimed {
T_CLAIM + v T_CLAIM + v
} else if owner == Owner::Rival1 {
T_RIVAL1 + v
} else if owner == Owner::Rival2 {
T_RIVAL2 + v
} else if hero { } else if hero {
T_HEROFLOOR + v T_HEROFLOOR + v
} else { } else {
+2 -1
View File
@@ -11,6 +11,7 @@ mod map;
mod menu; mod menu;
mod platform; mod platform;
mod props; mod props;
mod rival;
mod render3d; mod render3d;
mod save; mod save;
mod settings; mod settings;
@@ -133,7 +134,7 @@ fn finish_rect(app: &mut App, tool: Tool, start: (i32, i32), mx: f32, my: f32) {
} }
fn new_game(seed: u32, s: &Settings) -> (Game, Cam) { fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
let g = Game::new(seed, s.map_w, s.map_h); let g = Game::new_free(seed, s.map_w, s.map_h, s.difficulty);
let (cx, cy) = g.map.core; let (cx, cy) = g.map.core;
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
(g, cam) (g, cam)
+23 -2
View File
@@ -125,9 +125,23 @@ pub enum Owner {
None, None,
Player, Player,
Hero, Hero,
Rival1, // AI dungeon masters (free play, campaign)
Rival2,
} }
const OWNERS: [Owner; 3] = [Owner::None, Owner::Player, Owner::Hero]; const OWNERS: [Owner; 5] = [Owner::None, Owner::Player, Owner::Hero, Owner::Rival1, Owner::Rival2];
impl Owner {
pub fn of_side(side: u8) -> Owner {
match side {
0 => Owner::Player,
1 => Owner::Hero,
2 => Owner::Rival1,
3 => Owner::Rival2,
_ => Owner::None,
}
}
}
// Short serde names (only used by version 1 savegames, newer ones use the packed codec) // Short serde names (only used by version 1 savegames, newer ones use the packed codec)
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -648,15 +662,22 @@ impl Map {
} }
} }
// next to the player's soul core
pub fn adj_core(&self, i: usize) -> bool { pub fn adj_core(&self, i: usize) -> bool {
self.adj_core_of(i, Owner::Player)
}
// next to the soul core of a master (player or rival)
pub fn adj_core_of(&self, i: usize, owner: Owner) -> bool {
let (x, y) = xy(i); let (x, y) = xy(i);
DIR4.iter().any(|&(dx, dy)| self.get(x + dx, y + dy).map_or(false, |t| t.kind == Kind::Core)) DIR4.iter().any(|&(dx, dy)| self.get(x + dx, y + dy).map_or(false, |t| t.kind == Kind::Core && t.owner == owner))
} }
pub fn hero_path_to_core(&self, start: usize) -> Option<Vec<usize>> { pub fn hero_path_to_core(&self, start: usize) -> Option<Vec<usize>> {
self.search(start, self.hero_cost(), |i| self.adj_core(i), true) self.search(start, self.hero_cost(), |i| self.adj_core(i), true)
} }
pub fn path_to(&self, start: usize, target: usize) -> Option<Vec<usize>> { pub fn path_to(&self, start: usize, target: usize) -> Option<Vec<usize>> {
self.search(start, self.walk_cost(), |i| i == target, true) self.search(start, self.walk_cost(), |i| i == target, true)
} }
+25 -9
View File
@@ -189,6 +189,7 @@ enum Btn {
Change(usize, i32), Change(usize, i32),
MapW(i32), MapW(i32),
MapH(i32), MapH(i32),
Difficulty(i32),
Start, Start,
Key(usize), Key(usize),
ResetKeys, ResetKeys,
@@ -405,6 +406,10 @@ impl Menu {
settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
MenuAction::SettingsChanged MenuAction::SettingsChanged
} }
Some(Btn::Difficulty(d)) => {
settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8;
MenuAction::SettingsChanged
}
Some(Btn::MapH(d)) => { Some(Btn::MapH(d)) => {
settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
MenuAction::SettingsChanged MenuAction::SettingsChanged
@@ -631,13 +636,15 @@ impl Menu {
let sw = sw(); let sw = sw();
let sh = sh(); let sh = sh();
let (mx, my) = mouse(); let (mx, my) = mouse();
let cols = 44; let cols = 48.min((sw / CW()) as i32);
let rows = 17; let rows = 22;
let x = grid_center(sw, cols as f32 * CW()); let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0); let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI);
let lines = [("newgame.width", s.map_w, 0), ("newgame.height", s.map_h, 1)]; let diff = tr(&format!("diff.{}", s.difficulty.min(2)));
for (k, &(key, val, which)) in lines.iter().enumerate() { let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2)];
for (k, (key, val, which)) in lines.iter().enumerate() {
let (key, which) = (*key, *which);
let by = y + CH() * (2.0 + k as f32 * 2.5); let by = y + CH() * (2.0 + k as f32 * 2.5);
gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT); gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
let vw = 14.0 * CW(); let vw = 14.0 * CW();
@@ -646,9 +653,13 @@ impl Menu {
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0); let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
let v = val.to_string(); let v = val.clone();
gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD); gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD);
let (l, r) = if which == 0 { (Btn::MapW(-1), Btn::MapW(1)) } else { (Btn::MapH(-1), Btn::MapH(1)) }; let (l, r) = match which {
0 => (Btn::MapW(-1), Btn::MapW(1)),
1 => (Btn::MapH(-1), Btn::MapH(1)),
_ => (Btn::Difficulty(-1), Btn::Difficulty(1)),
};
self.buttons.push((left, l)); self.buttons.push((left, l));
self.buttons.push((right, r)); self.buttons.push((right, r));
} }
@@ -656,9 +667,14 @@ impl Menu {
let area = (s.map_w * s.map_h) as f32 / 4096.0; let area = (s.map_w * s.map_h) as f32 / 4096.0;
let portals = (area.round() as usize).max(1); let portals = (area.round() as usize).max(1);
let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]); let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]);
gfx.text(&info, x + CW() * 3.0, y + CH() * 7.5, S(), DIM); gfx.text(&info, x + CW() * 3.0, y + CH() * 10.0, S(), DIM);
let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" });
let maxc = (cols - 6) as usize;
for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() {
gfx.text(l, x + CW() * 3.0, y + CH() * (11.0 + k as f32), S(), DIM);
}
let w = (cols - 6) as f32 * CW(); let w = (cols - 6) as f32 * CW();
self.column(gfx, x + CW() * 3.0, y + CH() * 9.5, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]); self.column(gfx, x + CW() * 3.0, y + CH() * 14.0, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]);
} }
fn draw_pause(&mut self, gfx: &Gfx) { fn draw_pause(&mut self, gfx: &Gfx) {
+43 -2
View File
@@ -489,7 +489,7 @@ fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, atlas: &Texture2D, wall
for z in cz * CHUNK..((cz + 1) * CHUNK).min(mh() as i32) { for z in cz * CHUNK..((cz + 1) * CHUNK).min(mh() as i32) {
for x in cx * CHUNK..((cx + 1) * CHUNK).min(mw() as i32) { for x in cx * CHUNK..((cx + 1) * CHUNK).min(mw() as i32) {
let t = map.t[idx(x, z)]; let t = map.t[idx(x, z)];
let tex = Gfx::tile_tex(t.kind, t.owner == Owner::Player, t.owner == Owner::Hero, t.var, t.reinforced); let tex = Gfx::tile_tex(t.kind, t.owner, t.var, t.reinforced);
let (ua, ub) = cell_uv(tex); let (ua, ub) = cell_uv(tex);
let (xf, zf) = (x as f32, z as f32); let (xf, zf) = (x as f32, z as f32);
if let Some(river) = t.river() { if let Some(river) = t.river() {
@@ -901,6 +901,11 @@ impl R3d {
let (gx, gz) = g.gate_center(); let (gx, gz) = g.gate_center();
push(vec3(gx, 1.2, gz), 7.0, vec3(0.75, 0.85, 1.0), 1.0, 50.0); push(vec3(gx, 1.2, gz), 7.0, vec3(0.75, 0.85, 1.0), 1.0, 50.0);
} }
for rv in g.rivals.iter().filter(|r| r.alive) {
let (rx, rz) = rv.centre();
let c = if rv.side == 2 { vec3(0.3, 1.0, 0.4) } else { vec3(0.35, 0.55, 1.0) };
push(vec3(rx, 1.4, rz), 7.0, c, 1.1, 50.0);
}
// glowing lava near the camera (every third tile) // glowing lava near the camera (every third tile)
let r = (cam.zoom * 0.9) as i32; let r = (cam.zoom * 0.9) as i32;
let (ccx, ccz) = (cam.tx as i32, cam.tz as i32); let (ccx, ccz) = (cam.tx as i32, cam.tz as i32);
@@ -1427,6 +1432,16 @@ fn draw_landmarks_solid(g: &Game, t: f32) {
// claws reaching for the orb // claws reaching for the orb
line3(b + vec3(0.0, 1.0, 0.0), c + vec3(dx * 0.45, 1.55 - (k % 2) as f32 * 0.1, dz * 0.45), 0.07, Color::from_rgba(36, 24, 30, 255)); line3(b + vec3(0.0, 1.0, 0.0), c + vec3(dx * 0.45, 1.55 - (k % 2) as f32 * 0.1, dz * 0.45), 0.07, Color::from_rgba(36, 24, 30, 255));
} }
// rival masters' cores: obsidian pedestal (the orb glows in the glow pass)
for rv in g.rivals.iter().filter(|r| r.alive) {
let (rx, rz) = rv.centre();
let c = vec3(rx, 0.0, rz);
draw_cube(c + vec3(0.0, 0.08, 0.0), vec3(2.6, 0.16, 2.6), None, Color::from_rgba(24, 22, 30, 255));
draw_cube(c + vec3(0.0, 0.22, 0.0), vec3(1.8, 0.14, 1.8), None, Color::from_rgba(34, 32, 42, 255));
for (dx, dz) in [(-1.1f32, -1.1f32), (1.1, -1.1), (1.1, 1.1), (-1.1, 1.1)] {
draw_cube(c + vec3(dx, 0.5, dz), vec3(0.22, 1.0, 0.22), None, Color::from_rgba(30, 28, 38, 255));
}
}
// portal circles: eight standing stones of different height // portal circles: eight standing stones of different height
for &(px, py) in &g.map.portals { for &(px, py) in &g.map.portals {
let pc = vec3(px as f32 + 1.5, 0.0, py as f32 + 1.5); let pc = vec3(px as f32 + 1.5, 0.0, py as f32 + 1.5);
@@ -1506,6 +1521,20 @@ fn draw_landmarks_glow(g: &Game, t: f32) {
let p = c + vec3(a.cos() * 1.05, (t * 1.1 + k as f32).sin() * 0.25, a.sin() * 1.05); let p = c + vec3(a.cos() * 1.05, (t * 1.1 + k as f32).sin() * 0.25, a.sin() * 1.05);
draw_cube(p, vec3(0.1, 0.16, 0.04), None, Color::new(1.0, 0.7, 0.3, 0.85)); draw_cube(p, vec3(0.1, 0.16, 0.04), None, Color::new(1.0, 0.7, 0.3, 0.85));
} }
// rival masters' cores in their colours
for rv in g.rivals.iter().filter(|r| r.alive) {
let (rx, rz) = rv.centre();
let hp = (rv.hp / crate::rival::RIVAL_CORE_HP).clamp(0.0, 1.0);
let col = if rv.side == 2 { vec3(0.3, 0.95, 0.4) } else { vec3(0.35, 0.55, 1.0) };
let pulse = 1.0 + (t * (2.0 + (1.0 - hp) * 6.0) + rv.side as f32).sin() * 0.06;
let c = vec3(rx, 1.3 + (t * 1.2).sin() * 0.05, rz);
draw_sphere(c, 0.55 * pulse, None, Color::new(col.x * (0.4 + 0.6 * hp), col.y * (0.4 + 0.6 * hp), col.z * (0.4 + 0.6 * hp), 1.0));
draw_sphere(c, 0.85 * pulse, None, Color::new(col.x, col.y, col.z, 0.22));
for k in 0..4 {
let a = t * 0.8 + k as f32 * 1.57;
draw_cube(c + vec3(a.cos() * 0.9, (t + k as f32).sin() * 0.2, a.sin() * 0.9), vec3(0.08, 0.14, 0.04), None, Color::new(col.x, col.y, col.z, 0.8));
}
}
// portals: a circle of standing stones with runes, the vortex, a column of light, sparks // portals: a circle of standing stones with runes, the vortex, a column of light, sparks
for (pk, &(px, py)) in g.map.portals.iter().enumerate() { for (pk, &(px, py)) in g.map.portals.iter().enumerate() {
let owned = g.map.portal_owned(pk); let owned = g.map.portal_owned(pk);
@@ -1566,6 +1595,12 @@ fn draw_shadows(g: &Game) {
disc(vec3(e.x, 0.012, e.y), r, Color::new(0.0, 0.0, 0.0, 0.4), 12); disc(vec3(e.x, 0.012, e.y), r, Color::new(0.0, 0.0, 0.0, 0.4), 12);
let rc = if e.captive != 0 { let rc = if e.captive != 0 {
Color::new(0.6, 0.6, 0.6, 0.6) Color::new(0.6, 0.6, 0.6, 0.6)
} else if e.rival() {
if e.side == 2 {
Color::new(0.35, 0.95, 0.4, 0.85)
} else {
Color::new(0.4, 0.6, 1.0, 0.85)
}
} else if e.guard { } else if e.guard {
Color::new(1.0, 0.85, 0.3, 0.9) Color::new(1.0, 0.85, 0.3, 0.9)
} else if e.hero { } else if e.hero {
@@ -1744,6 +1779,8 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] {
Kind::Floor => match tile.owner { Kind::Floor => match tile.owner {
Owner::Player => [140, 60, 80, 255], Owner::Player => [140, 60, 80, 255],
Owner::Hero => [170, 175, 190, 255], Owner::Hero => [170, 175, 190, 255],
Owner::Rival1 => [70, 150, 80, 255],
Owner::Rival2 => [70, 100, 180, 255],
Owner::None => [70, 64, 60, 255], Owner::None => [70, 64, 60, 255],
}, },
Kind::Treasury => [200, 160, 60, 255], Kind::Treasury => [200, 160, 60, 255],
@@ -1751,7 +1788,11 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] {
Kind::Farm => [70, 140, 60, 255], Kind::Farm => [70, 140, 60, 255],
Kind::Training => [160, 100, 60, 255], Kind::Training => [160, 100, 60, 255],
Kind::Library => [150, 40, 60, 255], Kind::Library => [150, 40, 60, 255],
Kind::Core => [255, 60, 60, 255], Kind::Core => match tile.owner {
Owner::Rival1 => [90, 240, 110, 255],
Owner::Rival2 => [100, 150, 255, 255],
_ => [255, 60, 60, 255],
},
Kind::Portal => [190, 90, 240, 255], Kind::Portal => [190, 90, 240, 255],
Kind::Gate => [230, 230, 255, 255], Kind::Gate => [230, 230, 255, 255],
Kind::Torture => [120, 40, 40, 255], Kind::Torture => [120, 40, 40, 255],
+491
View File
@@ -0,0 +1,491 @@
// Rival dungeon masters (simplified): a dungeon around their own soul core that grows over
// time, creatures that appear there, guard it and attack in groups - the player, the other
// master or the humans. A master whose core falls is out; its ground becomes free.
use crate::game::*;
use crate::map::*;
use serde::{Deserialize, Serialize};
pub const RIVAL_CORE_HP: f32 = 2000.0;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Rival {
pub side: u8, // 2 (green) or 3 (blue)
pub core: (i32, i32), // top-left corner of the 3x3 core
pub hp: f32,
pub alive: bool,
pub radius: i32, // size of the dungeon around the core
pub grow_t: f32,
pub spawn_t: f32,
pub attack_t: f32,
pub difficulty: u8, // 0 easy, 1 medium, 2 hard
pub power: f32, // grows with the time: level of the new creatures
}
struct Pace {
spawn: f32, // seconds between new creatures
grow: f32, // seconds between growth steps
attack: f32, // seconds between attacks
cap: usize, // most creatures at a time
max_radius: i32, // largest dungeon
power: f32, // levels per minute
}
fn pace(d: u8) -> Pace {
match d {
0 => Pace { spawn: 55.0, grow: 45.0, attack: 330.0, cap: 6, max_radius: 8, power: 0.25 },
2 => Pace { spawn: 26.0, grow: 22.0, attack: 170.0, cap: 13, max_radius: 13, power: 0.6 },
_ => Pace { spawn: 38.0, grow: 30.0, attack: 240.0, cap: 9, max_radius: 11, power: 0.4 },
}
}
impl Rival {
pub fn centre(&self) -> (f32, f32) {
(self.core.0 as f32 + 1.5, self.core.1 as f32 + 1.5)
}
}
impl Game {
// Free game: one opponent per map up to 128 x 128 tiles, two on bigger maps; with a chance of
// one in two one of them is the humans' gate instead of a rival master
pub fn new_free(seed: u32, w: usize, h: usize, difficulty: u8) -> Game {
let opponents = if w * h > 128 * 128 { 2 } else { 1 };
let mut rng = Rng((seed ^ 0x9e37_79b9) | 1);
for _ in 0..5 {
rng.next();
}
let humans = rng.f() < 0.5;
let rivals = if humans { opponents - 1 } else { opponents };
let opts = GenOpts { gate: humans, ..GenOpts::default() };
let map = Map::generate_with(seed, w, h, &opts);
let mut g = Game::from_map(map, seed);
g.gate_on = humans;
if humans {
let (cx, cy) = g.map.core;
let (gx, gy) = g.map.gate;
let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32;
g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0);
} else {
g.gate_hp = 0.0;
}
g.guard_treasures();
// the rival dungeons on the far side, away from the gate
let (wi, hi) = (g.map.w as i32, g.map.h as i32);
let x = wi - 14;
let spots: Vec<(i32, i32)> = match (rivals, humans) {
(1, false) => vec![(x, hi / 2)],
(1, true) => vec![(x, if g.map.gate.1 < hi / 2 { hi * 3 / 4 } else { hi / 4 })],
(2, _) => vec![(x, hi / 4), (x, hi * 3 / 4)],
_ => Vec::new(),
};
for (k, &(rx, ry)) in spots.iter().enumerate() {
g.add_rival(2 + k as u8, rx, ry.clamp(8, hi - 9), difficulty);
}
g.place_all_torches();
g.msg("msg.welcome1", vec![]);
g.msg("msg.welcome2", vec![]);
g.msg(if rivals == 0 { "msg.welcome_humans" } else if humans { "msg.welcome_both" } else { "msg.welcome_rivals" }, vec![rivals.to_string()]);
g
}
pub fn rival_alive(&self, side: u8) -> bool {
self.rivals.iter().any(|r| r.side == side && r.alive)
}
// sets up a rival dungeon around (cx, cy)
pub fn add_rival(&mut self, side: u8, cx: i32, cy: i32, difficulty: u8) {
let owner = Owner::of_side(side);
let core = (cx - 1, cy - 1);
let r = 4;
for y in cy - r..=cy + r {
for x in cx - r..=cx + r {
if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
continue;
}
let t = &mut self.map.t[idx(x, y)];
if matches!(t.kind, Kind::Portal | Kind::Gate | Kind::Core) {
continue; // portals and other cores stay
}
t.kind = Kind::Floor;
t.owner = owner;
t.gold = 0;
t.marked = false;
t.reinforced = false;
}
}
for y in 0..3 {
for x in 0..3 {
let t = &mut self.map.t[idx(core.0 + x, core.1 + y)];
t.kind = Kind::Core;
t.owner = owner;
}
}
// a lair and a treasury (the player can take it)
for y in cy - 3..=cy + 3 {
let t = &mut self.map.t[idx(cx - 3, y)];
t.kind = Kind::Lair;
}
for y in cy - 1..=cy + 1 {
let t = &mut self.map.t[idx(cx + 3, y)];
t.kind = Kind::Treasury;
t.gold = 300;
t.res = 0;
}
self.rivals.push(Rival { side, core, hp: RIVAL_CORE_HP, alive: true, radius: r, grow_t: 20.0, spawn_t: 5.0, attack_t: pace(difficulty).attack, difficulty, power: 0.0 });
for k in 0..3 {
self.spawn_rival_unit(side, EK::Kobold, 1, k as f32);
}
}
fn spawn_rival_unit(&mut self, side: u8, kind: EK, level: u32, k: f32) {
let Some(rv) = self.rivals.iter().find(|r| r.side == side) else { return };
let (cx, cy) = rv.centre();
let a = k * 1.7 + self.rng.f() * 2.0;
let (x, y) = (cx + a.cos() * 2.4, cy + a.sin() * 2.4);
let id = self.spawn(kind, false, x, y, level);
if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) {
e.side = side;
e.home = Some((cx, cy));
}
}
pub fn update_rivals(&mut self, dt: f32) {
for k in 0..self.rivals.len() {
if !self.rivals[k].alive {
continue;
}
let p = pace(self.rivals[k].difficulty);
let side = self.rivals[k].side;
{
let r = &mut self.rivals[k];
r.power += dt / 60.0 * p.power;
r.grow_t -= dt;
r.spawn_t -= dt;
r.attack_t -= dt;
}
// growth: one more ring of ground (no imps needed)
if self.rivals[k].grow_t <= 0.0 {
self.rivals[k].grow_t = p.grow * (0.8 + self.rng.f() * 0.4);
if self.rivals[k].radius < p.max_radius {
self.rivals[k].radius += 1;
self.rival_grow(k);
}
}
// new creatures
let units = self.ents.iter().filter(|e| e.alive && e.side == side && !e.hero).count();
if self.rivals[k].spawn_t <= 0.0 {
self.rivals[k].spawn_t = p.spawn * (0.8 + self.rng.f() * 0.4);
if units < p.cap {
let power = self.rivals[k].power;
let mut pool = vec![EK::Kobold, EK::Spider];
if power >= 1.5 {
pool.extend([EK::Mage, EK::Ogre]);
}
if power >= 3.0 {
pool.push(EK::Gargoyle);
}
let kind = pool[(self.rng.next() as usize) % pool.len()];
let level = (1.0 + power) as u32;
self.spawn_rival_unit(side, kind, level.clamp(1, 10), units as f32);
}
}
// attack: about two thirds of the creatures march on an enemy
if self.rivals[k].attack_t <= 0.0 {
self.rivals[k].attack_t = p.attack * (0.8 + self.rng.f() * 0.4);
let mut targets: Vec<(u8, u32)> = vec![(0, 3)];
for o in &self.rivals {
if o.alive && o.side != side {
targets.push((o.side, 1));
}
}
if self.has_gate() && self.gate_hp > 0.0 {
targets.push((1, 1));
}
let total: u32 = targets.iter().map(|t| t.1).sum();
let mut r = self.rng.next() % total;
let mut target = 0;
for (s, w) in targets {
if r < w {
target = s;
break;
}
r -= w;
}
let mut n = 0;
for e in self.ents.iter_mut() {
if e.alive && !e.hero && e.side == side && e.target.is_none() && n * 3 < units * 2 {
e.target = Some(target);
e.path.clear();
n += 1;
}
}
if n > 0 && target == 0 {
self.msg(if side == 2 { "msg.rival1_attack" } else { "msg.rival2_attack" }, vec![n.to_string()]);
self.sfx_ui(Sfx::WaveAlarm);
}
}
}
}
// a ring of earth around the rival dungeon becomes its ground
fn rival_grow(&mut self, k: usize) {
let rv = self.rivals[k].clone();
let owner = Owner::of_side(rv.side);
let (cx, cy) = (rv.core.0 + 1, rv.core.1 + 1);
let r = rv.radius;
for y in cy - r..=cy + r {
for x in cx - r..=cx + r {
if (x - cx).abs() != r && (y - cy).abs() != r {
continue;
}
if !inb(x, y) || x < 2 || y < 2 || x >= mw() as i32 - 2 || y >= mh() as i32 - 2 {
continue;
}
let t = self.map.t[idx(x, y)];
let free = matches!(t.kind, Kind::Earth | Kind::Floor) && !t.reinforced && t.owner == Owner::None;
let touches = DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |n| n.owner == owner && n.walkable()));
if free && touches {
let tl = &mut self.map.t[idx(x, y)];
tl.kind = Kind::Floor;
tl.owner = owner;
tl.gold = 0;
tl.marked = false;
}
}
}
}
// damage to the core of a rival master
pub fn rival_core_hit(&mut self, side: u8, dmg: f32) {
let Some(k) = self.rivals.iter().position(|r| r.side == side && r.alive) else { return };
self.rivals[k].hp -= dmg;
let (x, y) = self.rivals[k].centre();
self.fx(2, x, y, 0.0, 0.0, 0.3, 0xff4040ff);
self.sfx(Sfx::CoreHit, x, y);
if self.rivals[k].hp <= 0.0 {
self.eliminate_rival(k);
}
}
fn eliminate_rival(&mut self, k: usize) {
let side = self.rivals[k].side;
let owner = Owner::of_side(side);
self.rivals[k].alive = false;
self.rivals[k].hp = 0.0;
let (x, y) = self.rivals[k].centre();
self.fx(1, x, y, 0.0, 0.0, 1.0, 0xffffffff);
self.sfx(Sfx::Victory, x, y);
self.msg(if side == 2 { "msg.rival1_out" } else { "msg.rival2_out" }, vec![]);
// its creatures scatter and vanish, its ground is free
for e in self.ents.iter_mut() {
if e.alive && !e.hero && e.side == side {
e.alive = false;
}
}
for t in self.map.t.iter_mut() {
if t.owner == owner {
if t.kind == Kind::Core {
t.kind = Kind::Floor;
}
t.owner = Owner::None;
}
}
}
// where an attacker of `side`'s enemy is heading: the core (or gate) of the target side
fn target_core_adjacent(&self, i: usize, target: u8) -> bool {
match target {
1 => {
let (x, y) = xy(i);
DIR4.iter().any(|&(dx, dy)| self.map.get(x + dx, y + dy).map_or(false, |t| t.kind == Kind::Gate))
}
s => self.map.adj_core_of(i, Owner::of_side(s)),
}
}
fn target_alive(&self, target: u8) -> bool {
match target {
0 => self.core_hp > 0.0,
1 => self.has_gate() && self.gate_hp > 0.0,
s => self.rival_alive(s),
}
}
// hit the core (or gate) of the target side
pub fn hit_target_core(&mut self, e: &mut Ent, target: u8) {
let dmg = e.atk();
e.cd = st(e.kind).interval;
match target {
0 => {
self.core_hp -= dmg;
let (cx, cy) = self.map.core;
self.fx(2, (e.x + cx as f32 + 1.5) * 0.5, (e.y + cy as f32 + 1.5) * 0.5, 0.0, 0.0, 0.3, 0xff4040ff);
self.sfx(Sfx::CoreHit, e.x, e.y);
self.hint(6, "hint.core_attacked");
}
1 => {
self.gate_hp -= dmg;
self.gate_hit_t = self.time;
let (gx, gy) = self.gate_center();
self.sfx(Sfx::GateHit, gx, gy);
}
s => self.rival_core_hit(s, dmg),
}
}
// the march of an attacker on a core; false = no target (any more)
pub fn march_on(&mut self, e: &mut Ent, dt: f32) -> bool {
let Some(target) = e.target else { return false };
if !self.target_alive(target) || target == e.side() {
e.target = None;
return false;
}
let cur = e.tile();
if e.path.is_empty() && self.target_core_adjacent(cur, target) {
e.working = true;
if e.cd <= 0.0 {
self.hit_target_core(e, target);
}
return true;
}
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.5;
// attackers dig their way like heroes; the player's creatures only walk
let goal = |i: usize| self.target_core_adjacent(i, target);
let found = if e.side() == 0 { self.map.search(cur, self.map.walk_cost(), goal, true) } else { self.map.search(cur, self.map.hero_cost(), goal, true) };
match found {
Some(p) => e.path = p,
None => {
e.target = None;
return false;
}
}
}
self.step_path(e, dt);
true
}
// units of a rival master: fight enemies nearby, march when sent, otherwise guard home
pub fn rival_ai(&mut self, e: &mut Ent, dt: f32) {
if e.think <= 0.0 {
e.think = 0.5;
if !matches!(e.job, Job::Attack(_)) {
let radius = if e.target.is_some() { 6.0 } else { 8.0 };
if let Some(id) = self.nearest_enemy(e, radius) {
e.job = Job::Attack(id);
e.path.clear();
e.repath = 0.0;
}
}
}
if let Job::Attack(id) = e.job {
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let far = e.target.is_none() && (e.x - hx).powi(2) + (e.y - hy).powi(2) > 15.0 * 15.0;
if !far && self.attack_job(e, id, dt) {
return;
}
e.job = Job::None;
e.path.clear();
}
if self.march_on(e, dt) {
return;
}
// guard the own dungeon
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let d2 = (e.x - hx).powi(2) + (e.y - hy).powi(2);
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 3.0 + self.rng.f() * 3.0;
let (tx, ty) = if d2 > 36.0 { (hx, hy) } else { (hx + self.rng.f() * 8.0 - 4.0, hy + self.rng.f() * 8.0 - 4.0) };
if self.map.walk(tx as i32, ty as i32) {
if let Some(p) = self.map.path_to(e.tile(), idx(tx as i32, ty as i32)) {
e.path = p;
}
}
}
self.step_path(e, dt);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_games_have_one_or_two_opponents() {
for seed in 1..=12u32 {
for &(w, h) in &[(64usize, 64usize), (160, 160)] {
let g = Game::new_free(seed, w, h, 1);
let opponents = g.rivals.len() + g.gate_on as usize;
assert_eq!(opponents, if w * h > 128 * 128 { 2 } else { 1 }, "seed {} {}x{}", seed, w, h);
for r in &g.rivals {
let (cx, cy) = (r.core.0 + 1, r.core.1 + 1);
assert_eq!(g.map.t[idx(cx, cy)].kind, Kind::Core);
assert_eq!(g.map.t[idx(cx, cy)].owner, Owner::of_side(r.side));
}
// the player's core is untouched
let (px, py) = g.map.core;
assert_eq!(g.map.t[idx(px + 1, py + 1)].owner, Owner::Player);
}
}
}
// a free game with exactly one rival master and no gate
fn rival_game(difficulty: u8) -> Game {
(1..200u32).map(|s| Game::new_free(s, 64, 64, difficulty)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap()
}
#[test]
fn rival_grows_spawns_and_attacks() {
let mut g = rival_game(2);
let r0 = g.rivals[0].radius;
let core0 = g.core_hp;
let mut attacked = false;
for _ in 0..(12 * 60 * 20) {
g.update(0.05);
attacked |= g.ents.iter().any(|e| e.rival() && e.target == Some(0));
if g.core_hp < core0 - 50.0 {
break;
}
}
assert!(g.rivals[0].radius > r0, "the rival dungeon grows");
assert!(attacked, "the rival sends creatures");
assert!(g.core_hp < core0, "they reach the player's core");
}
#[test]
fn beating_the_rival_wins_the_free_game() {
let mut g = rival_game(1);
let side = g.rivals[0].side;
g.update(0.05);
assert!(g.ents.iter().any(|e| e.side == side && e.alive));
g.rival_core_hit(side, RIVAL_CORE_HP + 1.0);
assert!(!g.rivals[0].alive);
assert!(!g.ents.iter().any(|e| e.side == side && e.alive));
assert!(!g.map.t.iter().any(|t| t.owner == Owner::of_side(side)));
g.update(0.05);
assert_eq!(g.state, 1, "all opponents down: victory");
}
#[test]
fn humans_attack_rival_masters_too() {
let mut g = (1..200u32).map(|s| Game::new_free(s, 160, 160, 1)).find(|g| g.rivals.len() == 1 && g.gate_on).unwrap();
let side = g.rivals[0].side;
let (rx, ry) = g.rivals[0].centre();
for k in 0..4 {
let id = g.spawn(EK::Knight, true, rx + 6.0 + k as f32 * 0.3, ry, 4);
if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) {
e.target = Some(side);
}
}
let hp0 = g.rivals[0].hp;
let mut fought = false;
for _ in 0..(120 * 20) {
g.update(0.05);
fought |= g.rivals[0].hp < hp0 || g.ents.iter().any(|e| e.rival() && e.hp < e.max_hp);
if fought {
break;
}
}
assert!(fought, "heroes fight the rival master");
}
}
+2
View File
@@ -147,6 +147,7 @@ pub struct Settings {
// game // game
pub game_speed: f32, // 1 or 2 pub game_speed: f32, // 1 or 2
pub hints: bool, pub hints: bool,
pub difficulty: u8, // rival masters in free games: 0 easy, 1 medium, 2 hard
pub map_w: usize, // size of new maps (64..256) pub map_w: usize, // size of new maps (64..256)
pub map_h: usize, pub map_h: usize,
// key bindings: action id -> key name // key bindings: action id -> key name
@@ -172,6 +173,7 @@ impl Default for Settings {
touch_ui: 0, touch_ui: 0,
game_speed: 1.0, game_speed: 1.0,
hints: true, hints: true,
difficulty: 1,
map_w: 64, map_w: 64,
map_h: 64, map_h: 64,
keys: BTreeMap::new(), keys: BTreeMap::new(),
+17 -1
View File
@@ -461,6 +461,12 @@ impl Ui {
if g.has_gate() { if g.has_gate() {
segs.insert(3, vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)]); segs.insert(3, vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)]);
} }
// the soul cores of the rival masters
for rv in g.rivals.iter().filter(|r| r.alive) {
let col = if rv.side == 2 { Color::new(0.4, 0.95, 0.45, 1.0) } else { Color::new(0.45, 0.65, 1.0, 1.0) };
let key = if rv.side == 2 { "hud.rival1" } else { "hud.rival2" };
segs.insert(3, vec![txt(tr(key), col), Part::Gap, Part::Bar(rv.hp / crate::rival::RIVAL_CORE_HP, col)]);
}
if g.waves_on() { if g.waves_on() {
segs.push(vec![txt(trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col), Part::Gap, txt(mmss(g.wave_timer), TXT)]); segs.push(vec![txt(trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col), Part::Gap, txt(mmss(g.wave_timer), TXT)]);
} }
@@ -646,7 +652,15 @@ impl Ui {
let dot = (2.0 * px).max(ms); let dot = (2.0 * px).max(ms);
for e in &g.ents { for e in &g.ents {
if e.alive && !e.held { if e.alive && !e.held {
let c = if e.hero { Color::new(0.6, 0.85, 1.0, 1.0) } else if e.kind == EK::Worker { Color::new(1.0, 0.6, 0.3, 1.0) } else { Color::new(1.0, 0.25, 0.25, 1.0) }; let c = if e.hero {
Color::new(0.6, 0.85, 1.0, 1.0)
} else if e.rival() {
if e.side == 2 {
Color::new(0.3, 0.95, 0.4, 1.0)
} else {
Color::new(0.4, 0.6, 1.0, 1.0)
}
} else if e.kind == EK::Worker { Color::new(1.0, 0.6, 0.3, 1.0) } else { Color::new(1.0, 0.25, 0.25, 1.0) };
draw_rectangle((mrx + e.x.floor() * ms).floor(), (mry + e.y.floor() * ms).floor(), dot, dot, c); draw_rectangle((mrx + e.x.floor() * ms).floor(), (mry + e.y.floor() * ms).floor(), dot, dot, c);
} }
} }
@@ -1061,6 +1075,8 @@ pub fn hover_info(g: &Game, ent: Option<usize>, tile: Option<(i32, i32)>) -> Vec
let owner = match t.owner { let owner = match t.owner {
Owner::Player => "owner.player", Owner::Player => "owner.player",
Owner::Hero => "owner.hero", Owner::Hero => "owner.hero",
Owner::Rival1 => "owner.rival1",
Owner::Rival2 => "owner.rival2",
Owner::None => "owner.none", Owner::None => "owner.none",
}; };
match t.kind { match t.kind {