Aerie and flying creatures: bats, chain gargoyles, arcane owls, ember wyrms

- New room: aerie (key Y, rooms category, campaign from mission 7); flyers sleep there
- Arrivals: aerie 4+ bats; + torture chamber gargoyles; + library owls; big aerie and treasury
  (16+ each) a rare ember wyrm; portal checks free places in lairs and aerie separately
- Flyers cross water and lava (path finding, flag, attacks), are not burnt by lava, patrol far
  when idle, attack single enemies, flee to the aerie below 25% health and rest there up to 75%
- Owls shoot arcane bolts, the wyrm breathes fire with splash damage; shots from the air
- Hovering sprites with flapping, aerie floor, perches, roosts, plinths, twig and ember nests
- Room counts are recounted on load (no longer stored); tests for flying and arrivals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 13:01:26 +01:00
1 parent 729dd4fe4c
commit b7d7fc2b52
10 files changed
+454 -56

No files matched your search

+2 -2
View File
@@ -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
+256 -36
View File
@@ -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<u32>,
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<usize> = 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);
+88 -2
View File
@@ -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<Color> {
})
}
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,
}
}
+13 -1
View File
@@ -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<u32> + '_ {
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<Vec<usize>> {
self.search(start, self.fly_cost(), |i| i == target, true)
}
pub fn hero_cost(&self) -> impl Fn(usize) -> Option<u32> + '_ {
move |i| {
let tl = &self.t[i];
+50
View File
@@ -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) {
+15 -7
View File
@@ -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],
}
}
+3 -3
View File
@@ -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<Action> {
let mut v: Vec<Action> = (0..18).map(Action::Tool).collect();
let mut v: Vec<Action> = (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,
+5 -3
View File
@@ -159,7 +159,7 @@ pub enum Tool {
}
// New tools are appended so that existing key bindings ("tool<i>") 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]),