From 4f20223c938f5157ae8a434ff9d9f2ac1af672ba Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 4 Nov 2026 06:55:37 +0100 Subject: [PATCH] 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 --- lang/de.lang | 76 ++++++ lang/en.lang | 76 ++++++ src/campaign.rs | 2 +- src/game.rs | 96 ++++++- src/gfx.rs | 142 +++++++++- src/main.rs | 6 +- src/map.rs | 19 +- src/menu/bestiary.rs | 5 +- src/props.rs | 35 ++- src/render3d.rs | 173 ++++++++++++- src/settings.rs | 8 +- src/traps.rs | 604 +++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 49 +++- 13 files changed, 1267 insertions(+), 24 deletions(-) create mode 100644 src/traps.rs diff --git a/lang/de.lang b/lang/de.lang index 1c690a0..e8e41b3 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -713,3 +713,79 @@ best.b.arrow.text = Der Pfeil durchbohrt bis zu zwei weitere Feinde hinter dem Z best.b.cannon.text = Flächenschaden und Rückstoß - schieb die Ritter in die Lava. best.b.magic.text = Trifft immer, verlangsamt das Ziel und springt auf einen zweiten Feind über. Verbraucht Mana. best.b.rune.text = Das Meisterstück der Höhlenzwerge: Runenblitze, die auf drei weitere Feinde überspringen, riesige Reichweite und Mauern aus Granit. Ein kostenloser Bau je Kiste; verkaufst du ihn, kommt er einmal zurück. + +# forge, traps and doors +room.forge = Schmiede +tool.forge.label = Schmiede +tool.forge.desc = Kreaturen fertigen hier Fallen und Türen - Arbeit, kein Gold. Fertige Stücke warten in Kisten, bis ein Imp sie einbaut. Coglinge kommen für eine Schmiede ab 6 Feldern. +unit.cogling = Cogling +cat.traps = Fallen +cost.work = {0} Arbeit in der Schmiede +job.forge = Schmiedet +job.install = Baut eine Falle ein +hint.trap_where = Fallen und Türen kommen auf deinen eigenen freien Boden. +hint.need_forge = Du brauchst zuerst eine Schmiede: Dort fertigen Kreaturen Fallen und Türen. +msg.trap_made = Die Schmiede ist fertig: {0}. Ein Imp baut es ein. +msg.door_locked = Tür verschlossen - niemand kommt durch, auch deine Kreaturen nicht. +msg.door_unlocked = Tür aufgeschlossen. +msg.door_broken = Eine Tür wurde eingeschlagen! +msg.alarm = Alarm! Deine Kreaturen eilen zu den Eindringlingen. +trap.ordered = Geplant - die Schmiede ist bei {0}% +trap.crated = Fertig - wartet in der Schmiede +trap.carried = Ein Imp bringt es +trap.armed = Scharf +trap.locked = Verschlossen (mit einem Tür-Werkzeug anklicken zum Öffnen) +trap.unlocked = Offen für deine Kreaturen (mit einem Tür-Werkzeug anklicken zum Abschließen) +trap.spikes = Stachelgrube +trap.poison = Giftschlot +trap.fire = Feuerstrahl +trap.roller = Steinwalze +trap.alarm = Alarmglocke +trap.mine = Runenmine +trap.frost = Eisglühen +trap.door_wood = Holztür +trap.door_iron = Eisentür +trap.door_magic = Magietür +tool.spikes.label = Stacheln +tool.spikes.name = Stachelgrube +tool.spikes.desc = Stacheln schießen hoch, wenn ein Feind darauf tritt. Schlägt immer wieder zu. +tool.poison.label = Gift +tool.poison.name = Giftschlot +tool.poison.desc = Stößt eine Giftwolke aus, die jeden Feind darin eine Weile verletzt. +tool.fire.label = Feuer +tool.fire.name = Feuerstrahl +tool.fire.desc = Ein Dämonenkopf speit einen Feuerstrahl auf Feinde bis drei Felder weit. +tool.roller.label = Walze +tool.roller.name = Steinwalze +tool.roller.desc = Lässt einen Felsbrocken den Gang hinunterrollen: trifft hart und stößt Feinde beiseite. +tool.alarm.label = Alarm +tool.alarm.name = Alarmglocke +tool.alarm.desc = Läutet, wenn Feinde nahen: deine Kreaturen in der Nähe eilen zum Kampf. +tool.mine.label = Mine +tool.mine.name = Runenmine +tool.mine.desc = Explodiert einmal mit großer Wucht, wenn ein Feind nahe kommt. +tool.frost.label = Frost +tool.frost.name = Eisglühen +tool.frost.desc = Ein Eiskristall: Feinde ringsum werden verlangsamt und frieren, immer. +tool.door_wood.label = Holztür +tool.door_wood.name = Holztür +tool.door_wood.desc = Deine Kreaturen gehen durch, Feinde müssen sie einschlagen. Tür erneut anklicken zum Abschließen. +tool.door_iron.label = Eisentür +tool.door_iron.name = Eisentür +tool.door_iron.desc = Eine schwere Tür, die lange hält. Tür erneut anklicken zum Abschließen. +tool.door_magic.label = Magietür +tool.door_magic.name = Magietür +tool.door_magic.desc = Die stärkste Tür: Sie schleudert zurück, wer auf sie einschlägt. Tür erneut anklicken zum Abschließen. +best.cogling.lure = eine Schmiede ab 6 Feldern +best.cogling.text = Ein Cogling ist Ruß, Messingbrille und reine Begeisterung. Er baut Fallen zweieinhalbmal so schnell wie alle anderen und summt beim Hämmern. | Kämpfen kann er kaum, aber jeder Gang, den er anfasst, wird ein schrecklicher Ort zum Spazierengehen. Ich liebe sie. +best.b.forge.text = Hämmerklang, der Geruch von heißem Eisen. Jede Falle und jede Tür entsteht zuerst hier - je mehr Kreaturen arbeiten, desto schneller. +best.b.spikes.text = Schlicht, ehrlich, wiederverwendbar. Ritter hassen es, wenn der Boden zurückbeißt. +best.b.poison.text = Ein grüner Hauch und viel Husten. Am besten in engen Gängen. +best.b.fire.text = Ein Dämonenkopf mit Temperament. Stell ihn dahin, wo die Helden Schlange stehen. +best.b.roller.text = Ein Felsbrocken und ein langer Gang - den Rest erledigt die Physik. +best.b.alarm.text = Selbst nicht gefährlich, aber sie ruft alle, die es sind. +best.b.mine.text = Ein Schritt zu viel, und der Gang wird neu eingerichtet. +best.b.frost.text = Kälte macht Helden langsam, und langsame Helden sind leichte Ziele. +best.b.door_wood.text = Hält Zugluft und Ritter draußen - eine Weile. +best.b.door_iron.text = Schwer, genietet, stur. Bringt einen Rammbock mit, Helden. +best.b.door_magic.text = Schlag zu, und sie schlägt zurück. Verschlossen ist sie das Ende jedes Ganges. diff --git a/lang/en.lang b/lang/en.lang index 119865a..bb60beb 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -714,3 +714,79 @@ best.b.arrow.text = The arrow pierces up to two more enemies behind the target. best.b.cannon.text = Splash damage and knockback - push the knights into the lava. best.b.magic.text = Never misses, slows the target and jumps on to a second enemy. Uses mana. best.b.rune.text = The masterpiece of the cave dwarves: rune bolts that jump on to three more enemies, enormous range and walls of granite. One free build per chest; sell it and it comes back once. + +# forge, traps and doors +room.forge = Forge +tool.forge.label = Forge +tool.forge.desc = Creatures manufacture traps and doors here - work, no gold. Finished pieces wait in crates until an imp installs them. Coglings come for a forge of 6+ tiles. +unit.cogling = Cogling +cat.traps = Traps +cost.work = {0} work in the forge +job.forge = Forging +job.install = Installing a trap +hint.trap_where = Traps and doors go on your own empty floor. +hint.need_forge = You need a forge first: creatures make traps and doors there. +msg.trap_made = The forge has finished: {0}. An imp will install it. +msg.door_locked = Door locked - nobody gets through, not even your own creatures. +msg.door_unlocked = Door unlocked. +msg.door_broken = A door has been smashed! +msg.alarm = Alarm! Your creatures rush to the intruders. +trap.ordered = Planned - the forge is at {0}% +trap.crated = Finished - waiting in the forge +trap.carried = An imp is bringing it +trap.armed = Armed +trap.locked = Locked (click with a door tool to unlock) +trap.unlocked = Open for your creatures (click with a door tool to lock) +trap.spikes = Spike Pit +trap.poison = Poison Vent +trap.fire = Fire Jet +trap.roller = Stone Roller +trap.alarm = Alarm Bell +trap.mine = Rune Mine +trap.frost = Frost Glow +trap.door_wood = Wooden Door +trap.door_iron = Iron Door +trap.door_magic = Magic Door +tool.spikes.label = Spikes +tool.spikes.name = Spike Pit +tool.spikes.desc = Spikes shoot up when an enemy steps on it. Strikes again and again. +tool.poison.label = Poison +tool.poison.name = Poison Vent +tool.poison.desc = Releases a cloud of poison that hurts every enemy inside for a while. +tool.fire.label = Fire +tool.fire.name = Fire Jet +tool.fire.desc = A demon head spits a jet of fire at enemies up to three tiles away. +tool.roller.label = Roller +tool.roller.name = Stone Roller +tool.roller.desc = Sends a boulder rolling down the corridor: hits hard and knocks enemies aside. +tool.alarm.label = Alarm +tool.alarm.name = Alarm Bell +tool.alarm.desc = Rings when enemies come near: your creatures nearby rush to fight them. +tool.mine.label = Mine +tool.mine.name = Rune Mine +tool.mine.desc = Explodes once with great force when an enemy steps close. +tool.frost.label = Frost +tool.frost.name = Frost Glow +tool.frost.desc = An ice crystal: enemies around it are slowed and chilled, always. +tool.door_wood.label = Wood door +tool.door_wood.name = Wooden Door +tool.door_wood.desc = Your creatures walk through, enemies have to smash it. Click a door again to lock it. +tool.door_iron.label = Iron door +tool.door_iron.name = Iron Door +tool.door_iron.desc = A heavy door that takes a long time to break. Click a door again to lock it. +tool.door_magic.label = Magic door +tool.door_magic.name = Magic Door +tool.door_magic.desc = The strongest door: it hurls back whoever beats on it. Click a door again to lock it. +best.cogling.lure = a forge of 6+ tiles +best.cogling.text = A cogling is soot, brass goggles and pure enthusiasm. It builds traps two and a half times as fast as anyone else and hums while it hammers. | It cannot fight much, but every corridor it touches becomes a terrible place to walk. I adore them. +best.b.forge.text = The clang of hammers, the smell of hot iron. Every trap and door is made here first - the more creatures work, the faster. +best.b.spikes.text = Simple, honest, reusable. Knights hate it when the floor bites back. +best.b.poison.text = A puff of green and a lot of coughing. Best in narrow corridors. +best.b.fire.text = A demon head with a temper. Put it where the heroes queue. +best.b.roller.text = A boulder and a long corridor - physics does the rest. +best.b.alarm.text = Not dangerous itself, but it calls everybody who is. +best.b.mine.text = One step too many and the corridor gets redecorated. +best.b.frost.text = Cold makes heroes slow, and slow heroes make easy targets. +best.b.door_wood.text = Keeps the draught and the knights out - for a while. +best.b.door_iron.text = Heavy, riveted, stubborn. Bring a battering ram, heroes. +best.b.door_magic.text = Strike it and it strikes back. Locked, it is the end of every corridor. diff --git a/src/campaign.rs b/src/campaign.rs index 4977217..268e57f 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; // From which mission (0-based) on each tool is available (same order as ui::TOOLS): // dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp, // torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel -pub const TOOL_UNLOCK: [usize; 21] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0]; +pub const TOOL_UNLOCK: [usize; 32] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16]; pub struct FortDef { pub at: (f32, f32), // centre as a fraction of the map size diff --git a/src/game.rs b/src/game.rs index 1458659..c1930b2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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, #[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, // 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, // traps and doors (ordered, in the forge, carried, armed) + #[serde(default)] + pub rollers: Vec, + #[serde(default)] + pub clouds: Vec, + #[serde(skip)] + pub forge_work: f32, // work done in the forge this frame #[serde(skip)] pub demo: Option, // 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(); diff --git a/src/gfx.rs b/src/gfx.rs index 51064a4..e5c8d6a 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -6,7 +6,7 @@ use macroquad::prelude::*; pub const CELL: f32 = 16.0; const ATLAS_COLS: u32 = 16; -const ATLAS_ROWS: u32 = 9; +pub const ATLAS_ROWS: u32 = 14; // atlas indices (4 variants each) pub const T_ROCK: u32 = 0; @@ -70,6 +70,8 @@ pub const I_STEEL: u32 = 134; pub const T_RIVAL1: u32 = 135; // 4 variants: floor of the green master pub const T_RIVAL2: u32 = 139; // 4 variants: floor of the blue master pub const I_RUNE: u32 = 143; // rune forge tower +pub const T_FORGE: u32 = 144; // 4 variants: sooty flagstones with glowing cracks +pub const I_TRAPS: u32 = 148; // 10 icons in TRAP_KINDS order pub struct Gfx { pub atlas: Texture2D, @@ -749,6 +751,118 @@ fn gen_atlas() -> Image { let h = hash(cu, cv, seed) % 3; Some(if e { lo } else if h == 0 || (u % 4 == 1 && v % 4 == 1) { hi } else if d > 0.65 { lo } else { mid }) }; + // forge: sooty dark flagstones, iron filings and glowing cracks + for v in 0..4u32 { + cv.cell(T_FORGE + v, |x, y| { + let base = stones(x, y, v + 300, (52, 46, 46), (22, 18, 18), 8); + if (x * 7 + y * 3 + v as i32 * 11) % 23 == 0 || ((x + y * 2 + v as i32) % 19 == 0 && y % 8 != 0) { + Some(c(255, 120, 40)) + } else if hash(x, y, v + 301) % 13 == 0 { + Some(c(120, 120, 130)) + } else { + Some(base) + } + }); + } + // trap and door icons + let icons: [fn(i32, i32) -> Option; 10] = [ + // spikes + |x, y| { + if y >= 12 { + Some(c(90, 90, 100)) + } else if y >= 3 && (x % 4 - 2).abs() <= (y - 3) / 4 && (2..14).contains(&x) { + Some(if x % 4 == 2 { c(230, 230, 240) } else { c(160, 160, 175) }) + } else { + None + } + }, + // poison cloud + |x, y| { + let d = |cx: i32, cy: i32, r: i32| (x - cx).pow(2) + (y - cy).pow(2) <= r * r; + if d(5, 9, 4) || d(10, 8, 4) || d(8, 5, 3) { + Some(if (x + y) % 5 == 0 { c(200, 255, 140) } else { c(110, 200, 70) }) + } else { + None + } + }, + // fire jet + |x, y| { + let w = (y - 1).max(0) / 3; + if (x - 8).abs() <= w && y <= 14 && y >= 1 { + Some(if (x - 8).abs() <= w / 2 && y > 6 { c(255, 230, 120) } else { c(255, 120, 30) }) + } else { + None + } + }, + // stone roller + |x, y| { + let d = (x - 8).pow(2) + (y - 8).pow(2); + if d <= 36 { + Some(if (x + y) % 6 == 0 || d > 28 { c(80, 76, 72) } else { c(140, 132, 124) }) + } else if y == 14 && x < 15 { + Some(c(90, 70, 50)) + } else { + None + } + }, + // alarm bell + |x, y| { + let w = 2 + y / 3; + if (2..=11).contains(&y) && (x - 8).abs() <= w.min(6) { + Some(if x < 8 { c(255, 220, 90) } else { c(200, 160, 50) }) + } else if (12..=13).contains(&y) && (x - 8).abs() <= 1 { + Some(c(140, 100, 40)) + } else { + None + } + }, + // rune mine + |x, y| { + let d = (((x - 8).pow(2) + (y - 8).pow(2)) as f32).sqrt(); + if (5.0..6.5).contains(&d) || ((x - 8).abs() <= 1 && (3..=12).contains(&y)) || (y == 8 && (5..=11).contains(&x)) { + Some(c(90, 200, 255)) + } else { + None + } + }, + // frost glow + |x, y| { + let (dx, dy) = (x - 8, y - 8); + if (dx == 0 || dy == 0 || dx == dy || dx == -dy) && dx.abs() <= 6 && dy.abs() <= 6 { + Some(if dx.abs() + dy.abs() <= 2 { c(255, 255, 255) } else { c(150, 220, 255) }) + } else { + None + } + }, + // wooden door + |x, y| { + if (3..=12).contains(&x) && y >= 2 { + Some(if x % 3 == 0 { c(80, 50, 28) } else if y == 5 || y == 11 { c(70, 70, 76) } else { c(130, 86, 48) }) + } else { + None + } + }, + // iron door + |x, y| { + if (3..=12).contains(&x) && y >= 2 { + Some(if (x == 4 || x == 11) && y % 3 == 0 { c(200, 200, 210) } else if y == 7 { c(60, 60, 66) } else { c(110, 112, 122) }) + } else { + None + } + }, + // magic door + |x, y| { + if (3..=12).contains(&x) && y >= 2 { + let rune = (x == 8 && (5..=11).contains(&y)) || (y == 8 && (6..=10).contains(&x)); + Some(if rune { c(240, 160, 255) } else if x == 3 || x == 12 || y == 2 { c(70, 40, 100) } else { c(110, 60, 160) }) + } else { + None + } + }, + ]; + for (k, f) in icons.iter().enumerate() { + cv.cell(I_TRAPS + k as u32, |x, y| f(x, y)); + } // aerie: straw and twigs on stone, a few feathers for v in 0..4u32 { cv.cell(T_AERIE + v, |x, y| { @@ -1005,6 +1119,19 @@ fn sprite_rows(k: EK) -> [&'static str; 10] { ".kubbbbuk.", "..ky..yk..", ], + // cogling: a sooty little tinkerer with brass goggles and a hammer + EK::Cogling => [ + "........SS", + "...kkkk.SS", + "..kOOOOk.b", + ".kyekkeykb", + "..kOooOk.b", + "...kCCk..b", + "..kOCCOkbb", + "..kOCCOk..", + "...kO.Ok..", + "...kk.kk..", + ], EK::Wyrm => [ "R.......kw", "RR....krrk", @@ -1074,7 +1201,7 @@ fn pal(ch: char) -> Option { }) } -pub const SPRITE_KINDS: [EK; 17] = [ +pub const SPRITE_KINDS: [EK; 18] = [ EK::Worker, EK::Kobold, EK::Spider, @@ -1092,9 +1219,10 @@ pub const SPRITE_KINDS: [EK; 17] = [ EK::Gargoyle, EK::Owl, EK::Wyrm, + EK::Cogling, ]; // width of the sprite texture in pixels -pub const SPRITE_TEX_W: f32 = (17 * 12) as f32; +pub const SPRITE_TEX_W: f32 = (SPRITE_KINDS.len() * 12) as f32; pub const SPRITE_TEX_H: f32 = (2 * VIEWS * GEAR_TIERS * SPR_CELL) as f32; pub fn sprite_index(k: EK) -> u32 { @@ -1504,6 +1632,14 @@ impl Gfx { Kind::Steel => T_STEEL + v, Kind::ManaSpring => T_PORTALFLOOR + v, Kind::RuneChest => T_FLOOR + v, + Kind::Forge => T_FORGE + v, + Kind::Door => { + if claimed { + T_CLAIM + v + } else { + T_FLOOR + v + } + } } } diff --git a/src/main.rs b/src/main.rs index a6e48db..e6814a6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod save; mod settings; mod sfx; mod tracks; +mod traps; mod sound; mod touch; mod ui; @@ -115,6 +116,7 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32, (Tool::Heal, _) => app.game.heal(wx, wy), (Tool::Summon, _) => app.game.summon(wx, wy), (Tool::Tower(k), Some((x, z))) => app.game.build_tower(k, x, z), + (Tool::Trap(k), Some((x, z))) => app.game.place_trap(k, x, z), _ => {} } } @@ -564,7 +566,8 @@ async fn main() { // ---------------- keyboard (only while playing) if playing { for i in 0..TOOLS.len() { - if is_key_pressed(key(Action::Tool(i))) && app.game.tool_allowed(i) { + let k = key(Action::Tool(i)); + if k != KeyCode::Unknown && is_key_pressed(k) && app.game.tool_allowed(i) && app.game.tool_visible(i) { ui.tool = TOOLS[i]; inp.ldown = None; } @@ -1043,6 +1046,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M Tool::Bridge => (Color::new(0.9, 0.7, 0.4, 1.0), None), Tool::Steel => (Color::new(0.75, 0.8, 0.9, 1.0), None), Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None), + Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None), }; let ov = Overlay { hover: if over_ui { None } else { hover_tile.filter(|&(x, z)| inb(x, z)) }, diff --git a/src/map.rs b/src/map.rs index 5ab150d..32dbe08 100644 --- a/src/map.rs +++ b/src/map.rs @@ -61,6 +61,8 @@ pub enum Kind { Steel, // steel wall: hardly anyone gets through ManaSpring, // crystal spring: once claimed, more mana and a higher mana maximum RuneChest, // dwarven chest with the plans of a rune forge tower (claim it: one free build) + Forge, // room: creatures manufacture traps and doors + Door, // floor with an installed door (res bit 0: locked) } // options for the map generator (campaign missions) @@ -89,7 +91,7 @@ pub struct River { } // order matters for the packed savegame codec: only append new kinds -const KINDS: [Kind; 27] = [ +const KINDS: [Kind; 29] = [ Kind::Rock, Kind::Earth, Kind::Gold, @@ -117,6 +119,8 @@ const KINDS: [Kind; 27] = [ Kind::Steel, Kind::ManaSpring, Kind::RuneChest, + Kind::Forge, + Kind::Door, ]; // treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond @@ -213,6 +217,8 @@ impl Tile { | Kind::Aerie | Kind::ManaSpring | Kind::RuneChest + | Kind::Forge + | Kind::Door ) } // river tiles (open, but nobody can walk there) @@ -642,7 +648,15 @@ impl Map { } pub fn walk_cost(&self) -> impl Fn(usize) -> Option + '_ { - move |i| if self.t[i].walkable() { Some(1) } else { None } + move |i| { + let t = &self.t[i]; + // a locked door stops the dungeon's own creatures too + if t.walkable() && !(t.kind == Kind::Door && t.res & 1 == 1) { + Some(1) + } else { + None + } + } } // flying creatures also cross water and lava @@ -663,6 +677,7 @@ impl Map { Kind::Earth => Some(7), Kind::Gold | Kind::Silver | Kind::Ruby | Kind::Diamond => Some(12), Kind::Rock | Kind::Core | Kind::Tower | Kind::Water | Kind::Lava => None, + Kind::Door => Some(10), _ => Some(1), } } diff --git a/src/menu/bestiary.rs b/src/menu/bestiary.rs index 30427a5..b8851e8 100644 --- a/src/menu/bestiary.rs +++ b/src/menu/bestiary.rs @@ -7,10 +7,10 @@ use crate::ui::{tool_cost_static, BLUE as BLUE_C, GREEN as GREEN_C, RED as RED_C pub const TABS: [&str; 4] = ["best.tab.creatures", "best.tab.humans", "best.tab.dungeon", "best.tab.ranks"]; -const CREATURES: [EK; 12] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm]; +const CREATURES: [EK; 13] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Jailer, EK::Priestess, EK::Chainbreaker, EK::Cogling, EK::Bat, EK::Gargoyle, EK::Owl, EK::Wyrm]; const HUMANS: [EK; 5] = [EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin]; // buildings: indices into ui::TOOLS -const BUILDINGS: [usize; 14] = [2, 3, 4, 5, 6, 12, 13, 18, 17, 19, 14, 15, 16, 20]; +const BUILDINGS: [usize; 25] = [2, 3, 4, 5, 6, 12, 13, 18, 21, 17, 19, 14, 15, 16, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]; #[derive(Clone, Copy)] enum Entry { @@ -243,6 +243,7 @@ impl Menu { crate::ui::Tool::Room(r) => crate::game::ROOM_NAMES[r].trim_start_matches("room.").to_string(), crate::ui::Tool::Tower(k) => crate::game::tower_name(k).trim_start_matches("tower.").to_string(), crate::ui::Tool::Bridge => "bridge".into(), + crate::ui::Tool::Trap(k) => k.id().into(), _ => "steel".into(), }; text_key = format!("best.b.{}.text", id); diff --git a/src/props.rs b/src/props.rs index 0dae716..c5e3895 100644 --- a/src/props.rs +++ b/src/props.rs @@ -72,7 +72,7 @@ fn usage(g: &Game) -> Use { continue; } match e.job { - Job::Train(t) | Job::Study(t) | Job::Pray(t) if e.working || e.path.is_empty() => { + Job::Train(t) | Job::Study(t) | Job::Pray(t) | Job::Forge(t) if e.working || e.path.is_empty() => { u.busy.insert(t); } _ => {} @@ -139,6 +139,8 @@ pub fn draw_props_solid(g: &Game, cam: &Cam, t: f32) { Kind::Library if h % 3 == 0 => lectern(c, l), Kind::ManaSpring => spring_basin(c, l), Kind::RuneChest => rune_chest(c, l), + Kind::Forge if (x + z) % 2 == 0 => anvil(c, h, l), + Kind::Forge if h % 4 == 1 => furnace(c, l), _ => {} } } @@ -169,6 +171,22 @@ fn rune_chest(c: Vec3, l: f32) { cube(c + vec3(0.0, 0.22, 0.18), vec3(0.08, 0.1, 0.03), rgb(200, 170, 80, l)); } +// forge: an anvil on a block, a hammer leaning against it +fn anvil(c: Vec3, h: u32, l: f32) { + let o = vec3(((h >> 3) % 3) as f32 * 0.08 - 0.08, 0.0, ((h >> 5) % 3) as f32 * 0.08 - 0.08); + cube(c + o + vec3(0.0, 0.12, 0.0), vec3(0.22, 0.24, 0.22), rgb(70, 50, 36, l)); + cube(c + o + vec3(0.0, 0.28, 0.0), vec3(0.16, 0.08, 0.16), rgb(56, 56, 62, l)); + cube(c + o + vec3(0.0, 0.35, 0.0), vec3(0.38, 0.06, 0.16), rgb(76, 76, 84, l)); + cube(c + o + vec3(0.22, 0.35, 0.0), vec3(0.1, 0.04, 0.08), rgb(76, 76, 84, l)); +} + +// forge: a squat stone furnace (its fire glows in the glow pass) +fn furnace(c: Vec3, l: f32) { + cube(c + vec3(0.0, 0.25, 0.0), vec3(0.6, 0.5, 0.6), rgb(64, 52, 50, l)); + cube(c + vec3(0.0, 0.55, 0.0), vec3(0.4, 0.1, 0.4), rgb(50, 42, 40, l)); + cube(c + vec3(0.0, 0.75, 0.0), vec3(0.16, 0.32, 0.16), rgb(44, 38, 38, l)); +} + // a stone basin around a mana spring fn spring_basin(c: Vec3, l: f32) { for (dx, dz, sx, sz) in [(0.0, -0.38, 0.8, 0.1), (0.0, 0.38, 0.8, 0.1), (-0.38, 0.0, 0.1, 0.8), (0.38, 0.0, 0.1, 0.8)] { @@ -234,6 +252,21 @@ pub fn draw_props_glow(g: &Game, cam: &Cam, t: f32) { } } } + Kind::Forge if h % 4 == 1 && (x + z) % 2 != 0 => { + // furnace fire + let f = 0.7 + 0.3 * (t * 9.0 + k).sin(); + draw_cube(c + vec3(0.0, 0.2, 0.31), vec3(0.28, 0.2 * f, 0.02), None, Color::new(1.0, 0.5, 0.1, 0.95)); + draw_sphere(c + vec3(0.0, 0.25, 0.35), 0.22 * f, None, Color::new(1.0, 0.35, 0.05, 0.25)); + } + Kind::Forge if (x + z) % 2 == 0 && (u.busy.contains(&i) || u.busy.iter().any(|&b| adj8(b, i))) => { + // sparks fly from the anvil while somebody hammers + for n in 0..5 { + let ph = (t * 2.2 + n as f32 * 0.21 + k * 0.1).fract(); + let a = n as f32 * 1.3 + k; + let p = c + vec3(a.cos() * ph * 0.5, 0.4 + ph * 0.4 - ph * ph * 0.9, a.sin() * ph * 0.5); + draw_cube(p, Vec3::splat(0.03), None, Color::new(1.0, 0.75, 0.3, 1.0 - ph)); + } + } Kind::RuneChest => { let f = 0.6 + 0.4 * (t * 2.5 + k).sin(); draw_cube(c + vec3(0.0, 0.39, 0.0), vec3(0.14, 0.02, 0.14), None, Color::new(0.35, 0.8, 1.0, f)); diff --git a/src/render3d.rs b/src/render3d.rs index 2b53aed..8cc2898 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -14,7 +14,7 @@ pub const WALL_H: f32 = 0.95; const CHUNK: i32 = 16; pub const MAX_LIGHTS: usize = 24; const ATLAS_W: f32 = 256.0; -const ATLAS_H: f32 = 144.0; +const ATLAS_H: f32 = (crate::gfx::ATLAS_ROWS * 16) as f32; const DISP_AMP: f32 = 0.11; // wall surface irregularity // ------------------------------------------------------------------ camera @@ -1027,6 +1027,7 @@ impl R3d { draw_towers_solid(g); crate::props::draw_props_solid(g, cam, t); draw_parts_solid(g); + draw_traps_solid(g, t); // ---- unlit glow / overlays gl_use_material(&self.glow); @@ -1063,6 +1064,7 @@ impl R3d { for f in &g.fx { draw_fx(f, t); } + draw_traps_glow(g, t); draw_parts_glow(g); gl_use_default_material(); crate::ui::begin_ui(); @@ -2119,6 +2121,173 @@ fn draw_projectile(f: &Fx, p: f32, t: f32) { } } +// a crate with a finished trap or door (in the forge or carried by an imp) +fn trap_crate(at: Vec3) { + draw_cube(at + vec3(0.0, 0.14, 0.0), vec3(0.34, 0.28, 0.34), None, Color::from_rgba(120, 82, 46, 255)); + for dy in [0.04f32, 0.24] { + draw_cube(at + vec3(0.0, dy, 0.0), vec3(0.36, 0.035, 0.36), None, Color::from_rgba(70, 70, 76, 255)); + } + draw_cube(at + vec3(0.0, 0.29, 0.0), vec3(0.1, 0.02, 0.1), None, Color::from_rgba(200, 60, 40, 255)); +} + +// traps and doors: the installed pieces, crates in the forge and on the way +fn draw_traps_solid(g: &Game, t: f32) { + use crate::traps::*; + for tr in &g.traps { + let (tx, tz) = xy(tr.tile); + let c = vec3(tx as f32 + 0.5, 0.0, tz as f32 + 0.5); + match tr.stage { + CRATED => { + let (x, z) = xy(tr.crate_tile); + let k = (tr.id % 3) as f32 * 0.2 - 0.2; + trap_crate(vec3(x as f32 + 0.5 + k, 0.0, z as f32 + 0.5 - k)); + } + CARRIED => { + if let Some(e) = g.ents.iter().find(|e| e.id + 1 == tr.carrier) { + trap_crate(vec3(e.x, 0.42, e.y)); + } + } + ARMED => { + let iron = Color::from_rgba(70, 70, 78, 255); + let fresh = (1.0 - tr.fired / 0.6).clamp(0.0, 1.0); + match tr.kind { + TrapKind::Spikes => { + draw_cube(c + vec3(0.0, 0.02, 0.0), vec3(0.8, 0.04, 0.8), None, iron); + let up = 0.06 + 0.3 * fresh; + for q in 0..9 { + let (dx, dz) = ((q % 3) as f32 * 0.24 - 0.24, (q / 3) as f32 * 0.24 - 0.24); + line3(c + vec3(dx, 0.03, dz), c + vec3(dx, 0.03 + up, dz), 0.05, Color::from_rgba(200, 200, 212, 255)); + } + } + TrapKind::Poison => { + draw_cube(c + vec3(0.0, 0.03, 0.0), vec3(0.6, 0.06, 0.6), None, iron); + for q in 0..3 { + draw_cube(c + vec3(q as f32 * 0.16 - 0.16, 0.065, 0.0), vec3(0.06, 0.02, 0.5), None, Color::from_rgba(30, 40, 26, 255)); + } + } + TrapKind::Fire => { + // a demon head on a pedestal, its mouth towards the last target + draw_cube(c + vec3(0.0, 0.15, 0.0), vec3(0.36, 0.3, 0.36), None, Color::from_rgba(50, 40, 44, 255)); + let (ax, az) = (tr.aim.cos(), tr.aim.sin()); + draw_cube(c + vec3(0.0, 0.4, 0.0), vec3(0.3, 0.22, 0.3), None, Color::from_rgba(70, 56, 52, 255)); + draw_cube(c + vec3(ax * 0.16, 0.38, az * 0.16), vec3(0.14, 0.1, 0.14), None, Color::from_rgba(30, 20, 20, 255)); + for sgn in [-1.0f32, 1.0] { + line3(c + vec3(-az * 0.12 * sgn, 0.5, ax * 0.12 * sgn), c + vec3(-az * 0.2 * sgn - ax * 0.08, 0.68, ax * 0.2 * sgn - az * 0.08), 0.04, Color::from_rgba(214, 204, 178, 255)); + } + } + TrapKind::Roller => { + // the boulder waits in its niche (until it rolls) + if tr.fired > 18.0 || tr.cd <= 0.0 { + draw_sphere(c + vec3(0.0, 0.3, 0.0), 0.3, None, Color::from_rgba(120, 112, 104, 255)); + } + draw_cube(c + vec3(0.0, 0.03, 0.0), vec3(0.8, 0.06, 0.8), None, Color::from_rgba(60, 56, 52, 255)); + } + TrapKind::Alarm => { + draw_cube(c + vec3(0.0, 0.35, 0.0), vec3(0.06, 0.7, 0.06), None, Color::from_rgba(90, 64, 40, 255)); + draw_cube(c + vec3(0.0, 0.7, 0.0), vec3(0.34, 0.05, 0.06), None, Color::from_rgba(90, 64, 40, 255)); + let swing = (t * 18.0).sin() * 0.06 * fresh.max(((tr.fired < 3.0) as u8) as f32); + draw_cube(c + vec3(swing, 0.58, 0.0), vec3(0.18, 0.18, 0.18), None, Color::from_rgba(220, 180, 70, 255)); + } + TrapKind::Mine => { + draw_cube(c + vec3(0.0, 0.015, 0.0), vec3(0.4, 0.03, 0.4), None, Color::from_rgba(50, 54, 66, 255)); + } + TrapKind::Frost => { + draw_cube(c + vec3(0.0, 0.06, 0.0), vec3(0.4, 0.12, 0.4), None, Color::from_rgba(80, 90, 110, 255)); + } + _ => { + // doors: posts, lintel and the leaf across the corridor + let across_x = g.map.get(tx - 1, tz).map_or(true, |t| t.solid()) || g.map.get(tx + 1, tz).map_or(true, |t| t.solid()); + let (len, thin) = if across_x { (vec3(1.0, 0.0, 0.0), vec3(0.0, 0.0, 1.0)) } else { (vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0)) }; + let size = |l: f32, h: f32, w: f32| len * l + Vec3::Y * h + thin * w; + let (leaf, trim) = match tr.kind { + TrapKind::WoodDoor => (Color::from_rgba(120, 80, 44, 255), Color::from_rgba(70, 68, 74, 255)), + TrapKind::IronDoor => (Color::from_rgba(96, 98, 108, 255), Color::from_rgba(150, 150, 160, 255)), + _ => (Color::from_rgba(70, 36, 100, 255), Color::from_rgba(30, 20, 40, 255)), + }; + for sgn in [-1.0f32, 1.0] { + draw_cube(c + len * (0.44 * sgn) + Vec3::Y * 0.6, size(0.12, 1.2, 0.3), None, Color::from_rgba(40, 32, 36, 255)); + } + draw_cube(c + Vec3::Y * 1.22, size(1.0, 0.14, 0.32), None, Color::from_rgba(40, 32, 36, 255)); + let shake = if tr.fired < 0.2 { (t * 60.0).sin() * 0.02 } else { 0.0 }; + draw_cube(c + Vec3::Y * 0.56 + thin * shake, size(0.76, 1.1, 0.1), None, leaf); + for y in [0.25f32, 0.85] { + draw_cube(c + Vec3::Y * y, size(0.78, 0.06, 0.12), None, trim); + } + if tr.locked { + draw_cube(c + Vec3::Y * 0.6, size(0.86, 0.08, 0.16), None, Color::from_rgba(60, 60, 66, 255)); + draw_cube(c + Vec3::Y * 0.6 + thin * 0.09, vec3(0.1, 0.12, 0.1), None, Color::from_rgba(230, 190, 70, 255)); + } + // damage: darker the more it is beaten + let _ = tr.hp / tr.kind.hp(); + } + } + } + _ => {} + } + } + for r in &g.rollers { + let spin = r.t * 9.0; + let at = vec3(r.x, 0.3, r.y); + draw_sphere(at, 0.3, None, Color::from_rgba(120, 112, 104, 255)); + draw_cube(at + vec3(spin.cos() * 0.22 * r.dy.abs(), spin.sin() * 0.22, spin.cos() * 0.22 * r.dx.abs()), Vec3::splat(0.1), None, Color::from_rgba(80, 74, 70, 255)); + } +} + +fn draw_traps_glow(g: &Game, t: f32) { + use crate::traps::*; + for tr in &g.traps { + let (tx, tz) = xy(tr.tile); + let c = vec3(tx as f32 + 0.5, 0.0, tz as f32 + 0.5); + if tr.stage == ORDERED || tr.stage == CRATED || tr.stage == CARRIED { + // the planned spot: a pulsing outline with a small progress bar + let a = 0.4 + 0.3 * (t * 3.0 + tr.id as f32).sin(); + let col = Color::new(0.4, 0.85, 1.0, a); + let y = 0.05; + for (p0, p1) in [((-0.42f32, -0.42f32), (0.42f32, -0.42f32)), ((0.42, -0.42), (0.42, 0.42)), ((0.42, 0.42), (-0.42, 0.42)), ((-0.42, 0.42), (-0.42, -0.42))] { + line3(c + vec3(p0.0, y, p0.1), c + vec3(p1.0, y, p1.1), 0.04, col); + } + let f = if tr.stage == ORDERED { (tr.progress / tr.kind.work()).clamp(0.0, 1.0) } else { 1.0 }; + line3(c + vec3(-0.35, y, 0.0), c + vec3(-0.35 + 0.7 * f, y, 0.0), 0.08, Color::new(1.0, 0.7, 0.3, 0.8)); + continue; + } + if tr.stage != ARMED { + continue; + } + match tr.kind { + TrapKind::Poison => disc(c + vec3(0.0, 0.07, 0.0), 0.22, Color::new(0.4, 1.0, 0.3, 0.3 + 0.15 * (t * 2.0).sin()), 12), + TrapKind::Fire => { + let (ax, az) = (tr.aim.cos(), tr.aim.sin()); + let f = 0.5 + 0.5 * (t * 6.0).sin(); + draw_sphere(c + vec3(ax * 0.24, 0.38, az * 0.24), 0.05 + 0.03 * f, None, Color::new(1.0, 0.5, 0.1, 0.9)); + } + TrapKind::Mine => { + let glow = 0.4 + 0.4 * (t * 2.5 + tr.id as f32).sin(); + ring(c + vec3(0.0, 0.04, 0.0), 0.16, Color::new(0.35, 0.8, 1.0, glow)); + line3(c + vec3(-0.1, 0.04, 0.0), c + vec3(0.1, 0.04, 0.0), 0.03, Color::new(0.35, 0.8, 1.0, glow)); + line3(c + vec3(0.0, 0.04, -0.1), c + vec3(0.0, 0.04, 0.1), 0.03, Color::new(0.35, 0.8, 1.0, glow)); + } + TrapKind::Frost => { + let p = 1.0 + 0.1 * (t * 2.0).sin(); + draw_cube(c + vec3(0.0, 0.3, 0.0), vec3(0.12, 0.36 * p, 0.12), None, Color::new(0.75, 0.92, 1.0, 0.9)); + draw_cube(c + vec3(0.1, 0.22, 0.05), vec3(0.07, 0.2, 0.07), None, Color::new(0.7, 0.9, 1.0, 0.85)); + draw_cube(c + vec3(-0.08, 0.2, -0.06), vec3(0.07, 0.16, 0.07), None, Color::new(0.7, 0.9, 1.0, 0.85)); + disc(c + vec3(0.0, 0.02, 0.0), 2.5, Color::new(0.6, 0.85, 1.0, 0.06), 24); + } + TrapKind::MagicDoor => { + let across_x = g.map.get(tx - 1, tz).map_or(true, |t| t.solid()) || g.map.get(tx + 1, tz).map_or(true, |t| t.solid()); + let thin = if across_x { vec3(0.0, 0.0, 1.0) } else { vec3(1.0, 0.0, 0.0) }; + let glow = 0.5 + 0.5 * (t * 2.0).sin(); + for side in [-1.0f32, 1.0] { + let p = c + Vec3::Y * 0.6 + thin * (0.06 * side); + draw_cube(p, vec3(0.06, 0.4, 0.06), None, Color::new(0.9, 0.5, 1.0, 0.5 + 0.4 * glow)); + draw_cube(p + Vec3::Y * 0.06, vec3(0.22, 0.06, 0.06), None, Color::new(0.9, 0.5, 1.0, 0.5 + 0.4 * glow)); + } + } + _ => {} + } + } +} + // particles that glow (embers, sparks, smoke, flashes, shock waves) fn draw_parts_glow(g: &Game) { use crate::particles::*; @@ -2361,6 +2530,8 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] { Kind::Steel => [150, 160, 180, 255], Kind::ManaSpring => [90, 230, 255, 255], Kind::RuneChest => [80, 200, 255, 255], + Kind::Forge => [150, 70, 40, 255], + Kind::Door => [140, 90, 50, 255], } } diff --git a/src/settings.rs b/src/settings.rs index 2d79c2e..88d9656 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -25,7 +25,7 @@ pub enum Action { } pub fn all_actions() -> Vec { - let mut v: Vec = (0..21).map(Action::Tool).collect(); + let mut v: Vec = (0..crate::ui::TOOLS.len()).map(Action::Tool).collect(); v.extend_from_slice(&[ Action::CamForward, Action::CamBack, @@ -70,7 +70,8 @@ impl Action { fn default_key(self) -> KeyCode { use KeyCode::*; match self { - Action::Tool(i) => [Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, Key0, X, V, K, J, B, C, T, U, Y, O, N][i.min(20)], + // later tools have no key of their own (they can be bound in the settings) + Action::Tool(i) => [Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, Key0, X, V, K, J, B, C, T, U, Y, O, N].get(i).copied().unwrap_or(Unknown), Action::CamForward => W, Action::CamBack => S, Action::CamLeft => A, @@ -112,6 +113,9 @@ const KEY_NAMES: &[(KeyCode, &str)] = &[ ]; pub fn key_name(k: KeyCode) -> String { + if k == KeyCode::Unknown { + return "-".into(); + } KEY_NAMES.iter().find(|(c, _)| *c == k).map(|(_, n)| n.to_string()).unwrap_or_else(|| "?".into()) } diff --git a/src/traps.rs b/src/traps.rs new file mode 100644 index 0000000..6774ccd --- /dev/null +++ b/src/traps.rs @@ -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, +} + +// 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)> { + let mut best: Option<(u32, Vec)> = 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| -> Vec { (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::>()); + 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); + } +} diff --git a/src/ui.rs b/src/ui.rs index 3a76760..3e73e5c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -157,10 +157,11 @@ pub enum Tool { Tower(TowerKind), Bridge, Steel, + Trap(crate::traps::TrapKind), } // New tools are appended so that existing key bindings ("tool") keep their meaning -pub const TOOLS: [Tool; 21] = [ +pub const TOOLS: [Tool; 32] = [ Tool::Dig, Tool::Hand, Tool::Room(0), @@ -182,6 +183,17 @@ pub const TOOLS: [Tool; 21] = [ Tool::Room(7), Tool::Steel, Tool::Tower(TowerKind::Rune), + Tool::Room(8), + Tool::Trap(crate::traps::TrapKind::Spikes), + Tool::Trap(crate::traps::TrapKind::Poison), + Tool::Trap(crate::traps::TrapKind::Fire), + Tool::Trap(crate::traps::TrapKind::Roller), + Tool::Trap(crate::traps::TrapKind::Alarm), + Tool::Trap(crate::traps::TrapKind::Mine), + Tool::Trap(crate::traps::TrapKind::Frost), + Tool::Trap(crate::traps::TrapKind::WoodDoor), + Tool::Trap(crate::traps::TrapKind::IronDoor), + Tool::Trap(crate::traps::TrapKind::MagicDoor), ]; @@ -203,7 +215,9 @@ pub fn tool_info(t: Tool) -> ToolInfo { Tool::Room(4) => ("library", T_LIBRARY, Some(4)), Tool::Room(5) => ("torture", T_TORTURE, Some(5)), Tool::Room(6) => ("temple", T_TEMPLE, Some(6)), - Tool::Room(_) => ("aerie", T_AERIE, Some(7)), + Tool::Room(7) => ("aerie", T_AERIE, Some(7)), + Tool::Room(_) => ("forge", T_FORGE, Some(8)), + Tool::Trap(k) => (k.id(), I_TRAPS + crate::traps::TRAP_KINDS.iter().position(|&t| t == k).unwrap_or(0) as u32, None), Tool::Tower(TowerKind::Arrow) => ("arrow", I_ARROW, None), Tool::Tower(TowerKind::Cannon) => ("cannon", I_CANNON, None), Tool::Tower(TowerKind::Magic) => ("magic", I_MAGIC, None), @@ -226,6 +240,7 @@ pub fn tool_info(t: Tool) -> ToolInfo { // costs that do not depend on the game (bestiary) pub fn tool_cost_static(t: Tool) -> String { match t { + Tool::Trap(k) => trf("cost.work", &[(k.work() as i32).to_string()]), Tool::Bridge => trf("cost.bridge", &[BRIDGE_COST[0].to_string(), BRIDGE_COST[1].to_string()]), Tool::Steel => trf("cost.steel", &[STEEL_COST[0].to_string(), STEEL_COST[1].to_string()]), Tool::Tower(TowerKind::Rune) => { @@ -246,6 +261,7 @@ pub fn tool_cost_static(t: Tool) -> String { pub fn tool_cost(t: Tool, g: &Game) -> String { match t { + Tool::Trap(_) => tool_cost_static(t), Tool::Room(r) => trf("cost.gold_tile", &[ROOM_COST[r].to_string()]), Tool::Lightning => trf("cost.mana", &["50".into()]), Tool::Heal => trf("cost.mana", &["60".into()]), @@ -269,11 +285,12 @@ pub fn tool_cost(t: Tool, g: &Game) -> String { } // Touch toolbar: tool categories (indices into TOOLS); categories with several tools open a row -pub const CATS: [(&str, &[usize]); 6] = [ +pub const CATS: [(&str, &[usize]); 7] = [ ("cat.dig", &[0]), ("cat.hand", &[1]), - ("cat.rooms", &[2, 3, 4, 5, 6, 12, 13, 18, 17]), + ("cat.rooms", &[2, 3, 4, 5, 6, 12, 13, 18, 21, 17]), ("cat.towers", &[14, 15, 16, 20]), + ("cat.traps", &[22, 23, 24, 25, 26, 27, 28, 29, 30, 31]), ("cat.spells", &[9, 10, 11]), ("cat.orders", &[8, 7, 19]), ]; @@ -1107,6 +1124,22 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec return out; } let Some((x, y)) = tile else { return out }; + if let Some(tr) = g.trap_at(idx(x, y)) { + use crate::traps::*; + out.push((tr_name(tr.kind), FRAME_HI)); + let state = match tr.stage { + ORDERED => trf("trap.ordered", &[((tr.progress / tr.kind.work() * 100.0) as i32).to_string()]), + CRATED => tr_key("trap.crated"), + CARRIED => tr_key("trap.carried"), + _ if tr.kind.door() => trf("info.hp", &[(tr.hp.max(0.0) as i32).to_string(), (tr.kind.hp() as i32).to_string()]), + _ => tr_key("trap.armed"), + }; + out.push((state, if tr.stage == ARMED { GREEN } else { GOLD })); + if tr.kind.door() && tr.stage == ARMED { + out.push((tr_key(if tr.locked { "trap.locked" } else { "trap.unlocked" }), DIM)); + } + return out; + } let Some(t) = g.map.get(x, y) else { return out }; let owner = match t.owner { Owner::Player => "owner.player", @@ -1249,3 +1282,11 @@ mod clip_tests { assert!(clip_segment(vec2(-5.0, -1.0), vec2(-1.0, 20.0), 10.0, 10.0).is_none()); } } + +fn tr_name(k: crate::traps::TrapKind) -> String { + tr(&format!("trap.{}", k.id())) +} + +fn tr_key(key: &str) -> String { + tr(key) +}