From a1ad5ad1415d810f491ba05dee4e95bdbb14c6fe Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 07:53:58 +0100 Subject: [PATCH] Torture chamber with prisoners and defectors, Jailer; temple with prayer, Flame Priestess - defeated heroes are knocked out if the torture chamber has room; imps carry them in - torture drains HP over ~100 s; ~40% defect (paladins ~25%) and become own creatures - Jailer (6+ torture tiles): tanky melee that pulls enemies in with a chain - temple: creatures pray for mana (more than studying) and mood - Flame Priestess (6+ temple tiles): fireballs with splash, heals creatures and the Soul Core - tests: prisoner chain + defect rate, temple prayer + core healing Co-Authored-By: Claude Opus 5.5 --- lang/de.lang | 13 ++ lang/en.lang | 13 ++ src/game.rs | 467 ++++++++++++++++++++++++++++++++++++++++++++++-- src/gfx.rs | 29 ++- src/main.rs | 2 +- src/render3d.rs | 38 ++-- src/ui.rs | 8 + 7 files changed, 545 insertions(+), 25 deletions(-) diff --git a/lang/de.lang b/lang/de.lang index d8bdb34..8ba8c6d 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -309,3 +309,16 @@ hint.tower_where = Türme können nur auf eroberten, leeren Bodenfeldern gebaut hint.tower_mana = Deinen Magietürmen ist das Mana ausgegangen. job.repair = Repariert einen Turm job.attack_tower = Greift einen Turm an + +unit.jailer = Kerkermeister +unit.priestess = Flammenpriesterin +msg.hero_captured = {0} bewusstlos geschlagen und gefangen genommen! Beute: {1} Gold. +msg.defected = {0} ist zu dir übergelaufen! +msg.prisoner_died = {0} ist unter der Folter gestorben. +job.carry = Trägt einen Gefangenen +job.pray = Betet +job.heal_core = Heilt den Seelenkern +info.unconscious = bewusstlos +info.carried = wird in die Folterkammer getragen +info.tortured = wird gefoltert +info.prison = Gefangene: {0}/{1} diff --git a/lang/en.lang b/lang/en.lang index ace2b02..66d8203 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -310,3 +310,16 @@ hint.tower_where = Towers can only be built on claimed, empty floor tiles. hint.tower_mana = Your magic towers have run out of mana. job.repair = Repairing a tower job.attack_tower = Attacking a tower + +unit.jailer = Jailer +unit.priestess = Flame Priestess +msg.hero_captured = {0} knocked out and taken prisoner! Loot: {1} gold. +msg.defected = {0} has defected to your side! +msg.prisoner_died = {0} died under torture. +job.carry = Carrying a prisoner +job.pray = Praying +job.heal_core = Healing the Soul Core +info.unconscious = unconscious +info.carried = being carried to the torture chamber +info.tortured = being tortured +info.prison = Prisoners: {0}/{1} diff --git a/src/game.rs b/src/game.rs index b5fde22..b15410a 100644 --- a/src/game.rs +++ b/src/game.rs @@ -90,6 +90,8 @@ pub enum EK { Priest, Dwarf, Paladin, + Jailer, // attracted by the torture chamber + Priestess, // attracted by the temple } pub struct St { @@ -111,11 +113,14 @@ pub fn st(k: EK) -> St { EK::Spider => s("unit.spider", 95.0, 10.0, 1.0, 2.9, 1.0, 60, 0), EK::Mage => s("unit.mage", 60.0, 13.0, 4.5, 2.0, 1.4, 110, 0), EK::Ogre => s("unit.ogre", 230.0, 20.0, 1.0, 1.8, 1.4, 150, 0), - EK::Knight => s("unit.knight", 140.0, 11.0, 1.0, 1.9, 1.1, 0, 120), - EK::Archer => s("unit.archer", 75.0, 9.0, 4.5, 2.1, 1.3, 0, 100), - EK::Priest => s("unit.priest", 80.0, 5.0, 3.5, 1.9, 1.5, 0, 120), - EK::Dwarf => s("unit.dwarf", 110.0, 9.0, 1.0, 2.0, 1.0, 0, 100), - EK::Paladin => s("unit.paladin", 700.0, 26.0, 1.0, 1.7, 1.2, 0, 1000), + // heroes: the wage applies when they defected to the player + EK::Knight => s("unit.knight", 140.0, 11.0, 1.0, 1.9, 1.1, 70, 120), + EK::Archer => s("unit.archer", 75.0, 9.0, 4.5, 2.1, 1.3, 60, 100), + EK::Priest => s("unit.priest", 80.0, 5.0, 3.5, 1.9, 1.5, 60, 120), + EK::Dwarf => s("unit.dwarf", 110.0, 9.0, 1.0, 2.0, 1.0, 60, 100), + 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), } } @@ -137,6 +142,9 @@ pub enum Job { Reinforce(usize), // wall tile Repair(u32), // tower id AttackTower(u32), // tower id + Carry(u32), // imp carries an unconscious hero (id) to the torture chamber + Pray(usize), // temple tile + HealCore, // flame priestess heals the soul core } #[derive(Clone, Serialize, Deserialize)] @@ -170,6 +178,14 @@ pub struct Ent { pub working: bool, #[serde(default)] pub slow: f32, // seconds of slowdown left (magic tower) + #[serde(default)] + pub captive: u8, // heroes: 0 free, 1 unconscious, 2 carried, 3 in the torture chamber + #[serde(default)] + pub carrier: u32, // imp that has taken care of this captive + #[serde(default)] + pub torture_t: f32, + #[serde(default)] + pub skill_cd: f32, // special abilities (jailer pull, priestess heal) pub guard: bool, // stays at its home (gate defenders, treasure guards) #[serde(default)] pub home: Option<(f32, f32)>, // guard post @@ -206,6 +222,10 @@ impl Ent { moving: false, working: false, slow: 0.0, + captive: 0, + carrier: 0, + torture_t: 0.0, + skill_cd: 0.0, guard: false, home: None, } @@ -219,6 +239,10 @@ impl Ent { pub fn creature(&self) -> bool { !self.hero && self.kind != EK::Worker } + // a hero that is still fighting (not a prisoner) + pub fn active_hero(&self) -> bool { + self.hero && self.captive == 0 + } } #[derive(Clone, Copy)] @@ -484,7 +508,7 @@ impl Game { // gate defenders alive (not the treasure guards) pub fn gate_defenders_alive(&self) -> bool { let (gx, gy) = self.gate_center(); - self.ents.iter().any(|e| e.alive && e.guard && e.home.map_or(false, |(x, y)| (x - gx).abs() < 1.0 && (y - gy).abs() < 1.0)) + self.ents.iter().any(|e| e.alive && e.guard && e.captive == 0 && e.home.map_or(false, |(x, y)| (x - gx).abs() < 1.0 && (y - gy).abs() < 1.0)) } #[allow(dead_code)] @@ -641,6 +665,12 @@ impl Game { if self.counts[3] >= 9 { opts.push((EK::Ogre, 2)); } + if self.counts[5] >= 6 { + opts.push((EK::Jailer, 2)); + } + if self.counts[6] >= 6 { + opts.push((EK::Priestess, 2)); + } let total: u32 = opts.iter().map(|o| o.1).sum(); let mut r = self.rng.next() % total; let mut kind = EK::Kobold; @@ -822,6 +852,7 @@ impl Game { e.anim += dt; e.flash = (e.flash - dt).max(0.0); e.slow = (e.slow - dt).max(0.0); + e.skill_cd -= dt; e.cd -= dt; e.think -= dt; e.repath -= dt; @@ -842,7 +873,9 @@ impl Game { self.msg("msg.level_up", vec![format!("@{}", s.name), e.level.to_string()]); } } - if e.hero { + if e.hero && e.captive != 0 { + self.captive_update(e, dt); + } else if e.hero { self.hero_ai(e, dt); } else if e.kind == EK::Worker { self.worker_ai(e, dt); @@ -966,7 +999,7 @@ impl Game { let mut best = None; let mut bd = radius * radius; for o in &self.ents { - if !o.alive || o.held || o.hero == e.hero { + if !o.alive || o.held || o.hero == e.hero || o.captive != 0 { continue; } let d = (o.x - e.x).powi(2) + (o.y - e.y).powi(2); @@ -987,6 +1020,21 @@ impl Game { if d > 12.0 { return false; } + if e.kind == EK::Jailer && e.skill_cd <= 0.0 && d > 1.4 && d < 4.5 && self.los(e.x, e.y, tx, ty) { + // jailer: pulls the enemy in with a chain + let (dx, dy) = norm(tx - e.x, ty - e.y); + let (nx, ny) = (e.x + dx * 0.9, e.y + dy * 0.9); + if self.map.walk(nx as i32, ny as i32) { + self.fx(0, tx, ty - 0.3, e.x, e.y - 0.3, 0.3, 0xff8090a0); + self.sfx(Sfx::Capture, e.x, e.y); + let t = &mut self.ents[j]; + t.x = nx; + t.y = ny; + t.path.clear(); + e.skill_cd = 6.0; + return true; + } + } if d <= s.range + 0.45 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) { e.path.clear(); e.face = sgn(tx - e.x); @@ -1025,6 +1073,7 @@ impl Game { let col = match e.kind { EK::Mage => 0xffff44cc, EK::Priest => 0xff88ffff, + EK::Priestess => 0xff3080ff, _ => 0xff70b0d0, }; self.fx(0, e.x, e.y - 0.3, tx, ty - 0.3, 0.25, col); @@ -1034,6 +1083,30 @@ impl Game { self.sfx(Sfx::MeleeHit, tx, ty); } e.xp += dmg * 0.6; + 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); + let side = !e.hero; + let splash: Vec = self + .ents + .iter() + .enumerate() + .filter(|(o, x)| *o != j && x.alive && !x.held && x.hero == side && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 1.44) + .map(|(o, _)| o) + .collect(); + for o in splash { + if self.ents[o].alive { + self.ents[o].hp -= dmg * 0.5; + self.ents[o].flash = 0.15; + if self.ents[o].hp <= 0.0 { + self.kill(o); + } + } + } + if !self.ents[j].alive { + return; + } + } let my_id = e.id; let t = &mut self.ents[j]; t.hp -= dmg; @@ -1047,10 +1120,46 @@ impl Game { } } + // free places in the torture chamber (one prisoner per 2 tiles) + pub fn prison_space(&self) -> usize { + let cap = self.counts[5] / 2; + let used = self.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count(); + cap.saturating_sub(used) + } + fn kill(&mut self, j: usize) { + let e = &self.ents[j]; + if e.hero && e.captive == 0 && self.prison_space() > 0 { + // knocked out instead of killed: imps carry the hero to the torture chamber + let (kind, x, y) = (e.kind, e.x, e.y); + let id = e.id; + self.release_all(id); + let e = &mut self.ents[j]; + e.captive = 1; + e.hp = 1.0; + e.carrier = 0; + e.torture_t = 0.0; + e.job = Job::None; + e.path.clear(); + e.guard = false; + e.home = None; + let b = st(kind).bounty as f32; + let got = self.add_gold(b); + self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff); + self.sfx(Sfx::Capture, x, y); + self.msg("msg.hero_captured", vec![format!("@{}", st(kind).name), (got as i32).to_string()]); + return; + } 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); + // prisoners carried by this unit are dropped + for o in self.ents.iter_mut() { + if o.captive == 2 && o.carrier == id { + o.captive = 1; + o.carrier = 0; + } + } self.release_all(id); self.fx(3, x, y, 0.0, 0.0, 0.7, 0xff505070); let s = st(kind); @@ -1204,6 +1313,9 @@ impl Game { } } } + Job::Carry(id) => { + self.carry_job(e, id, dt); + } Job::Repair(id) => { let Some(k) = self.towers.iter().position(|t| t.id == id) else { e.job = Job::None; @@ -1309,6 +1421,21 @@ impl Game { if e.carry >= CARRY_MAX && self.start_deliver(e) { return; } + // prisoners lying around are carried to the torture chamber first + let lying = self.ents.iter().any(|o| o.alive && o.captive == 1 && o.carrier == 0); + if lying && self.map.t.iter().any(|t| t.kind == Kind::Torture && t.bed == 0) { + if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.captive_near(i).is_some(), true) { + let end = p.last().copied().unwrap_or(cur); + if let Some(id) = self.captive_near(end) { + if let Some(k) = self.find(id) { + self.ents[k].carrier = e.id; + } + e.path = p; + e.job = Job::Carry(id); + return; + } + } + } if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.dig_neighbor(i, me).is_some(), true) { let end = p.last().copied().unwrap_or(cur); if let Some(t) = self.dig_neighbor(end, me) { @@ -1376,6 +1503,31 @@ impl Game { if e.hunger > 1.8 { e.happy -= dt * 0.004; } + if e.kind == EK::Priestess && e.skill_cd <= 0.0 { + // flame priestess: heals the most hurt own creature nearby (or herself) + let mut best: Option = None; + let mut worst = if e.hp < e.max_hp * 0.8 { e.hp / e.max_hp } else { 0.8 }; + for (j, o) in self.ents.iter().enumerate() { + if o.alive && !o.held && o.creature() && (o.x - e.x).powi(2) + (o.y - e.y).powi(2) < 4.5 * 4.5 && o.hp / o.max_hp < worst { + worst = o.hp / o.max_hp; + best = Some(j); + } + } + if let Some(j) = best { + let o = &mut self.ents[j]; + o.hp = (o.hp + 20.0).min(o.max_hp); + let (ox, oy) = (o.x, o.y); + self.fx(4, ox, oy, 0.0, 0.0, 0.8, 0xff60ff80); + self.sfx(Sfx::Heal, ox, oy); + e.skill_cd = 2.5; + } else if e.hp < e.max_hp * 0.8 { + e.hp = (e.hp + 20.0).min(e.max_hp); + self.fx(4, e.x, e.y, 0.0, 0.0, 0.8, 0xff60ff80); + e.skill_cd = 2.5; + } else { + e.skill_cd = 1.0; + } + } if e.think <= 0.0 { e.think = 0.4 + self.rng.f() * 0.3; if !matches!(e.job, Job::Attack(_) | Job::Leave) { @@ -1462,6 +1614,36 @@ impl Game { e.job = Job::None; } } + Job::Pray(t) => { + if cur != t || self.map.t[t].kind != Kind::Temple { + self.unreserve(me); + e.job = Job::None; + return; + } + e.working = true; + let rate = if e.kind == EK::Priestess { 1.6 } else { 0.9 }; + self.mana = (self.mana + rate * dt).min(MANA_MAX); + e.happy = (e.happy + 0.01 * dt).min(1.0); + e.xp += 1.0 * dt; + e.timer -= dt; + if e.timer <= 0.0 { + self.unreserve(me); + e.job = Job::None; + } + } + Job::HealCore => { + if !self.map.adj_core(cur) || self.core_hp >= CORE_HP { + e.job = Job::None; + return; + } + e.working = true; + let (cx, cy) = self.map.core; + e.face = sgn(cx as f32 + 1.5 - e.x); + self.core_hp = (self.core_hp + 8.0 * dt).min(CORE_HP); + if (e.anim * 1.5).fract() < dt * 1.5 { + self.fx(4, cx as f32 + 1.5, cy as f32 + 1.5, 0.0, 0.0, 0.8, 0xff60ff80); + } + } Job::Wander => { e.timer -= dt; if e.timer <= 0.0 { @@ -1661,6 +1843,26 @@ impl Game { self.creature_choose(e); return; } + if e.kind == EK::Priestess && self.core_hp < CORE_HP * 0.9 && self.nearest_enemy(e, 8.0).is_none() { + if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.adj_core(i), true) { + e.path = p; + e.job = Job::HealCore; + return; + } + } + if self.counts[6] > 0 { + let r2 = self.rng.f(); + if r2 < 0.3 || (e.kind == EK::Priestess && r2 < 0.7) { + if let Some(p) = self.find_room_tile(cur, Kind::Temple, me, false) { + let t = p.last().copied().unwrap_or(cur); + self.map.t[t].reserved = me; + e.path = p; + e.job = Job::Pray(t); + e.timer = 8.0 + self.rng.f() * 6.0; + return; + } + } + } let r = self.rng.f(); if (e.kind == EK::Mage && r < 0.8) || r < 0.2 { if let Some(p) = self.find_room_tile(cur, Kind::Library, me, false) { @@ -1685,6 +1887,176 @@ impl Game { self.wander(e, 4); } + // ---------------------------------------------------------------- prisoners + + fn captive_update(&mut self, e: &mut Ent, dt: f32) { + e.moving = false; + match e.captive { + 1 => { + // unconscious: bleeds out if nobody carries the hero away + e.torture_t += dt; + if e.torture_t > 60.0 { + e.alive = false; + self.release_all(e.id); + self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070); + } + } + 2 => { + // carried: follows the imp + match self.find(e.carrier) { + Some(k) => { + e.x = self.ents[k].x; + e.y = self.ents[k].y; + } + None => { + e.captive = 1; + e.carrier = 0; + } + } + } + _ => { + let t = e.tile(); + if self.map.t[t].kind != Kind::Torture { + // the chamber is gone: the prisoner breaks free + e.captive = 0; + e.hp = e.max_hp * 0.3; + self.release_all(e.id); + return; + } + e.working = true; + e.hp -= e.max_hp / 100.0 * dt; + e.torture_t += dt; + if e.torture_t >= 5.0 { + e.torture_t -= 5.0; + // ~40% defect over the whole torture (paladins ~25%) + let p = if e.kind == EK::Paladin { 0.0143 } else { 0.0252 }; + if self.rng.f() < p { + self.defect(e); + return; + } + } + if e.hp <= 0.0 { + e.alive = false; + self.release_all(e.id); + self.fx(3, e.x, e.y, 0.0, 0.0, 0.7, 0xff505070); + self.sfx(Sfx::HeroDeath, e.x, e.y); + self.msg("msg.prisoner_died", vec![format!("@{}", st(e.kind).name)]); + } + } + } + } + + // a prisoner joins the player's side + fn defect(&mut self, e: &mut Ent) { + self.release_all(e.id); + e.hero = false; + e.captive = 0; + e.carrier = 0; + e.guard = false; + e.home = None; + e.hp = e.max_hp * 0.5; + e.happy = 0.6; + e.hunger = 0.5; + e.energy = 0.4; + e.job = Job::None; + e.path.clear(); + e.bed = None; + self.fx(7, e.x, e.y, 0.0, 0.0, 1.0, 0xff4040e0); + self.sfx(Sfx::Convert, e.x, e.y); + self.msg("msg.defected", vec![format!("@{}", st(e.kind).name)]); + } + + // unconscious hero lying next to tile i that no imp takes care of yet + fn captive_near(&self, i: usize) -> Option { + let (x, y) = xy(i); + self.ents.iter().find(|o| { + o.alive && o.hero && o.captive == 1 && o.carrier == 0 && (o.x as i32 - x).abs() <= 1 && (o.y as i32 - y).abs() <= 1 + }).map(|o| o.id) + } + + fn free_torture_tile(&self, i: usize) -> bool { + let t = &self.map.t[i]; + t.kind == Kind::Torture && t.owner == Owner::Player && t.bed == 0 + } + + fn carry_job(&mut self, e: &mut Ent, id: u32, dt: f32) { + let Some(k) = self.find(id) else { + e.job = Job::None; + return; + }; + let cur = e.tile(); + match self.ents[k].captive { + 1 => { + let (cx, cy) = (self.ents[k].x, self.ents[k].y); + if (cx - e.x).abs() < 1.2 && (cy - e.y).abs() < 1.2 { + // pick up and look for a free place in the torture chamber + match self.map.search(cur, self.map.walk_cost(), |i| self.free_torture_tile(i), true) { + Some(p) => { + let target = p.last().copied().unwrap_or(cur); + self.map.t[target].bed = id + 1; + let c = &mut self.ents[k]; + c.captive = 2; + c.carrier = e.id; + e.path = p; + } + None => { + self.ents[k].carrier = 0; + e.job = Job::None; + } + } + } else if e.path.is_empty() { + match self.map.path_to(cur, idx(cx as i32, cy as i32)) { + Some(p) if !p.is_empty() => e.path = p, + _ => { + self.ents[k].carrier = 0; + e.job = Job::None; + } + } + } + } + 2 if self.ents[k].carrier == e.id => { + if !self.step_path(e, dt) { + // arrived (or blocked): put the prisoner down on the reserved torture tile + let target = self.map.t.iter().position(|t| t.bed == id + 1); + match target { + Some(t) if t == cur || adj4(t, cur) || t == e.tile() => { + let (tx, ty) = xy(t); + let c = &mut self.ents[k]; + c.captive = 3; + c.carrier = 0; + c.hp = c.max_hp; // the torture drains it over ~100 s + c.x = tx as f32 + 0.5; + c.y = ty as f32 + 0.5; + c.torture_t = 0.0; + self.sfx(Sfx::Capture, tx as f32 + 0.5, ty as f32 + 0.5); + e.job = Job::None; + } + Some(t) => { + if let Some(p) = self.map.path_to(cur, t) { + e.path = p; + } else { + self.ents[k].captive = 1; + self.ents[k].carrier = 0; + e.job = Job::None; + } + } + None => { + self.ents[k].captive = 1; + self.ents[k].carrier = 0; + e.job = Job::None; + } + } + } + return; + } + _ => { + e.job = Job::None; + return; + } + } + self.step_path(e, dt); + } + // ---------------------------------------------------------------- heroes fn hero_ai(&mut self, e: &mut Ent, dt: f32) { @@ -1985,7 +2357,7 @@ impl Game { let mut best = None; let mut bd = r * r; for (j, e) in self.ents.iter().enumerate() { - if !e.alive || e.held || !e.hero { + if !e.alive || e.held || !e.active_hero() { continue; } let d = (e.x - px).powi(2) + (e.y - py).powi(2); @@ -2032,7 +2404,7 @@ impl Game { self.sfx(Sfx::Shoot, px, py); let mut hits = vec![(j, dmg)]; for (o, e) in self.ents.iter().enumerate() { - if o == j || !e.alive || e.held || !e.hero || hits.len() >= 3 { + if o == j || !e.alive || e.held || !e.active_hero() || hits.len() >= 3 { continue; } let rel = (e.x - px, e.y - py); @@ -2056,7 +2428,7 @@ impl Game { self.sfx(Sfx::Cannon, px, py); let mut hits = Vec::new(); for (o, e) in self.ents.iter().enumerate() { - if !e.alive || e.held || !e.hero { + if !e.alive || e.held || !e.active_hero() { continue; } let d = ((e.x - ex).powi(2) + (e.y - ey).powi(2)).sqrt(); @@ -2090,7 +2462,7 @@ impl Game { .ents .iter() .enumerate() - .filter(|(o, e)| *o != j && e.alive && !e.held && e.hero && (e.x - ex).powi(2) + (e.y - ey).powi(2) < 9.0) + .filter(|(o, e)| *o != j && e.alive && !e.held && e.active_hero() && (e.x - ex).powi(2) + (e.y - ey).powi(2) < 9.0) .min_by(|a, b| { let da = (a.1.x - ex).powi(2) + (a.1.y - ey).powi(2); let db = (b.1.x - ex).powi(2) + (b.1.y - ey).powi(2); @@ -2163,6 +2535,12 @@ impl Game { let id = self.ents[j].id; let me = id + 1; self.unreserve(me); + for o in self.ents.iter_mut() { + if o.carrier == id && o.captive != 0 { + o.captive = 1; + o.carrier = 0; + } + } let e = &mut self.ents[j]; e.held = true; e.job = Job::None; @@ -2211,7 +2589,7 @@ impl Game { self.fx(1, wx, wy, 0.0, 0.0, 0.5, 0xffffeeaa); let mut kills = Vec::new(); for (j, e) in self.ents.iter_mut().enumerate() { - if e.alive && !e.held && e.hero && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 2.6 { + if e.alive && !e.held && e.active_hero() && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 2.6 { e.hp -= 55.0; e.flash = 0.2; if e.hp <= 0.0 { @@ -2361,6 +2739,9 @@ pub fn job_name(e: &Ent) -> &'static str { Job::Leave => "job.leave", Job::Reinforce(_) => "job.reinforce", Job::Repair(_) => "job.repair", + Job::Carry(_) => "job.carry", + Job::Pray(_) => "job.pray", + Job::HealCore => "job.heal_core", Job::AttackTower(_) => "job.attack_tower", Job::Rally => { if e.working { @@ -2515,4 +2896,64 @@ mod tests { println!("tower after attack: {:?}", t.map(|t| t.hp)); assert!(t.map_or(true, |t| t.hp < t.max_hp())); } + + #[test] + fn prisoners_are_tortured_and_resolved() { + let mut converted = 0; + let mut died = 0; + for seed in 1..=40u32 { + let mut g = Game::new(seed, 64, 64); + g.gold = 5000.0; + let (cx, cy) = g.map.core; + g.build_rect(5, cx - 3, cy - 3, cx - 2, cy + 1); // torture chamber 2x5 = 5 places... 10 tiles + assert!(g.prison_space() >= 2, "space {}", g.prison_space()); + let id = g.spawn(EK::Knight, true, (cx + 4) as f32 + 0.5, (cy + 4) as f32 + 0.5, 1); + let j = g.ents.iter().position(|e| e.id == id).unwrap(); + g.kill(j); + assert_eq!(g.ents[j].captive, 1); + let mut reached = false; + for _ in 0..(200 * 20) { + g.update(0.05); + match g.ents.iter().find(|e| e.id == id) { + Some(e) if e.captive == 3 => reached = true, + Some(e) if !e.hero => { + converted += 1; + break; + } + None => { + died += 1; + break; + } + _ => {} + } + } + assert!(reached, "seed {}: prisoner never reached the torture chamber", seed); + } + println!("40 prisoners: {} defected, {} died", converted, died); + assert_eq!(converted + died, 40); + assert!((8..=26).contains(&converted), "defect rate off: {}/40", converted); + } + + #[test] + fn temple_prayer_and_priestess_heals_core() { + let mut g = Game::new(9, 64, 64); + g.gold = 5000.0; + let (cx, cy) = g.map.core; + g.build_rect(6, cx - 3, cy - 3, cx - 2, cy + 1); // temple + g.build_rect(1, cx + 4, cy - 3, cx + 5, cy - 1); // lair + g.build_rect(2, cx + 4, cy + 3, cx + 5, cy + 5); // farm + g.spawn(EK::Priestess, false, (cx + 4) as f32 + 0.5, (cy + 2) as f32 + 0.5, 1); + for k in 0..3 { + g.spawn(EK::Kobold, false, (cx - 1) as f32 + 0.5, (cy + k) as f32 + 0.5, 1); + } + g.core_hp = 1000.0; + let mut prayed = false; + for _ in 0..(120 * 20) { + g.update(0.05); + prayed |= g.ents.iter().any(|e| matches!(e.job, Job::Pray(_)) && e.working); + } + println!("core after 2 min: {:.0}, prayed: {}", g.core_hp, prayed); + assert!(prayed); + assert!(g.core_hp > 1300.0); + } } diff --git a/src/gfx.rs b/src/gfx.rs index 7b113e6..c1b489d 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -650,6 +650,30 @@ fn sprite_rows(k: EK) -> [&'static str; 10] { "..kBkkBk..", "..kk..kk..", ], + EK::Jailer => [ + "...kkkk...", + "..kCCCCk..", + "..kCkkCk..", + "..kCeeCk..", + ".kCCCCCCk.", + "kCCRRRRCCk", + "kCCRRRRCCS", + ".kCCCCCCkS", + "..kCk.kCkS", + "..kk...kk.", + ], + EK::Priestess => [ + "....oo..y.", + "...kkkk.o.", + "..kffffk..", + "..kfkfkk..", + "..kRRRRk..", + ".kRRooRRk.", + ".kRRooRRkf", + ".kRRRRRRk.", + ".kRRRRRRk.", + "..kkkkkk..", + ], EK::Paladin => [ "...kkkk..w", "..kyyyyk.w", @@ -693,7 +717,10 @@ fn pal(ch: char) -> Option { }) } -pub const SPRITE_KINDS: [EK; 10] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin]; +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]; +// width of the sprite texture in pixels +pub const SPRITE_TEX_W: f32 = (12 * 12) as f32; +pub const SPRITE_TEX_H: f32 = 24.0; pub fn sprite_index(k: EK) -> u32 { SPRITE_KINDS.iter().position(|&s| s == k).unwrap_or(0) as u32 diff --git a/src/main.rs b/src/main.rs index fe547fd..6571e05 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,7 +83,7 @@ impl MusicDirector { use audio::Track; const BUILD: [Track; 3] = [Track::Build1, Track::Build2, Track::Build3]; let gate_fight = g.time - g.gate_hit_t < 12.0 || g.gate_defenders_alive(); - let invasion = g.ents.iter().any(|e| e.alive && e.hero && !e.guard); + let invasion = g.ents.iter().any(|e| e.alive && e.active_hero() && !e.guard); let want = if title { Track::Title } else if g.state == 1 { diff --git a/src/render3d.rs b/src/render3d.rs index 05a4821..e7493e6 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -935,18 +935,28 @@ impl R3d { } }; let mut mb = MB::new(); - let sw = 120.0; - let sh = 24.0; + let sw = SPRITE_TEX_W; + let sh = SPRITE_TEX_H; for e in &g.ents { if !e.alive || e.held { continue; } - let big = matches!(e.kind, EK::Ogre | EK::Paladin); + let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer); let w = if big { 0.8 } else { 0.62 }; let h = w; let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving; - let bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; - let rot = if e.working && !sleeping { (t * 14.0 + e.anim).sin() * 0.15 } else { 0.0 }; + let mut bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; + let mut rot = if e.working && !sleeping { (t * 14.0 + e.anim).sin() * 0.15 } else { 0.0 }; + // prisoners: lying unconscious, carried above an imp, or shaking in the torture chamber + match e.captive { + 1 => rot = std::f32::consts::FRAC_PI_2, + 2 => { + rot = std::f32::consts::FRAC_PI_2; + bob = 0.4; + } + 3 => rot = (t * 25.0 + e.anim).sin() * 0.08, + _ => {} + } let white = e.flash > 0.07; let src = Gfx::sprite_rect(e.kind, white); let (mut u0, v0) = (src.x / sw, src.y / sh); @@ -954,8 +964,14 @@ impl R3d { if e.face < 0.0 { std::mem::swap(&mut u0, &mut u1); } - let a = amb(e.x, e.y) * if sleeping { 0.6 } else { 1.0 }; - let c = if white { [255, 255, 255, 255] } else { col(Vec3::splat(a), 1.0) }; + let a = amb(e.x, e.y) * if sleeping || e.captive == 1 { 0.6 } else { 1.0 }; + let c = if white { + [255, 255, 255, 255] + } else if e.captive == 3 { + col(vec3(a * 1.2, a * 0.55, a * 0.55), 1.0) + } else { + col(Vec3::splat(a), 1.0) + }; // pulled towards the camera: unchanged on screen (orthographic), but walls behind // the unit no longer cut into the sprite let base = vec3(e.x, bob, e.y) + toward * 0.45; @@ -985,7 +1001,7 @@ impl R3d { if !e.alive || e.held { continue; } - let big = matches!(e.kind, EK::Ogre | EK::Paladin); + let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer); let head = Cam::project(m, vec3(e.x, if big { 0.9 } else { 0.72 }, e.y)); let w = (px_per_tile * 0.6).clamp(12.0, 40.0); if e.hp < e.max_hp || e.hero { @@ -1206,9 +1222,11 @@ fn draw_shadows(g: &Game) { if !e.alive || e.held { continue; } - let r = if matches!(e.kind, EK::Ogre | EK::Paladin) { 0.32 } else { 0.24 }; + let r = if matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer) { 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.guard { + let rc = if e.captive != 0 { + Color::new(0.6, 0.6, 0.6, 0.6) + } else if e.guard { Color::new(1.0, 0.85, 0.3, 0.9) } else if e.hero { Color::new(0.5, 0.8, 1.0, 0.8) diff --git a/src/ui.rs b/src/ui.rs index d4c733a..652d482 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -607,6 +607,10 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec let s = st(e.kind); out.push((trf("info.level", &[tr(s.name), e.level.to_string()]), if e.hero { BLUE } else { FRAME_HI })); out.push((trf("info.hp", &[(e.hp.max(0.0) as i32).to_string(), (e.max_hp as i32).to_string()]), GREEN)); + if e.hero && e.captive != 0 { + out.push((tr(["", "info.unconscious", "info.carried", "info.tortured"][e.captive.min(3) as usize]), RED)); + return out; + } out.push((tr(job_name(e)), TXT)); if e.hero { out.push((tr(if e.guard { "info.guard" } else { "info.intruder" }), BLUE)); @@ -678,6 +682,10 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec match k { Kind::Farm => out.push((trf("farm.food", &[(t.food as i32).to_string()]), GREEN)), Kind::Lair => out.push((tr(if t.bed != 0 { "lair.taken" } else { "lair.free" }), DIM)), + Kind::Torture => { + let used = g.ents.iter().filter(|e| e.alive && e.hero && e.captive != 0).count(); + out.push((trf("info.prison", &[used.to_string(), (g.counts[5] / 2).to_string()]), DIM)); + } _ => {} } }