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

+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);