From c5b7896609028c5c91889fbd7ce54d011c999ce9 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 09:14:39 +0100 Subject: [PATCH] Chainbreaker: rare, very strong original creature (twin-axe cleave, restless without fights) Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 5 ++++ lang/de.lang | 3 ++ lang/en.lang | 3 ++ src/game.rs | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ src/gfx.rs | 18 ++++++++++-- src/render3d.rs | 8 +++--- src/save.rs | 5 +++- 7 files changed, 111 insertions(+), 7 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index ede93ed..8e0ab86 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -78,6 +78,11 @@ An original dungeon management game (genre: dungeon building & defence) that run - **Torture chamber**: one prisoner per 2 tiles. Defeated heroes are knocked out (if there is room) and carried in by imps; torture drains their HP over ~100 s, ~40% defect (paladins ~25%) and become own creatures (needs, wage, lair). Attracts the **Jailer** (6+ tiles): tanky melee that pulls enemies in with a chain - **Temple**: creatures pray for mana (more than studying) and mood. Attracts the **Flame Priestess** (6+ tiles): fireballs with splash damage, heals creatures, heals the Soul Core (~8 HP/s) when it is below 90% and no fight is nearby +## Chainbreaker (rare creature) +- Original design: massive obsidian demon with glowing cracks, broken chains and twin axes; very strong (600 HP, 36 attack), its axes also hit a second enemy nearby +- Comes through the portal only rarely, and only to great dungeons: 40+ room tiles, a creature of level 5+, 4000+ gold; only one at a time +- Restless: after two minutes without a fight its mood sinks + ## Hero wave AI - Each wave picks an **entry point** on the border of the realm facing the hero gate: few towers/creatures, no reinforced walls and a short way are attractive, entry points of recent waves are avoided, plus some randomness - Waves of **6+ heroes split into two groups** with different entry points (the paladin goes with the bigger group) diff --git a/lang/de.lang b/lang/de.lang index b322690..09ac531 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -340,3 +340,6 @@ set.render_scale = 3D-Auflösung hint.pickup_prisoner_where = Gefangene können nur auf eigenem Gebiet aufgehoben werden. hint.punch_where = Die Hand kann Helden nur auf eigenem Gebiet schlagen. + +unit.chainbreaker = Kettenbrecher +hint.chainbreaker_restless = Der Kettenbrecher wird unruhig: Er will kämpfen! diff --git a/lang/en.lang b/lang/en.lang index 5bc5800..cf510e7 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -341,3 +341,6 @@ set.render_scale = 3D resolution hint.pickup_prisoner_where = Prisoners can only be picked up on your own territory. hint.punch_where = The hand can only punch heroes on your own territory. + +unit.chainbreaker = Chainbreaker +hint.chainbreaker_restless = The Chainbreaker is restless: it wants something to fight! diff --git a/src/game.rs b/src/game.rs index 3a78404..f391f22 100644 --- a/src/game.rs +++ b/src/game.rs @@ -94,6 +94,7 @@ pub enum EK { Paladin, Jailer, // attracted by the torture chamber Priestess, // attracted by the temple + Chainbreaker, // rare and very strong } pub struct St { @@ -123,6 +124,7 @@ pub fn st(k: EK) -> St { EK::Paladin => s("unit.paladin", 700.0, 26.0, 1.0, 1.7, 1.2, 220, 1000), EK::Jailer => s("unit.jailer", 260.0, 16.0, 1.0, 1.6, 1.3, 140, 0), EK::Priestess => s("unit.priestess", 70.0, 14.0, 5.0, 2.0, 1.6, 120, 0), + EK::Chainbreaker => s("unit.chainbreaker", 600.0, 36.0, 1.1, 2.3, 1.1, 400, 0), } } @@ -720,6 +722,13 @@ impl Game { if self.counts[6] >= 6 { opts.push((EK::Priestess, 2)); } + // the Chainbreaker only visits big dungeons with experienced creatures and a rich treasury + let room_tiles: usize = self.counts.iter().sum(); + let veteran = self.ents.iter().any(|e| e.alive && e.creature() && e.level >= 5); + let has_one = self.ents.iter().any(|e| e.alive && e.kind == EK::Chainbreaker && !e.hero); + if room_tiles >= 40 && veteran && self.gold >= 4000.0 && !has_one { + opts.push((EK::Chainbreaker, 1)); + } let total: u32 = opts.iter().map(|o| o.1).sum(); let mut r = self.rng.next() % total; let mut kind = EK::Kobold; @@ -1160,6 +1169,25 @@ impl Game { self.sfx(Sfx::MeleeHit, tx, ty); } 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 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) + .map(|(o, _)| o); + if let Some(o) = second { + let (ox, oy) = (self.ents[o].x, self.ents[o].y); + self.fx(2, ox, oy - 0.2, 0.0, 0.0, 0.25, 0xff40a0ff); + self.ents[o].hp -= dmg * 0.5; + self.ents[o].flash = 0.15; + if self.ents[o].hp <= 0.0 { + self.kill(o); + } + } + } if e.kind == EK::Priestess { // fireball: half damage to enemies around the target self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff); @@ -1617,6 +1645,15 @@ impl Game { } } } + if e.kind == EK::Chainbreaker { + // restless: without a fight for two minutes its mood sinks (skill_cd counts down as idle time) + if matches!(e.job, Job::Attack(_)) { + e.skill_cd = 0.0; + } else if e.skill_cd < -120.0 { + e.happy -= 0.005 * dt; + self.hint(20, "hint.chainbreaker_restless"); + } + } if let Job::Attack(id) = e.job { if !self.attack_job(e, id, dt) { e.job = Job::None; @@ -3270,5 +3307,44 @@ mod tests { assert!(g.pickup(Some(imp))); assert!(!g.drop(40.5, 40.5) || g.map.t[idx(40, 40)].owner == Owner::Player); } + + #[test] + fn chainbreaker_visits_only_great_dungeons() { + let mut g = Game::new(8, 64, 64); + g.gold = 9000.0; + // conditions not met: no Chainbreaker among many portal visits + let (px, py) = g.map.portals[0]; + for y in 0..3 { + for x in 0..3 { + g.map.t[idx(px + x, py + y)].owner = Owner::Player; + } + } + let (cx, cy) = g.map.core; + g.build_rect(1, cx + 4, cy - 3, cx + 5, cy + 5); // lair 18 + g.build_rect(2, cx - 3, cy - 3, cx - 2, cy + 5); // farm 18 + g.recount(); + for _ in 0..60 { + g.try_portal(); + } + assert!(!g.ents.iter().any(|e| e.kind == EK::Chainbreaker)); + // a veteran creature makes it possible (rooms 16 ring + 36 = 52 tiles, gold 9000) + if let Some(e) = g.ents.iter_mut().find(|e| e.creature()) { + e.level = 5; + } + for e in g.ents.iter_mut().filter(|e| e.creature()) { + e.alive = false; // free beds + } + g.ents.retain(|e| e.alive); + g.spawn(EK::Kobold, false, cx as f32, cy as f32, 6); + let mut seen = false; + for _ in 0..400 { + g.gold = 9000.0; + g.try_portal(); + seen |= g.ents.iter().any(|e| e.kind == EK::Chainbreaker); + g.ents.retain(|e| !e.creature() || e.kind == EK::Chainbreaker || e.level >= 5); + } + assert!(seen); + assert!(g.ents.iter().filter(|e| e.kind == EK::Chainbreaker).count() <= 1); + } } diff --git a/src/gfx.rs b/src/gfx.rs index fb8c605..3a4427f 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -675,6 +675,19 @@ fn sprite_rows(k: EK) -> [&'static str; 10] { ".kRRRRRRk.", "..kkkkkk..", ], + // obsidian demon with glowing cracks, broken chains and twin axes + EK::Chainbreaker => [ + "s.kkkkkk.s", + "sskdoodkss", + ".SkeddekS.", + ".SkdoodkS.", + "kdddoodddk", + "kdoddddodk", + "SkddooddkS", + ".kddddddk.", + ".kdok.odk.", + ".kkk..kkk.", + ], EK::Paladin => [ "...kkkk..w", "..kyyyyk.w", @@ -714,13 +727,14 @@ fn pal(ch: char) -> Option { 'C' => c(96, 98, 92), 'o' => c(226, 124, 48), 'O' => c(160, 72, 24), + 'd' => c(44, 36, 48), _ => return None, }) } -pub const SPRITE_KINDS: [EK; 12] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin, EK::Jailer, EK::Priestess]; +pub const SPRITE_KINDS: [EK; 13] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin, EK::Jailer, EK::Priestess, EK::Chainbreaker]; // width of the sprite texture in pixels -pub const SPRITE_TEX_W: f32 = (12 * 12) as f32; +pub const SPRITE_TEX_W: f32 = (13 * 12) as f32; pub const SPRITE_TEX_H: f32 = 24.0; pub fn sprite_index(k: EK) -> u32 { diff --git a/src/render3d.rs b/src/render3d.rs index 4d617c3..b145df1 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -976,8 +976,8 @@ impl R3d { if !e.alive || e.held { continue; } - let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer); - let w = if big { 0.8 } else { 0.62 }; + let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker); + let w = if e.kind == EK::Chainbreaker { 1.0 } else if big { 0.8 } else { 0.62 }; let h = w; let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving; let mut bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; @@ -1045,7 +1045,7 @@ impl R3d { if !e.alive || e.held { continue; } - let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer); + let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker); let head = proj(vec3(e.x, if big { 0.9 } else { 0.72 }, e.y)); // bar width in block characters, depending on the zoom let cols = ((px_per_tile * 0.6 / cw) as usize).clamp(3, 8); @@ -1265,7 +1265,7 @@ fn draw_shadows(g: &Game) { if !e.alive || e.held { continue; } - let r = if matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer) { 0.32 } else { 0.24 }; + let r = if matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker) { 0.32 } else { 0.24 }; 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) diff --git a/src/save.rs b/src/save.rs index 9254514..5471a29 100644 --- a/src/save.rs +++ b/src/save.rs @@ -125,7 +125,10 @@ mod tests { g.update(0.05); // the scouts warn 30 s before the wave } assert!(!g.planned_entries.is_empty()); - let (ex, ey) = g.planned_entries[0]; + let (cx, cy) = g.map.core; + g.spawn(crate::game::EK::Chainbreaker, false, (cx - 1) as f32 + 0.5, (cy - 1) as f32 + 0.5, 1); + g.spawn(crate::game::EK::Ogre, false, (cx + 4) as f32 + 0.5, (cy - 1) as f32 + 0.5, 1); + let (ex, ey) = (cx + 1, cy + 1); let mut cam = Cam::new(ex as f32 + 0.5, ey as f32 + 0.5); cam.zoom = 12.0; std::fs::write(path, to_json(&g, &cam, &Settings { language: "en".into(), ..Settings::default() })).unwrap();