Chainbreaker: rare, very strong original creature (twin-axe cleave, restless without fights)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 09:14:39 +01:00
1 parent 05ba88359e
commit c5b7896609
7 files changed
+111 -7

No files matched your search

+5
View File
@@ -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 - **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 - **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 ## 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 - 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) - Waves of **6+ heroes split into two groups** with different entry points (the paladin goes with the bigger group)
+3
View File
@@ -340,3 +340,6 @@ set.render_scale = 3D-Auflösung
hint.pickup_prisoner_where = Gefangene können nur auf eigenem Gebiet aufgehoben werden. 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. 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!
+3
View File
@@ -341,3 +341,6 @@ set.render_scale = 3D resolution
hint.pickup_prisoner_where = Prisoners can only be picked up on your own territory. 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. 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!
+76
View File
@@ -94,6 +94,7 @@ pub enum EK {
Paladin, Paladin,
Jailer, // attracted by the torture chamber Jailer, // attracted by the torture chamber
Priestess, // attracted by the temple Priestess, // attracted by the temple
Chainbreaker, // rare and very strong
} }
pub struct St { 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::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::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::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 { if self.counts[6] >= 6 {
opts.push((EK::Priestess, 2)); 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 total: u32 = opts.iter().map(|o| o.1).sum();
let mut r = self.rng.next() % total; let mut r = self.rng.next() % total;
let mut kind = EK::Kobold; let mut kind = EK::Kobold;
@@ -1160,6 +1169,25 @@ impl Game {
self.sfx(Sfx::MeleeHit, tx, ty); self.sfx(Sfx::MeleeHit, tx, ty);
} }
e.xp += dmg * 0.6; 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 { if e.kind == EK::Priestess {
// fireball: half damage to enemies around the target // fireball: half damage to enemies around the target
self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff); self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff);
@@ -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 let Job::Attack(id) = e.job {
if !self.attack_job(e, id, dt) { if !self.attack_job(e, id, dt) {
e.job = Job::None; e.job = Job::None;
@@ -3270,5 +3307,44 @@ mod tests {
assert!(g.pickup(Some(imp))); assert!(g.pickup(Some(imp)));
assert!(!g.drop(40.5, 40.5) || g.map.t[idx(40, 40)].owner == Owner::Player); 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);
}
} }
+16 -2
View File
@@ -675,6 +675,19 @@ fn sprite_rows(k: EK) -> [&'static str; 10] {
".kRRRRRRk.", ".kRRRRRRk.",
"..kkkkkk..", "..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 => [ EK::Paladin => [
"...kkkk..w", "...kkkk..w",
"..kyyyyk.w", "..kyyyyk.w",
@@ -714,13 +727,14 @@ fn pal(ch: char) -> Option<Color> {
'C' => c(96, 98, 92), 'C' => c(96, 98, 92),
'o' => c(226, 124, 48), 'o' => c(226, 124, 48),
'O' => c(160, 72, 24), 'O' => c(160, 72, 24),
'd' => c(44, 36, 48),
_ => return None, _ => 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 // 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 const SPRITE_TEX_H: f32 = 24.0;
pub fn sprite_index(k: EK) -> u32 { pub fn sprite_index(k: EK) -> u32 {
+4 -4
View File
@@ -976,8 +976,8 @@ impl R3d {
if !e.alive || e.held { if !e.alive || e.held {
continue; 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 w = if big { 0.8 } else { 0.62 }; let w = if e.kind == EK::Chainbreaker { 1.0 } else if big { 0.8 } else { 0.62 };
let h = w; let h = w;
let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving; 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 }; 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 { if !e.alive || e.held {
continue; 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)); let head = proj(vec3(e.x, if big { 0.9 } else { 0.72 }, e.y));
// bar width in block characters, depending on the zoom // bar width in block characters, depending on the zoom
let cols = ((px_per_tile * 0.6 / cw) as usize).clamp(3, 8); 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 { if !e.alive || e.held {
continue; 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); disc(vec3(e.x, 0.012, e.y), r, Color::new(0.0, 0.0, 0.0, 0.4), 12);
let rc = if e.captive != 0 { let rc = if e.captive != 0 {
Color::new(0.6, 0.6, 0.6, 0.6) Color::new(0.6, 0.6, 0.6, 0.6)
+4 -1
View File
@@ -125,7 +125,10 @@ mod tests {
g.update(0.05); // the scouts warn 30 s before the wave g.update(0.05); // the scouts warn 30 s before the wave
} }
assert!(!g.planned_entries.is_empty()); 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); let mut cam = Cam::new(ex as f32 + 0.5, ey as f32 + 0.5);
cam.zoom = 12.0; cam.zoom = 12.0;
std::fs::write(path, to_json(&g, &cam, &Settings { language: "en".into(), ..Settings::default() })).unwrap(); std::fs::write(path, to_json(&g, &cam, &Settings { language: "en".into(), ..Settings::default() })).unwrap();