Traps and doors made in a new forge room, installed by imps; new creature: the cogling

- forge room: creatures work there (coglings fastest), the work goes into the oldest planned trap or door; finished pieces wait as crates, imps carry and install them
- traps: spike pit, poison vent (cloud), fire jet, stone roller, alarm bell (calls creatures), rune mine (one big blast), frost glow (slows around it)
- doors: wood, iron, magic (hurls attackers back); own creatures pass, enemies smash them on their way; clicking a door with a door tool locks it for everybody
- new 'Traps' category in the toolbar, forge in the rooms; texts EN/DE, bestiary entries; atlas grown for new textures and icons
- tests: forge to installed trap, spikes hurt, doors lock and get smashed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-04 06:55:37 +01:00
1 parent 1696297ac3
commit 4f20223c93
13 files changed
+1267 -24

No files matched your search

+604
View File
@@ -0,0 +1,604 @@
// Traps and doors: the player marks a spot in the build menu, creatures in the forge
// manufacture the piece (work, no gold), the finished crate waits in the forge, an imp carries
// it to the spot and installs it. Traps fire at every non-player unit; doors let the dungeon's
// creatures through, enemies have to smash them (locked doors stop everybody).
use crate::game::*;
use crate::map::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum TrapKind {
Spikes,
Poison,
Fire,
Roller,
Alarm,
Mine,
Frost,
WoodDoor,
IronDoor,
MagicDoor,
}
pub const TRAP_KINDS: [TrapKind; 10] = [
TrapKind::Spikes,
TrapKind::Poison,
TrapKind::Fire,
TrapKind::Roller,
TrapKind::Alarm,
TrapKind::Mine,
TrapKind::Frost,
TrapKind::WoodDoor,
TrapKind::IronDoor,
TrapKind::MagicDoor,
];
// stages of a trap
pub const ORDERED: u8 = 0; // marked, waiting for the forge
pub const CRATED: u8 = 1; // finished, a crate in the forge
pub const CARRIED: u8 = 2; // an imp carries it
pub const ARMED: u8 = 3; // installed
impl TrapKind {
pub fn door(self) -> bool {
matches!(self, TrapKind::WoodDoor | TrapKind::IronDoor | TrapKind::MagicDoor)
}
// work the forge needs (worker rate ~1 per second)
pub fn work(self) -> f32 {
match self {
TrapKind::Spikes => 40.0,
TrapKind::Poison => 60.0,
TrapKind::Fire => 75.0,
TrapKind::Roller => 80.0,
TrapKind::Alarm => 25.0,
TrapKind::Mine => 70.0,
TrapKind::Frost => 60.0,
TrapKind::WoodDoor => 35.0,
TrapKind::IronDoor => 70.0,
TrapKind::MagicDoor => 110.0,
}
}
// health: doors are smashed, traps can be wrecked by giants
pub fn hp(self) -> f32 {
match self {
TrapKind::WoodDoor => 400.0,
TrapKind::IronDoor => 1100.0,
TrapKind::MagicDoor => 1600.0,
_ => 150.0,
}
}
pub fn id(self) -> &'static str {
match self {
TrapKind::Spikes => "spikes",
TrapKind::Poison => "poison",
TrapKind::Fire => "fire",
TrapKind::Roller => "roller",
TrapKind::Alarm => "alarm",
TrapKind::Mine => "mine",
TrapKind::Frost => "frost",
TrapKind::WoodDoor => "door_wood",
TrapKind::IronDoor => "door_iron",
TrapKind::MagicDoor => "door_magic",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Trap {
pub id: u32,
pub kind: TrapKind,
pub tile: usize,
pub stage: u8,
pub crate_tile: usize,
pub progress: f32,
pub cd: f32,
pub hp: f32,
pub locked: bool,
pub carrier: u32, // imp carrying or installing it (entity id + 1)
pub fired: f32, // time since it last went off (for drawing)
pub aim: f32, // direction of the last shot (fire jet, roller)
}
// a boulder rolling down a corridor
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Roller {
pub x: f32,
pub y: f32,
pub dx: f32,
pub dy: f32,
pub t: f32,
pub hit: Vec<u32>,
}
// a poison cloud
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Cloud {
pub x: f32,
pub y: f32,
pub t: f32,
}
const CLOUD_TIME: f32 = 6.0;
// how hard a unit hits doors
pub fn door_breaker(_k: EK) -> f32 {
1.0
}
fn enemy(e: &Ent) -> bool {
e.alive && !e.held && e.captive == 0 && !e.chained && e.side() != 0
}
impl Game {
pub fn trap_at(&self, i: usize) -> Option<&Trap> {
self.traps.iter().find(|t| t.tile == i)
}
pub fn forge_tiles(&self) -> usize {
self.counts[crate::game::ROOM_FORGE]
}
// the build menu: mark a spot for a trap or door (a door that stands there is locked/unlocked)
pub fn place_trap(&mut self, kind: TrapKind, x: i32, y: i32) {
if !inb(x, y) {
return;
}
let i = idx(x, y);
if let Some(k) = self.traps.iter().position(|t| t.tile == i) {
let t = &mut self.traps[k];
if t.kind.door() && t.stage == ARMED {
t.locked = !t.locked;
let locked = t.locked;
self.map.t[i].res = locked as u8;
self.msg(if locked { "msg.door_locked" } else { "msg.door_unlocked" }, vec![]);
self.sfx_ui(Sfx::Build);
} else {
self.hint(25, "hint.trap_where");
self.sfx_ui(Sfx::Error);
}
return;
}
let t = self.map.t[i];
if t.kind != Kind::Floor || t.owner != Owner::Player {
self.hint(25, "hint.trap_where");
self.sfx_ui(Sfx::Error);
return;
}
if self.forge_tiles() == 0 {
self.hint(26, "hint.need_forge");
self.sfx_ui(Sfx::Error);
return;
}
let id = self.next_id;
self.next_id += 1;
self.traps.push(Trap { id, kind, tile: i, stage: ORDERED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: kind.hp(), locked: false, carrier: 0, fired: 99.0, aim: 0.0 });
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.4, 0xff40d0ff);
self.sfx_ui(Sfx::FlagSet);
}
// sell: traps and doors are taken out again (nothing comes back)
pub(crate) fn remove_trap_at(&mut self, i: usize) -> bool {
let Some(k) = self.traps.iter().position(|t| t.tile == i) else { return false };
let t = self.traps.remove(k);
if t.kind.door() && t.stage == ARMED {
self.map.t[i].kind = Kind::Floor;
self.map.t[i].res = 0;
}
true
}
// the forge: the work of the creatures there goes into the oldest order
pub(crate) fn update_forge(&mut self, dt: f32) {
let work = std::mem::take(&mut self.forge_work);
if let Some(k) = self.traps.iter().position(|t| t.stage == ORDERED) {
self.traps[k].progress += work;
if self.traps[k].progress >= self.traps[k].kind.work() {
// finished: a crate on a forge tile
let spot = self.map.t.iter().enumerate().filter(|(_, t)| t.kind == Kind::Forge && t.owner == Owner::Player).map(|(i, _)| i).nth(self.traps[k].id as usize % self.forge_tiles().max(1));
match spot {
Some(s) => {
let t = &mut self.traps[k];
t.stage = CRATED;
t.crate_tile = s;
let (x, y) = xy(s);
let key = t.kind.id();
self.fx(7, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.8, 0xff40c0ff);
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
self.msg("msg.trap_made", vec![format!("@trap.{}", key)]);
}
None => self.traps[k].progress = self.traps[k].kind.work(),
}
}
}
// orders on lost ground are dropped
let map = &self.map;
self.traps.retain(|t| t.stage == ARMED || map.t[t.tile].owner == Owner::Player);
for t in self.traps.iter_mut() {
t.fired += dt;
t.cd -= dt;
}
self.update_traps(dt);
}
// is there forge work to do (an order waiting)?
pub fn forge_orders(&self) -> bool {
self.traps.iter().any(|t| t.stage == ORDERED)
}
// an imp looking for work: a finished crate to carry; returns the path to the crate
pub(crate) fn crate_to_carry(&self, cur: usize) -> Option<(u32, Vec<usize>)> {
let mut best: Option<(u32, Vec<usize>)> = None;
for t in self.traps.iter().filter(|t| t.stage == CRATED && t.carrier == 0) {
if let Some(p) = self.map.path_to(cur, t.crate_tile) {
if best.as_ref().map_or(true, |b| p.len() < b.1.len()) {
best = Some((t.id, p));
}
}
}
best
}
// the imp's job: fetch the crate, carry it to the spot, install it
pub(crate) fn install_job(&mut self, e: &mut Ent, id: u32, dt: f32) {
let me = e.id + 1;
let Some(k) = self.traps.iter().position(|t| t.id == id) else {
e.job = Job::None;
return;
};
let cur = e.tile();
let (stage, crate_tile, tile) = (self.traps[k].stage, self.traps[k].crate_tile, self.traps[k].tile);
if self.traps[k].carrier != me && self.traps[k].carrier != 0 {
e.job = Job::None;
return;
}
self.traps[k].carrier = me;
match stage {
CRATED => {
if cur != crate_tile {
if e.path.is_empty() {
match self.map.path_to(cur, crate_tile) {
Some(p) => e.path = p,
None => {
self.traps[k].carrier = 0;
e.job = Job::None;
}
}
}
return;
}
self.traps[k].stage = CARRIED;
e.path.clear();
}
CARRIED => {
if cur != tile {
if e.path.is_empty() {
match self.map.path_to(cur, tile) {
Some(p) => e.path = p,
None => {
// no way there: the crate goes back into the forge
self.traps[k].stage = CRATED;
self.traps[k].crate_tile = cur;
self.traps[k].carrier = 0;
e.job = Job::None;
}
}
}
return;
}
e.working = true;
e.timer += dt;
if e.timer >= 2.0 {
e.timer = 0.0;
let t = &mut self.traps[k];
t.stage = ARMED;
t.carrier = 0;
let door = t.kind.door();
let (x, y) = xy(tile);
if door {
self.map.t[tile].kind = Kind::Door;
self.map.t[tile].res = 0;
}
self.fx(8, x as f32 + 0.5, y as f32 + 0.5, 0.0, 0.0, 0.6, 0xff40d0ff);
self.sfx(Sfx::Build, x as f32 + 0.5, y as f32 + 0.5);
e.job = Job::None;
}
}
_ => {
self.traps[k].carrier = 0;
e.job = Job::None;
}
}
}
// enemies on their way: doors are smashed (the magic door strikes back); true = busy
pub(crate) fn bash_door(&mut self, e: &mut Ent, tile: usize, dt: f32) -> bool {
let Some(k) = self.traps.iter().position(|t| t.tile == tile && t.stage == ARMED && t.kind.door()) else { return false };
e.working = true;
let (tx, ty) = xy(tile);
e.face = if tx as f32 + 0.5 > e.x { 1.0 } else { -1.0 };
e.timer += dt;
let interval = st(e.kind).interval;
if e.timer < interval {
return true;
}
e.timer = 0.0;
let mult = crate::traps::door_breaker(e.kind);
let dmg = e.atk() * mult;
let (cx, cy) = (tx as f32 + 0.5, ty as f32 + 0.5);
self.traps[k].hp -= dmg;
self.traps[k].fired = 0.0;
self.fx(2, cx, cy, 0.0, 0.0, 0.25, 0xff4080c0);
self.sfx(Sfx::Dig, cx, cy);
if self.traps[k].kind == TrapKind::MagicDoor && self.traps[k].cd <= 0.0 {
// the magic door hurls the attacker back
self.traps[k].cd = 1.5;
e.hp -= 12.0;
e.flash = 0.15;
let (dx, dy) = (e.x - cx, e.y - cy);
let l = (dx * dx + dy * dy).sqrt().max(0.01);
let (nx, ny) = (e.x + dx / l * 0.8, e.y + dy / l * 0.8);
if self.map.walk(nx as i32, ny as i32) {
e.x = nx;
e.y = ny;
e.path.clear();
}
self.fx(0, cx, cy, e.x, e.y, 0.2, 0xffff60d0);
}
if self.traps[k].hp <= 0.0 {
self.traps.remove(k);
self.map.t[tile].kind = Kind::Floor;
self.map.t[tile].res = 0;
self.fx(3, cx, cy, 0.0, 0.0, 0.8, 0xff505070);
self.fx(6, cx, cy, 0.0, 0.0, 0.5, 0xff3a5a7a);
self.msg("msg.door_broken", vec![]);
}
true
}
fn damage_unit(&mut self, j: usize, dmg: f32) {
let o = &mut self.ents[j];
o.hp -= dmg;
o.flash = 0.15;
if o.hp <= 0.0 {
self.kill(j);
}
}
// armed traps go off when enemies come close
fn update_traps(&mut self, dt: f32) {
// poison clouds and rolling boulders
for c in self.clouds.iter_mut() {
c.t += dt;
}
self.clouds.retain(|c| c.t < CLOUD_TIME);
let clouds: Vec<(f32, f32)> = self.clouds.iter().map(|c| (c.x, c.y)).collect();
for (cx, cy) in clouds {
for j in 0..self.ents.len() {
if enemy(&self.ents[j]) && (self.ents[j].x - cx).powi(2) + (self.ents[j].y - cy).powi(2) < 1.8 * 1.8 {
self.damage_unit(j, 9.0 * dt);
}
}
if self.vrng.f() < 6.0 * dt {
let (ox, oy) = (self.vrng.f() * 2.4 - 1.2, self.vrng.f() * 2.4 - 1.2);
self.parts.push(crate::particles::Part { kind: crate::particles::SMOKE, x: cx + ox, y: cy + oy, h: 0.2, vx: 0.0, vy: 0.0, vh: 0.15, t: 0.0, max: 1.6, col: crate::particles::abgr(120, 220, 80), size: 0.3 });
}
}
let mut rollers = std::mem::take(&mut self.rollers);
for r in rollers.iter_mut() {
r.t += dt;
let (nx, ny) = (r.x + r.dx * 5.0 * dt, r.y + r.dy * 5.0 * dt);
if !self.map.walk(nx as i32, ny as i32) || r.t > 6.0 {
r.t = 99.0;
self.fx(3, r.x, r.y, 0.0, 0.0, 0.6, 0xff505070);
continue;
}
r.x = nx;
r.y = ny;
for j in 0..self.ents.len() {
let o = &self.ents[j];
if enemy(o) && !r.hit.contains(&o.id) && (o.x - r.x).powi(2) + (o.y - r.y).powi(2) < 0.55 * 0.55 {
r.hit.push(o.id);
self.knock(j, r.dx * 0.9, r.dy * 0.9);
if self.ents[j].alive {
self.damage_unit(j, 70.0);
}
}
}
}
rollers.retain(|r| r.t < 50.0);
self.rollers = rollers;
for k in 0..self.traps.len() {
if k >= self.traps.len() {
break;
}
let t = self.traps[k].clone();
if t.stage != ARMED || t.kind.door() {
continue;
}
let (tx, ty) = xy(t.tile);
let (cx, cy) = (tx as f32 + 0.5, ty as f32 + 0.5);
let near = |r: f32, ents: &Vec<Ent>| -> Vec<usize> { (0..ents.len()).filter(|&j| enemy(&ents[j]) && (ents[j].x - cx).powi(2) + (ents[j].y - cy).powi(2) < r * r).collect() };
match t.kind {
TrapKind::Frost => {
// always on: cold around it slows and bites
for j in near(2.5, &self.ents) {
self.ents[j].slow = self.ents[j].slow.max(0.5);
self.damage_unit(j, 2.0 * dt);
}
if self.vrng.f() < 4.0 * dt {
let a = self.vrng.f() * 6.28;
self.parts.push(crate::particles::Part { kind: crate::particles::MOTE, x: cx + a.cos() * 1.5, y: cy + a.sin() * 1.5, h: 0.1, vx: 0.0, vy: 0.0, vh: 0.3, t: 0.0, max: 1.2, col: crate::particles::abgr(170, 230, 255), size: 0.04 });
}
}
_ if t.cd > 0.0 => {}
TrapKind::Spikes => {
let hit = near(0.7, &self.ents);
if !hit.is_empty() {
self.traps[k].cd = 2.5;
self.traps[k].fired = 0.0;
for j in hit {
self.damage_unit(j, 45.0);
}
self.fx(2, cx, cy, 0.0, 0.0, 0.3, 0xffd0d0d0);
self.sfx(Sfx::MeleeHit, cx, cy);
}
}
TrapKind::Poison => {
if !near(0.9, &self.ents).is_empty() {
self.traps[k].cd = 12.0;
self.traps[k].fired = 0.0;
self.clouds.push(Cloud { x: cx, y: cy, t: 0.0 });
self.sfx(Sfx::Magic, cx, cy);
}
}
TrapKind::Fire => {
// a jet of fire towards the nearest enemy in reach
let targets = near(3.2, &self.ents);
if let Some(&j) = targets.first() {
let (ex, ey) = (self.ents[j].x, self.ents[j].y);
let a = (ey - cy).atan2(ex - cx);
self.traps[k].cd = 4.0;
self.traps[k].fired = 0.0;
self.traps[k].aim = a;
let (dx, dy) = (a.cos(), a.sin());
for o in near(3.4, &self.ents) {
let (rx, ry) = (self.ents[o].x - cx, self.ents[o].y - cy);
let along = rx * dx + ry * dy;
let side = (rx * dy - ry * dx).abs();
if along > 0.0 && side < 0.6 {
self.damage_unit(o, 55.0);
}
}
for q in 0..14 {
let f = q as f32 / 14.0 * 3.2;
let v = (dx * 3.0 + self.vrng.f() - 0.5, dy * 3.0 + self.vrng.f() - 0.5, 0.8);
self.parts.push(crate::particles::Part { kind: crate::particles::EMBER, x: cx + dx * f * 0.3, y: cy + dy * f * 0.3, h: 0.35, vx: v.0, vy: v.1, vh: v.2, t: 0.0, max: 0.8, col: crate::particles::abgr(255, 140, 40), size: 0.07 });
}
self.sfx(Sfx::Cannon, cx, cy);
}
}
TrapKind::Roller => {
let targets = near(4.0, &self.ents);
if let Some(&j) = targets.first() {
let (ex, ey) = (self.ents[j].x - cx, self.ents[j].y - cy);
// along the main axis of the corridor towards the enemy
let (dx, dy) = if ex.abs() > ey.abs() { (ex.signum(), 0.0) } else { (0.0, ey.signum()) };
self.traps[k].cd = 18.0;
self.traps[k].fired = 0.0;
self.traps[k].aim = dy.atan2(dx);
self.rollers.push(Roller { x: cx, y: cy, dx, dy, t: 0.0, hit: Vec::new() });
self.sfx(Sfx::DigDone, cx, cy);
}
}
TrapKind::Alarm => {
let seen = near(3.0, &self.ents);
if let Some(&j) = seen.first() {
self.traps[k].cd = 20.0;
self.traps[k].fired = 0.0;
let target = self.ents[j].id;
for o in self.ents.iter_mut() {
if o.alive && o.creature() && !matches!(o.job, Job::Attack(_)) && (o.x - cx).powi(2) + (o.y - cy).powi(2) < 18.0 * 18.0 {
o.job = Job::Attack(target);
o.path.clear();
o.repath = 0.0;
}
}
self.fx(7, cx, cy, 0.0, 0.0, 0.8, 0xff30a0ff);
self.sfx(Sfx::FlagSet, cx, cy);
self.msg("msg.alarm", vec![]);
}
}
TrapKind::Mine => {
if !near(0.8, &self.ents).is_empty() {
for j in near(2.2, &self.ents) {
if self.ents[j].alive {
self.damage_unit(j, 160.0);
}
}
self.shot(cx, cy, 0.6, cx, cy, 0.1, 0.05, 0xff40c0ff);
self.style_last_fx(crate::particles::ST_CANNON);
self.sfx(Sfx::Cannon, cx, cy);
self.traps.remove(k);
}
}
_ => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn forge_game() -> (Game, i32, i32) {
let mut g = Game::new(4, 64, 64);
let (cx, cy) = g.map.core;
for y in cy - 8..=cy + 8 {
for x in cx + 4..=cx + 14 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
for y in cy - 8..=cy - 6 {
for x in cx + 4..=cx + 7 {
g.map.t[idx(x, y)].kind = Kind::Forge;
}
}
g.recount();
(g, cx, cy)
}
#[test]
fn forge_makes_traps_and_imps_install_them() {
let (mut g, cx, cy) = forge_game();
let (tx, ty) = (cx + 12, cy + 4);
g.place_trap(TrapKind::Spikes, tx, ty);
g.place_trap(TrapKind::WoodDoor, cx + 12, cy + 6);
assert_eq!(g.traps.len(), 2);
for k in 0..3 {
g.spawn(EK::Cogling, false, cx as f32 + 5.5 + k as f32, cy as f32 - 2.5, 3);
}
let mut armed = false;
for _ in 0..(20 * 200) {
g.update(0.05);
if g.traps.iter().filter(|t| t.stage == ARMED).count() == 2 {
armed = true;
break;
}
}
assert!(armed, "stages: {:?}", g.traps.iter().map(|t| t.stage).collect::<Vec<_>>());
assert_eq!(g.map.t[idx(cx + 12, cy + 6)].kind, Kind::Door);
// a knight steps on the spikes
let id = g.spawn(EK::Knight, true, tx as f32 + 0.5, ty as f32 + 0.5, 1);
let hp0 = g.ents[g.find(id).unwrap()].max_hp;
g.update(0.05);
let hp = g.find(id).map_or(0.0, |j| g.ents[j].hp);
assert!(hp < hp0, "spikes hurt");
}
#[test]
fn doors_stop_enemies_until_smashed_and_lock() {
let (mut g, cx, cy) = forge_game();
let i = idx(cx + 10, cy);
g.traps.push(Trap { id: 999, kind: TrapKind::WoodDoor, tile: i, stage: ARMED, crate_tile: 0, progress: 0.0, cd: 0.0, hp: 400.0, locked: false, carrier: 0, fired: 99.0, aim: 0.0 });
g.map.t[i].kind = Kind::Door;
assert!(g.map.walk_cost()(i).is_some());
g.place_trap(TrapKind::WoodDoor, cx + 10, cy);
assert!(g.traps.iter().find(|t| t.id == 999).unwrap().locked);
assert!(g.map.walk_cost()(i).is_none(), "locked doors stop our own creatures too");
// an ogre hero... a knight bashes it down
let id = g.spawn(EK::Knight, true, cx as f32 + 10.5, cy as f32 - 0.5, 8);
let j = g.find(id).unwrap();
let mut e = g.ents[j].clone();
let mut steps = 0;
while g.trap_at(i).is_some() && steps < 2000 {
g.bash_door(&mut e, i, 0.05);
steps += 1;
}
assert!(g.trap_at(i).is_none(), "door smashed");
assert_eq!(g.map.t[i].kind, Kind::Floor);
}
}