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

+76
View File
@@ -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.
+76
View File
@@ -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.
+1 -1
View File
@@ -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
+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();
+139 -3
View File
@@ -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<Color>; 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<Color> {
})
}
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
}
}
}
}
+5 -1
View File
@@ -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)) },
+17 -2
View File
@@ -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<u32> + '_ {
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),
}
}
+3 -2
View File
@@ -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);
+34 -1
View File
@@ -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));
+172 -1
View File
@@ -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],
}
}
+6 -2
View File
@@ -25,7 +25,7 @@ pub enum Action {
}
pub fn all_actions() -> Vec<Action> {
let mut v: Vec<Action> = (0..21).map(Action::Tool).collect();
let mut v: Vec<Action> = (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())
}
+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);
}
}
+45 -4
View File
@@ -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<i>") 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<usize>, 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)
}