diff --git a/lang/de.lang b/lang/de.lang index c225594..06633e5 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -576,3 +576,25 @@ job.make_bed = Richtet sein Bett ein 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.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 diff --git a/lang/en.lang b/lang/en.lang index 4d14710..3676757 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -577,3 +577,25 @@ job.make_bed = Setting up its bed job.fetch_wage = Fetching its wage 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! +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 diff --git a/src/game.rs b/src/game.rs index ff839a1..c36623f 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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, // 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, #[serde(default)] - pub mission: Option, // campaign mission (None: free game) // tiles next to fighting heroes: knight ground there cannot be claimed + pub mission: Option, // campaign mission (None: free game) + #[serde(default)] + pub rivals: Vec, // 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) { @@ -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 = 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 { + pub(crate) fn nearest_enemy(&self, e: &Ent, radius: f32) -> Option { 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 = 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) -> 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); diff --git a/src/gfx.rs b/src/gfx.rs index 305747c..86268ea 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -1,7 +1,7 @@ // Procedurally generated pixel art: tile atlas, unit sprites and the bitmap font. use crate::game::EK; -use crate::map::Kind; +use crate::map::{Kind, Owner}; use macroquad::prelude::*; 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_STEEL: u32 = 130; // 4 variants: riveted iron plates 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 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 for v in 0..4u32 { 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) } - 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; match kind { Kind::Rock => T_ROCK + v, @@ -1337,6 +1354,10 @@ impl Gfx { Kind::Floor => { if claimed { T_CLAIM + v + } else if owner == Owner::Rival1 { + T_RIVAL1 + v + } else if owner == Owner::Rival2 { + T_RIVAL2 + v } else if hero { T_HEROFLOOR + v } else { diff --git a/src/main.rs b/src/main.rs index dda8dca..61ba4b9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod map; mod menu; mod platform; mod props; +mod rival; mod render3d; mod save; 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) { - 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 cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5); (g, cam) diff --git a/src/map.rs b/src/map.rs index feddc21..677f312 100644 --- a/src/map.rs +++ b/src/map.rs @@ -125,9 +125,23 @@ pub enum Owner { None, Player, 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) #[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 { + 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); - 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> { self.search(start, self.hero_cost(), |i| self.adj_core(i), true) } + pub fn path_to(&self, start: usize, target: usize) -> Option> { self.search(start, self.walk_cost(), |i| i == target, true) } diff --git a/src/menu.rs b/src/menu.rs index bc8614a..ea1641a 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -189,6 +189,7 @@ enum Btn { Change(usize, i32), MapW(i32), MapH(i32), + Difficulty(i32), Start, Key(usize), 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; MenuAction::SettingsChanged } + Some(Btn::Difficulty(d)) => { + settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8; + MenuAction::SettingsChanged + } 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; MenuAction::SettingsChanged @@ -631,13 +636,15 @@ impl Menu { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); - let cols = 44; - let rows = 17; - let x = grid_center(sw, cols as f32 * CW()); + let cols = 48.min((sw / CW()) as i32); + let rows = 22; + 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); 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)]; - for (k, &(key, val, which)) in lines.iter().enumerate() { + let diff = tr(&format!("diff.{}", s.difficulty.min(2))); + 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); gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT); 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); button(gfx, left, "<", left.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); - 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((right, r)); } @@ -656,9 +667,14 @@ impl Menu { let area = (s.map_w * s.map_h) as f32 / 4096.0; let portals = (area.round() as usize).max(1); 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(); - 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) { diff --git a/src/render3d.rs b/src/render3d.rs index f6d8412..972ae9b 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -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 x in cx * CHUNK..((cx + 1) * CHUNK).min(mw() as i32) { 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 (xf, zf) = (x as f32, z as f32); if let Some(river) = t.river() { @@ -901,6 +901,11 @@ impl R3d { 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); } + 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) let r = (cam.zoom * 0.9) 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 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 for &(px, py) in &g.map.portals { 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); 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 for (pk, &(px, py)) in g.map.portals.iter().enumerate() { 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); let rc = if e.captive != 0 { 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 { Color::new(1.0, 0.85, 0.3, 0.9) } else if e.hero { @@ -1744,6 +1779,8 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] { Kind::Floor => match tile.owner { Owner::Player => [140, 60, 80, 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], }, 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::Training => [160, 100, 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::Gate => [230, 230, 255, 255], Kind::Torture => [120, 40, 40, 255], diff --git a/src/rival.rs b/src/rival.rs new file mode 100644 index 0000000..fe71253 --- /dev/null +++ b/src/rival.rs @@ -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"); + } +} diff --git a/src/settings.rs b/src/settings.rs index 993522d..ca486c3 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -147,6 +147,7 @@ pub struct Settings { // game pub game_speed: f32, // 1 or 2 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_h: usize, // key bindings: action id -> key name @@ -172,6 +173,7 @@ impl Default for Settings { touch_ui: 0, game_speed: 1.0, hints: true, + difficulty: 1, map_w: 64, map_h: 64, keys: BTreeMap::new(), diff --git a/src/ui.rs b/src/ui.rs index 6bc5ce0..2422324 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -461,6 +461,12 @@ impl Ui { if g.has_gate() { 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() { 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); for e in &g.ents { 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); } } @@ -1061,6 +1075,8 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec let owner = match t.owner { Owner::Player => "owner.player", Owner::Hero => "owner.hero", + Owner::Rival1 => "owner.rival1", + Owner::Rival2 => "owner.rival2", Owner::None => "owner.none", }; match t.kind {