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

+89 -7
View File
@@ -107,6 +107,7 @@ pub enum EK {
Gargoyle, // aerie + torture chamber: stone, tough
Owl, // aerie + library: arcane bolts from afar
Wyrm, // big aerie + treasury: rare, very strong, fire breath
Cogling, // forge: a sooty little tinkerer, builds traps and doors fastest
}
pub struct St {
@@ -143,6 +144,7 @@ pub fn st(k: EK) -> St {
EK::Gargoyle => s("unit.gargoyle", 260.0, 15.0, 1.0, 2.2, 1.2, 120, 0),
EK::Owl => s("unit.owl", 70.0, 12.0, 4.5, 2.8, 1.3, 100, 0),
EK::Wyrm => s("unit.wyrm", 650.0, 30.0, 3.0, 2.5, 1.4, 380, 0),
EK::Cogling => s("unit.cogling", 90.0, 9.0, 1.0, 2.4, 0.9, 70, 0),
}
}
@@ -179,6 +181,8 @@ pub enum Job {
MakeBed(usize), // new creature: sets up its bed
FetchWage(usize), // payday: walks to the treasury (or the core) for the wage
Help(usize), // imp with nothing else to do helps another imp at this tile
Forge(usize), // creature works in the forge (traps and doors)
Install(u32), // imp carries a finished trap or door (id) to its spot and installs it
}
#[derive(Clone, Serialize, Deserialize)]
@@ -405,7 +409,7 @@ pub struct Game {
pub paused: bool,
pub hand: Vec<u32>,
#[serde(skip)]
pub counts: [usize; 8], // treasury, lair, farm, training hall, library, torture chamber, temple, aerie (recounted)
pub counts: [usize; N_ROOMS], // room tiles per room kind (ROOM_KINDS order, recounted)
pub gate_hp: f32,
pub gate_hit_t: f32,
pub gate_stage: u32, // defender thresholds triggered (0..3)
@@ -452,6 +456,14 @@ pub struct Game {
pub stuck: Vec<crate::particles::Part>, // arrows stuck in walls and floors, scorch marks
#[serde(skip, default = "crate::particles::vrng")]
pub vrng: Rng, // random numbers for the particles only
#[serde(default)]
pub traps: Vec<crate::traps::Trap>, // traps and doors (ordered, in the forge, carried, armed)
#[serde(default)]
pub rollers: Vec<crate::traps::Roller>,
#[serde(default)]
pub clouds: Vec<crate::traps::Cloud>,
#[serde(skip)]
pub forge_work: f32, // work done in the forge this frame
#[serde(skip)]
pub demo: Option<crate::demo::Demo>, // the endless bridge battle behind the title menu
#[serde(default)]
@@ -492,9 +504,11 @@ impl Torch {
}
}
pub const ROOM_KINDS: [Kind; 8] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple, Kind::Aerie];
pub const ROOM_COST: [i32; 8] = [60, 80, 100, 140, 160, 200, 180, 120];
pub const ROOM_NAMES: [&str; 8] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple", "room.aerie"];
pub const N_ROOMS: usize = 9;
pub const ROOM_FORGE: usize = 8;
pub const ROOM_KINDS: [Kind; N_ROOMS] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple, Kind::Aerie, Kind::Forge];
pub const ROOM_COST: [i32; N_ROOMS] = [60, 80, 100, 140, 160, 200, 180, 120, 150];
pub const ROOM_NAMES: [&str; N_ROOMS] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple", "room.aerie", "room.forge"];
// bridge cost per tile: over water, over lava
pub const BRIDGE_COST: [i32; 2] = [30, 75];
@@ -585,7 +599,7 @@ impl Game {
state: 0,
paused: false,
hand: Vec::new(),
counts: [0; 8],
counts: [0; N_ROOMS],
gate_hp: GATE_HP,
gate_hit_t: -100.0,
gate_stage: 0,
@@ -613,6 +627,10 @@ impl Game {
springs: 0,
freed: 0,
demo: None,
traps: Vec::new(),
rollers: Vec::new(),
clouds: Vec::new(),
forge_work: 0.0,
parts: Vec::new(),
stuck: Vec::new(),
vrng: crate::particles::vrng(),
@@ -754,7 +772,7 @@ impl Game {
}
pub fn recount(&mut self) {
let mut c = [0usize; 8];
let mut c = [0usize; N_ROOMS];
self.springs = self.map.t.iter().filter(|t| t.kind == Kind::ManaSpring && t.owner == Owner::Player).count();
for t in &self.map.t {
if t.owner == Owner::Player {
@@ -1050,6 +1068,7 @@ impl Game {
self.update_contested();
self.update_units(dt);
self.update_towers(dt);
self.update_forge(dt);
if !self.rivals.is_empty() {
self.update_rivals(dt);
}
@@ -1260,6 +1279,9 @@ impl Game {
if self.counts[6] >= 6 {
opts.push((EK::Priestess, 2));
}
if self.counts[ROOM_FORGE] >= 6 {
opts.push((EK::Cogling, 3));
}
// 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);
@@ -1687,6 +1709,18 @@ impl Game {
}
let next = e.path[0];
let tile = self.map.t[next];
if tile.kind == Kind::Door {
if e.side() != 0 {
// enemies smash doors on their way
if self.bash_door(e, next, dt) {
return true;
}
} else if tile.res & 1 == 1 && e.tile() != next {
// locked: our own creatures have to find another way
e.path.clear();
return false;
}
}
if !tile.walkable() && !(flies(e.kind) && tile.liquid()) {
if (e.hero || e.rival()) && tile.diggable() {
// heroes dig their way through
@@ -2563,6 +2597,9 @@ impl Game {
Job::Carry(id) => {
self.carry_job(e, id, dt);
}
Job::Install(id) => {
self.install_job(e, id, dt);
}
Job::Repair(id) => {
let Some(k) = self.towers.iter().position(|t| t.id == id) else {
e.job = Job::None;
@@ -2731,6 +2768,16 @@ impl Game {
if e.carry > 0 && self.start_deliver(e) {
return;
}
// finished traps and doors are carried to their spots
if let Some((id, p)) = self.crate_to_carry(cur) {
if let Some(k) = self.traps.iter().position(|t| t.id == id) {
self.traps[k].carrier = me;
}
e.path = p;
e.job = Job::Install(id);
e.timer = 0.0;
return;
}
// damaged towers are repaired (free of charge) before more ground is claimed
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
@@ -2992,6 +3039,22 @@ impl Game {
e.job = Job::None;
}
}
Job::Forge(t) => {
if cur != t || self.map.t[t].kind != Kind::Forge || !self.forge_orders() {
self.unreserve(me);
e.job = Job::None;
return;
}
e.working = true;
let rate = if e.kind == EK::Cogling { 2.5 } else { 1.0 } * (1.0 + 0.1 * (e.level - 1) as f32);
self.forge_work += rate * dt;
e.xp += 2.5 * dt;
e.timer -= dt;
if e.timer <= 0.0 {
self.unreserve(me);
e.job = Job::None;
}
}
Job::Study(t) => {
if cur != t || self.map.t[t].kind != Kind::Library {
self.unreserve(me);
@@ -3281,6 +3344,20 @@ impl Game {
}
}
}
// the forge: orders waiting (coglings love it, others lend a hand now and then)
if self.forge_orders() && self.counts[ROOM_FORGE] > 0 {
let r = self.rng.f();
if (e.kind == EK::Cogling && r < 0.9) || r < 0.25 {
if let Some(p) = self.find_room_tile(cur, Kind::Forge, me, false) {
let t = p.last().copied().unwrap_or(cur);
self.map.t[t].reserved = me;
e.path = p;
e.job = Job::Forge(t);
e.timer = 10.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) {
@@ -3788,6 +3865,9 @@ impl Game {
continue;
}
let i = idx(x, y);
if self.remove_trap_at(i) {
continue;
}
if let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) {
refund += self.towers[k].invested() as f32 * 0.5;
if self.towers[k].kind == TowerKind::Rune && !self.towers[k].rune_final {
@@ -4620,6 +4700,8 @@ pub fn job_name(e: &Ent) -> &'static str {
}
Job::Eat(_) => "job.eat",
Job::Train(_) => "job.train",
Job::Forge(_) => "job.forge",
Job::Install(_) => "job.install",
Job::Study(_) => "job.study",
Job::Attack(_) => "job.fight",
Job::TearBridge(_) => "job.tear_bridge",
@@ -5191,7 +5273,7 @@ mod tests {
#[test]
fn aerie_attracts_flyers() {
let mut g = Game::new(3, 64, 64);
g.counts = [16, 0, 9, 0, 6, 4, 0, 16];
g.counts = [16, 0, 9, 0, 6, 4, 0, 16, 0];
let mut seen = std::collections::HashSet::new();
for _ in 0..200 {
g.try_portal_for_test();