Knight fortresses: knight towers, patrols, alarm, claimable knight ground and vaults

- fortress.rs: fortress builder (masonry walls with doors, courtyard, corner towers, vault chambers
  full of mixed treasure, shard chamber, gate of the order as the heart, guards, patrols)
- Knight towers shoot the player's units; creatures tear down knight towers within reach
- Patrols walk their round; guards and patrols that spot an enemy alarm the others nearby
- Imps claim knight ground (slower, not next to fighting heroes); vaults hand over their treasure,
  shard chambers give the shard back; claiming alarms the knights
- Knights tear down the player's bridges near their posts
- Fix: units next to a tower could stop at the tile edge just out of melee reach
- Tests for towers, vault looting, patrols/alarm and bridge tearing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 10:57:37 +01:00
1 parent 7e84cae910
commit e15b16cc60
7 files changed
+565 -23

No files matched your search

+176
View File
@@ -0,0 +1,176 @@
// Knight fortresses (campaign): an outer wall of fortress masonry with doors, a courtyard of
// knight ground, corner towers, vault chambers full of treasure, optionally a shard chamber or
// the gate of the order (the fortress heart), guards at their posts and patrols on their rounds.
use crate::game::*;
use crate::map::*;
#[derive(Clone, Debug)]
pub struct FortSpec {
pub x: i32, // top-left corner of the outer wall
pub y: i32,
pub w: i32, // outer size (at least 13 x 13)
pub h: i32,
pub doors: Vec<usize>, // sides with a door: index into DIR4 (0 north, 1 east, 2 south, 3 west)
pub towers: Vec<TowerKind>, // placed in the inner corners, then along the walls
pub tower_level: u32,
pub guards: Vec<(EK, u32)>, // kind, level
pub patrols: Vec<Vec<(EK, u32)>>, // groups walking around the courtyard
pub vaults: u32, // number of vault chambers
pub vault_value: i32, // treasure per vault chamber
pub shard: bool, // shard chamber in the middle
pub heart: bool, // the gate of the order in the middle (map.gate)
}
pub const FORT_WALL_HP: f32 = 30.0;
impl Game {
fn fort_tile(&mut self, x: i32, y: i32, kind: Kind) {
if !inb(x, y) || x < 1 || y < 1 || x >= mw() as i32 - 1 || y >= mh() as i32 - 1 {
return;
}
let t = &mut self.map.t[idx(x, y)];
t.kind = kind;
t.owner = Owner::Hero;
t.gold = 0;
t.marked = false;
t.reinforced = kind == Kind::Earth;
t.hp = if kind == Kind::Earth { FORT_WALL_HP } else { 0.0 };
}
// walls around a rectangle (inclusive), floor inside
fn fort_box(&mut self, x0: i32, y0: i32, x1: i32, y1: i32, inside: Kind) {
for y in y0..=y1 {
for x in x0..=x1 {
let edge = x == x0 || x == x1 || y == y0 || y == y1;
self.fort_tile(x, y, if edge { Kind::Earth } else { inside });
}
}
}
fn fort_hero(&mut self, kind: EK, level: u32, x: f32, y: f32) -> usize {
let id = self.spawn(kind, true, x, y, level.max(1));
self.ents.iter().position(|e| e.id == id).unwrap()
}
pub fn build_fortress(&mut self, f: &FortSpec) {
let (x0, y0, x1, y1) = (f.x, f.y, f.x + f.w - 1, f.y + f.h - 1);
let (cx, cy) = ((x0 + x1) / 2, (y0 + y1) / 2);
self.fort_box(x0, y0, x1, y1, Kind::Floor);
// doors: three tiles wide in the middle of a side
for &d in &f.doors {
for k in -1..=1 {
let (x, y) = match d {
0 => (cx + k, y0),
1 => (x1, cy + k),
2 => (cx + k, y1),
_ => (x0, cy + k),
};
self.fort_tile(x, y, Kind::Floor);
// the way out of the door is dug free
let (dx, dy) = DIR4[d];
for s in 1..=2 {
let (ox, oy) = (x + dx * s, y + dy * s);
if let Some(t) = self.map.get(ox, oy) {
if t.solid() && t.kind != Kind::Rock {
let t = &mut self.map.t[idx(ox, oy)];
t.kind = Kind::Floor;
t.owner = Owner::None;
t.gold = 0;
}
}
}
}
}
// centre: shard chamber and/or gate of the order
if f.heart {
for y in cy - 1..=cy + 1 {
for x in cx - 1..=cx + 1 {
self.fort_tile(x, y, Kind::Gate);
}
}
self.map.gate = (cx - 1, cy - 1);
}
if f.shard {
// a walled chamber with one door (below the gate if both exist)
let sy = if f.heart { cy + 3 } else { cy };
self.fort_box(cx - 2, sy - 2, cx + 2, sy + 2, Kind::Floor);
self.fort_tile(cx, sy + 2, Kind::Floor);
self.fort_tile(cx, sy, Kind::Shard);
}
// vault chambers in the corners: 3 x 3 treasury behind a wall with a door
let corners = [(x0 + 1, y0 + 1, 1), (x1 - 5, y0 + 1, -1), (x0 + 1, y1 - 5, 1), (x1 - 5, y1 - 5, -1)];
for &(vx, vy, sx) in corners.iter().take(f.vaults as usize) {
self.fort_box(vx, vy, vx + 4, vy + 4, Kind::Treasury);
// door towards the middle of the courtyard
self.fort_tile(if sx > 0 { vx + 4 } else { vx }, vy + 2, Kind::Floor);
// treasure: mostly gold, some silver, rubies, now and then diamonds
let per = (f.vault_value / 9).max(1);
for y in vy + 1..=vy + 3 {
for x in vx + 1..=vx + 3 {
let r = self.rng.f();
let res = if r < 0.08 { 3 } else if r < 0.25 { 2 } else if r < 0.45 { 1 } else { 0 };
let t = &mut self.map.t[idx(x, y)];
t.gold = per;
t.res = res;
}
}
}
// towers: inner corners of the courtyard first, then along the walls
let mut spots = vec![(x0 + 2, y0 + 2), (x1 - 2, y0 + 2), (x0 + 2, y1 - 2), (x1 - 2, y1 - 2), (cx, y0 + 2), (cx, y1 - 2), (x0 + 2, cy), (x1 - 2, cy)];
if f.vaults > 0 {
// vault chambers take the corners: towers stand in front of them
for s in spots.iter_mut().take(4) {
s.0 += if s.0 < cx { 5 } else { -5 };
}
}
for (k, &kind) in f.towers.iter().enumerate() {
let Some(&(tx, ty)) = spots.get(k) else { break };
if self.map.get(tx, ty).map_or(true, |t| t.kind != Kind::Floor) {
continue;
}
let i = idx(tx, ty);
self.map.t[i].kind = Kind::Tower;
let id = self.next_id;
self.next_id += 1;
let s = tower_stats(kind);
let mut tw = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: true };
while tw.level < f.tower_level.min(3) {
let old = tw.max_hp();
tw.level += 1;
tw.hp += tw.max_hp() - old;
}
self.towers.push(tw);
}
// guards: posts spread over the courtyard
let n = f.guards.len().max(1) as f32;
for (k, &(kind, level)) in f.guards.iter().enumerate() {
let a = k as f32 / n * std::f32::consts::TAU;
let (rx, ry) = ((f.w as f32 * 0.5 - 3.5).max(1.0), (f.h as f32 * 0.5 - 3.5).max(1.0));
let (mut hx, mut hy) = (cx as f32 + 0.5 + a.cos() * rx * 0.6, cy as f32 + 0.5 + a.sin() * ry * 0.6);
if !self.map.walk(hx as i32, hy as i32) {
(hx, hy) = (cx as f32 + 0.5, (y0 + 2) as f32 + 0.5);
}
let j = self.fort_hero(kind, level, hx, hy);
let e = &mut self.ents[j];
e.guard = true;
e.home = Some((hx, hy));
}
// patrols: round through the courtyard along the inside of the walls
let route = vec![(x0 + 2, cy), (cx, y0 + 2), (x1 - 2, cy), (cx, y1 - 2)];
let route: Vec<(i32, i32)> = route.into_iter().filter(|&(x, y)| self.map.walk(x, y)).collect();
for (g, group) in f.patrols.iter().enumerate() {
if route.is_empty() {
break;
}
let start = (g * 2) % route.len();
let (sx, sy) = route[start];
for (k, &(kind, level)) in group.iter().enumerate() {
let j = self.fort_hero(kind, level, sx as f32 + 0.5 + (k as f32 - 0.5) * 0.4, sy as f32 + 0.5);
let e = &mut self.ents[j];
e.patrol = route.clone();
e.patrol_k = (start + 1) % route.len();
}
}
}
}
+360 -15
View File
@@ -153,6 +153,7 @@ pub enum Job {
Carry(u32), // imp carries an unconscious hero (id) to the torture chamber
Pray(usize), // temple tile
HealCore, // flame priestess heals the soul core
TearBridge(usize), // knight tears down a bridge of the player
}
#[derive(Clone, Serialize, Deserialize)]
@@ -205,6 +206,10 @@ pub struct Ent {
pub guard: bool, // stays at its home (gate defenders, treasure guards)
#[serde(default)]
pub home: Option<(f32, f32)>, // guard post
#[serde(default)]
pub patrol: Vec<(i32, i32)>, // fortress patrol: route of tiles, walked in a loop
#[serde(default)]
pub patrol_k: usize, // next point of the route
}
impl Ent {
@@ -248,6 +253,8 @@ impl Ent {
stun: 0.0,
guard: false,
home: None,
patrol: Vec::new(),
patrol_k: 0,
}
}
pub fn tile(&self) -> usize {
@@ -326,6 +333,14 @@ pub struct Game {
pub training_bill: f32, // training costs, paid on the next payday
#[serde(default)]
pub payday_warned: bool,
#[serde(default)]
pub loot: f32, // value taken from knight vaults
#[serde(default)]
pub shards_taken: u32,
#[serde(default)]
pub towers_razed: u32, // knight towers destroyed
#[serde(skip)]
pub contested: Vec<bool>, // tiles next to fighting heroes: knight ground there cannot be claimed
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
#[serde(skip, default = "hint_times")]
@@ -426,6 +441,10 @@ impl Game {
incoming: Vec::new(),
training_bill: 0.0,
payday_warned: false,
loot: 0.0,
shards_taken: 0,
towers_razed: 0,
contested: Vec::new(),
};
let (cx, cy) = g.map.core;
// the soul core starts surrounded by a one tile wide ring of treasury
@@ -504,7 +523,7 @@ impl Game {
}
}
// towers count with half their combat value
for t in &self.towers {
for t in self.towers.iter().filter(|t| !t.hero) {
h += 0.5 * t.max_hp();
d += 0.5 * t.dmg() / tower_stats(t.kind).interval;
}
@@ -799,6 +818,7 @@ impl Game {
self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP);
}
self.update_contested();
for i in 0..self.ents.len() {
if !self.ents[i].alive || self.ents[i].held {
continue;
@@ -1387,6 +1407,7 @@ impl Game {
e.path.clear();
e.guard = false;
e.home = None;
e.patrol.clear();
let b = st(kind).bounty as f32;
let got = self.add_gold(b);
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
@@ -1546,6 +1567,126 @@ impl Game {
}
}
// tiles within two of a fighting hero
fn update_contested(&mut self) {
let n = self.map.t.len();
if self.contested.len() != n {
self.contested = vec![false; n];
} else {
self.contested.iter_mut().for_each(|c| *c = false);
}
for e in &self.ents {
if !e.alive || e.held || !e.active_hero() {
continue;
}
let (x, y) = (e.x as i32, e.y as i32);
for dy in -2..=2 {
for dx in -2..=2 {
if inb(x + dx, y + dy) {
self.contested[idx(x + dx, y + dy)] = true;
}
}
}
}
}
// a knight tile was claimed: vaults hand over their treasure, shards come back
fn take_knight_tile(&mut self, i: usize) {
let t = self.map.t[i];
let (x, y) = (xy(i).0 as f32 + 0.5, xy(i).1 as f32 + 0.5);
if t.kind == Kind::Treasury && t.gold > 0 {
// the content stays in the (now own) treasury tile and counts as own gold
self.gold += t.gold as f32;
self.loot += t.gold as f32;
self.fx(5, x, y, 0.0, 0.0, 1.0, 0xff30d0ff);
self.sfx(Sfx::Payday, x, y);
self.msg("msg.vault_taken", vec![t.gold.to_string()]);
}
if t.kind == Kind::Shard {
self.shards_taken += 1;
self.fx(7, x, y, 0.0, 0.0, 1.2, 0xff6060ff);
self.sfx(Sfx::LevelUp, x, y);
self.msg("msg.shard_taken", vec![]);
}
}
// a fortress unit spotted an enemy: guards and patrols nearby join the fight
fn alarm(&mut self, x: f32, y: f32, target: u32) {
for o in self.ents.iter_mut() {
if !o.alive || o.held || !o.active_hero() || !(o.guard || !o.patrol.is_empty()) {
continue;
}
if matches!(o.job, Job::Attack(_)) || (o.x - x).powi(2) + (o.y - y).powi(2) > 13.0 * 13.0 {
continue;
}
o.job = Job::Attack(target);
o.path.clear();
o.repath = 0.0;
}
}
// fortress units: a bridge of the player near the post (None if none)
fn bridge_to_tear(&self, e: &Ent) -> Option<usize> {
let (hx, hy) = e.home.unwrap_or((e.x, e.y));
let (cx, cy) = (hx as i32, hy as i32);
for r in 0..=9i32 {
for dy in -r..=r {
for dx in -r..=r {
if dx.abs() != r && dy.abs() != r {
continue;
}
if let Some(t) = self.map.get(cx + dx, cy + dy) {
if t.bridge() && t.owner == Owner::Player {
return Some(idx(cx + dx, cy + dy));
}
}
}
}
}
None
}
// a knight walks next to the bridge tile and tears it down
fn tear_bridge(&mut self, e: &mut Ent, i: usize, dt: f32) -> bool {
let t = self.map.t[i];
if !t.bridge() || t.owner != Owner::Player {
return false;
}
let (bx, by) = xy(i);
let cur = e.tile();
let (cx, cy) = xy(cur);
if (cx - bx).abs() + (cy - by).abs() == 1 {
e.path.clear();
e.working = true;
e.face = sgn(bx as f32 + 0.5 - e.x);
e.timer += dt;
if e.timer >= 4.0 {
e.timer = 0.0;
let lava = t.kind == Kind::LavaBridge;
let tl = &mut self.map.t[i];
tl.kind = if lava { Kind::Lava } else { Kind::Water };
tl.owner = Owner::None;
tl.reserved = 0;
self.ground_lost(i);
self.fx(6, bx as f32 + 0.5, by as f32 + 0.5, 0.0, 0.0, 0.6, 0xff3a5a7a);
self.sfx(Sfx::DigDone, bx as f32 + 0.5, by as f32 + 0.5);
self.msg("msg.bridge_torn", vec![]);
return false;
}
return true;
}
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.5;
e.timer = 0.0;
match self.map.search(cur, self.map.walk_cost(), |j| j != i && adj4(i, j), true) {
Some(p) => e.path = p,
None => return false,
}
}
self.step_path(e, dt);
true
}
// ---------------------------------------------------------------- pit imps (workers)
fn dig_neighbor(&self, i: usize, me: u32) -> Option<usize> {
@@ -1571,7 +1712,7 @@ impl Game {
let (x, y) = xy(i);
self.towers.iter().find(|t| {
let (tx, ty) = xy(t.tile);
(tx - x).abs() <= 1 && (ty - y).abs() <= 1 && t.hp < t.max_hp() - 1.0
!t.hero && (tx - x).abs() <= 1 && (ty - y).abs() <= 1 && t.hp < t.max_hp() - 1.0
}).map(|t| t.id)
}
@@ -1593,7 +1734,13 @@ impl Game {
fn claimable(&self, i: usize, me: u32) -> bool {
let t = &self.map.t[i];
if t.owner != Owner::None || !matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard) {
let ok = match t.owner {
Owner::None => matches!(t.kind, Kind::Floor | Kind::Portal | Kind::Bridge | Kind::LavaBridge | Kind::Shard),
// knight ground (also vaults and shard chambers), but not while heroes fight there
Owner::Hero => matches!(t.kind, Kind::Floor | Kind::Treasury | Kind::Bridge | Kind::LavaBridge | Kind::Shard) && !self.contested.get(i).copied().unwrap_or(false),
Owner::Player => false,
};
if !ok {
return false;
}
if t.reserved != 0 && t.reserved != me {
@@ -1745,10 +1892,16 @@ impl Game {
}
e.working = true;
e.timer += dt;
if e.timer >= 1.0 {
// knight ground takes longer
let knight = self.map.t[t].owner == Owner::Hero;
if e.timer >= if knight { 3.0 } else { 1.0 } {
self.unreserve(me);
e.job = Job::None;
let (tx, ty) = xy(t);
if knight {
self.take_knight_tile(t);
self.alarm(tx as f32 + 0.5, ty as f32 + 0.5, e.id);
}
if let (Kind::Portal, Some(pk)) = (self.map.t[t].kind, self.map.portal_at(tx, ty)) {
let (px, py) = self.map.portals[pk];
for y in 0..3 {
@@ -1929,9 +2082,24 @@ impl Game {
e.job = Job::Attack(id);
e.path.clear();
e.repath = 0.0;
} else if !matches!(e.job, Job::AttackTower(_) | Job::Sleep(_) | Job::Eat(_)) {
// knight towers in reach are torn down
if let Some(id) = self.enemy_tower_near(e.x, e.y, radius - 1.0) {
self.unreserve(me);
e.job = Job::AttackTower(id);
e.path.clear();
e.repath = 0.0;
}
}
}
}
if let Job::AttackTower(id) = e.job {
if !self.attack_tower(e, id, dt) {
e.job = Job::None;
e.path.clear();
}
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(_)) {
@@ -2489,15 +2657,27 @@ impl Game {
e.cd = 2.5;
}
}
let fort = e.guard || !e.patrol.is_empty();
if !matches!(e.job, Job::Attack(_)) {
let radius = if e.guard { 8.0 } else { 6.5 };
let radius = if fort { 8.0 } else { 6.5 };
if let Some(id) = self.nearest_enemy(e, radius) {
e.job = Job::Attack(id);
e.path.clear();
e.repath = 0.0;
} else if !matches!(e.job, Job::AttackTower(_)) {
if fort {
self.alarm(e.x, e.y, id);
}
} else if e.guard && !matches!(e.job, Job::TearBridge(_)) && matches!(e.kind, EK::Knight | EK::Dwarf | EK::Paladin) && self.rng.f() < 0.05 {
// knights cut the player's way in: bridges near their post are torn down
if let Some(b) = self.bridge_to_tear(e) {
e.job = Job::TearBridge(b);
e.path.clear();
e.repath = 0.0;
e.timer = 0.0;
}
} else if !matches!(e.job, Job::AttackTower(_) | Job::TearBridge(_)) {
// no unit around: towers within reach are attacked
let near = self.towers.iter().filter(|t| {
let near = self.towers.iter().filter(|t| !t.hero).filter(|t| {
let (tx, ty) = t.pos();
(tx - e.x).powi(2) + (ty - e.y).powi(2) < radius * radius
}).min_by(|a, b| {
@@ -2521,9 +2701,21 @@ impl Game {
e.path.clear();
e.think = 1.5;
}
if let Job::TearBridge(b) = e.job {
if self.tear_bridge(e, b, dt) {
return;
}
e.job = Job::None;
e.path.clear();
}
if let Job::Attack(id) = e.job {
let (gx, gy) = e.home.unwrap_or_else(|| self.gate_center());
let far = e.guard && (e.x - gx).powi(2) + (e.y - gy).powi(2) > 14.0 * 14.0;
// guards and patrols do not follow too far
let (gx, gy) = match (e.home, e.patrol.get(e.patrol_k % e.patrol.len().max(1))) {
(Some(h), _) => h,
(None, Some(&(px, py))) => (px as f32 + 0.5, py as f32 + 0.5),
_ => self.gate_center(),
};
let far = (e.guard || !e.patrol.is_empty()) && (e.x - gx).powi(2) + (e.y - gy).powi(2) > 14.0 * 14.0;
if !far && self.attack_job(e, id, dt) {
return;
}
@@ -2531,6 +2723,25 @@ impl Game {
e.path.clear();
e.think = 1.5;
}
if !e.patrol.is_empty() {
// patrols walk their route in a loop
let k = e.patrol_k % e.patrol.len();
let (px, py) = e.patrol[k];
if (e.x - px as f32 - 0.5).powi(2) + (e.y - py as f32 - 0.5).powi(2) < 0.8 {
e.patrol_k = (k + 1) % e.patrol.len();
e.path.clear();
}
if e.path.is_empty() && e.repath <= 0.0 {
e.repath = 1.0;
let (px, py) = e.patrol[e.patrol_k % e.patrol.len()];
match self.map.path_to(e.tile(), idx(px, py)) {
Some(p) => e.path = p,
None => e.patrol_k = (e.patrol_k + 1) % e.patrol.len(), // blocked: next point
}
}
self.step_path(e, dt);
return;
}
if e.guard {
// guards stay at their post (gate or treasure cave)
let (gx, gy) = e.home.unwrap_or_else(|| self.gate_center());
@@ -2716,7 +2927,7 @@ impl Game {
continue;
}
let i = idx(x, y);
if let Some(k) = self.towers.iter().position(|t| t.tile == i) {
if let Some(k) = self.towers.iter().position(|t| t.tile == i && !t.hero) {
refund += self.towers[k].invested() as f32 * 0.5;
self.towers.remove(k);
self.map.t[i].kind = Kind::Floor;
@@ -2764,6 +2975,11 @@ impl Game {
let s = tower_stats(kind);
if let Some(k) = self.towers.iter().position(|t| t.tile == i) {
let t = &self.towers[k];
if t.hero {
self.hint(16, "hint.tower_where");
self.sfx_ui(Sfx::Error);
return;
}
if t.kind != kind {
self.hint(14, "hint.tower_other");
return;
@@ -2806,7 +3022,7 @@ impl Game {
self.map.t[i].kind = Kind::Tower;
let id = self.next_id;
self.next_id += 1;
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0 };
let tower = Tower { id, kind, tile: i, level: 1, hp: s.hp, cd: 1.0, aim: 0.0, flash: 0.0, hero: false };
let (px, py) = tower.pos();
self.towers.push(tower);
// units standing on the tile step aside
@@ -2832,7 +3048,23 @@ impl Game {
let (px, py) = t.pos();
self.fx(3, px, py, 0.0, 0.0, 1.0, 0xff505070);
self.sfx(Sfx::DigDone, px, py);
self.msg("msg.tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
if t.hero {
self.towers_razed += 1;
self.msg("msg.enemy_tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
} else {
self.msg("msg.tower_destroyed", vec![format!("@{}", tower_name(t.kind))]);
}
}
// a knight tower near the unit (for the player's creatures)
fn enemy_tower_near(&self, x: f32, y: f32, radius: f32) -> Option<u32> {
self.towers
.iter()
.filter(|t| t.hero)
.map(|t| (t.id, (t.pos().0 - x).powi(2) + (t.pos().1 - y).powi(2)))
.filter(|&(_, d)| d < radius * radius)
.min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
.map(|(id, _)| id)
}
// damage dealt to a unit by a tower (no attacker unit involved)
@@ -2852,7 +3084,9 @@ impl Game {
let mut best = None;
let mut bd = r * r;
for (j, e) in self.ents.iter().enumerate() {
if !e.alive || e.held || !e.active_hero() {
// knight towers shoot the player's units, the player's towers shoot heroes
let enemy = if t.hero { !e.hero } else { e.active_hero() };
if !e.alive || e.held || !enemy {
continue;
}
let d = (e.x - px).powi(2) + (e.y - py).powi(2);
@@ -2878,12 +3112,14 @@ impl Game {
let t = self.towers[k].clone();
let Some(j) = self.tower_target(&t) else { continue };
let s = tower_stats(t.kind);
if s.mana > 0.0 && self.mana < s.mana {
if s.mana > 0.0 && !t.hero && self.mana < s.mana {
self.hint(17, "hint.tower_mana");
self.towers[k].cd = 1.0;
continue;
}
self.mana -= s.mana;
if !t.hero {
self.mana -= s.mana;
}
self.towers[k].cd = s.interval;
self.towers[k].flash = 0.15;
let (px, py) = t.pos();
@@ -3017,6 +3253,8 @@ impl Game {
let (ix, iy) = xy(i);
(ix - x).abs() <= 1 && (iy - y).abs() <= 1 && i != tile
}, true) {
// already next to the tower: step to the middle of the tile (in reach)
Some(p) if p.is_empty() => e.path = vec![e.tile()],
Some(p) => e.path = p,
None => return false,
}
@@ -3268,6 +3506,8 @@ pub struct Tower {
pub cd: f32,
pub aim: f32, // direction of the last shot (radians, for drawing)
pub flash: f32,
#[serde(default)]
pub hero: bool, // tower of a knight fortress: shoots the player's units
}
impl Tower {
@@ -3302,6 +3542,8 @@ impl Tower {
// Language key describing what a unit is doing
pub fn job_name(e: &Ent) -> &'static str {
match e.job {
Job::None if !e.patrol.is_empty() => "job.patrol",
Job::None if e.guard => "job.guard",
Job::None => "job.none",
Job::Dig(_) => "job.dig",
Job::Claim(_) => "job.claim",
@@ -3318,6 +3560,7 @@ pub fn job_name(e: &Ent) -> &'static str {
Job::Train(_) => "job.train",
Job::Study(_) => "job.study",
Job::Attack(_) => "job.fight",
Job::TearBridge(_) => "job.tear_bridge",
Job::AttackCore => {
if e.working {
"job.core_attack"
@@ -3531,6 +3774,108 @@ mod tests {
assert!((gold - g.gold - cost).abs() < 1.0);
}
// a fortress east of the start area, joined to it by a claimed corridor
fn fort_game(spec: impl Fn(i32, i32) -> crate::fortress::FortSpec) -> (Game, crate::fortress::FortSpec) {
let mut g = Game::new(11, 96, 64);
let (cx, cy) = g.map.core;
let f = spec(cx + 14, cy - 6);
g.build_fortress(&f);
for x in cx + 5..f.x {
for y in cy..=cy + 2 {
let t = &mut g.map.t[idx(x, y)];
t.kind = Kind::Floor;
t.owner = Owner::Player;
}
}
(g, f)
}
fn spec(x: i32, y: i32) -> crate::fortress::FortSpec {
crate::fortress::FortSpec { x, y, w: 15, h: 15, doors: vec![3], towers: vec![], tower_level: 1, guards: vec![], patrols: vec![], vaults: 0, vault_value: 0, shard: false, heart: false }
}
#[test]
fn knight_towers_and_creatures_fight() {
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { towers: vec![TowerKind::Arrow], ..spec(x, y) });
assert_eq!(g.towers.len(), 1);
assert!(g.towers[0].hero);
let (tx, ty) = g.towers[0].pos();
let id = g.spawn(EK::Ogre, false, tx + 3.0, ty + 2.0, 4);
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
let thp0 = g.towers[0].hp;
for _ in 0..200 {
g.update(0.05);
}
let hurt = g.ents.iter().find(|e| e.id == id).map_or(true, |e| e.hp < hp0);
let tower_hurt = g.towers.first().map_or(true, |t| t.hp < thp0);
assert!(hurt, "the knight tower shoots the creature");
assert!(tower_hurt, "the creature attacks the knight tower");
let _ = f;
}
#[test]
fn vaults_are_claimed_and_looted() {
let (mut g, _) = fort_game(|x, y| crate::fortress::FortSpec { vaults: 2, vault_value: 1800, ..spec(x, y) });
let gold0 = g.gold;
for _ in 0..(240 * 20) {
g.update(0.05);
if g.loot >= 3000.0 {
break;
}
}
println!("loot after claiming: {}", g.loot);
assert!(g.loot > 1000.0);
assert!(g.gold > gold0);
}
#[test]
fn patrols_walk_and_guards_raise_the_alarm() {
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec {
guards: vec![(EK::Knight, 1); 4],
patrols: vec![vec![(EK::Archer, 1)]],
..spec(x, y)
});
let pj = g.ents.iter().position(|e| !e.patrol.is_empty()).unwrap();
let pid = g.ents[pj].id;
let start = (g.ents[pj].x, g.ents[pj].y);
for _ in 0..200 {
g.update(0.05);
}
let p = g.ents.iter().find(|e| e.id == pid).unwrap();
assert!((p.x - start.0).abs() + (p.y - start.1).abs() > 3.0, "the patrol moves");
// an intruder next to one guard: the others join
let (hx, hy) = g.ents.iter().find(|e| e.guard).unwrap().home.unwrap();
g.spawn(EK::Ogre, false, hx + 1.0, hy, 8);
for _ in 0..20 {
g.update(0.05);
}
let fighting = g.ents.iter().filter(|e| e.guard && matches!(e.job, Job::Attack(_))).count();
assert!(fighting >= 3, "alarm: {} guards fight", fighting);
let _ = f;
}
#[test]
fn knights_tear_down_bridges() {
let (mut g, f) = fort_game(|x, y| crate::fortress::FortSpec { guards: vec![(EK::Knight, 1)], ..spec(x, y) });
// a bridge of the player in the courtyard, on a small pond
let (bx, by) = (f.x + f.w / 2 + 2, f.y + f.h / 2 + 2);
for y in by - 1..=by + 1 {
g.map.t[idx(bx, y)].kind = Kind::Water;
g.map.t[idx(bx, y)].owner = Owner::None;
}
g.map.t[idx(bx, by)].kind = Kind::Bridge;
g.map.t[idx(bx, by)].owner = Owner::Player;
let mut torn = false;
for _ in 0..(90 * 20) {
g.update(0.05);
if g.map.t[idx(bx, by)].kind == Kind::Water {
torn = true;
break;
}
}
assert!(torn);
}
#[test]
fn some_imps_always_claim() {
// lots of digging work: still at least 10% (min. one) of the imps claim the dug-out floor
+1
View File
@@ -2,6 +2,7 @@
mod audio;
mod cloud;
mod fortress;
mod game;
mod gfx;
mod i18n;
+2
View File
@@ -1200,6 +1200,8 @@ fn draw_towers_solid(g: &Game) {
let (x, z) = tw.pos();
let h = tower_height(tw);
let f = 0.75 + tw.flash * 1.5; // brightens briefly when firing
// knight towers: pale stone with blue tones
let shade_c = |r: u8, g: u8, b: u8, f: f32| if tw.hero { shade_c(r / 3 + 110, g / 3 + 115, b / 3 + 135, f) } else { shade_c(r, g, b, f) };
match tw.kind {
TowerKind::Arrow => {
draw_cube(vec3(x, h * 0.5, z), vec3(0.7, h, 0.7), None, shade_c(110, 75, 45, f));
+8 -8
View File
@@ -419,15 +419,15 @@ impl Ui {
let cap = g.counts[1].min(30);
let wave_col = if g.wave_timer < 30.0 { RED } else { TXT };
let vol = if settings.muted { tr("hud.off") } else { format!("{}%", (settings.volume * 100.0).round() as i32) };
let mut gold_seg = vec![txt(tr("hud.gold"), GOLD), Part::Gap, txt(format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT)];
let due = g.next_payday_cost().ceil() as i32;
if due > 0 {
// what the next payday costs, in red
gold_seg.push(Part::Gap);
gold_seg.push(txt(format!("-{}", due), RED));
}
let mut segs: Vec<Vec<Part>> = vec![
vec![
txt(tr("hud.gold"), GOLD),
Part::Gap,
txt(format!("{}/{}", g.gold as i32, g.gold_cap() as i32), TXT),
Part::Gap,
// what the next payday costs, in red
txt(format!("-{}", g.next_payday_cost().ceil() as i32), RED),
],
gold_seg,
vec![txt(tr("hud.mana"), MANA), Part::Gap, Part::Bar(g.mana / MANA_MAX, MANA), Part::Gap, txt(format!("{:<3}", g.mana as i32), TXT)],
vec![txt(tr("hud.core"), RED), Part::Gap, Part::Bar(g.core_hp / CORE_HP, RED)],
vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)],