From 9a9f04ae1fffff3f69659ea0fba63ee859e45a72 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 15:14:36 +0100 Subject: [PATCH] Dwarf caves in all modes: neutral dwarves guard a hoard and sometimes a rune chest; the rune forge tower; imps repair towers before claiming - 1-4 cave slots by map size, 50% each (at least one); knight treasure caves only where a mission places them - dwarves fight intruders near the cave, never chase far, heal at home, grow with distance and the player's army, never count for victory - rune chest: one free rune forge tower (chain bolt to three more enemies), upgradeable for gold, sold once it comes back (next mission / free play at once), unbuilt ones carry over - new tool (key N) shown in the tower menu while a rune tower can be built or upgraded Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 5 +- README.md | 2 +- lang/de.lang | 14 ++ lang/en.lang | 14 ++ src/campaign.rs | 11 +- src/dwarves.rs | 355 ++++++++++++++++++++++++++++++++++++++++++++++++ src/fortress.rs | 2 +- src/game.rs | 155 ++++++++++++++++++--- src/gfx.rs | 19 +++ src/main.rs | 1 + src/map.rs | 9 +- src/props.rs | 16 +++ src/render3d.rs | 54 ++++++++ src/rival.rs | 3 +- src/save.rs | 3 +- src/settings.rs | 4 +- src/ui.rs | 23 +++- 17 files changed, 658 insertions(+), 32 deletions(-) create mode 100644 src/dwarves.rs diff --git a/DESIGN.md b/DESIGN.md index 92d6f33..d95f301 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -55,7 +55,10 @@ An original dungeon management game (genre: dungeon building & defence) that run - Chosen in the **New Game dialog**: width and height separately, **64–256 tiles** in steps of 32 (remembered in the settings) - Content scales with the area: rock, gold veins and caves; the Hero Gate lies on the far side, the **grace period grows with the distance** to the gate - **One portal per 64×64 tiles**; every claimed portal brings creatures (more portals = faster arrivals, still limited by the lair) -- Bigger maps get **guarded treasure caves** (one less than the number of portals): very rich gold veins, defended by a hero squad that stays at its post +- **Dwarf caves** in every mode: 1 to 4 slots depending on the map size, each filled with a 50% chance (at least one cave). A hoard (1500-4000 in gold and gems, more the further from the core) inside a cave ringed by rich veins, guarded by 4-6 neutral dwarves (bronze rings). Dwarves fight whoever comes within about 7 tiles of the cave, never chase further, heal at home and never count as opponents for victory. Their level grows with the distance to the core, and every 30 s they gain levels until they match about half the player's army strength. +- Knight treasure caves only exist where a campaign mission places them; everywhere else dwarf caves took their place. +- 30% of the dwarf caves hold a **rune chest**: claiming it gives one free build of the **rune forge tower** (1400 HP, range 10, the rune bolt jumps on to three more enemies). It can be upgraded for gold (800 / 1200). Selling it returns the base tower once: in the campaign as a chest for the next mission, in free play at once. Unbuilt rune towers carry over to the next mission. +- Imps repair damaged towers for free; repairs come before claiming more ground. - Savegames store the tiles packed (run-length encoded, base64), so even 256×256 maps stay small (~300 KB); version 1 savegames still load ## Game content diff --git a/README.md b/README.md index 99c7410..3f26908 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Dig out your underground realm, claim the portal to attract creatures, keep them - 3D isometric view with a freely rotatable and tiltable camera - Procedural pixel art, irregular cave walls, flickering torch light - CP437 text-mode style interface with the DINOBYTE bitmap font -- Map sizes from 64×64 to 256×256 tiles (one portal per 64×64 tiles, guarded treasure caves on bigger maps) +- Map sizes from 64×64 to 256×256 tiles (one portal per 64×64 tiles, dwarf caves with treasure and rune forge plans) - English and German (language files in `lang/`, easy to extend) See [DESIGN.md](DESIGN.md) for all design decisions and [CREDITS.md](CREDITS.md) for third-party assets. diff --git a/lang/de.lang b/lang/de.lang index 2509708..56f41aa 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -624,3 +624,17 @@ hud.rival1 = Grün hud.rival2 = Blau msg.creature_freed = Ein {0} sprengt seine Ketten und schließt sich dir an! msg.spring_taken = Eine Manaquelle gehört dir: mehr Mana und ein höheres Maximum. +tool.rune.label = Rune +tool.rune.name = Runenschmiede-Turm +tool.rune.desc = Pläne aus einer Zwergenkiste: kostenlos gebaut, ein Turm je Kiste. Riesige Reichweite, Runenblitze springen auf drei weitere Feinde über. Verkaufst du ihn, kommt der Basisturm einmal zurück, in der nächsten Mission. +tower.rune = Runenschmiede-Turm +cost.rune = kostenlos: noch {0} (Ausbau {1} / {2} Gold) +hint.rune_none = Keine Runenschmiede-Pläne mehr. Nimm eine Zwergenkiste ein, um neue zu bekommen. +msg.rune_chest = Eine Zwergenkiste! Die Pläne eines Runenschmiede-Turms gehören dir - bau ihn kostenlos im Turmmenü. +msg.rune_next = Die Runenschmiede-Pläne sind verstaut: Der Turm kommt in der nächsten Mission zurück. +msg.rune_back = Die Runenschmiede-Pläne sind zurück - du darfst den Turm noch einmal bauen. +msg.dwarf_slain = Ein Zwerg fällt. +{0} Gold. +info.dwarf = Bewacht den Schatz einer Zwergenhöhle +info.dwarf_neutral = Neutral: kämpft gegen jeden, der zu nahe kommt, verlässt die Höhle nie. +tile.rune_chest = Zwergische Runenkiste +tile.rune_chest_info = Nimm sie ein: die Pläne eines Runenschmiede-Turms (einmal kostenlos bauen). diff --git a/lang/en.lang b/lang/en.lang index bc9e3b1..58a4d62 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -625,3 +625,17 @@ hud.rival1 = Green hud.rival2 = Blue msg.creature_freed = A {0} breaks its chains and joins you! msg.spring_taken = A mana spring is yours: more mana and a higher maximum. +tool.rune.label = Rune +tool.rune.name = Rune Forge Tower +tool.rune.desc = Plans from a dwarven chest: built for free, one tower per chest. Huge range, rune bolts jump on to three more enemies. Sell it and the base tower comes back once, in the next mission. +tower.rune = Rune Forge Tower +cost.rune = free: {0} left (upgrades {1} / {2} gold) +hint.rune_none = No rune forge plans left. Claim a dwarven chest for a new one. +msg.rune_chest = A dwarven chest! The plans of a rune forge tower are yours - build it for free from the tower menu. +msg.rune_next = The rune forge plans are packed away: the tower comes back in the next mission. +msg.rune_back = The rune forge plans are back - you may build the tower once more. +msg.dwarf_slain = A dwarf falls. +{0} gold. +info.dwarf = Guards the hoard of a dwarf cave +info.dwarf_neutral = Neutral: fights whoever comes close, never leaves the cave. +tile.rune_chest = Dwarven rune chest +tile.rune_chest_info = Claim it: the plans of a rune forge tower (one free build). diff --git a/src/campaign.rs b/src/campaign.rs index 6a913bd..4977217 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; // From which mission (0-based) on each tool is available (same order as ui::TOOLS): // dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp, // torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel -pub const TOOL_UNLOCK: [usize; 20] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17]; +pub const TOOL_UNLOCK: [usize; 21] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0]; pub struct FortDef { pub at: (f32, f32), // centre as a fraction of the map size @@ -79,6 +79,10 @@ fn cave(at: (f32, f32), chained: Option<(EK, u32)>, treasure: i32) -> CaveDef { pub struct Carry { pub creatures: Vec<(EK, u32, f32)>, // kind, level, experience pub gold: f32, + #[serde(default)] + pub rune_free: u32, // unbuilt rune forge towers from chests + #[serde(default)] + pub rune_final: u32, // unbuilt or sold rune forge towers that were already given back once } pub const CARRY_CREATURES: usize = 5; @@ -499,6 +503,7 @@ pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { let y = ((ry * h as f32) as i32).clamp(10, h as i32 - 10); g.add_rival(side, x, y, diff); } + g.place_dwarf_caves(d.seed); let heart = d.gate || d.forts.iter().any(|f| f.heart); if !heart { g.gate_hp = 0.0; @@ -524,6 +529,8 @@ pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { if let Some(c) = carry { joiners.extend(c.creatures.iter().copied()); g.gold += c.gold; + g.rune_free += c.rune_free; + g.rune_final += c.rune_final; } for (k, &(kind, level, xp)) in joiners.iter().enumerate() { let a = k as f32 * 0.9; @@ -543,7 +550,7 @@ pub fn carry_from(g: &Game) -> Carry { let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect(); let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval; c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal)); - Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor() } + Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor(), rune_free: g.rune_free, rune_final: g.rune_final + g.rune_next } } #[cfg(test)] diff --git a/src/dwarves.rs b/src/dwarves.rs new file mode 100644 index 0000000..5c02942 --- /dev/null +++ b/src/dwarves.rs @@ -0,0 +1,355 @@ +// Dwarf caves: a neutral people that guards a treasure in a cave surrounded by rich veins. +// The dwarves defend themselves but never stray far from their cave, and they never count +// as opponents for victory. Some caves hold a chest with the plans of a rune forge tower: +// the player may build it once for free. + +use crate::game::*; +use crate::map::*; +use serde::{Deserialize, Serialize}; + +pub const DWARF_SIDE: u8 = 4; +const LEASH: f32 = 7.0; // dwarves do not chase enemies further from the cave centre +const CHEST_CHANCE: f32 = 0.3; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct DwarfCave { + pub at: (i32, i32), // centre + pub base: u32, // base level of its dwarves (grows with the distance to the player's core) +} + +impl Game { + // 1 to 4 cave slots depending on the map size, each filled with a 50% chance (at least one) + pub fn place_dwarf_caves(&mut self, seed: u32) { + let mut rng = Rng(seed.wrapping_mul(0x9e37_79b9) ^ 0x5bd1_e995 | 1); + for _ in 0..8 { + rng.next(); + } + let area = (mw() * mh()) as f32 / 4096.0; + let slots = (area.sqrt().ceil() as usize).clamp(1, 4); + let mut n = (0..slots).filter(|_| rng.f() < 0.5).count(); + if n == 0 { + n = 1; + } + for _ in 0..n { + if let Some(at) = self.dwarf_spot(&mut rng) { + self.carve_dwarf_cave(at, &mut rng); + } + } + } + + // a spot far from everything else whose surroundings are untouched neutral rock and earth + fn dwarf_spot(&self, rng: &mut Rng) -> Option<(i32, i32)> { + let (w, h) = (mw() as i32, mh() as i32); + let (cx, cy) = (self.map.core.0 + 1, self.map.core.1 + 1); + let mut others: Vec<(i32, i32)> = vec![(cx, cy), (self.map.gate.0 + 1, self.map.gate.1 + 1)]; + others.extend(self.map.portals.iter().map(|&(x, y)| (x + 1, y + 1))); + others.extend(self.map.treasures.iter().copied()); + others.extend(self.rivals.iter().map(|r| (r.core.0 + 1, r.core.1 + 1))); + others.extend(self.dwarf_caves.iter().map(|c| c.at)); + let mut best = None; + let mut bd = -1; + for _ in 0..200 { + let p = (rng.range(9, w - 10), rng.range(9, h - 10)); + if (p.0 - cx).pow(2) + (p.1 - cy).pow(2) < 20 * 20 { + continue; + } + let clear = (p.1 - 6..=p.1 + 6).all(|y| { + (p.0 - 6..=p.0 + 6).all(|x| { + let t = &self.map.t[idx(x, y)]; + t.owner == Owner::None && !t.liquid() && !matches!(t.kind, Kind::Core | Kind::Portal | Kind::Gate | Kind::Tower | Kind::Shard | Kind::Bridge | Kind::LavaBridge | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest) + }) + }); + if !clear { + continue; + } + let d = others.iter().map(|q| (p.0 - q.0).pow(2) + (p.1 - q.1).pow(2)).min().unwrap_or(i32::MAX); + if d < 14 * 14 { + continue; + } + // not hidden among the knights: keep off fortress ground and units + if self.ents.iter().any(|e| e.alive && (e.x - p.0 as f32).abs() < 8.0 && (e.y - p.1 as f32).abs() < 8.0) { + continue; + } + if d > bd { + bd = d; + best = Some(p); + } + } + best + } + + fn carve_dwarf_cave(&mut self, (cx, cy): (i32, i32), rng: &mut Rng) { + let (px, py) = (self.map.core.0 + 1, self.map.core.1 + 1); + let dist = (((cx - px).pow(2) + (cy - py).pow(2)) as f32).sqrt(); + // rich veins around the cave, with gems + for y in cy - 5..=cy + 5 { + for x in cx - 5..=cx + 5 { + let d = (((x - cx).pow(2) + (y - cy).pow(2)) as f32).sqrt(); + let t = &mut self.map.t[idx(x, y)]; + t.marked = false; + t.reinforced = false; + t.hp = 6.0; + if (x - cx).abs() <= 2 && (y - cy).abs() <= 2 { + t.kind = Kind::Floor; + t.gold = 0; + } else if d <= 5.2 && rng.f() < 0.7 { + let r = rng.f(); + let (k, g) = if r < 0.08 { (Kind::Diamond, rng.range(1200, 1800)) } else if r < 0.28 { (Kind::Ruby, rng.range(800, 1200)) } else if r < 0.45 { (Kind::Silver, rng.range(500, 800)) } else { (Kind::Gold, rng.range(800, 1400)) }; + t.kind = k; + t.gold = g; + } else { + t.kind = Kind::Earth; + t.gold = 0; + } + } + } + // the hoard: treasury piles in the corners and along the walls, more the further away + let hoard = (1500.0 + dist * 20.0).min(4000.0) as i32; + let spots = [(-2, -2), (2, -2), (-2, 2), (2, 2), (0, -2), (0, 2), (-2, 0), (2, 0)]; + let piles = ((hoard + TREASURY_TILE as i32 - 1) / TREASURY_TILE as i32).clamp(1, spots.len() as i32) as usize; + for (k, &(dx, dy)) in spots.iter().take(piles).enumerate() { + let t = &mut self.map.t[idx(cx + dx, cy + dy)]; + t.kind = Kind::Treasury; + t.gold = (hoard - k as i32 * TREASURY_TILE as i32).clamp(0, TREASURY_TILE as i32); + let r = rng.f(); + t.res = if r < 0.12 { 3 } else if r < 0.35 { 2 } else if r < 0.55 { 1 } else { 0 }; + } + if rng.f() < CHEST_CHANCE { + self.map.t[idx(cx, cy)].kind = Kind::RuneChest; + } + let base = (2 + (dist / 30.0) as u32).min(7); + let count = 4 + (dist > 60.0) as usize + (dist > 100.0) as usize; + for k in 0..count { + let a = k as f32 / count as f32 * std::f32::consts::TAU; + let id = self.spawn(EK::Dwarf, false, cx as f32 + 0.5 + a.cos() * 1.2, cy as f32 + 0.5 + a.sin() * 1.2, base); + if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { + e.side = DWARF_SIDE; + e.home = Some((cx as f32 + 0.5, cy as f32 + 0.5)); + e.happy = 1.0; + } + } + self.dwarf_caves.push(DwarfCave { at: (cx, cy), base }); + } + + // Every 30 s the dwarves of a cave grow stronger until they match about half the player's + // army (or their base level): one of the weakest idle dwarves gains a level. + pub fn update_dwarves(&mut self, dt: f32) { + self.dwarf_t -= dt; + if self.dwarf_t > 0.0 || self.dwarf_caves.is_empty() { + return; + } + self.dwarf_t = 30.0; + let army = self.army_value() * 0.5; + for c in 0..self.dwarf_caves.len() { + let home = (self.dwarf_caves[c].at.0 as f32 + 0.5, self.dwarf_caves[c].at.1 as f32 + 0.5); + let mine: Vec = (0..self.ents.len()).filter(|&j| { + let e = &self.ents[j]; + e.alive && e.dwarf() && e.home == Some(home) + }).collect(); + if mine.is_empty() { + continue; + } + let list: Vec<(EK, u32)> = mine.iter().map(|&j| (self.ents[j].kind, self.ents[j].level)).collect(); + if Game::squad_value(&list) >= army { + continue; + } + if let Some(&j) = mine.iter().filter(|&&j| self.ents[j].level < 10 && !matches!(self.ents[j].job, Job::Attack(_))).min_by_key(|&&j| self.ents[j].level) { + let e = &mut self.ents[j]; + e.level += 1; + let old = e.max_hp; + e.max_hp = st(e.kind).hp * (1.0 + 0.15 * (e.level - 1) as f32); + e.hp += e.max_hp - old; + } + } + } + + // a dwarf: fights what comes close to the cave, otherwise potters about at home and heals + pub fn dwarf_ai(&mut self, e: &mut Ent, dt: f32) { + let (hx, hy) = e.home.unwrap_or((e.x, e.y)); + let from_home = |x: f32, y: f32| ((x - hx).powi(2) + (y - hy).powi(2)).sqrt(); + if e.think <= 0.0 { + e.think = 0.4; + if !matches!(e.job, Job::Attack(_)) { + if let Some(id) = self.nearest_enemy(e, 6.5) { + if let Some(j) = self.find(id) { + if from_home(self.ents[j].x, self.ents[j].y) < LEASH { + e.job = Job::Attack(id); + e.path.clear(); + e.repath = 0.0; + } + } + } + } + } + if let Job::Attack(id) = e.job { + let near = self.find(id).map_or(false, |j| from_home(self.ents[j].x, self.ents[j].y) < LEASH + 1.0); + if near && from_home(e.x, e.y) < LEASH + 1.0 && self.attack_job(e, id, dt) { + return; + } + e.job = Job::None; + e.path.clear(); + e.repath = 0.0; + } + e.hp = (e.hp + e.max_hp * 0.01 * dt).min(e.max_hp); + if e.path.is_empty() && e.repath <= 0.0 { + e.repath = 3.0 + self.rng.f() * 4.0; + let (tx, ty) = if from_home(e.x, e.y) > 3.0 { (hx, hy) } else { (hx + self.rng.f() * 4.0 - 2.0, hy + self.rng.f() * 4.0 - 2.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); + } + + // rune forge towers that can be built now + pub fn rune_available(&self) -> u32 { + self.rune_free + self.rune_final + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dwarves(g: &Game) -> usize { + g.ents.iter().filter(|e| e.alive && e.dwarf()).count() + } + + #[test] + fn caves_by_map_size() { + for seed in 1..=10u32 { + for &(w, h, max) in &[(64usize, 64usize, 1usize), (128, 128, 2), (256, 256, 4)] { + let g = Game::new_free(seed, w, h, 1); + let n = g.dwarf_caves.len(); + assert!(n >= 1 && n <= max, "seed {} {}x{}: {} caves", seed, w, h, n); + assert!(g.map.treasures.is_empty(), "knight caves replaced"); + assert!(dwarves(&g) >= 4 * n); + for c in &g.dwarf_caves { + let t = &g.map.t[idx(c.at.0, c.at.1)]; + assert!(matches!(t.kind, Kind::Floor | Kind::RuneChest), "cave centre {:?}", t.kind); + } + } + } + } + + #[test] + fn dwarves_are_no_opponents_and_stay_home() { + let mut g = (1..100u32).map(|s| Game::new_free(s, 64, 64, 0)).find(|g| g.rivals.len() == 1 && !g.gate_on).unwrap(); + g.eliminate_rival(0); + for _ in 0..40 { + g.update(0.05); + } + assert_eq!(g.state, 1, "dwarves alive, but the game is won"); + assert!(dwarves(&g) > 0); + } + + #[test] + fn dwarves_fight_intruders_but_do_not_chase() { + let mut g = Game::new_free(3, 64, 64, 0); + let c = g.dwarf_caves[0].clone(); + // an ogre of the player walks into the cave + let id = g.spawn(EK::Ogre, false, c.at.0 as f32 + 0.5, c.at.1 as f32 + 0.5, 3); + let mut hit = false; + for _ in 0..(20 * 20) { + g.update(0.05); + if let Some(j) = g.find(id) { + hit |= g.ents[j].hp < g.ents[j].max_hp; + } + } + assert!(hit, "the dwarves attack"); + let home = (c.at.0 as f32 + 0.5, c.at.1 as f32 + 0.5); + for e in g.ents.iter().filter(|e| e.alive && e.dwarf()) { + let d = ((e.x - home.0).powi(2) + (e.y - home.1).powi(2)).sqrt(); + assert!(d < LEASH + 2.5, "dwarf strayed {:.1} tiles", d); + } + } + + #[test] + fn dwarves_grow_with_the_army() { + let mut g = Game::new_free(5, 64, 64, 0); + let lvl = |g: &Game| g.ents.iter().filter(|e| e.alive && e.dwarf()).map(|e| e.level).sum::(); + let before = lvl(&g); + let (cx, cy) = g.map.core; + for k in 0..8 { + g.spawn(EK::Ogre, false, cx as f32 + 1.0 + k as f32 * 0.2, cy as f32 - 1.0, 8); + } + g.dwarf_t = 0.0; + g.update_dwarves(0.1); + assert!(lvl(&g) > before); + } + + #[test] + fn rune_chest_gives_a_free_tower_once() { + let mut g = Game::new_free(5, 64, 64, 0); + let (cx, cy) = g.map.core; + let (tx, ty) = (cx + 4, cy + 1); + g.map.t[idx(tx, ty)].kind = Kind::RuneChest; + g.map.t[idx(tx, ty)].owner = Owner::None; + g.open_rune_chest(idx(tx, ty)); + assert_eq!(g.rune_available(), 1); + g.map.t[idx(tx, ty)].owner = Owner::Player; + let gold = g.gold; + g.build_tower(TowerKind::Rune, tx, ty); + assert_eq!(g.towers.len(), 1); + assert_eq!(g.gold, gold, "free"); + assert_eq!(g.rune_available(), 0); + g.build_tower(TowerKind::Rune, tx + 1, ty); + assert_eq!(g.towers.len(), 1, "only one per chest"); + // free play: selling gives the base tower back at once - but only once + g.sell_rect(tx, ty, tx, ty); + assert_eq!(g.rune_available(), 1); + g.build_tower(TowerKind::Rune, tx, ty); + assert!(g.towers[0].rune_final); + g.sell_rect(tx, ty, tx, ty); + assert_eq!(g.rune_available(), 0, "no second refund"); + // in a mission the tower comes back in the next one + let mut m = crate::campaign::start_mission(0, None); + let (cx, cy) = m.map.core; + m.rune_free = 1; + m.build_tower(TowerKind::Rune, cx + 4, cy + 1); + assert_eq!(m.towers.len(), 1, "built in the mission"); + m.sell_rect(cx + 4, cy + 1, cx + 4, cy + 1); + assert_eq!((m.rune_available(), m.rune_next), (0, 1)); + let c = crate::campaign::carry_from(&m); + let next = crate::campaign::start_mission(1, Some(&c)); + assert_eq!(next.rune_final, 1); + } + + #[test] + fn imps_repair_towers_before_claiming() { + let mut g = Game::new(4, 64, 64); + let (cx, cy) = g.map.core; + // plenty of unclaimed floor next to the dungeon + for y in cy - 3..=cy + 5 { + for x in cx + 6..=cx + 12 { + g.map.t[idx(x, y)].kind = Kind::Floor; + g.map.t[idx(x, y)].owner = Owner::None; + } + } + let (tx, ty) = (cx + 4, cy - 2); + g.map.t[idx(tx, ty)].kind = Kind::Floor; + g.map.t[idx(tx, ty)].owner = Owner::Player; + g.build_tower(TowerKind::Arrow, tx, ty); + assert_eq!(g.towers.len(), 1); + g.towers[0].hp = 50.0; + let gold = g.gold; + for _ in 0..(20 * 20) { + g.update(0.05); + } + assert!(g.towers[0].hp >= g.towers[0].max_hp() - 1.0, "repaired: {}", g.towers[0].hp); + assert!(g.gold >= gold - 1.0, "repairs are free"); + } + + #[test] + fn missions_have_dwarf_caves() { + let mut total = 0; + for i in 0..crate::campaign::missions().len() { + let g = crate::campaign::start_mission(i, None); + total += g.dwarf_caves.len(); + } + println!("{} dwarf caves in the campaign", total); + assert!(total >= 20); + } +} diff --git a/src/fortress.rs b/src/fortress.rs index a1bbe37..0a2c950 100644 --- a/src/fortress.rs +++ b/src/fortress.rs @@ -134,7 +134,7 @@ impl Game { let id = self.next_id; self.next_id += 1; let s = tower_stats(kind); - let mut tw = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: true }; + let mut tw = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: true, rune_final: false }; while tw.level < f.tower_level.min(3) { let old = tw.max_hp(); tw.level += 1; diff --git a/src/game.rs b/src/game.rs index afc2565..8d62a55 100644 --- a/src/game.rs +++ b/src/game.rs @@ -120,6 +120,8 @@ pub struct St { pub bounty: i32, } +pub const RUNE_TOOL: usize = 20; // index of the rune forge tower in ui::TOOLS + pub fn st(k: EK) -> St { let s = |name, hp, atk, range, speed, interval, wage, bounty| St { name, hp, atk, range, speed, interval, wage, bounty }; match k { @@ -322,7 +324,11 @@ impl Ent { } // a unit of a rival master pub fn rival(&self) -> bool { - !self.hero && self.side >= 2 + !self.hero && (self.side == 2 || self.side == 3) + } + // a dwarf of a dwarf cave (neutral, never an opponent for victory) + pub fn dwarf(&self) -> bool { + !self.hero && self.side == crate::dwarves::DWARF_SIDE } // a hero that is still fighting (not a prisoner) pub fn active_hero(&self) -> bool { @@ -436,6 +442,16 @@ pub struct Game { #[serde(skip)] pub springs: usize, // claimed mana springs (recounted) #[serde(default)] + pub dwarf_caves: Vec, + #[serde(default)] + pub dwarf_t: f32, + #[serde(default)] + pub rune_free: u32, // rune forge towers that can be built for free (from chests) + #[serde(default)] + pub rune_final: u32, // the same, but given back for a sold tower: no second refund + #[serde(default)] + pub rune_next: u32, // sold rune forge towers: back as a chest in the next mission + #[serde(default)] pub freed: u32, // chained creatures freed // tiles next to fighting heroes: knight ground there cannot be claimed #[serde(skip)] pub hints: bool, // show hint messages (setting) @@ -511,6 +527,7 @@ impl Game { 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); g.guard_treasures(); + g.place_dwarf_caves(seed); g.msg("msg.welcome1", vec![]); g.msg("msg.welcome2", vec![]); g.msg("msg.welcome3", vec![]); @@ -582,6 +599,11 @@ impl Game { gate_on: true, springs: 0, freed: 0, + dwarf_caves: Vec::new(), + dwarf_t: 30.0, + rune_free: 0, + rune_final: 0, + rune_next: 0, }; let (cx, cy) = g.map.core; // the soul core starts surrounded by a one tile wide ring of treasury @@ -615,6 +637,11 @@ impl Game { // ---------------------------------------------------------------- campaign missions + // the rune forge tower only shows up in the build menu once there is one to build or upgrade + pub fn tool_visible(&self, tool: usize) -> bool { + tool != RUNE_TOOL || self.rune_available() > 0 || self.towers.iter().any(|t| !t.hero && t.kind == TowerKind::Rune) + } + pub fn tool_allowed(&self, tool: usize) -> bool { self.mission.as_ref().map_or(true, |m| m.tools.get(tool).copied().unwrap_or(true)) } @@ -989,6 +1016,7 @@ impl Game { if !self.rivals.is_empty() { self.update_rivals(dt); } + self.update_dwarves(dt); self.check_end(); } @@ -1357,7 +1385,7 @@ impl Game { } // strength of a squad (same formula as the army value) - fn squad_value(list: &[(EK, u32)]) -> f32 { + pub(crate) fn squad_value(list: &[(EK, u32)]) -> f32 { let (mut h, mut d) = (0.0, 0.0); for &(k, l) in list { let s = st(k); @@ -1574,6 +1602,8 @@ impl Game { self.captive_update(e, dt); } else if e.rival() { self.rival_ai(e, dt); + } else if e.dwarf() { + self.dwarf_ai(e, dt); } else if e.hero { self.hero_ai(e, dt); } else if e.kind == EK::Worker { @@ -1900,6 +1930,10 @@ 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 == crate::dwarves::DWARF_SIDE { + let got = self.add_gold(s.bounty as f32 * 0.5); + self.sfx(Sfx::HeroDeath, x, y); + self.msg("msg.dwarf_slain", vec![(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); @@ -2161,6 +2195,9 @@ impl Game { self.sfx(Sfx::Magic, x, y); self.msg("msg.spring_taken", vec![]); } + if t.kind == Kind::RuneChest { + self.open_rune_chest(i); + } if t.kind == Kind::Shard { self.shards_taken += 1; self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff6060ff); @@ -2169,6 +2206,16 @@ impl Game { } } + // a dwarven chest: the plans of a rune forge tower (one free build) + pub fn open_rune_chest(&mut self, i: usize) { + let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5); + self.map.t[i].kind = Kind::Floor; + self.rune_free += 1; + self.fx(7, x, y, 0.0, 0.0, 1.4, 0xff40c0ff); + self.sfx(Sfx::LevelUp, x, y); + self.msg("msg.rune_chest", vec![]); + } + // a fortress unit spotted an enemy: guards and patrols nearby join the fight fn alarm(&mut self, x: f32, y: f32, target: u32) { for o in self.ents.iter_mut() { @@ -2294,7 +2341,7 @@ impl Game { fn claimable(&self, i: usize, me: u32) -> bool { let t = &self.map.t[i]; let ok = match t.owner { - Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring), + Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard | Kind::Treasury | Kind::ManaSpring | Kind::RuneChest), // knight ground (also vaults and shard chambers), but not while heroes fight there // knight and rival ground (also vaults and shard chambers), but not while enemies fight there Owner::Hero | Owner::Rival1 | Owner::Rival2 => { @@ -2604,13 +2651,7 @@ impl Game { if e.carry > 0 && self.start_deliver(e) { return; } - if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) { - let end = p.last().copied().unwrap_or(cur); - self.map.t[end].reserved = me; - e.path = p; - e.job = Job::Claim(end); - return; - } + // damaged towers are repaired (free of charge) before more ground is claimed if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) { let end = p.last().copied().unwrap_or(cur); if let Some(id) = self.damaged_tower_near(end) { @@ -2619,6 +2660,13 @@ impl Game { return; } } + if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.claimable(i, me), true) { + let end = p.last().copied().unwrap_or(cur); + self.map.t[end].reserved = me; + e.path = p; + e.job = Job::Claim(end); + return; + } if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.reinforce_neighbor(i, me).is_some(), true) { let end = p.last().copied().unwrap_or(cur); if let Some(w) = self.reinforce_neighbor(end, me) { @@ -3662,6 +3710,16 @@ impl Game { let i = idx(x, y); if let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) { refund += self.towers[k].invested() as f32 * 0.5; + if self.towers[k].kind == TowerKind::Rune && !self.towers[k].rune_final { + // the base tower comes back once: in the next mission (free play: at once) + if self.mission.is_some() { + self.rune_next += 1; + self.msg("msg.rune_next", vec![]); + } else { + self.rune_final += 1; + self.msg("msg.rune_back", vec![]); + } + } self.towers.remove(k); self.map.t[i].kind = Kind::Floor; continue; @@ -3758,11 +3816,25 @@ impl Game { self.sfx_ui(Sfx::Error); return; } + // rune forge towers: one build per opened chest (fresh plans first) + let mut rune_final = false; + if kind == TowerKind::Rune { + if self.rune_free > 0 { + self.rune_free -= 1; + } else if self.rune_final > 0 { + self.rune_final -= 1; + rune_final = true; + } else { + self.hint(24, "hint.rune_none"); + self.sfx_ui(Sfx::Error); + return; + } + } self.gold -= cost; self.map.t[i].kind = Kind::Tower; let id = self.next_id; self.next_id += 1; - let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false }; + let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false, rune_final }; let (px, py) = tower.pos(); self.towers.push(tower); // units standing on the tile step aside @@ -3826,8 +3898,7 @@ impl Game { for (j, e) in self.ents.iter().enumerate() { // knight towers shoot the player's units, the player's towers shoot heroes // knight towers shoot everybody but the humans, the player's towers everybody else - let enemy = e.captive == 0 && !e.chained && if t.hero { e.side() != 1 } else { e.side() != 0 }; - if !e.alive || e.held || !enemy { + if !e.alive || e.held || !self.tower_enemy(t, e) { continue; } let d = (e.x - px).powi(2) + (e.y - py).powi(2); @@ -3910,18 +3981,20 @@ impl Game { TowerKind::Arrow => (px, py, t.top() + 0.25), TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2), TowerKind::Magic => (px, py, t.top() + 0.45), + TowerKind::Rune => (px, py, t.top() + 0.55), }; let col = match t.kind { TowerKind::Arrow => 0xffd0e0f0, TowerKind::Cannon => 0xff303030, TowerKind::Magic => 0xffff60d0, + TowerKind::Rune => 0xff40c0ff, }; self.shot(mx, my, mh, ex, ey, eh, 0.3, col); self.fx_at(2, ex, ey, eh, 0.3, 0xffffffff); self.sfx(match t.kind { TowerKind::Arrow => Sfx::Shoot, TowerKind::Cannon => Sfx::Cannon, - TowerKind::Magic => Sfx::Magic, + TowerKind::Magic | TowerKind::Rune => Sfx::Magic, }, px, py); let dmg = t.dmg(); match target { @@ -3983,6 +4056,7 @@ impl Game { TowerKind::Arrow => (px, py, t.top() + 0.25), TowerKind::Cannon => (px + aim.cos() * 0.55, py + aim.sin() * 0.55, t.top() + 0.2), TowerKind::Magic => (px, py, t.top() + 0.45), + TowerKind::Rune => (px, py, t.top() + 0.55), }; let dmg = t.dmg(); match t.kind { @@ -4065,10 +4139,46 @@ impl Game { } } } + TowerKind::Rune => { + // a rune bolt that jumps on to up to three more enemies nearby + self.shot(mx, my, mh, ex, ey, 0.45, 0.25, 0xff40c0ff); + self.fx(2, ex, ey, 0.0, 0.0, 0.35, 0xffffc040); + self.sfx(Sfx::Magic, px, py); + let mut hit = vec![j]; + let (mut lx, mut ly) = (ex, ey); + self.tower_damage(j, dmg); + for n in 0..3 { + let next = self + .ents + .iter() + .enumerate() + .filter(|(o, e)| !hit.contains(o) && e.alive && !e.held && self.tower_enemy(&t, e) && (e.x - lx).powi(2) + (e.y - ly).powi(2) < 3.5 * 3.5) + .min_by(|a, b| { + let da = (a.1.x - lx).powi(2) + (a.1.y - ly).powi(2); + let db = (b.1.x - lx).powi(2) + (b.1.y - ly).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(o, _)| o); + let Some(o) = next else { break }; + let (ox, oy) = (self.ents[o].x, self.ents[o].y); + self.fx(0, lx, ly, ox, oy, 0.25, 0xff40c0ff); + hit.push(o); + if self.ents[o].alive { + self.tower_damage(o, dmg * (0.75 - 0.1 * n as f32)); + } + lx = ox; + ly = oy; + } + } } } } + // a unit the tower shoots at + fn tower_enemy(&self, t: &Tower, e: &Ent) -> bool { + e.captive == 0 && !e.chained && if t.hero { e.side() != 1 } else { e.side() != 0 } + } + // a hero attacks a tower; false = tower gone or unreachable fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool { let Some(k) = self.towers.iter().position(|t| t.id == id) else { return false }; @@ -4127,8 +4237,8 @@ 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.rival() || e.dwarf() { + return false; // the rivals' creatures and the dwarves cannot be picked up } if e.hero { if e.captive != 1 { @@ -4209,6 +4319,9 @@ 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].dwarf() { + return 0; + } if !self.ents[j].hero && !self.ents[j].rival() { let e = &mut self.ents[j]; e.boost = 15.0; @@ -4323,6 +4436,7 @@ pub enum TowerKind { Arrow, Cannon, Magic, + Rune, // dwarven rune forge tower: from a chest of a dwarf cave, built for free } pub struct TowerStats { @@ -4340,6 +4454,7 @@ pub fn tower_stats(k: TowerKind) -> TowerStats { TowerKind::Arrow => TowerStats { cost: [250, 200, 300], hp: 300.0, dmg: 10.0, range: 6.0, min_range: 0.0, interval: 0.8, mana: 0.0 }, TowerKind::Cannon => TowerStats { cost: [450, 350, 500], hp: 450.0, dmg: 32.0, range: 8.5, min_range: 2.0, interval: 2.6, mana: 0.0 }, TowerKind::Magic => TowerStats { cost: [350, 300, 450], hp: 300.0, dmg: 24.0, range: 7.0, min_range: 0.0, interval: 1.5, mana: 8.0 }, + TowerKind::Rune => TowerStats { cost: [0, 800, 1200], hp: 1400.0, dmg: 55.0, range: 10.0, min_range: 0.0, interval: 1.3, mana: 0.0 }, } } @@ -4348,6 +4463,7 @@ pub fn tower_name(k: TowerKind) -> &'static str { TowerKind::Arrow => "tower.arrow", TowerKind::Cannon => "tower.cannon", TowerKind::Magic => "tower.magic", + TowerKind::Rune => "tower.rune", } } @@ -4363,6 +4479,8 @@ pub struct Tower { pub flash: f32, #[serde(default)] pub hero: bool, // tower of a knight fortress: shoots the player's units + #[serde(default)] + pub rune_final: bool, // rune forge tower built from a refund: selling it gives nothing back } impl Tower { @@ -4386,6 +4504,7 @@ impl Tower { TowerKind::Arrow => 1.0 + 0.3 * (self.level - 1) as f32, TowerKind::Cannon => 0.6 + 0.15 * (self.level - 1) as f32, TowerKind::Magic => 1.2 + 0.3 * (self.level - 1) as f32, + TowerKind::Rune => 1.4 + 0.25 * (self.level - 1) as f32, } } // gold spent on this tower so far @@ -4615,7 +4734,9 @@ mod tests { g.gold = g.gold_cap(); g.update(0.01); assert!((treasury_total(&g) - g.gold).abs() < 2.0); - assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + 1.0); + // (tiles partly filled with rubies take no gold, so the core may hold a little more) + let gem_room: f32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Player && t.res != 0).map(|t| TREASURY_TILE - t.gold as f32).sum(); + assert!(g.reserve > 0.0 && g.reserve <= CORE_STORE + gem_room + 1.0, "reserve {} gold {} cap {}", g.reserve, g.gold, g.gold_cap()); } #[test] diff --git a/src/gfx.rs b/src/gfx.rs index 7a473ca..7f5e50b 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -69,6 +69,7 @@ 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 const I_RUNE: u32 = 143; // rune forge tower pub struct Gfx { pub atlas: Texture2D, @@ -570,6 +571,23 @@ fn gen_atlas() -> Image { None } }); + cv.cell(I_RUNE, |x, y| { + // a stone obelisk with a glowing rune, bronze bands + let body = (5..=10).contains(&x) && y >= 3 && y <= 14 && !(y == 3 && (x == 5 || x == 10)); + if !body { + return None; + } + let rune = (x == 7 || x == 8) && (6..=10).contains(&y) || (y == 8 && (6..=9).contains(&x)); + Some(if rune { + c(90, 210, 255) + } else if y == 4 || y == 12 { + c(190, 120, 60) + } else if (x + y) % 5 == 0 { + c(80, 80, 92) + } else { + c(110, 110, 124) + }) + }); cv.cell(I_SUMMON, |x, y| { let d = (((x - 8) * (x - 8) + (y - 8) * (y - 8)) as f32).sqrt(); let a = ((y - 8) as f32).atan2((x - 8) as f32); @@ -1383,6 +1401,7 @@ impl Gfx { Kind::Aerie => T_AERIE + v, Kind::Steel => T_STEEL + v, Kind::ManaSpring => T_PORTALFLOOR + v, + Kind::RuneChest => T_FLOOR + v, } } diff --git a/src/main.rs b/src/main.rs index 3baf024..28bb402 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,6 +3,7 @@ mod audio; mod campaign; mod cloud; +mod dwarves; mod fortress; mod game; mod gfx; diff --git a/src/map.rs b/src/map.rs index 0067e11..5ab150d 100644 --- a/src/map.rs +++ b/src/map.rs @@ -60,13 +60,14 @@ pub enum Kind { Aerie, // room for flying creatures (their beds) Steel, // steel wall: hardly anyone gets through ManaSpring, // crystal spring: once claimed, more mana and a higher mana maximum + RuneChest, // dwarven chest with the plans of a rune forge tower (claim it: one free build) } // options for the map generator (campaign missions) #[derive(Clone, Debug)] pub struct GenOpts { pub portals: Option, // None: one per 64 x 64 tiles - pub treasures: Option, // guarded treasure caves; None: portals - 1 + pub treasures: Option, // knight treasure caves; None: none (free play has dwarf caves instead) pub gate: bool, // hero gate at the far side pub core_y: Option, // row of the core (None: about the middle) } @@ -88,7 +89,7 @@ pub struct River { } // order matters for the packed savegame codec: only append new kinds -const KINDS: [Kind; 26] = [ +const KINDS: [Kind; 27] = [ Kind::Rock, Kind::Earth, Kind::Gold, @@ -115,6 +116,7 @@ const KINDS: [Kind; 26] = [ Kind::Aerie, Kind::Steel, Kind::ManaSpring, + Kind::RuneChest, ]; // treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond @@ -210,6 +212,7 @@ impl Tile { | Kind::Shard | Kind::Aerie | Kind::ManaSpring + | Kind::RuneChest ) } // river tiles (open, but nobody can walk there) @@ -349,7 +352,7 @@ impl Map { let area = (w * h) as f32 / 4096.0; let scale = |n: f32| (n * area).round() as i32; let n_portals = opts.portals.unwrap_or((area.round() as usize).max(1)); - let n_treasures = opts.treasures.unwrap_or(n_portals.saturating_sub(1)); + let n_treasures = opts.treasures.unwrap_or(0); let mut t = vec![Tile::new(Kind::Earth); w * h]; for (i, tile) in t.iter_mut().enumerate() { diff --git a/src/props.rs b/src/props.rs index 7e20e62..8f4ef0b 100644 --- a/src/props.rs +++ b/src/props.rs @@ -98,6 +98,7 @@ pub fn draw_props_solid(g: &Game, cam: &Cam, t: f32) { Kind::Library if z % 2 == 0 => shelf(c, h, l), Kind::Library if h % 3 == 0 => lectern(c, l), Kind::ManaSpring => spring_basin(c, l), + Kind::RuneChest => rune_chest(c, l), _ => {} } } @@ -118,6 +119,16 @@ pub fn draw_props_solid(g: &Game, cam: &Cam, t: f32) { } } +// the dwarves' iron-bound chest with the rune forge plans (the rune glows in the glow pass) +fn rune_chest(c: Vec3, l: f32) { + cube(c + vec3(0.0, 0.16, 0.0), vec3(0.5, 0.3, 0.34), rgb(92, 60, 34, l)); + cube(c + vec3(0.0, 0.34, 0.0), vec3(0.52, 0.08, 0.36), rgb(110, 72, 40, l)); + for dx in [-0.18f32, 0.18] { + cube(c + vec3(dx, 0.2, 0.0), vec3(0.05, 0.4, 0.38), rgb(176, 112, 56, l)); + } + cube(c + vec3(0.0, 0.22, 0.18), vec3(0.08, 0.1, 0.03), rgb(200, 170, 80, l)); +} + // a stone basin around a mana spring fn spring_basin(c: Vec3, l: f32) { for (dx, dz, sx, sz) in [(0.0, -0.38, 0.8, 0.1), (0.0, 0.38, 0.8, 0.1), (-0.38, 0.0, 0.1, 0.8), (0.38, 0.0, 0.1, 0.8)] { @@ -183,6 +194,11 @@ pub fn draw_props_glow(g: &Game, cam: &Cam, t: f32) { } } } + Kind::RuneChest => { + let f = 0.6 + 0.4 * (t * 2.5 + k).sin(); + draw_cube(c + vec3(0.0, 0.39, 0.0), vec3(0.14, 0.02, 0.14), None, Color::new(0.35, 0.8, 1.0, f)); + draw_sphere(c + vec3(0.0, 0.45, 0.0), 0.2, None, Color::new(0.3, 0.7, 1.0, 0.15 * f)); + } Kind::ManaSpring => { // rising mana: a pulsing pool and motes that drift up let f = 0.8 + 0.2 * (t * 2.0 + k).sin(); diff --git a/src/render3d.rs b/src/render3d.rs index af418ed..8b031c9 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1347,6 +1347,34 @@ fn draw_towers_solid(g: &Game) { bx(vec3(dx, h + 0.12, dz), vec3(0.06, 0.18, 0.06), col(120, 104, 140)); } } + TowerKind::Rune => { + // dwarven rune forge: broad granite foot, bronze bands, a tapering keep with + // carved buttresses, an anvil crown and a forge hearth on top (runes glow) + bx(vec3(0.0, 0.07, 0.0), vec3(0.96, 0.14, 0.96), col(70, 66, 70)); + bx(vec3(0.0, 0.19, 0.0), vec3(0.84, 0.1, 0.84), col(84, 80, 86)); + let steps = 5; + for k in 0..steps { + let y0 = 0.24 + (h - 0.24) * k as f32 / steps as f32; + let hh = (h - 0.24) / steps as f32; + let w = 0.72 - k as f32 * 0.05; + bx(vec3(0.0, y0 + hh * 0.5, 0.0), vec3(w, hh + 0.005, w), col(96 - k as u8 * 2, 92 - k as u8 * 2, 98)); + bx(vec3(0.0, y0 + 0.01, 0.0), vec3(w + 0.03, 0.035, w + 0.03), shade_c(176, 112, 56, f)); + } + for (dx, dz) in [(0.38f32, 0.38f32), (-0.38, 0.38), (0.38, -0.38), (-0.38, -0.38)] { + bx(vec3(dx, h * 0.3, dz), vec3(0.14, h * 0.6, 0.14), col(78, 74, 80)); + bx(vec3(dx, h * 0.6 + 0.05, dz), vec3(0.17, 0.08, 0.17), shade_c(176, 112, 56, f)); + } + // gate with a bronze door + bx(vec3(0.0, 0.42, 0.37), vec3(0.26, 0.4, 0.04), shade_c(120, 76, 40, f)); + bx(vec3(0.0, 0.64, 0.38), vec3(0.32, 0.06, 0.05), col(110, 106, 112)); + // anvil crown and hearth + bx(vec3(0.0, h + 0.05, 0.0), vec3(0.62, 0.1, 0.62), col(60, 58, 64)); + bx(vec3(0.0, h + 0.16, 0.0), vec3(0.3, 0.12, 0.42), col(48, 48, 54)); + bx(vec3(0.0, h + 0.25, 0.0), vec3(0.42, 0.08, 0.56), col(56, 56, 62)); + for (dx, dz) in [(-0.27f32, -0.27f32), (0.27, -0.27), (0.27, 0.27), (-0.27, 0.27)] { + bx(vec3(dx, h + 0.16, dz), vec3(0.08, 0.22, 0.08), shade_c(176, 112, 56, f)); + } + } } // level pips on the plinth for k in 0..tw.level { @@ -1357,6 +1385,29 @@ fn draw_towers_solid(g: &Game) { fn draw_towers_glow(g: &Game, t: f32) { for (k, tw) in g.towers.iter().enumerate() { + if tw.kind == TowerKind::Rune { + // forge glow on the crown, runes on the keep and three rune stones circling it + let (x, z) = tw.pos(); + let h = tower_height(tw); + let pulse = 1.0 + (t * 3.0 + k as f32).sin() * 0.12 + tw.flash * 2.5; + draw_sphere(vec3(x, h + 0.34, z), 0.1 * pulse, None, Color::new(1.0, 0.75, 0.3, 0.95)); + draw_sphere(vec3(x, h + 0.36, z), 0.24 * pulse, None, Color::new(1.0, 0.55, 0.15, 0.22)); + for r in 0..4 { + let y = 0.4 + (h - 0.5) * r as f32 / 4.0; + let glow = 0.35 + 0.65 * ((t * 2.0 - r as f32 * 0.9 + k as f32).sin() * 0.5 + 0.5); + let w = 0.72 - r as f32 * 0.05; + for (dx, dz) in [(w * 0.5 + 0.01, 0.0f32), (-w * 0.5 - 0.01, 0.0), (0.0, w * 0.5 + 0.01), (0.0, -w * 0.5 - 0.01)] { + draw_cube(vec3(x + dx, y + 0.1, z + dz), vec3(if dx == 0.0 { 0.12 } else { 0.02 }, 0.1, if dz == 0.0 { 0.12 } else { 0.02 }), None, Color::new(0.35, 0.8, 1.0, glow)); + } + } + for n in 0..(2 + tw.level) { + let a = t * 1.2 + n as f32 * std::f32::consts::TAU / (2 + tw.level) as f32; + let p = vec3(x + a.cos() * 0.62, h * 0.7 + (t * 2.0 + n as f32).sin() * 0.08, z + a.sin() * 0.62); + draw_cube(p, vec3(0.1, 0.12, 0.1), None, Color::new(0.3, 0.75, 1.0, 0.9)); + draw_sphere(p, 0.12, None, Color::new(0.3, 0.7, 1.0, 0.2)); + } + continue; + } if tw.kind != TowerKind::Magic { continue; } @@ -1595,6 +1646,8 @@ 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.dwarf() { + Color::new(0.85, 0.55, 0.25, 0.85) } else if e.rival() { if e.side == 2 { Color::new(0.35, 0.95, 0.4, 0.85) @@ -1806,6 +1859,7 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] { Kind::Aerie => [170, 140, 90, 255], Kind::Steel => [150, 160, 180, 255], Kind::ManaSpring => [90, 230, 255, 255], + Kind::RuneChest => [80, 200, 255, 255], } } diff --git a/src/rival.rs b/src/rival.rs index fe71253..61bc93b 100644 --- a/src/rival.rs +++ b/src/rival.rs @@ -85,6 +85,7 @@ impl Game { 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.place_dwarf_caves(seed); g } @@ -267,7 +268,7 @@ impl Game { } } - fn eliminate_rival(&mut self, k: usize) { + pub(crate) fn eliminate_rival(&mut self, k: usize) { let side = self.rivals[k].side; let owner = Owner::of_side(side); self.rivals[k].alive = false; diff --git a/src/save.rs b/src/save.rs index 5471a29..645d495 100644 --- a/src/save.rs +++ b/src/save.rs @@ -98,7 +98,8 @@ mod tests { let mut g = Game::new(7, 256, 256); let gen = t0.elapsed(); assert_eq!(g.map.portals.len(), 16); - assert_eq!(g.map.treasures.len(), 15); + assert_eq!(g.map.treasures.len(), 0); // knight caves are replaced by dwarf caves + assert!((1..=4).contains(&g.dwarf_caves.len())); let (cx, cy) = g.map.core; g.mark_rect(cx - 5, cy - 5, cx + 8, cy + 8, true); let t1 = std::time::Instant::now(); diff --git a/src/settings.rs b/src/settings.rs index ca486c3..2d79c2e 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -25,7 +25,7 @@ pub enum Action { } pub fn all_actions() -> Vec { - let mut v: Vec = (0..20).map(Action::Tool).collect(); + let mut v: Vec = (0..21).map(Action::Tool).collect(); v.extend_from_slice(&[ Action::CamForward, Action::CamBack, @@ -70,7 +70,7 @@ impl Action { fn default_key(self) -> KeyCode { use KeyCode::*; match self { - Action::Tool(i) => [Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, Key0, X, V, K, J, B, C, T, U, Y, O][i.min(19)], + Action::Tool(i) => [Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, Key0, X, V, K, J, B, C, T, U, Y, O, N][i.min(20)], Action::CamForward => W, Action::CamBack => S, Action::CamLeft => A, diff --git a/src/ui.rs b/src/ui.rs index 4dbe537..f198bcd 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -160,7 +160,7 @@ pub enum Tool { } // New tools are appended so that existing key bindings ("tool") keep their meaning -pub const TOOLS: [Tool; 20] = [ +pub const TOOLS: [Tool; 21] = [ Tool::Dig, Tool::Hand, Tool::Room(0), @@ -181,6 +181,7 @@ pub const TOOLS: [Tool; 20] = [ Tool::Bridge, Tool::Room(7), Tool::Steel, + Tool::Tower(TowerKind::Rune), ]; @@ -206,6 +207,7 @@ pub fn tool_info(t: Tool) -> ToolInfo { Tool::Tower(TowerKind::Arrow) => ("arrow", I_ARROW, None), Tool::Tower(TowerKind::Cannon) => ("cannon", I_CANNON, None), Tool::Tower(TowerKind::Magic) => ("magic", I_MAGIC, None), + Tool::Tower(TowerKind::Rune) => ("rune", I_RUNE, None), Tool::Sell => ("sell", I_SELL, None), Tool::Flag => ("flag", I_FLAG, None), Tool::Lightning => ("lightning", I_BOLT, None), @@ -229,6 +231,10 @@ pub fn tool_cost(t: Tool, g: &Game) -> String { Tool::Summon => trf("cost.mana", &[(g.summon_cost() as i32).to_string()]), Tool::Bridge => trf("cost.bridge", &[BRIDGE_COST[0].to_string(), BRIDGE_COST[1].to_string()]), Tool::Steel => trf("cost.steel", &[STEEL_COST[0].to_string(), STEEL_COST[1].to_string()]), + Tool::Tower(TowerKind::Rune) => { + let s = tower_stats(TowerKind::Rune); + trf("cost.rune", &[g.rune_available().to_string(), s.cost[1].to_string(), s.cost[2].to_string()]) + } Tool::Tower(k) => { let s = tower_stats(k); let mut c = trf("cost.tower", &[s.cost[0].to_string(), s.cost[1].to_string(), s.cost[2].to_string()]); @@ -246,7 +252,7 @@ pub const CATS: [(&str, &[usize]); 6] = [ ("cat.dig", &[0]), ("cat.hand", &[1]), ("cat.rooms", &[2, 3, 4, 5, 6, 12, 13, 18, 17]), - ("cat.towers", &[14, 15, 16]), + ("cat.towers", &[14, 15, 16, 20]), ("cat.spells", &[9, 10, 11]), ("cat.orders", &[8, 7, 19]), ]; @@ -654,6 +660,8 @@ impl Ui { if e.alive && !e.held { let c = if e.hero { Color::new(0.6, 0.85, 1.0, 1.0) + } else if e.dwarf() { + Color::new(0.85, 0.55, 0.25, 1.0) } else if e.rival() { if e.side == 2 { Color::new(0.3, 0.95, 0.4, 1.0) @@ -762,7 +770,7 @@ impl Ui { // the open row of tools (wraps into several rows if needed) if let Some((c, x0, bw, bcols)) = popup { - let tools = CATS[c].1; + let tools: Vec = CATS[c].1.iter().copied().filter(|&i| g.tool_visible(i)).collect(); let per_row = (((cols - 2) as f32 / bcols).floor() as usize).max(1).min(tools.len()); let nrows = (tools.len() + per_row - 1) / per_row; let pw = per_row as f32 * bw + 2.0 * CW(); @@ -1052,6 +1060,11 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec out.push((tr(["", "info.unconscious", "info.carried", "info.tortured"][e.captive.min(3) as usize]), RED)); return out; } + if e.dwarf() { + out.push((tr("info.dwarf"), Color::new(0.9, 0.6, 0.3, 1.0))); + out.push((tr("info.dwarf_neutral"), DIM)); + return out; + } out.push((tr(job_name(e)), TXT)); if e.hero { out.push((tr(if e.guard { "info.guard" } else { "info.intruder" }), BLUE)); @@ -1136,6 +1149,10 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec out.push((tr("tile.shard"), RED)); out.push((tr("tile.shard_info"), DIM)); } + Kind::RuneChest => { + out.push((tr("tile.rune_chest"), BLUE)); + out.push((tr("tile.rune_chest_info"), DIM)); + } Kind::Gate => { out.push((tr("tile.gate"), BLUE)); out.push((trf("info.hp", &[(g.gate_hp as i32).to_string(), (GATE_HP as i32).to_string()]), BLUE));