diff --git a/lang/de.lang b/lang/de.lang index efcae86..13863ee 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -145,7 +145,7 @@ help.k_slap = Shift+Links Hand: Rückhand für eigene, Faust für Helden au help.k_pan = Rechts ziehen Karte verschieben (auch WASD/Pfeile, Bildrand) help.k_rot = Mitte ziehen Kamera drehen und neigen (auch Q/E, R/F) help.k_zoom = Mausrad Zoomen -help.k_tools = 1-9,0,X,V,K,J,B,C,T,U Werkzeug +help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y Werkzeug help.k_misc = P / Leertaste Pause M Ton an/aus +/- Lautstärke help.k_misc2 = G Kachel-Umrisse an/aus H Diese Hilfe help.s1 = 1. Grabe zum Portal und lass es von Grubenwichten erobern. @@ -517,3 +517,13 @@ rank.7 = Hauptfeldwebel rank.8 = Hauptmann rank.9 = Oberst rank.10 = Feldherr + +# aerie and flying creatures +tool.aerie.label = Horst +tool.aerie.desc = Sitzstangen und Nester für Flugwesen. Ab 4 Feldern kommen Riesenfledermäuse; mit Folterkammer Kettengargoyles, mit Bibliothek Arkane Eulen; ein großer Horst (16+) mit Schatzkammer (16+) lockt vielleicht einen Glutwyrm. Flugwesen überfliegen Wasser und Lava. +room.aerie = Horst +unit.bat = Riesenfledermaus +unit.gargoyle = Kettengargoyle +unit.owl = Arkane Eule +unit.wyrm = Glutwyrm +job.rest = Erholt sich im Horst diff --git a/lang/en.lang b/lang/en.lang index 2172fa2..4204e68 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -146,7 +146,7 @@ help.k_slap = Shift+Left Hand: backhand own units, punch heroes on your gro help.k_pan = Right drag Move the map (also WASD/arrows, screen edge) help.k_rot = Middle drag Rotate and tilt the camera (also Q/E, R/F) help.k_zoom = Mouse wheel Zoom -help.k_tools = 1-9,0,X,V,K,J,B,C,T,U Select tool +help.k_tools = 1-9,0,X,V,K,J,B,C,T,U,Y Select tool help.k_misc = P / Space Pause M Sound on/off +/- Volume help.k_misc2 = G Tile outlines on/off H This help help.s1 = 1. Dig to the portal and let your Pit Imps claim it. @@ -518,3 +518,13 @@ rank.7 = Master Sergeant rank.8 = Captain rank.9 = Colonel rank.10 = Warlord + +# aerie and flying creatures +tool.aerie.label = Aerie +tool.aerie.desc = Perches and nests for flying creatures. 4+ tiles attract giant bats; with a torture chamber chain gargoyles, with a library arcane owls; a big aerie (16+) and treasury (16+) may lure an ember wyrm. Flyers cross water and lava. +room.aerie = Aerie +unit.bat = Giant Bat +unit.gargoyle = Chain Gargoyle +unit.owl = Arcane Owl +unit.wyrm = Ember Wyrm +job.rest = Recovering in the aerie diff --git a/src/campaign.rs b/src/campaign.rs index 1e5925c..5bb0dec 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -9,8 +9,8 @@ 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 -pub const TOOL_UNLOCK: [usize; 18] = [0, 0, 0, 0, 0, 2, 4, 0, 0, 3, 4, 0, 6, 9, 3, 5, 9, 1]; +// torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie +pub const TOOL_UNLOCK: [usize; 19] = [0, 0, 0, 0, 0, 2, 4, 0, 0, 3, 4, 0, 6, 9, 3, 5, 9, 1, 6]; 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 fa9a311..aaf43e3 100644 --- a/src/game.rs +++ b/src/game.rs @@ -85,7 +85,7 @@ pub struct SfxEvent { pub const MANA_MAX: f32 = 400.0; pub const CARRY_MAX: i32 = 300; -#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] pub enum EK { Worker, Kobold, @@ -100,6 +100,11 @@ pub enum EK { Jailer, // attracted by the torture chamber Priestess, // attracted by the temple Chainbreaker, // rare and very strong + // flying creatures, attracted by the aerie + Bat, // fast and weak + Gargoyle, // aerie + torture chamber: stone, tough + Owl, // aerie + library: arcane bolts from afar + Wyrm, // big aerie + treasury: rare, very strong, fire breath } pub struct St { @@ -130,9 +135,18 @@ pub fn st(k: EK) -> St { EK::Jailer => s("unit.jailer", 260.0, 16.0, 1.0, 1.6, 1.3, 140, 0), EK::Priestess => s("unit.priestess", 70.0, 14.0, 5.0, 2.0, 1.6, 120, 0), EK::Chainbreaker => s("unit.chainbreaker", 600.0, 36.0, 1.1, 2.3, 1.1, 400, 0), + EK::Bat => s("unit.bat", 55.0, 6.0, 1.0, 3.6, 0.7, 30, 0), + 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), } } +// flying creatures cross water and lava and sleep in the aerie +pub fn flies(k: EK) -> bool { + matches!(k, EK::Bat | EK::Gargoyle | EK::Owl | EK::Wyrm) +} + #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum Job { None, @@ -155,6 +169,7 @@ pub enum Job { Pray(usize), // temple tile HealCore, // flame priestess heals the soul core TearBridge(usize), // knight tears down a bridge of the player + Rest(usize), // flying creature recovers in the aerie (flees when badly hurt) Help(usize), // imp with nothing else to do helps another imp at this tile } @@ -334,7 +349,8 @@ pub struct Game { pub state: u8, // 0 running, 1 victory, 2 defeat pub paused: bool, pub hand: Vec, - pub counts: [usize; 7], // treasury, lair, farm, training hall, library, torture chamber, temple + #[serde(skip)] + pub counts: [usize; 8], // treasury, lair, farm, training hall, library, torture chamber, temple, aerie (recounted) pub gate_hp: f32, pub gate_hit_t: f32, pub gate_stage: u32, // defender thresholds triggered (0..3) @@ -395,9 +411,9 @@ impl Torch { } } -pub const ROOM_KINDS: [Kind; 7] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library, Kind::Torture, Kind::Temple]; -pub const ROOM_COST: [i32; 7] = [60, 80, 100, 140, 160, 200, 180]; -pub const ROOM_NAMES: [&str; 7] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library", "room.torture", "room.temple"]; +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"]; // bridge cost per tile: over water, over lava pub const BRIDGE_COST: [i32; 2] = [30, 75]; @@ -482,7 +498,7 @@ impl Game { state: 0, paused: false, hand: Vec::new(), - counts: [0; 7], + counts: [0; 8], gate_hp: GATE_HP, gate_hit_t: -100.0, gate_stage: 0, @@ -630,7 +646,7 @@ impl Game { } pub fn recount(&mut self) { - let mut c = [0usize; 7]; + let mut c = [0usize; 8]; for t in &self.map.t { if t.owner == Owner::Player { if let Some(r) = room_index(t.kind) { @@ -1011,19 +1027,37 @@ impl Game { // free beds in the lairs: much room lets creatures arrive faster, little room slower pub fn free_beds(&self) -> usize { - self.counts[1].min(30).saturating_sub(self.creature_count()) + let (lair, aerie) = self.free_lair_aerie(); + lair + aerie + } + + // free places in the lairs (walking creatures) and in the aerie (flying creatures) + pub fn free_lair_aerie(&self) -> (usize, usize) { + let flyers = self.ents.iter().filter(|e| e.alive && e.creature() && flies(e.kind)).count(); + let walkers = self.creature_count() - flyers; + (self.counts[1].min(30).saturating_sub(walkers), self.counts[7].min(20).saturating_sub(flyers)) } pub fn lair_factor(&self) -> f32 { (8.0 / (self.free_beds() as f32 + 2.0)).clamp(0.35, 3.0) } + #[cfg(test)] + pub fn try_portal_for_test(&mut self) { + let (px, py) = self.map.portals[0]; + for y in 0..3 { + for x in 0..3 { + self.map.t[idx(px + x, py + y)].owner = Owner::Player; + } + } + self.try_portal(); + } + fn try_portal(&mut self) { - let creatures = self.creature_count(); let lair = self.counts[1]; + let aerie = self.counts[7]; let farm = self.counts[2]; - let cap = lair.min(30); - if lair == 0 { + if lair == 0 && aerie == 0 { self.hint(0, "hint.portal_no_lair"); return; } @@ -1031,11 +1065,46 @@ impl Game { self.hint(1, "hint.no_farm"); return; } - if creatures >= cap { + let (free_lair, free_aerie) = self.free_lair_aerie(); + if free_lair == 0 && free_aerie == 0 { self.hint(2, "hint.lair_full"); return; } - let mut opts: Vec<(EK, u32)> = vec![(EK::Kobold, 4)]; + let mut opts: Vec<(EK, u32)> = Vec::new(); + // flying creatures need room in the aerie + if free_aerie > 0 { + if aerie >= 4 { + opts.push((EK::Bat, 4)); + } + if aerie >= 6 && self.counts[5] >= 4 { + opts.push((EK::Gargoyle, 2)); + } + if aerie >= 6 && self.counts[4] >= 6 { + opts.push((EK::Owl, 2)); + } + let has_wyrm = self.ents.iter().any(|e| e.alive && e.kind == EK::Wyrm && !e.hero); + if aerie >= 16 && self.counts[0] >= 16 && !has_wyrm { + opts.push((EK::Wyrm, 1)); + } + } + if free_lair == 0 { + if opts.is_empty() { + self.hint(2, "hint.lair_full"); + return; + } + } else { + self.portal_walkers(&mut opts); + } + if opts.is_empty() { + return; + } + self.portal_arrival(opts); + } + + // walking creatures that the rooms attract + fn portal_walkers(&mut self, opts: &mut Vec<(EK, u32)>) { + let farm = self.counts[2]; + opts.push((EK::Kobold, 4)); if farm >= 6 { opts.push((EK::Spider, 3)); } @@ -1058,6 +1127,9 @@ impl Game { if room_tiles >= 40 && veteran && self.gold >= 4000.0 && !has_one { opts.push((EK::Chainbreaker, 1)); } + } + + fn portal_arrival(&mut self, opts: Vec<(EK, u32)>) { let total: u32 = opts.iter().map(|o| o.1).sum(); let mut r = self.rng.next() % total; let mut kind = EK::Kobold; @@ -1313,7 +1385,7 @@ impl Game { } let next = e.path[0]; let tile = self.map.t[next]; - if !tile.walkable() { + if !tile.walkable() && !(flies(e.kind) && tile.liquid()) { if e.hero && tile.diggable() { // heroes dig their way through e.working = true; @@ -1470,7 +1542,8 @@ impl Game { if e.path.is_empty() || e.repath <= 0.0 { e.repath = 0.6; let tt = idx(tx as i32, ty as i32); - match self.map.path_to(e.tile(), tt) { + let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), tt) } else { self.map.path_to(e.tile(), tt) }; + match p { Some(p) => e.path = p, None => return false, } @@ -1496,9 +1569,13 @@ impl Game { EK::Mage => 0xffff44cc, EK::Priest => 0xff88ffff, EK::Priestess => 0xff3080ff, + EK::Owl => 0xffffa060, + EK::Wyrm => 0xff2080ff, _ => 0xff70b0d0, }; - self.fx(0, e.x, e.y, tx, ty, 0.25, col); + // flyers shoot from the air + let h = if flies(e.kind) { 0.8 } else { 0.45 }; + self.shot(e.x, e.y, h, tx, ty, 0.45, 0.25, col); self.sfx(Sfx::Shoot, e.x, e.y); } else { self.fx(2, tx, ty, 0.0, 0.0, 0.25, 0xffffffff); @@ -1524,8 +1601,8 @@ impl Game { } } } - if e.kind == EK::Priestess { - // fireball: half damage to enemies around the target + if e.kind == EK::Priestess || e.kind == EK::Wyrm { + // fireball / fire breath: half damage to enemies around the target self.fx(3, tx, ty, 0.0, 0.0, 0.5, 0xff3080ff); let side = !e.hero; let splash: Vec = self @@ -1651,6 +1728,15 @@ impl Game { let e = &self.ents[j]; let (nx, ny) = (e.x + dx, e.y + dy); let Some(t) = self.map.get(nx as i32, ny as i32).copied() else { return }; + if flies(self.ents[j].kind) { + if t.walkable() || t.liquid() { + let e = &mut self.ents[j]; + e.x = nx; + e.y = ny; + e.path.clear(); + } + return; + } if t.kind == Kind::Lava { let e = &mut self.ents[j]; e.x = nx; @@ -1670,7 +1756,7 @@ impl Game { let (x, y) = xy(i); for j in 0..self.ents.len() { let e = &self.ents[j]; - if !e.alive || e.held || e.tile() != i { + if !e.alive || e.held || e.tile() != i || flies(e.kind) { continue; } if lava { @@ -1973,6 +2059,38 @@ impl Game { false } + // flying creatures patrol far around (also over rivers and the land of the enemy) + fn patrol_flight(&mut self, e: &mut Ent) { + let (x, y) = xy(e.tile()); + for _ in 0..8 { + let nx = x + self.rng.range(-14, 14); + let ny = y + self.rng.range(-14, 14); + let ok = self.map.get(nx, ny).map_or(false, |t| t.walkable() || t.liquid()); + if ok { + if let Some(p) = self.map.fly_path_to(e.tile(), idx(nx, ny)) { + if p.len() <= 40 { + e.path = p; + break; + } + } + } + } + e.job = Job::Wander; + e.timer = 2.0 + self.rng.f() * 3.0; + } + + // badly hurt flyers flee to the aerie and rest until they are healed to 75% + fn start_rest(&mut self, e: &mut Ent) -> bool { + let cur = e.tile(); + let target = e.bed.filter(|&b| self.map.t[b].kind == Kind::Aerie).or_else(|| { + self.map.search(cur, self.map.fly_cost(), |i| self.map.t[i].kind == Kind::Aerie, true).map(|p| p.last().copied().unwrap_or(cur)) + }); + let Some(b) = target else { return false }; + e.path = self.map.fly_path_to(cur, b).unwrap_or_default(); + e.job = Job::Rest(b); + true + } + fn wander(&mut self, e: &mut Ent, radius: i32) { let (x, y) = xy(e.tile()); for _ in 0..6 { @@ -2303,9 +2421,13 @@ impl Game { if e.think <= 0.0 { e.think = 0.4 + self.rng.f() * 0.3; // a new creature builds its own bed in the lair soon after its arrival - if e.bed.is_none() && self.counts[1] > 0 && !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rally) && self.rng.f() < 0.15 { + let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair }; + let beds = if flies(e.kind) { self.counts[7] } else { self.counts[1] }; + if e.bed.is_none() && beds > 0 && !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rally) && self.rng.f() < 0.15 { let cur = e.tile(); - if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.map.t[i].kind == Kind::Lair && self.map.t[i].bed == 0, true) { + let free = |i: usize| self.map.t[i].kind == bed_kind && self.map.t[i].bed == 0; + let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) }; + if let Some(p) = found { let b = p.last().copied().unwrap_or(cur); self.map.t[b].bed = me; e.bed = Some(b); @@ -2313,8 +2435,15 @@ impl Game { self.fx(8, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff40d0ff); } } - if !matches!(e.job, Job::Attack(_) | Job::Leave) { - let radius = if e.job == Job::Rally { 8.5 } else { 7.0 }; + // flyers flee when badly hurt and come back at 75% + if flies(e.kind) && e.hp < e.max_hp * 0.25 && !matches!(e.job, Job::Rest(_) | Job::Leave) { + self.unreserve(me); + if self.start_rest(e) { + return; + } + } + if !matches!(e.job, Job::Attack(_) | Job::Leave | Job::Rest(_)) { + let radius = if e.job == Job::Rally { 8.5 } else if flies(e.kind) { 9.0 } else { 7.0 }; if let Some(id) = self.nearest_enemy(e, radius) { self.unreserve(me); e.job = Job::Attack(id); @@ -2338,6 +2467,28 @@ impl Game { } return; } + if let Job::Rest(b) = e.job { + if self.step_path(e, dt) { + return; + } + let (bx, by) = xy(b); + let at_aerie = (e.x - bx as f32 - 0.5).abs() < 1.5 && (e.y - by as f32 - 0.5).abs() < 1.5; + if !at_aerie { + // pushed away on the way: fly on, or give up if there is no way + match self.map.fly_path_to(e.tile(), b) { + Some(p) if !p.is_empty() => e.path = p, + _ => e.job = Job::None, + } + return; + } + // resting in the aerie heals quickly, back to work at 75% + e.hp = (e.hp + e.max_hp * dt / 12.0).min(e.max_hp); + e.energy = (e.energy + dt / 14.0).min(1.0); + if e.hp >= e.max_hp * 0.75 { + e.job = Job::None; + } + return; + } if e.kind == EK::Chainbreaker { // restless: without a fight for two minutes its mood sinks (skill_cd counts down as idle time) if matches!(e.job, Job::Attack(_)) { @@ -2529,7 +2680,8 @@ impl Game { } if e.path.is_empty() || e.repath <= 0.0 { e.repath = 1.5; - match self.map.path_to(e.tile(), target) { + let p = if flies(e.kind) { self.map.fly_path_to(e.tile(), target) } else { self.map.path_to(e.tile(), target) }; + match p { Some(p) => e.path = p, None => { self.hint(11, "hint.flag_unreachable"); @@ -2592,22 +2744,20 @@ impl Game { return; } if e.energy < 0.25 { + // flying creatures sleep in the aerie + let bed_kind = if flies(e.kind) { Kind::Aerie } else { Kind::Lair }; if let Some(b) = e.bed { let t = &self.map.t[b]; - if t.kind != Kind::Lair || t.bed != me { + if t.kind != bed_kind || t.bed != me { e.bed = None; } } if e.bed.is_none() { - let found = self.map.search( - cur, - self.map.walk_cost(), - |i| { - let t = &self.map.t[i]; - t.kind == Kind::Lair && t.bed == 0 - }, - true, - ); + let free = |i: usize| { + let t = &self.map.t[i]; + t.kind == bed_kind && t.bed == 0 + }; + let found = if flies(e.kind) { self.map.search(cur, self.map.fly_cost(), free, true) } else { self.map.search(cur, self.map.walk_cost(), free, true) }; if let Some(p) = found { let b = p.last().copied().unwrap_or(cur); self.map.t[b].bed = me; @@ -2615,7 +2765,8 @@ impl Game { } } if let Some(b) = e.bed { - if let Some(p) = self.map.path_to(cur, b) { + let way = if flies(e.kind) { self.map.fly_path_to(cur, b) } else { self.map.path_to(cur, b) }; + if let Some(p) = way { e.path = p; e.job = Job::Sleep(b); return; @@ -2692,7 +2843,11 @@ impl Game { return; } } - self.wander(e, 4); + if flies(e.kind) { + self.patrol_flight(e); + } else { + self.wander(e, 4); + } } // ---------------------------------------------------------------- prisoners @@ -3910,6 +4065,7 @@ pub fn job_name(e: &Ent) -> &'static str { Job::Study(_) => "job.study", Job::Attack(_) => "job.fight", Job::TearBridge(_) => "job.tear_bridge", + Job::Rest(_) => "job.rest", Job::Help(_) => "job.help", Job::AttackCore => { if e.working { @@ -4382,6 +4538,70 @@ mod tests { assert!(best <= 2.0); } + #[test] + fn flyers_cross_rivers_flee_and_heal() { + let (mut g, rx) = river_game(true); + let (cx, cy) = g.map.core; + // an aerie in the start area + g.gold = 5000.0; + g.build_rect(7, cx - 3, cy - 3, cx - 2, cy + 2); + assert!(g.counts[7] >= 8); + // a knight on the far side of the lava: an owl reaches it, a kobold does not + let far = (rx + 4, cy); + for x in rx + 3..rx + 7 { + let t = &mut g.map.t[idx(x, cy)]; + t.kind = Kind::Floor; + t.owner = Owner::None; + } + let k = g.spawn(EK::Knight, true, far.0 as f32 + 0.5, far.1 as f32 + 0.5, 1); + let owl = g.spawn(EK::Owl, false, (cx + 4) as f32 + 0.5, cy as f32 + 0.5, 3); + assert!(g.map.fly_path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_some()); + assert!(g.map.path_to(idx(cx + 4, cy), idx(far.0, far.1)).is_none()); + let hp0 = g.ents.iter().find(|e| e.id == k).unwrap().hp; + for _ in 0..300 { + g.update(0.05); + } + let hit = g.ents.iter().find(|e| e.id == k).map_or(true, |e| e.hp < hp0); + assert!(hit, "the owl attacks across the lava"); + // a badly hurt flyer flees to the aerie and heals up to 75% + g.ents.retain(|e| !e.hero); + let j = g.ents.iter().position(|e| e.id == owl).unwrap(); + g.ents[j].hp = g.ents[j].max_hp * 0.2; + let mut rested = false; + for _ in 0..800 { + g.update(0.05); + let e = g.ents.iter().find(|e| e.id == owl).unwrap(); + rested |= matches!(e.job, Job::Rest(_)); + if e.hp >= e.max_hp * 0.75 { + break; + } + } + let e = g.ents.iter().find(|e| e.id == owl).unwrap(); + assert!(rested && e.hp >= e.max_hp * 0.74, "fled and healed: {} / {}", e.hp, e.max_hp); + // lava does not burn flyers + let j = g.ents.iter().position(|e| e.id == owl).unwrap(); + g.ents[j].x = (rx - 1) as f32 + 0.5; + g.ents[j].y = cy as f32 + 0.5; + g.knock(j, 1.0, 0.0); + assert!(g.ents[j].alive); + } + + #[test] + fn aerie_attracts_flyers() { + let mut g = Game::new(3, 64, 64); + g.counts = [16, 0, 9, 0, 6, 4, 0, 16]; + let mut seen = std::collections::HashSet::new(); + for _ in 0..200 { + g.try_portal_for_test(); + for e in g.ents.iter().filter(|e| e.creature()) { + seen.insert(e.kind); + } + g.ents.retain(|e| !e.creature()); + } + assert!(seen.contains(&EK::Bat) && seen.contains(&EK::Gargoyle) && seen.contains(&EK::Owl), "{:?}", seen); + assert!(seen.iter().all(|&k| flies(k)), "no lair: only flyers come ({:?})", seen); + } + #[test] fn imps_reinforce_walls() { let mut g = Game::new(3, 64, 64); diff --git a/src/gfx.rs b/src/gfx.rs index c191f9e..5127d4d 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -64,6 +64,7 @@ pub const D_RUBY: u32 = 122; pub const D_DIAMOND: u32 = 123; pub const D_RUBBLE: u32 = 124; // floor decals: pebbles and fragments pub const D_CRACK: u32 = 125; // a long crack through several stones +pub const T_AERIE: u32 = 126; // 4 variants: straw, twigs and feathers pub struct Gfx { pub atlas: Texture2D, @@ -726,6 +727,23 @@ 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 }) }; + // aerie: straw and twigs on stone, a few feathers + for v in 0..4u32 { + cv.cell(T_AERIE + v, |x, y| { + let base = stones(x, y, v + 230, (70, 62, 58), (36, 30, 30), 8); + let twig = (x * 3 + y * 7 + v as i32 * 5) % 13 == 0 || (x * 5 - y * 2 + v as i32) % 17 == 0; + let straw = rnd(x / 2, y, v + 231) > 0.62; + if hash(x, y, v + 232) % 41 == 0 { + Some(c(235, 232, 225)) // feather + } else if twig { + Some(c(96, 66, 40)) + } else if straw { + Some(shade((190, 160, 80), (rnd(x, y, v + 233) * 20.0) as i32 - 10)) + } else { + Some(base) + } + }); + } // floor decals cv.cell(D_RUBBLE, |x, y| { for k in 0..9 { @@ -890,6 +908,55 @@ fn sprite_rows(k: EK) -> [&'static str; 10] { ".kRRRRRRk.", "..kkkkkk..", ], + // flying creatures + EK::Bat => [ + "..........", + "k..k..k..k", + "Pk.kPPk.kP", + "PPkPPPPkPP", + "PPPPrPrPPP", + "PPPPPPPPPP", + ".PPkPPkPP.", + "..k.PP.k..", + ".....k....", + "..........", + ], + EK::Gargoyle => [ + "w........w", + "Cw.kkkk.wC", + "CCkceeckCC", + "CCkcccckCC", + "CkcccccckC", + "k.kcCCck.k", + ".kcccccck.", + ".kcCkkCck.", + ".kck..kck.", + ".kk....kk.", + ], + EK::Owl => [ + "..k....k..", + ".kBk..kBk.", + ".kbbbbbbk.", + "kbeebbeebk", + "kbekbbekbk", + ".kbbyybbk.", + "kubbbbbbuk", + "kubbwwbbuk", + ".kubbbbuk.", + "..ky..yk..", + ], + EK::Wyrm => [ + "R.......kw", + "RR....krrk", + "RRR..krekr", + "RRRRkrrrrk", + ".RRrrrrok.", + "..rrooork.", + ".krrooorr.", + "krrrrrrRk.", + "k.rrk.rk..", + "..kk..kk..", + ], // obsidian demon with glowing cracks, broken chains and twin axes EK::Chainbreaker => [ "s.kkkkkk.s", @@ -947,9 +1014,27 @@ fn pal(ch: char) -> Option { }) } -pub const SPRITE_KINDS: [EK; 13] = [EK::Worker, EK::Kobold, EK::Spider, EK::Mage, EK::Ogre, EK::Knight, EK::Archer, EK::Priest, EK::Dwarf, EK::Paladin, EK::Jailer, EK::Priestess, EK::Chainbreaker]; +pub const SPRITE_KINDS: [EK; 17] = [ + EK::Worker, + EK::Kobold, + EK::Spider, + EK::Mage, + EK::Ogre, + EK::Knight, + EK::Archer, + EK::Priest, + EK::Dwarf, + EK::Paladin, + EK::Jailer, + EK::Priestess, + EK::Chainbreaker, + EK::Bat, + EK::Gargoyle, + EK::Owl, + EK::Wyrm, +]; // width of the sprite texture in pixels -pub const SPRITE_TEX_W: f32 = (13 * 12) as f32; +pub const SPRITE_TEX_W: f32 = (17 * 12) as f32; pub const SPRITE_TEX_H: f32 = 24.0; pub fn sprite_index(k: EK) -> u32 { @@ -1143,6 +1228,7 @@ impl Gfx { Kind::Bridge => T_BRIDGE + (v & 1), Kind::LavaBridge => T_STONEBRIDGE + (v & 1), Kind::Shard => T_HEROFLOOR + v, + Kind::Aerie => T_AERIE + v, } } diff --git a/src/map.rs b/src/map.rs index e6aeeb0..e785946 100644 --- a/src/map.rs +++ b/src/map.rs @@ -57,6 +57,7 @@ pub enum Kind { Silver, // silver vein: common, little value Ruby, // ruby vein: rare, valuable Diamond, // diamond vein: very rare, very valuable + Aerie, // room for flying creatures (their beds) } // options for the map generator (campaign missions) @@ -85,7 +86,7 @@ pub struct River { } // order matters for the packed savegame codec: only append new kinds -const KINDS: [Kind; 23] = [ +const KINDS: [Kind; 24] = [ Kind::Rock, Kind::Earth, Kind::Gold, @@ -109,6 +110,7 @@ const KINDS: [Kind; 23] = [ Kind::Silver, Kind::Ruby, Kind::Diamond, + Kind::Aerie, ]; // treasures (veins, treasury content, imp loads): 0 gold, 1 silver, 2 ruby, 3 diamond @@ -188,6 +190,7 @@ impl Tile { | Kind::Bridge | Kind::LavaBridge | Kind::Shard + | Kind::Aerie ) } // river tiles (open, but nobody can walk there) @@ -620,6 +623,15 @@ impl Map { move |i| if self.t[i].walkable() { Some(1) } else { None } } + // flying creatures also cross water and lava + pub fn fly_cost(&self) -> impl Fn(usize) -> Option + '_ { + move |i| if self.t[i].walkable() || self.t[i].liquid() { Some(1) } else { None } + } + + pub fn fly_path_to(&self, start: usize, target: usize) -> Option> { + self.search(start, self.fly_cost(), |i| i == target, true) + } + pub fn hero_cost(&self) -> impl Fn(usize) -> Option + '_ { move |i| { let tl = &self.t[i]; diff --git a/src/props.rs b/src/props.rs index 826dc62..874f68c 100644 --- a/src/props.rs +++ b/src/props.rs @@ -84,6 +84,12 @@ pub fn draw_props_solid(g: &Game, cam: &Cam, t: f32) { bed(kind, c, h, l, t); } } + Kind::Aerie if tile.bed != 0 => { + if let Some(&kind) = u.beds.get(&tile.bed) { + bed(kind, c, h, l, t); + } + } + Kind::Aerie if h % 3 == 0 => perch(c, h, l), Kind::Treasury if chest_tile(x, z) => chest(c, h, l, tile.gold as f32 / TREASURY_TILE, tile.res), Kind::Training if (x + z) % 2 == 0 => training(c, h, l, t, u.busy.contains(&i) || u.busy.iter().any(|&b| adj8(b, i))), Kind::Torture if (x + z) % 2 == 1 => torture(c, h, l, t, u.tortured.contains(&i) || u.tortured.iter().any(|&b| adj8(b, i))), @@ -145,6 +151,16 @@ pub fn draw_props_glow(g: &Game, cam: &Cam, t: f32) { } } } + Kind::Aerie if tile.bed != 0 => { + // the wyrm's nest glows + if let Some(EK::Wyrm) = u.beds.get(&tile.bed) { + for n in 0..5 { + let a = n as f32 * 1.3 + k; + let f = 0.6 + 0.4 * (t * 3.0 + a).sin(); + draw_sphere(c + vec3(a.cos() * 0.2, 0.06, a.sin() * 0.2), 0.06 * f, None, Color::new(1.0, 0.5, 0.1, 0.9)); + } + } + } Kind::Lair if tile.bed != 0 => { // the flame priestess sleeps on embers if let Some(EK::Priestess) = u.beds.get(&tile.bed) { @@ -285,6 +301,33 @@ fn bed(kind: EK, c: Vec3, h: u32, l: f32, t: f32) { cube(at(0.0, 0.0, 0.05), vec3(0.6, 0.1, 0.48), rgb(150, 40, 30, l)); cube(at(0.0, 0.0, 0.11), vec3(0.4, 0.03, 0.3), rgb(220, 110, 40, l)); } + EK::Bat => { + // roost: a beam between two posts to hang from + for side in [-0.32f32, 0.32] { + cube(at(side, 0.0, 0.45), vec3(0.06, 0.9, 0.06), rgb(90, 64, 40, l)); + } + cube(at(0.0, 0.0, 0.88), vec3(0.72, 0.06, 0.06), rgb(110, 78, 48, l)); + } + EK::Gargoyle => { + // stone plinth to sit on + cube(at(0.0, 0.0, 0.2), vec3(0.5, 0.4, 0.5), rgb(110, 110, 118, l)); + cube(at(0.0, 0.0, 0.42), vec3(0.58, 0.06, 0.58), rgb(130, 130, 138, l)); + } + EK::Owl => { + // twig nest on a post + cube(at(0.0, 0.0, 0.3), vec3(0.08, 0.6, 0.08), rgb(90, 64, 40, l)); + for n in 0..6 { + let a = n as f32 * 1.05 + rot; + cube(c + vec3(a.cos() * 0.16, 0.62, a.sin() * 0.16), vec3(0.16, 0.06, 0.05), rgb(110 + (n * 6) as u8, 80, 44, l)); + } + } + EK::Wyrm => { + // a big nest of dark rocks (the embers glow in the glow pass) + for n in 0..9 { + let a = n as f32 * 0.7 + rot; + cube(c + vec3(a.cos() * 0.42, 0.09, a.sin() * 0.42), vec3(0.2, 0.18 + (n % 3) as f32 * 0.04, 0.2), rgb(60, 44, 44, l)); + } + } EK::Chainbreaker => { // a hollow of broken rocks and a burst chain for n in 0..7 { @@ -308,6 +351,13 @@ fn bed(kind: EK, c: Vec3, h: u32, l: f32, t: f32) { let _ = t; } +// an empty perch in the aerie: post with a crossbar +fn perch(c: Vec3, h: u32, l: f32) { + let dx = ((h >> 4) % 3) as f32 * 0.12 - 0.12; + cube(c + vec3(dx, 0.35, 0.0), vec3(0.06, 0.7, 0.06), rgb(96, 68, 42, l)); + cube(c + vec3(dx, 0.68, 0.0), vec3(0.4, 0.05, 0.05), rgb(110, 78, 48, l)); +} + // ---------------------------------------------------------------- training hall fn training(c: Vec3, h: u32, l: f32, t: f32, busy: bool) { diff --git a/src/render3d.rs b/src/render3d.rs index d934f2f..40c795e 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -1088,11 +1088,14 @@ impl R3d { if !e.alive || e.held { continue; } - let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker); - let w = if e.kind == EK::Chainbreaker { 1.0 } else if big { 0.8 } else { 0.62 }; + let w = sprite_size(e.kind); let h = w; let sleeping = matches!(e.job, Job::Sleep(_)) && !e.moving; let mut bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; + // flying creatures hover and flap (on their perch while sleeping) + if flies(e.kind) && !sleeping { + bob = 0.45 + (t * 7.0 + e.anim * 3.0).sin() * 0.06; + } let mut rot = if e.working && !sleeping { (t * 14.0 + e.anim).sin() * 0.15 } else { 0.0 }; // prisoners: lying unconscious, carried above an imp, or shaking in the torture chamber match e.captive { @@ -1157,8 +1160,9 @@ impl R3d { if !e.alive || e.held { continue; } - let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker); - let head = proj(vec3(e.x, if big { 0.9 } else { 0.72 }, e.y)); + let big = matches!(e.kind, EK::Ogre | EK::Paladin | EK::Jailer | EK::Chainbreaker | EK::Wyrm | EK::Gargoyle); + let lift = if flies(e.kind) && !(matches!(e.job, Job::Sleep(_)) && !e.moving) { 0.45 } else { 0.0 }; + let head = proj(vec3(e.x, lift + if big { 0.9 } else { 0.72 }, e.y)); // bar width in block characters, depending on the zoom let cols = ((px_per_tile * 0.6 / cw) as usize).clamp(3, 8); if (e.hp < e.max_hp || e.hero) && e.captive == 0 { @@ -1222,9 +1226,9 @@ fn shade_c(r: u8, g: u8, b: u8, f: f32) -> Color { // size of a unit's sprite (same as in the billboards) fn sprite_size(k: EK) -> f32 { - if k == EK::Chainbreaker { + if matches!(k, EK::Chainbreaker | EK::Wyrm) { 1.0 - } else if matches!(k, EK::Ogre | EK::Paladin | EK::Jailer) { + } else if matches!(k, EK::Ogre | EK::Paladin | EK::Jailer | EK::Gargoyle) { 0.8 } else { 0.62 @@ -1242,7 +1246,10 @@ fn draw_gear(g: &Game, cam: &Cam) { let toward = cam.eye_dir(); for e in g.ents.iter().filter(|e| gear_shown(e)) { let w = sprite_size(e.kind); - let bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; + let mut bob = if e.moving { (e.anim * 12.0).sin().abs() * 0.06 } else { 0.0 }; + if flies(e.kind) { + bob = 0.45; + } let base = vec3(e.x, bob, e.y) + toward * 0.5; let at = |dx: f32, dy: f32| base + r * (dx * w) + Vec3::Y * (dy * w); let metal = if e.level >= 8 { @@ -1709,6 +1716,7 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] { Kind::Bridge => [150, 110, 60, 255], Kind::LavaBridge => [130, 120, 115, 255], Kind::Shard => [255, 60, 120, 255], + Kind::Aerie => [170, 140, 90, 255], } } diff --git a/src/settings.rs b/src/settings.rs index a987d57..be56e69 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -7,7 +7,7 @@ use std::collections::BTreeMap; // Rebindable keyboard actions. Esc (menu) and the arrow keys (camera) are fixed. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Action { - Tool(usize), // 0..18, same order as ui::TOOLS + Tool(usize), // 0..19, same order as ui::TOOLS CamForward, CamBack, CamLeft, @@ -25,7 +25,7 @@ pub enum Action { } pub fn all_actions() -> Vec { - let mut v: Vec = (0..18).map(Action::Tool).collect(); + let mut v: Vec = (0..19).map(Action::Tool).collect(); v.extend_from_slice(&[ Action::CamForward, Action::CamBack, @@ -70,7 +70,7 @@ 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][i.min(17)], + Action::Tool(i) => [Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, Key0, X, V, K, J, B, C, T, U, Y][i.min(18)], Action::CamForward => W, Action::CamBack => S, Action::CamLeft => A, diff --git a/src/ui.rs b/src/ui.rs index 351e211..2b6cce2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -159,7 +159,7 @@ pub enum Tool { } // New tools are appended so that existing key bindings ("tool") keep their meaning -pub const TOOLS: [Tool; 18] = [ +pub const TOOLS: [Tool; 19] = [ Tool::Dig, Tool::Hand, Tool::Room(0), @@ -178,6 +178,7 @@ pub const TOOLS: [Tool; 18] = [ Tool::Tower(TowerKind::Cannon), Tool::Tower(TowerKind::Magic), Tool::Bridge, + Tool::Room(7), ]; @@ -198,7 +199,8 @@ pub fn tool_info(t: Tool) -> ToolInfo { Tool::Room(3) => ("training", T_TRAINING, Some(3)), Tool::Room(4) => ("library", T_LIBRARY, Some(4)), Tool::Room(5) => ("torture", T_TORTURE, Some(5)), - Tool::Room(_) => ("temple", T_TEMPLE, Some(6)), + Tool::Room(6) => ("temple", T_TEMPLE, Some(6)), + Tool::Room(_) => ("aerie", T_AERIE, Some(7)), Tool::Tower(TowerKind::Arrow) => ("arrow", I_ARROW, None), Tool::Tower(TowerKind::Cannon) => ("cannon", I_CANNON, None), Tool::Tower(TowerKind::Magic) => ("magic", I_MAGIC, None), @@ -239,7 +241,7 @@ pub fn tool_cost(t: Tool, g: &Game) -> String { pub const CATS: [(&str, &[usize]); 6] = [ ("cat.dig", &[0]), ("cat.hand", &[1]), - ("cat.rooms", &[2, 3, 4, 5, 6, 12, 13, 17]), + ("cat.rooms", &[2, 3, 4, 5, 6, 12, 13, 18, 17]), ("cat.towers", &[14, 15, 16]), ("cat.spells", &[9, 10, 11]), ("cat.orders", &[8, 7]),