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