Defence towers: arrow (piercing), cannon (splash + knockback), magic (mana, slow, chain); 3 levels, repair by imps, heroes attack towers; torture/temple room tools prepared

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 07:47:50 +01:00
1 parent a1fe02ffa6
commit 903d6fb0f4
10 files changed
+708 -19

No files matched your search

+488 -8
View File
@@ -135,6 +135,8 @@ pub enum Job {
Leave,
Rally,
Reinforce(usize), // wall tile
Repair(u32), // tower id
AttackTower(u32), // tower id
}
#[derive(Clone, Serialize, Deserialize)]
@@ -166,6 +168,8 @@ pub struct Ent {
pub face: f32,
pub moving: bool,
pub working: bool,
#[serde(default)]
pub slow: f32, // seconds of slowdown left (magic tower)
pub guard: bool, // stays at its home (gate defenders, treasure guards)
#[serde(default)]
pub home: Option<(f32, f32)>, // guard post
@@ -201,6 +205,7 @@ impl Ent {
face: 1.0,
moving: false,
working: false,
slow: 0.0,
guard: false,
home: None,
}
@@ -250,7 +255,7 @@ pub struct Game {
pub state: u8, // 0 running, 1 victory, 2 defeat
pub paused: bool,
pub hand: Vec<u32>,
pub counts: [usize; 5], // treasury, lair, farm, training hall, library
pub counts: [usize; 7], // treasury, lair, farm, training hall, library, torture chamber, temple
pub gate_hp: f32,
pub gate_hit_t: f32,
pub gate_stage: u32, // defender thresholds triggered (0..3)
@@ -259,6 +264,8 @@ pub struct Game {
pub events: Vec<SfxEvent>,
pub last_wave_value: f32,
pub torches: Vec<Torch>,
#[serde(default)]
pub towers: Vec<Tower>,
#[serde(skip)]
pub hints: bool, // show hint messages (setting)
hint_t: [f32; 16],
@@ -280,9 +287,9 @@ impl Torch {
}
}
pub const ROOM_KINDS: [Kind; 5] = [Kind::Treasury, Kind::Lair, Kind::Farm, Kind::Training, Kind::Library];
pub const ROOM_COST: [i32; 5] = [60, 80, 100, 140, 160];
pub const ROOM_NAMES: [&str; 5] = ["room.treasury", "room.lair", "room.farm", "room.training", "room.library"];
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 fn room_index(k: Kind) -> Option<usize> {
ROOM_KINDS.iter().position(|&r| r == k)
@@ -294,6 +301,15 @@ fn adj4(a: usize, b: usize) -> bool {
(ax - bx).abs() + (ay - by).abs() == 1
}
fn norm(x: f32, y: f32) -> (f32, f32) {
let l = (x * x + y * y).sqrt();
if l < 1e-6 {
(0.0, 0.0)
} else {
(x / l, y / l)
}
}
fn sgn(v: f32) -> f32 {
if v < 0.0 {
-1.0
@@ -324,7 +340,7 @@ impl Game {
state: 0,
paused: false,
hand: Vec::new(),
counts: [0; 5],
counts: [0; 7],
gate_hp: GATE_HP,
gate_hit_t: -100.0,
gate_stage: 0,
@@ -332,6 +348,7 @@ impl Game {
events: Vec::new(),
last_wave_value: 0.0,
torches: Vec::new(),
towers: Vec::new(),
hints: true,
hint_t: [-1000.0; 16],
};
@@ -401,6 +418,11 @@ impl Game {
d += e.atk() / st(e.kind).interval;
}
}
// towers count with half their combat value
for t in &self.towers {
h += 0.5 * t.max_hp();
d += 0.5 * t.dmg() / tower_stats(t.kind).interval;
}
(h * d).sqrt()
}
@@ -433,7 +455,7 @@ impl Game {
}
pub fn recount(&mut self) {
let mut c = [0usize; 5];
let mut c = [0usize; 7];
for t in &self.map.t {
if t.owner == Owner::Player {
if let Some(r) = room_index(t.kind) {
@@ -577,6 +599,7 @@ impl Game {
self.ents[i] = e;
}
self.ents.retain(|e| e.alive);
self.update_towers(dt);
if self.core_hp <= 0.0 {
self.core_hp = 0.0;
@@ -798,6 +821,7 @@ impl Game {
fn update_ent(&mut self, e: &mut Ent, dt: f32) {
e.anim += dt;
e.flash = (e.flash - dt).max(0.0);
e.slow = (e.slow - dt).max(0.0);
e.cd -= dt;
e.think -= dt;
e.repath -= dt;
@@ -866,7 +890,7 @@ impl Game {
let dx = cx - e.x;
let dy = cy - e.y;
let d = (dx * dx + dy * dy).sqrt();
let mut sp = st(e.kind).speed * dt;
let mut sp = st(e.kind).speed * dt * if e.slow > 0.0 { 0.55 } else { 1.0 };
if !e.hero && e.creature() && e.hunger > 2.0 {
sp *= 0.8;
}
@@ -1062,6 +1086,15 @@ impl Game {
found
}
// a damaged tower next to walkable tile i
fn damaged_tower_near(&self, i: usize) -> Option<u32> {
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
}).map(|t| t.id)
}
// an earth wall next to own floor that can be reinforced from tile i
fn reinforce_neighbor(&self, i: usize, me: u32) -> Option<usize> {
if self.map.t[i].owner != Owner::Player {
@@ -1171,6 +1204,27 @@ impl Game {
}
}
}
Job::Repair(id) => {
let Some(k) = self.towers.iter().position(|t| t.id == id) else {
e.job = Job::None;
return;
};
let (tx, ty) = self.towers[k].pos();
if (tx - e.x).abs() > 1.6 || (ty - e.y).abs() > 1.6 {
e.job = Job::None;
return;
}
e.face = sgn(tx - e.x);
e.working = true;
let t = &mut self.towers[k];
t.hp = (t.hp + 30.0 * dt).min(t.max_hp());
if t.hp >= t.max_hp() {
e.job = Job::None;
}
if (e.anim * 3.0).fract() < dt * 3.0 {
self.sfx(Sfx::Reinforce, tx, ty);
}
}
Job::Reinforce(w) => {
let t = self.map.t[w];
if !adj4(cur, w) || t.kind != Kind::Earth || t.reinforced || t.marked {
@@ -1274,6 +1328,14 @@ impl Game {
e.job = Job::Claim(end);
return;
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.damaged_tower_near(i).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(id) = self.damaged_tower_near(end) {
e.path = p;
e.job = Job::Repair(id);
return;
}
}
if let Some(p) = self.map.search(cur, self.map.walk_cost(), |i| self.reinforce_neighbor(i, me).is_some(), true) {
let end = p.last().copied().unwrap_or(cur);
if let Some(w) = self.reinforce_neighbor(end, me) {
@@ -1659,9 +1721,32 @@ impl Game {
e.job = Job::Attack(id);
e.path.clear();
e.repath = 0.0;
} else if !matches!(e.job, Job::AttackTower(_)) {
// no unit around: towers within reach are attacked
let near = self.towers.iter().filter(|t| {
let (tx, ty) = t.pos();
(tx - e.x).powi(2) + (ty - e.y).powi(2) < radius * radius
}).min_by(|a, b| {
let da = (a.pos().0 - e.x).powi(2) + (a.pos().1 - e.y).powi(2);
let db = (b.pos().0 - e.x).powi(2) + (b.pos().1 - e.y).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
}).map(|t| t.id);
if let Some(id) = near {
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) {
return;
}
e.job = Job::AttackCore;
e.path.clear();
e.think = 1.5;
}
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;
@@ -1773,7 +1858,14 @@ impl Game {
if !inb(x, y) {
continue;
}
let t = &mut self.map.t[idx(x, y)];
let i = idx(x, y);
if let Some(k) = self.towers.iter().position(|t| t.tile == i) {
refund += self.towers[k].invested() as f32 * 0.5;
self.towers.remove(k);
self.map.t[i].kind = Kind::Floor;
continue;
}
let t = &mut self.map.t[i];
if t.owner == Owner::Player {
if let Some(r) = room_index(t.kind) {
t.kind = Kind::Floor;
@@ -1792,6 +1884,276 @@ impl Game {
}
}
pub fn tower_at(&self, i: usize) -> Option<&Tower> {
self.towers.iter().find(|t| t.tile == i)
}
// Build a tower on own floor, or upgrade an own tower of the same kind
pub fn build_tower(&mut self, kind: TowerKind, x: i32, y: i32) {
if !inb(x, y) {
return;
}
let i = idx(x, y);
let s = tower_stats(kind);
if let Some(k) = self.towers.iter().position(|t| t.tile == i) {
let t = &self.towers[k];
if t.kind != kind {
self.hint(14, "hint.tower_other");
return;
}
if t.level >= 3 {
self.hint(15, "hint.tower_max");
return;
}
let cost = s.cost[t.level as usize] as f32;
if self.gold < cost {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
self.gold -= cost;
let t = &mut self.towers[k];
let old_max = t.max_hp();
t.level += 1;
t.hp += t.max_hp() - old_max;
let (px, py) = t.pos();
let lvl = t.level;
self.fx(7, px, py, 0.0, 0.0, 0.8, 0xff44ddff);
self.sfx(Sfx::LevelUp, px, py);
self.msg("msg.tower_upgraded", vec![format!("@{}", tower_name(kind)), lvl.to_string()]);
return;
}
let t = self.map.t[i];
if t.kind != Kind::Floor || t.owner != Owner::Player {
self.hint(16, "hint.tower_where");
self.sfx_ui(Sfx::Error);
return;
}
let cost = s.cost[0] as f32;
if self.gold < cost {
self.msg("msg.no_gold", vec![]);
self.sfx_ui(Sfx::Error);
return;
}
self.gold -= cost;
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 (px, py) = tower.pos();
self.towers.push(tower);
// units standing on the tile step aside
for e in self.ents.iter_mut() {
if e.alive && e.tile() == i {
for &(dx, dy) in &DIR8 {
if self.map.walk(x + dx, y + dy) {
e.x = (x + dx) as f32 + 0.5;
e.y = (y + dy) as f32 + 0.5;
e.path.clear();
break;
}
}
}
}
self.fx(8, px, py, 0.0, 0.0, 0.5, 0xff40d0ff);
self.sfx(Sfx::Build, px, py);
}
fn destroy_tower(&mut self, k: usize) {
let t = self.towers.remove(k);
self.map.t[t.tile].kind = Kind::Floor;
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))]);
}
// damage dealt to a unit by a tower (no attacker unit involved)
fn tower_damage(&mut self, j: usize, dmg: f32) {
let e = &mut self.ents[j];
e.hp -= dmg;
e.flash = 0.15;
if e.hp <= 0.0 && e.alive {
self.kill(j);
}
}
fn tower_target(&self, t: &Tower) -> Option<usize> {
let (px, py) = t.pos();
let r = t.range();
let minr = tower_stats(t.kind).min_range;
let mut best = None;
let mut bd = r * r;
for (j, e) in self.ents.iter().enumerate() {
if !e.alive || e.held || !e.hero {
continue;
}
let d = (e.x - px).powi(2) + (e.y - py).powi(2);
if d < bd && d >= minr * minr && self.los(px, py, e.x, e.y) {
bd = d;
best = Some(j);
}
}
best
}
fn update_towers(&mut self, dt: f32) {
for k in 0..self.towers.len() {
if k >= self.towers.len() {
break;
}
let t = &mut self.towers[k];
t.cd -= dt;
t.flash = (t.flash - dt).max(0.0);
if t.cd > 0.0 {
continue;
}
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 {
self.hint(17, "hint.tower_mana");
self.towers[k].cd = 1.0;
continue;
}
self.mana -= s.mana;
self.towers[k].cd = s.interval;
self.towers[k].flash = 0.15;
let (px, py) = t.pos();
let (ex, ey) = (self.ents[j].x, self.ents[j].y);
self.towers[k].aim = (ey - py).atan2(ex - px);
let dmg = t.dmg();
match t.kind {
TowerKind::Arrow => {
// the arrow pierces up to two more heroes behind the target
let dir = norm(ex - px, ey - py);
let end = (px + dir.0 * t.range(), py + dir.1 * t.range());
self.fx(0, px, py - 0.6, end.0, end.1, 0.3, 0xffd0e0f0);
self.sfx(Sfx::Shoot, px, py);
let mut hits = vec![(j, dmg)];
for (o, e) in self.ents.iter().enumerate() {
if o == j || !e.alive || e.held || !e.hero || hits.len() >= 3 {
continue;
}
let rel = (e.x - px, e.y - py);
let along = rel.0 * dir.0 + rel.1 * dir.1;
let side = (rel.0 * dir.1 - rel.1 * dir.0).abs();
if along > 0.0 && along <= t.range() && side < 0.45 {
hits.push((o, dmg * 0.6));
}
}
for (o, d) in hits {
if self.ents[o].alive {
self.tower_damage(o, d);
}
}
}
TowerKind::Cannon => {
// splash damage and knockback around the impact
self.fx(0, px, py - 0.8, ex, ey, 0.35, 0xff303030);
self.fx(3, ex, ey, 0.0, 0.0, 0.6, 0xff4070a0);
self.fx(2, ex, ey - 0.2, 0.0, 0.0, 0.3, 0xff60c0ff);
self.sfx(Sfx::Cannon, px, py);
let mut hits = Vec::new();
for (o, e) in self.ents.iter().enumerate() {
if !e.alive || e.held || !e.hero {
continue;
}
let d = ((e.x - ex).powi(2) + (e.y - ey).powi(2)).sqrt();
if d < 1.6 {
hits.push((o, dmg * (1.0 - d / 2.0), d));
}
}
for (o, d, dist) in hits {
if !self.ents[o].alive {
continue;
}
// knockback away from the impact, only onto walkable ground
let e = &self.ents[o];
let away = if dist > 0.05 { norm(e.x - ex, e.y - ey) } else { norm(e.x - px, e.y - py) };
let (nx, ny) = (e.x + away.0 * 0.7, e.y + away.1 * 0.7);
if self.map.walk(nx as i32, ny as i32) {
let e = &mut self.ents[o];
e.x = nx;
e.y = ny;
e.path.clear();
}
self.tower_damage(o, d);
}
}
TowerKind::Magic => {
// sure hit, slows, jumps to a second hero
self.fx(0, px, py - 1.0, ex, ey, 0.2, 0xffff60d0);
self.sfx(Sfx::Magic, px, py);
self.ents[j].slow = 2.0;
let chain = self
.ents
.iter()
.enumerate()
.filter(|(o, e)| *o != j && e.alive && !e.held && e.hero && (e.x - ex).powi(2) + (e.y - ey).powi(2) < 9.0)
.min_by(|a, b| {
let da = (a.1.x - ex).powi(2) + (a.1.y - ey).powi(2);
let db = (b.1.x - ex).powi(2) + (b.1.y - ey).powi(2);
da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
})
.map(|(o, _)| o);
self.tower_damage(j, dmg);
if let Some(o) = chain {
let (ox, oy) = (self.ents[o].x, self.ents[o].y);
self.fx(0, ex, ey, ox, oy, 0.2, 0xffff60d0);
self.ents[o].slow = 2.0;
if self.ents[o].alive {
self.tower_damage(o, dmg * 0.6);
}
}
}
}
}
}
// a hero attacks a tower; false = tower gone or unreachable
fn attack_tower(&mut self, e: &mut Ent, id: u32, dt: f32) -> bool {
let Some(k) = self.towers.iter().position(|t| t.id == id) else { return false };
let (tx, ty) = self.towers[k].pos();
let s = st(e.kind);
let d = ((tx - e.x).powi(2) + (ty - e.y).powi(2)).sqrt();
if d <= s.range + 0.75 && (s.range < 1.5 || self.los(e.x, e.y, tx, ty)) {
e.path.clear();
e.face = sgn(tx - e.x);
e.working = true;
if e.cd <= 0.0 {
e.cd = s.interval;
let dmg = e.atk() * (0.85 + self.rng.f() * 0.3);
if s.range > 1.5 {
self.fx(0, e.x, e.y - 0.3, tx, ty - 0.5, 0.25, 0xff70b0d0);
} else {
self.fx(2, tx, ty - 0.4, 0.0, 0.0, 0.25, 0xffffffff);
}
self.sfx(Sfx::MeleeHit, tx, ty);
let t = &mut self.towers[k];
t.hp -= dmg;
if t.hp <= 0.0 {
self.destroy_tower(k);
}
}
return true;
}
if e.path.is_empty() || e.repath <= 0.0 {
e.repath = 1.0;
let tile = self.towers[k].tile;
let (x, y) = xy(tile);
match self.map.search(e.tile(), self.map.walk_cost(), |i| {
let (ix, iy) = xy(i);
(ix - x).abs() <= 1 && (iy - y).abs() <= 1 && i != tile
}, true) {
Some(p) => e.path = p,
None => return false,
}
}
self.step_path(e, dt);
true
}
pub fn pickup(&mut self, target: Option<usize>) -> bool {
if self.hand.len() >= 8 {
self.hint(8, "hint.hand_full");
@@ -1903,6 +2265,73 @@ impl Game {
}
}
// ------------------------------------------------------------------ defence towers
#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
pub enum TowerKind {
Arrow,
Cannon,
Magic,
}
pub struct TowerStats {
pub cost: [i32; 3], // build, upgrade to level 2, upgrade to level 3
pub hp: f32,
pub dmg: f32,
pub range: f32,
pub min_range: f32,
pub interval: f32,
pub mana: f32, // per shot
}
pub fn tower_stats(k: TowerKind) -> TowerStats {
match k {
TowerKind::Arrow => TowerStats { cost: [250, 200, 300], hp: 300.0, dmg: 10.0, range: 6.0, min_range: 0.0, interval: 0.8, mana: 0.0 },
TowerKind::Cannon => TowerStats { cost: [450, 350, 500], hp: 450.0, dmg: 32.0, range: 8.5, min_range: 2.0, interval: 2.6, mana: 0.0 },
TowerKind::Magic => TowerStats { cost: [350, 300, 450], hp: 300.0, dmg: 24.0, range: 7.0, min_range: 0.0, interval: 1.5, mana: 8.0 },
}
}
pub fn tower_name(k: TowerKind) -> &'static str {
match k {
TowerKind::Arrow => "tower.arrow",
TowerKind::Cannon => "tower.cannon",
TowerKind::Magic => "tower.magic",
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct Tower {
pub id: u32,
pub kind: TowerKind,
pub tile: usize,
pub level: u32,
pub hp: f32,
pub cd: f32,
pub aim: f32, // direction of the last shot (radians, for drawing)
pub flash: f32,
}
impl Tower {
pub fn max_hp(&self) -> f32 {
tower_stats(self.kind).hp * [1.0, 1.4, 1.9][(self.level as usize - 1).min(2)]
}
pub fn dmg(&self) -> f32 {
tower_stats(self.kind).dmg * [1.0, 1.5, 2.2][(self.level as usize - 1).min(2)]
}
pub fn range(&self) -> f32 {
tower_stats(self.kind).range + 0.75 * (self.level - 1) as f32
}
pub fn pos(&self) -> (f32, f32) {
let (x, y) = xy(self.tile);
(x as f32 + 0.5, y as f32 + 0.5)
}
// gold spent on this tower so far
pub fn invested(&self) -> i32 {
tower_stats(self.kind).cost[..self.level as usize].iter().sum()
}
}
// Language key describing what a unit is doing
pub fn job_name(e: &Ent) -> &'static str {
match e.job {
@@ -1931,6 +2360,8 @@ pub fn job_name(e: &Ent) -> &'static str {
}
Job::Leave => "job.leave",
Job::Reinforce(_) => "job.reinforce",
Job::Repair(_) => "job.repair",
Job::AttackTower(_) => "job.attack_tower",
Job::Rally => {
if e.working {
"job.gate_attack"
@@ -2035,4 +2466,53 @@ mod tests {
assert_eq!(g.map.hero_cost()(idx(w.0, w.1)), Some(70));
}
}
#[test]
fn towers_shoot_and_get_attacked() {
for kind in [TowerKind::Arrow, TowerKind::Cannon, TowerKind::Magic] {
let mut g = Game::new(5, 64, 64);
g.gold = 2000.0;
g.mana = 400.0;
let (cx, cy) = g.map.core;
let (tx, ty) = (cx - 2, cy - 2);
g.build_tower(kind, tx, ty);
assert_eq!(g.towers.len(), 1);
g.build_tower(kind, tx, ty); // upgrade
assert_eq!(g.towers[0].level, 2);
// a hero inside the start area, 4 tiles away
let id = g.spawn(EK::Knight, true, (cx + 3) as f32 + 0.5, (cy - 2) as f32 + 0.5, 1);
let hp0 = g.ents.iter().find(|e| e.id == id).unwrap().hp;
let mut hurt = false;
let mut tower_hit = false;
for _ in 0..400 {
g.update(0.05);
if let Some(e) = g.ents.iter().find(|e| e.id == id) {
hurt |= e.hp < hp0;
} else {
hurt = true;
}
tower_hit |= g.towers.first().map_or(true, |t| t.hp < t.max_hp());
}
println!("{:?}: hero hurt {}, tower attacked {}", kind, hurt, tower_hit);
assert!(hurt, "{:?} did not damage the hero", kind);
}
}
#[test]
fn heroes_attack_towers() {
let mut g = Game::new(5, 64, 64);
g.gold = 2000.0;
for e in g.ents.iter_mut() {
e.alive = false; // no imps: the tower is the only target
}
let (cx, cy) = g.map.core;
g.build_tower(TowerKind::Cannon, cx - 2, cy - 2);
g.spawn(EK::Ogre, true, (cx - 2) as f32 + 0.5, (cy + 4) as f32 + 0.5, 5);
for _ in 0..600 {
g.update(0.05);
}
let t = g.towers.first();
println!("tower after attack: {:?}", t.map(|t| t.hp));
assert!(t.map_or(true, |t| t.hp < t.max_hp()));
}
}