diff --git a/lang/de.lang b/lang/de.lang index 3d12086..d8bdb34 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -144,7 +144,7 @@ help.k_right = Rechtsklick Einheit aus der Hand absetzen 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 Werkzeug wählen +help.k_tools = 1-9,0,X,V,K,J,B,C,T 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. @@ -277,3 +277,35 @@ newgame.info = {0} Portal(e), {1} bewachte Schatzhöhle(n) job.reinforce = Verstärkt eine Wand tile.reinforced = Verstärkte Wand tile.reinforced_info = Helden kommen kaum durch + +room.torture = Folterkammer +room.temple = Tempel +tool.torture.label = Folter +tool.torture.desc = Ein Gefangener je 2 Felder. Besiegte Helden werden hergetragen und gefoltert, bis sie sterben oder überlaufen. Ab 6 Feldern kommen Kerkermeister. +tool.temple.label = Tempel +tool.temple.desc = Kreaturen beten hier: mehr Mana als Studieren, und es hebt ihre Laune. Ab 6 Feldern kommen Flammenpriesterinnen. +tool.arrow.label = Pfeile +tool.arrow.name = Pfeilturm +tool.arrow.desc = Günstig und schnell. Pfeile durchschlagen bis zu zwei weitere Helden. Mit diesem Werkzeug auf den eigenen Turm klicken wertet ihn auf. +tool.cannon.label = Kanone +tool.cannon.name = Kanonenturm +tool.cannon.desc = Langsam, große Reichweite, Flächenschaden mit Rückstoß. Trifft keine Ziele direkt neben sich. +tool.magic.label = Magie +tool.magic.name = Magieturm +tool.magic.desc = Verbraucht Mana pro Schuss. Trifft immer, verlangsamt das Ziel und springt auf einen zweiten Helden über. +tower.arrow = Pfeilturm +tower.cannon = Kanonenturm +tower.magic = Magieturm +cost.tower = {0} Gold (Ausbau {1} / {2}) +cost.per_shot = , {0} Mana pro Schuss +info.tower = {0} (Stufe {1}) +info.tower_stats = Schaden {0} Reichweite {1} +info.tower_upgrade = Ausbau: {0} Gold +msg.tower_upgraded = {0} auf Stufe {1} ausgebaut. +msg.tower_destroyed = Dein {0} wurde zerstört! +hint.tower_other = Hier steht bereits ein anderer Turmtyp. +hint.tower_max = Dieser Turm ist voll ausgebaut. +hint.tower_where = Türme können nur auf eroberten, leeren Bodenfeldern gebaut werden. +hint.tower_mana = Deinen Magietürmen ist das Mana ausgegangen. +job.repair = Repariert einen Turm +job.attack_tower = Greift einen Turm an diff --git a/lang/en.lang b/lang/en.lang index 682a876..ace2b02 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -145,7 +145,7 @@ help.k_right = Right click Drop a unit from your hand 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 Select tool +help.k_tools = 1-9,0,X,V,K,J,B,C,T 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. @@ -278,3 +278,35 @@ newgame.info = {0} portal(s), {1} guarded treasure cave(s) job.reinforce = Reinforcing a wall tile.reinforced = Reinforced wall tile.reinforced_info = heroes can hardly break through + +room.torture = Torture Chamber +room.temple = Temple +tool.torture.label = Torture +tool.torture.desc = Holds one prisoner per 2 tiles. Defeated heroes are carried here and tortured until they die or defect. From 6 tiles on, Jailers come. +tool.temple.label = Temple +tool.temple.desc = Creatures pray here: more mana than studying, and it lifts their mood. From 6 tiles on, Flame Priestesses come. +tool.arrow.label = Arrows +tool.arrow.name = Arrow Tower +tool.arrow.desc = Cheap and fast. Arrows pierce up to two more heroes behind the target. Click your tower with this tool to upgrade it. +tool.cannon.label = Cannon +tool.cannon.name = Cannon Tower +tool.cannon.desc = Slow, long range, splash damage that knocks heroes back. Cannot hit targets right next to it. +tool.magic.label = Magic +tool.magic.name = Magic Tower +tool.magic.desc = Uses mana per shot. Never misses, slows the target and jumps to a second hero. +tower.arrow = Arrow Tower +tower.cannon = Cannon Tower +tower.magic = Magic Tower +cost.tower = {0} gold (upgrades {1} / {2}) +cost.per_shot = , {0} mana per shot +info.tower = {0} (level {1}) +info.tower_stats = Damage {0} Range {1} +info.tower_upgrade = Upgrade: {0} gold +msg.tower_upgraded = {0} upgraded to level {1}. +msg.tower_destroyed = Your {0} was destroyed! +hint.tower_other = There is already another kind of tower here. +hint.tower_max = This tower is fully upgraded. +hint.tower_where = Towers can only be built on claimed, empty floor tiles. +hint.tower_mana = Your magic towers have run out of mana. +job.repair = Repairing a tower +job.attack_tower = Attacking a tower diff --git a/src/game.rs b/src/game.rs index 19df1bc..b5fde22 100644 --- a/src/game.rs +++ b/src/game.rs @@ -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, - 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, pub last_wave_value: f32, pub torches: Vec, + #[serde(default)] + pub towers: Vec, #[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 { 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 { + 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 { 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 { + 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) -> 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())); + } } diff --git a/src/gfx.rs b/src/gfx.rs index 451b07f..7b113e6 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -46,6 +46,8 @@ pub const D_RACK: u32 = 80; pub const I_ARROW: u32 = 81; pub const I_CANNON: u32 = 82; pub const I_MAGIC: u32 = 83; +pub const T_TEMPLE: u32 = 84; // 4 variants +pub const D_ALTAR: u32 = 88; pub struct Gfx { pub atlas: Texture2D, @@ -391,6 +393,31 @@ fn gen_atlas() -> Image { Some(stones(x, y, v + 94, (52, 40, 44), (26, 16, 18), 10)) }); } + // temple: dark marble with a glowing red-gold rune circle + for v in 0..4u32 { + cv.cell(T_TEMPLE + v, |x, y| { + let (dx, dy) = (x as f32 - 7.5, y as f32 - 7.5); + let d = (dx * dx + dy * dy).sqrt(); + if (5.0..6.0).contains(&d) { + return Some(c(170, 70, 40)); + } + if d < 1.5 { + return Some(c(220, 150, 60)); + } + let vein = ((x as f32 * 0.6 + (y as f32 * 0.7).sin() * 2.0 + v as f32 * 3.0) as i32) % 7 == 0; + Some(if vein { c(70, 54, 66) } else { shade((44, 34, 46), (rnd(x, y, v + 95) * 10.0) as i32) }) + }); + } + // altar with a flame + cv.cell(D_ALTAR, |x, y| { + if y >= 9 && (3..=12).contains(&x) { + Some(if y == 9 { c(150, 140, 150) } else { c(100, 92, 104) }) + } else if y < 9 && y >= 3 && (x - 7).abs() + (y - 8).abs() / 2 <= 3 { + Some(if (x + y) % 3 == 0 { c(255, 220, 80) } else { c(255, 120, 30) }) + } else { + None + } + }); // torture wheel cv.cell(D_RACK, |x, y| { let (dx, dy) = (x as f32 - 7.5, y as f32 - 7.0); @@ -743,6 +770,7 @@ impl Gfx { Kind::Portal => T_PORTALFLOOR + v, Kind::Gate => T_GATEFLOOR + v, Kind::Torture => T_TORTURE + v, + Kind::Temple => T_TEMPLE + v, Kind::Tower => T_CLAIM + v, } } diff --git a/src/main.rs b/src/main.rs index 28e20c0..fe547fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -476,6 +476,7 @@ async fn main() { (Tool::Lightning, _) => app.game.lightning(wx, wy), (Tool::Heal, _) => app.game.heal(wx, wy), (Tool::Summon, _) => app.game.summon(wx, wy), + (Tool::Tower(k), Some((x, z))) => app.game.build_tower(k, x, z), _ => {} } } @@ -568,6 +569,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M Tool::Lightning => (Color::new(1.0, 1.0, 0.6, 1.0), Some((ground.x, ground.y, 1.6))), Tool::Heal => (Color::new(0.4, 1.0, 0.5, 1.0), Some((ground.x, ground.y, 2.55))), Tool::Summon => (Color::new(0.8, 0.5, 1.0, 1.0), None), + Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None), }; let ov = Overlay { hover: if over_ui { None } else { hover_tile.filter(|&(x, z)| inb(x, z)) }, diff --git a/src/map.rs b/src/map.rs index c7101b6..197e5ff 100644 --- a/src/map.rs +++ b/src/map.rs @@ -48,10 +48,11 @@ pub enum Kind { Gate, Torture, Tower, // floor tile occupied by a defence tower (not walkable) + Temple, } // order matters for the packed savegame codec: only append new kinds -const KINDS: [Kind; 14] = [ +const KINDS: [Kind; 15] = [ Kind::Rock, Kind::Earth, Kind::Gold, @@ -66,6 +67,7 @@ const KINDS: [Kind; 14] = [ Kind::Gate, Kind::Torture, Kind::Tower, + Kind::Temple, ]; #[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)] @@ -115,7 +117,7 @@ impl Tile { pub fn walkable(&self) -> bool { matches!( self.kind, - Kind::Floor | Kind::Treasury | Kind::Lair | Kind::Farm | Kind::Training | Kind::Library | Kind::Portal | Kind::Gate | Kind::Torture + Kind::Floor | Kind::Treasury | Kind::Lair | Kind::Farm | Kind::Training | Kind::Library | Kind::Portal | Kind::Gate | Kind::Torture | Kind::Temple ) } } diff --git a/src/menu.rs b/src/menu.rs index 12cd2f0..285a19d 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -413,7 +413,7 @@ impl Menu { let sh = screen_height(); let (mx, my) = mouse_position(); let cols = ((sw / CW()) as i32 - 2).min(66); - let rows = ((sh / CH()) as i32 - 2).min(28); + let rows = ((sh / CH()) as i32 - 2).min(34); let x = ((sw - cols as f32 * CW()) * 0.5).max(0.0); let y = ((sh - rows as f32 * CH()) * 0.5).max(0.0); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.settings"), FRAME_HI); diff --git a/src/render3d.rs b/src/render3d.rs index a70797e..05a4821 100644 --- a/src/render3d.rs +++ b/src/render3d.rs @@ -531,6 +531,8 @@ fn build_chunk(map: &Map, amb: &[f32], cx: i32, cz: i32, gold_fill: f32, atlas: } Kind::Training if (x + z) % 2 == 0 => mb.cross(xf + 0.5, zf + 0.5, 0.7, 0.75, D_DUMMY, dc), Kind::Library if z % 2 == 0 => mb.cross(xf + 0.5, zf + 0.5, 0.9, 0.85, D_SHELF, dc), + Kind::Torture if (x + z) % 2 == 1 => mb.cross(xf + 0.5, zf + 0.5, 0.8, 0.8, D_RACK, dc), + Kind::Temple if x % 3 == 1 && z % 3 == 1 => mb.cross(xf + 0.5, zf + 0.5, 0.8, 0.8, D_ALTAR, dc), Kind::Treasury if gold_fill > 0.02 => { let s = 0.35 + 0.55 * gold_fill; mb.cross(xf + 0.5, zf + 0.5, s, s * 0.8, D_COINS, dc); @@ -801,6 +803,12 @@ impl R3d { let (gx, gz) = g.gate_center(); push(vec3(gx, 1.2, gz), 7.0, vec3(0.75, 0.85, 1.0), 1.0, 50.0); } + for tw in &g.towers { + if tw.kind == TowerKind::Magic { + let (x, z) = tw.pos(); + push(vec3(x, tower_height(tw) + 0.6, z), 4.5, vec3(0.75, 0.4, 1.0), 0.8 + tw.flash * 3.0, 10.0); + } + } for f in &g.fx { if f.kind == 1 { push(vec3(f.x, 2.0, f.y), 8.0, vec3(1.0, 1.0, 0.85), 3.0 * (1.0 - f.t / f.max), 200.0); @@ -874,6 +882,7 @@ impl R3d { draw_cube(vec3(x, 0.55, z), vec3(0.07, 0.3, 0.07), None, Color::from_rgba(70, 45, 28, 255)); } draw_landmarks_solid(g, t); + draw_towers_solid(g); // ---- unlit glow / overlays gl_use_material(&self.glow); @@ -898,6 +907,7 @@ impl R3d { } draw_shadows(g); draw_landmarks_glow(g, t); + draw_towers_glow(g, t); if let Some(f) = g.flag { draw_flag(f, t); } @@ -1007,6 +1017,16 @@ impl R3d { draw_circle(p.x + w * 0.35, p.y, 3.0, Color::from_rgba(250, 210, 60, 255)); } } + for tw in &g.towers { + if tw.hp < tw.max_hp() { + let (x, z) = tw.pos(); + let p = Cam::project(m, vec3(x, tower_height(tw) + 0.9, z)); + let w = 40.0; + let f = (tw.hp / tw.max_hp()).clamp(0.0, 1.0); + draw_rectangle(p.x - w * 0.5 - 1.0, p.y - 1.0, w + 2.0, 5.0, BLACK); + draw_rectangle(p.x - w * 0.5, p.y, w * f, 3.0, Color::from_rgba(255, 160, 60, 255)); + } + } if g.gate_hp > 0.0 && g.gate_hp < GATE_HP { let (gx, gz) = g.gate_center(); let p = Cam::project(m, vec3(gx, 2.4, gz)); @@ -1017,6 +1037,68 @@ impl R3d { } } +// ------------------------------------------------------------------ towers + +pub fn tower_height(t: &Tower) -> f32 { + match t.kind { + TowerKind::Arrow => 1.0 + 0.3 * (t.level - 1) as f32, + TowerKind::Cannon => 0.6 + 0.15 * (t.level - 1) as f32, + TowerKind::Magic => 1.2 + 0.3 * (t.level - 1) as f32, + } +} + +fn shade_c(r: u8, g: u8, b: u8, f: f32) -> Color { + Color::new(r as f32 / 255.0 * f, g as f32 / 255.0 * f, b as f32 / 255.0 * f, 1.0) +} + +fn draw_towers_solid(g: &Game) { + for tw in &g.towers { + let (x, z) = tw.pos(); + let h = tower_height(tw); + let f = 0.75 + tw.flash * 1.5; // brightens briefly when firing + 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)); + draw_cube(vec3(x, h + 0.06, z), vec3(0.92, 0.12, 0.92), None, shade_c(130, 90, 55, f)); + for (dx, dz) in [(-0.38, -0.38), (0.38, -0.38), (0.38, 0.38), (-0.38, 0.38)] { + draw_cube(vec3(x + dx, h + 0.2, z + dz), vec3(0.15, 0.18, 0.15), None, shade_c(130, 90, 55, f)); + } + } + TowerKind::Cannon => { + draw_cube(vec3(x, h * 0.5, z), vec3(0.88, h, 0.88), None, shade_c(100, 100, 110, f)); + draw_cube(vec3(x, h + 0.08, z), vec3(0.5, 0.16, 0.5), None, shade_c(70, 70, 78, f)); + let (ax, az) = (tw.aim.cos(), tw.aim.sin()); + for k in 0..4 { + let d = 0.1 + k as f32 * 0.14; + draw_cube(vec3(x + ax * d, h + 0.2, z + az * d), Vec3::splat(0.2 - k as f32 * 0.015), None, shade_c(40, 40, 46, f)); + } + } + TowerKind::Magic => { + draw_cube(vec3(x, h * 0.5, z), vec3(0.42, h, 0.42), None, shade_c(84, 74, 104, f)); + draw_cube(vec3(x, 0.1, z), vec3(0.75, 0.2, 0.75), None, shade_c(70, 62, 86, f)); + draw_cube(vec3(x, h, z), vec3(0.6, 0.1, 0.6), None, shade_c(110, 96, 130, f)); + } + } + // level pips + for k in 0..tw.level { + draw_cube(vec3(x - 0.2 + k as f32 * 0.2, 0.25, z + 0.46), Vec3::splat(0.09), None, Color::from_rgba(240, 200, 70, 255)); + } + } +} + +fn draw_towers_glow(g: &Game, t: f32) { + for (k, tw) in g.towers.iter().enumerate() { + if tw.kind != TowerKind::Magic { + continue; + } + let (x, z) = tw.pos(); + let c = vec3(x, tower_height(tw) + 0.45 + (t * 2.0 + k as f32).sin() * 0.06, z); + let pulse = 1.0 + (t * 4.0 + k as f32).sin() * 0.1 + tw.flash * 2.0; + draw_sphere(c, 0.16 * pulse, None, Color::new(0.85, 0.5, 1.0, 0.95)); + draw_sphere(c, 0.3 * pulse, None, Color::new(0.7, 0.3, 1.0, 0.25)); + } +} + // ------------------------------------------------------------------ landmarks, fx, overlay // Camera direction for camera-facing line quads (set once per frame) @@ -1286,5 +1368,6 @@ pub fn minimap_color(tile: &Tile) -> [u8; 4] { Kind::Gate => [230, 230, 255, 255], Kind::Torture => [120, 40, 40, 255], Kind::Tower => [255, 140, 60, 255], + Kind::Temple => [200, 110, 50, 255], } } diff --git a/src/settings.rs b/src/settings.rs index 3fde209..4045758 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..12, same order as ui::TOOLS + Tool(usize), // 0..17, 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..12).map(Action::Tool).collect(); + let mut v: Vec = (0..17).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][i.min(11)], + Action::Tool(i) => [Key1, Key2, Key3, Key4, Key5, Key6, Key7, Key8, Key9, Key0, X, V, K, J, B, C, T][i.min(16)], Action::CamForward => W, Action::CamBack => S, Action::CamLeft => A, diff --git a/src/ui.rs b/src/ui.rs index ebb40a1..d4c733a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -55,9 +55,11 @@ pub enum Tool { Lightning, Heal, Summon, + Tower(TowerKind), } -pub const TOOLS: [Tool; 12] = [ +// New tools are appended so that existing key bindings ("tool") keep their meaning +pub const TOOLS: [Tool; 17] = [ Tool::Dig, Tool::Hand, Tool::Room(0), @@ -70,6 +72,11 @@ pub const TOOLS: [Tool; 12] = [ Tool::Lightning, Tool::Heal, Tool::Summon, + Tool::Room(5), + Tool::Room(6), + Tool::Tower(TowerKind::Arrow), + Tool::Tower(TowerKind::Cannon), + Tool::Tower(TowerKind::Magic), ]; @@ -88,7 +95,12 @@ pub fn tool_info(t: Tool) -> ToolInfo { Tool::Room(1) => ("lair", T_LAIR, Some(1)), Tool::Room(2) => ("farm", T_FARM, Some(2)), Tool::Room(3) => ("training", T_TRAINING, Some(3)), - Tool::Room(_) => ("library", T_LIBRARY, Some(4)), + Tool::Room(4) => ("library", T_LIBRARY, Some(4)), + Tool::Room(5) => ("torture", T_TORTURE, Some(5)), + Tool::Room(_) => ("temple", T_TEMPLE, Some(6)), + Tool::Tower(TowerKind::Arrow) => ("arrow", I_ARROW, None), + Tool::Tower(TowerKind::Cannon) => ("cannon", I_CANNON, None), + Tool::Tower(TowerKind::Magic) => ("magic", I_MAGIC, None), Tool::Sell => ("sell", I_SELL, None), Tool::Flag => ("flag", I_FLAG, None), Tool::Lightning => ("lightning", I_BOLT, None), @@ -108,6 +120,14 @@ pub fn tool_cost(t: Tool, g: &Game) -> String { Tool::Lightning => trf("cost.mana", &["50".into()]), Tool::Heal => trf("cost.mana", &["60".into()]), Tool::Summon => trf("cost.mana", &[(g.summon_cost() as i32).to_string()]), + Tool::Tower(k) => { + let s = tower_stats(k); + let mut c = trf("cost.tower", &[s.cost[0].to_string(), s.cost[1].to_string(), s.cost[2].to_string()]); + if s.mana > 0.0 { + c += &trf("cost.per_shot", &[(s.mana as i32).to_string()]); + } + c + } _ => String::new(), } } @@ -291,7 +311,7 @@ impl Ui { gfx.window(0.0, tb_y, cols, tb_rows, S(), false, FRAME, BG); self.rects.push(Rect::new(0.0, tb_y, sw, tb_rows as f32 * CH())); self.tool_rects.clear(); - let bw = 80.0 * K(); + let bw = (80.0 * K()).min(((sw - CW() * 2.0 - 150.0) / TOOLS.len() as f32 - 4.0 * K()).max(48.0)); let mut hover_tool = None; for (i, &t) in TOOLS.iter().enumerate() { let bx = CW() + i as f32 * (bw + 4.0 * K()); @@ -638,6 +658,16 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec out.push((tr("tile.portal"), MANA)); out.push((tr(if t.owner == Owner::Player { "portal.claimed" } else { "portal.neutral" }), DIM)); } + Kind::Tower => { + if let Some(tw) = g.tower_at(idx(x, y)) { + out.push((trf("info.tower", &[tr(tower_name(tw.kind)), tw.level.to_string()]), FRAME_HI)); + out.push((trf("info.hp", &[(tw.hp as i32).to_string(), (tw.max_hp() as i32).to_string()]), GREEN)); + out.push((trf("info.tower_stats", &[format!("{:.0}", tw.dmg()), format!("{:.1}", tw.range())]), TXT)); + if tw.level < 3 { + out.push((trf("info.tower_upgrade", &[tower_stats(tw.kind).cost[tw.level as usize].to_string()]), GOLD)); + } + } + } Kind::Gate => { out.push((tr("tile.gate"), BLUE)); out.push((trf("info.hp", &[(g.gate_hp as i32).to_string(), (GATE_HP as i32).to_string()]), BLUE));