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 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 07:53:58 +01:00
1 parent 903d6fb0f4
commit a1ad5ad141
7 files changed
+545 -25

No files matched your search

+13
View File
@@ -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}
+13
View File
@@ -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}
+454 -13
View File
@@ -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<usize> = self
.ents
.iter()
.enumerate()
.filter(|(o, x)| *o != j && x.alive && !x.held && x.hero == side && x.captive == 0 && (x.x - tx).powi(2) + (x.y - ty).powi(2) < 1.44)
.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<usize> = 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<u32> {
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);
}
}
+28 -1
View File
@@ -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<Color> {
})
}
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
+1 -1
View File
@@ -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 {
+28 -10
View File
@@ -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)
+8
View File
@@ -607,6 +607,10 @@ pub fn hover_info(g: &Game, ent: Option<usize>, 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<usize>, 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));
}
_ => {}
}
}