From 0606c1f9945f4e927ec60782e86a0e7b9165b193 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 4 Nov 2026 07:52:03 +0100 Subject: [PATCH] Orders: attack (fighting creatures nearby go for an enemy or enemy tower) and guard (up to three creatures hold a room for two minutes); right click with an order tool cancels Co-Authored-By: Claude Opus 5.5 --- lang/de.lang | 14 +++++ lang/en.lang | 14 +++++ src/campaign.rs | 2 +- src/game.rs | 5 +- src/gfx.rs | 20 ++++++ src/main.rs | 9 +++ src/orders.rs | 161 ++++++++++++++++++++++++++++++++++++++++++++++++ src/research.rs | 2 +- src/ui.rs | 9 ++- 9 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 src/orders.rs diff --git a/lang/de.lang b/lang/de.lang index 39847f2..7009a79 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -902,3 +902,17 @@ msg.trap_wrecked = Die Helden haben eine Falle zerstört: {0}. best.giant.text = Ein Riese ist, was passiert, wenn ein Ritter dreihundert Jahre lang sein Gemüse isst. Langsam, gewaltig, und Türen hält er für einen Vorschlag. | Fallen zertrümmert er, indem er einfach danebensteht. Widerliche Manieren. Bearbeite ihn mit allem, am besten aus der Ferne. best.fairy.text = Der kleine Spitzel des Ordens: eine leuchtende Mücke mit Flügeln, vorausgeschickt, um deine Fallen zu finden und es allen zu erzählen. Hat sie eine Falle gesehen, schlagen die Helden sie kaputt. | Außerdem streut sie Heilstaub auf die Helden. Erschlag sie zuerst. Zweimal, sicherheitshalber. best.battlemage.text = Ein Magier in der Robe des Ordens, der Mauern als persönliche Beleidigung empfindet. Verstärkte Wände zerplatzen unter seinen Feuerbällen, und er hört nicht auf, davon zu reden. | Aus der Nähe zerbrechlich. Stahlwände ärgern ihn am meisten - gut so. + +# orders +tool.order_attack.label = Angriff +tool.order_attack.name = Angriff! +tool.order_attack.desc = Klicke einen Feind oder einen feindlichen Turm an: Alle kampfbereiten Kreaturen im Umkreis von 18 Feldern gehen darauf los. Rechtsklick mit einem Befehlswerkzeug hebt die Wachbefehle auf. +tool.order_guard.label = Bewachen +tool.order_guard.name = Raum bewachen +tool.order_guard.desc = Klicke einen Raum an: Bis zu drei Kreaturen in der Nähe beziehen dort für zwei Minuten Stellung und greifen alles an, was in Sicht kommt. Rechtsklick hebt auf. +hint.order_attack = Klicke einen Feind oder einen feindlichen Turm an. +hint.order_guard = Klicke einen deiner Räume (oder deinen Boden) an. +hint.order_nobody = Keine Kreatur ist für diesen Befehl frei. +msg.order_attack = {0} Kreaturen greifen an! +msg.order_guard = {0} Kreaturen beziehen Stellung. +msg.orders_cancelled = Wachbefehle aufgehoben. diff --git a/lang/en.lang b/lang/en.lang index f970228..d32020d 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -903,3 +903,17 @@ msg.trap_wrecked = The heroes wrecked a trap: {0}. best.giant.text = A giant is what happens when a knight eats his vegetables for three hundred years. Slow, enormous, and it treats doors as a suggestion. | It wrecks traps simply by standing next to them. Revolting manners. Hit it with everything, preferably from afar. best.fairy.text = The Order's little informer: a glowing gnat with wings, sent ahead to find your traps and tell everybody. Once it has seen a trap, the heroes smash it. | It also sprinkles healing dust on the heroes. Swat it first. Twice, to be sure. best.battlemage.text = A mage in the Order's robes who thinks walls are a personal insult. Reinforced walls burst under his fireballs, and he never stops talking about it. | Fragile up close. Steel walls annoy him most - good. + +# orders +tool.order_attack.label = Attack +tool.order_attack.name = Attack! +tool.order_attack.desc = Click an enemy or an enemy tower: all fighting creatures within 18 tiles go for it. Right click with an order tool cancels the guard orders. +tool.order_guard.label = Guard +tool.order_guard.name = Guard this room +tool.order_guard.desc = Click a room: up to three creatures nearby take positions there for two minutes and attack whatever comes into view. Right click cancels. +hint.order_attack = Click an enemy or an enemy tower. +hint.order_guard = Click one of your rooms (or your own floor). +hint.order_nobody = No creature is free for that order. +msg.order_attack = {0} creatures attack! +msg.order_guard = {0} creatures take their positions. +msg.orders_cancelled = Guard orders cancelled. diff --git a/src/campaign.rs b/src/campaign.rs index 33182a4..78dc193 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; // From which mission (0-based) on each tool is available (same order as ui::TOOLS): // dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp, // torture, temple, arrow tower, cannon tower, magic tower, bridge, aerie, steel -pub const TOOL_UNLOCK: [usize; 45] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22, 9, 13, 6, 11, 18]; +pub const TOOL_UNLOCK: [usize; 47] = [0, 0, 0, 0, 0, 2, 5, 0, 0, 3, 5, 0, 8, 12, 3, 7, 12, 1, 8, 17, 0, 7, 7, 10, 12, 12, 7, 16, 16, 7, 10, 16, 9, 13, 6, 10, 14, 17, 19, 22, 9, 13, 6, 11, 18, 0, 0]; pub struct FortDef { pub at: (f32, f32), // centre as a fraction of the map size diff --git a/src/game.rs b/src/game.rs index a1e8c12..e69a1d5 100644 --- a/src/game.rs +++ b/src/game.rs @@ -1909,7 +1909,7 @@ impl Game { self.sfx(Sfx::DigDone, x as f32 + 0.5, y as f32 + 0.5); } - fn unreserve(&mut self, me: u32) { + pub(crate) fn unreserve(&mut self, me: u32) { for t in self.map.t.iter_mut() { if t.reserved == me { t.reserved = 0; @@ -3200,8 +3200,9 @@ impl Game { } } Job::Guard(t) => { + // a guard post, or a guard order in any room e.timer -= dt; - if cur != t || self.map.t[t].kind != Kind::GuardPost || e.timer <= 0.0 { + if cur != t || !self.map.t[t].walkable() || self.map.t[t].owner != Owner::Player || e.timer <= 0.0 { self.unreserve(me); e.job = Job::None; } diff --git a/src/gfx.rs b/src/gfx.rs index 97a1dc6..7388ab1 100644 --- a/src/gfx.rs +++ b/src/gfx.rs @@ -78,6 +78,7 @@ pub const T_GRAVEYARD: u32 = 170; pub const T_GUARDPOST: u32 = 174; pub const T_ARENA: u32 = 178; pub const T_ALTAR: u32 = 182; +pub const I_ORDERS: u32 = 186; // attack, guard pub struct Gfx { pub atlas: Texture2D, @@ -996,6 +997,25 @@ fn gen_atlas() -> Image { for (k, f) in spells.iter().enumerate() { cv.cell(I_SPELLS + k as u32, |x, y| f(x, y)); } + // order icons: crossed blades (attack), a shield (guard) + cv.cell(I_ORDERS, |x, y| { + let blade = (x - y).abs() <= 1 || (x + y - 15).abs() <= 1; + if blade && (2..=13).contains(&x) && (2..=13).contains(&y) { + Some(if (x - y).abs() == 0 || (x + y - 15) == 0 { c(240, 240, 250) } else { c(170, 170, 185) }) + } else if (y == 12 || y == 13) && (x == 2 || x == 3 || x == 12 || x == 13) { + Some(c(140, 90, 40)) + } else { + None + } + }); + cv.cell(I_ORDERS + 1, |x, y| { + let w = if y < 9 { 6 } else { 6 - (y - 8) }; + if (2..=14).contains(&y) && (x - 8).abs() <= w.max(0) { + Some(if (x - 8).abs() == w.max(0) || y == 2 { c(220, 190, 80) } else if x == 8 || y == 6 { c(220, 190, 80) } else { c(150, 30, 36) }) + } else { + None + } + }); // aerie: straw and twigs on stone, a few feathers for v in 0..4u32 { cv.cell(T_AERIE + v, |x, y| { diff --git a/src/main.rs b/src/main.rs index ca86bd4..17f8d10 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod map; mod menu; mod personality; mod platform; +mod orders; mod particles; mod props; mod research; @@ -123,6 +124,11 @@ fn world_action(app: &mut App, ui: &mut Ui, r3d: &R3d, inp: &mut Input, mx: f32, (Tool::Tower(k), Some((x, z))) => app.game.build_tower(k, x, z), (Tool::Trap(k), Some((x, z))) => app.game.place_trap(k, x, z), (Tool::Spell(sp), _) => app.game.cast(sp, wx, wy), + (Tool::Order(0), _) => { + let j = r3d.pick_ent(&app.game, &app.cam, mx, my, false); + app.game.order_attack(j, wx, wy); + } + (Tool::Order(_), Some((x, z))) => app.game.order_guard(x, z), _ => {} } } @@ -720,6 +726,8 @@ async fn main() { if !inp.rdrag && !over_ui && app.game.state == 0 { if inp.ldown.is_some() { inp.ldown = None; + } else if app.game.hand.is_empty() && matches!(ui.tool, Tool::Order(_)) { + app.game.cancel_orders(); } else { app.game.drop(wx, wy); } @@ -1055,6 +1063,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M Tool::Tower(_) => (Color::new(1.0, 0.6, 0.25, 1.0), None), Tool::Trap(_) => (Color::new(1.0, 0.4, 0.3, 1.0), None), Tool::Spell(sp) => (Color::new(0.8, 0.55, 1.0, 1.0), Some((ground.x, ground.y, sp.radius().max(0.6)))), + Tool::Order(_) => (Color::new(1.0, 0.3, 0.3, 1.0), None), }; let ov = Overlay { hover: if over_ui { None } else { hover_tile.filter(|&(x, z)| inb(x, z)) }, diff --git a/src/orders.rs b/src/orders.rs new file mode 100644 index 0000000..df20b54 --- /dev/null +++ b/src/orders.rs @@ -0,0 +1,161 @@ +// Orders from the hand: "attack" sends the fighting creatures nearby at an enemy unit or tower, +// "guard" posts a few creatures in a room. Right click with an order tool cancels the orders. + +use crate::game::*; +use crate::map::*; + +const ORDER_RADIUS: f32 = 18.0; +const GUARDS: usize = 3; + +fn ready(e: &Ent) -> bool { + e.alive && !e.held && e.creature() && !matches!(e.job, Job::Leave | Job::Register | Job::MakeBed(_)) && e.hp > e.max_hp * 0.25 +} + +impl Game { + // attack: the enemy (unit, or tower) at (wx, wy) + pub fn order_attack(&mut self, picked: Option, wx: f32, wy: f32) { + let unit = picked.filter(|&j| j < self.ents.len() && self.ents[j].alive && self.ents[j].side() != 0 && self.ents[j].captive == 0).or_else(|| { + self.ents.iter().position(|e| e.alive && !e.held && e.side() != 0 && e.captive == 0 && !e.chained && (e.x - wx).powi(2) + (e.y - wy).powi(2) < 1.5 * 1.5) + }); + let tower = if unit.is_none() { + self.towers.iter().find(|t| t.hero && (t.pos().0 - wx).powi(2) + (t.pos().1 - wy).powi(2) < 1.2 * 1.2).map(|t| (t.id, t.pos())) + } else { + None + }; + let (tx, ty) = match (unit, tower) { + (Some(j), _) => (self.ents[j].x, self.ents[j].y), + (None, Some((_, p))) => p, + _ => { + self.hint(31, "hint.order_attack"); + self.sfx_ui(Sfx::Error); + return; + } + }; + let mut n = 0; + for e in self.ents.iter_mut().filter(|e| ready(e) && (e.x - tx).powi(2) + (e.y - ty).powi(2) < ORDER_RADIUS * ORDER_RADIUS) { + e.job = match (unit, tower) { + (Some(_), _) => Job::Attack(0), + (None, Some((id, _))) => Job::AttackTower(id), + _ => continue, + }; + e.path.clear(); + e.repath = 0.0; + n += 1; + } + if let Some(j) = unit { + let id = self.ents[j].id; + for e in self.ents.iter_mut() { + if e.job == Job::Attack(0) { + e.job = Job::Attack(id); + } + } + } + if n == 0 { + self.hint(31, "hint.order_nobody"); + return; + } + self.fx(7, tx, ty, 0.0, 0.0, 0.6, 0xff3040ff); + self.sfx_ui(Sfx::FlagSet); + self.msg("msg.order_attack", vec![n.to_string()]); + } + + // guard: a few creatures take positions in the room at (x, y) + pub fn order_guard(&mut self, x: i32, y: i32) { + if !inb(x, y) { + return; + } + let i = idx(x, y); + let kind = self.map.t[i].kind; + if self.map.t[i].owner != Owner::Player || room_index(kind).is_none() && kind != Kind::Floor { + self.hint(31, "hint.order_guard"); + self.sfx_ui(Sfx::Error); + return; + } + // the spots: tiles of the same room (or floor) around the click + let mut spots: Vec = Vec::new(); + for dy in -3..=3 { + for dx in -3..=3 { + if let Some(t) = self.map.get(x + dx, y + dy) { + let j = idx(x + dx, y + dy); + if t.kind == kind && t.owner == Owner::Player && t.reserved == 0 && !self.ents.iter().any(|e| e.job == Job::Guard(j)) { + spots.push(j); + } + } + } + } + spots.sort_by_key(|&j| { + let (sx, sy) = xy(j); + (sx - x).pow(2) + (sy - y).pow(2) + }); + let (fx, fy) = (x as f32 + 0.5, y as f32 + 0.5); + let mut cands: Vec = (0..self.ents.len()).filter(|&j| ready(&self.ents[j]) && !matches!(self.ents[j].job, Job::Attack(_) | Job::Guard(_))).collect(); + cands.sort_by(|&a, &b| { + let da = (self.ents[a].x - fx).powi(2) + (self.ents[a].y - fy).powi(2); + let db = (self.ents[b].x - fx).powi(2) + (self.ents[b].y - fy).powi(2); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }); + let mut n = 0; + for (&j, &spot) in cands.iter().zip(spots.iter()).take(GUARDS) { + let cur = self.ents[j].tile(); + let Some(p) = self.map.path_to(cur, spot) else { continue }; + let me = self.ents[j].id + 1; + self.unreserve(me); + self.map.t[spot].reserved = me; + let e = &mut self.ents[j]; + e.job = Job::Guard(spot); + e.path = p; + e.timer = 120.0; + n += 1; + } + if n == 0 { + self.hint(31, "hint.order_nobody"); + return; + } + self.fx(8, fx, fy, 0.0, 0.0, 0.6, 0xff40d0ff); + self.sfx_ui(Sfx::FlagSet); + self.msg("msg.order_guard", vec![n.to_string()]); + } + + // right click with an order tool: everybody stands down + pub fn cancel_orders(&mut self) { + let mut n = 0; + for k in 0..self.ents.len() { + if matches!(self.ents[k].job, Job::Guard(_)) && self.ents[k].creature() { + let me = self.ents[k].id + 1; + self.unreserve(me); + self.ents[k].job = Job::None; + n += 1; + } + } + if n > 0 { + self.msg("msg.orders_cancelled", vec![]); + self.sfx_ui(Sfx::Click); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creatures_follow_attack_and_guard_orders() { + let mut g = Game::new(14, 64, 64); + let (cx, cy) = g.map.core; + let ids: Vec = (0..4).map(|k| g.spawn(EK::Ogre, false, cx as f32 - 0.5, cy as f32 + 0.5 + k as f32 * 0.3, 2)).collect(); + let knight = g.spawn(EK::Knight, true, cx as f32 + 2.5, cy as f32 - 1.5, 1); + let j = g.find(knight).unwrap(); + g.order_attack(Some(j), 0.0, 0.0); + assert!(ids.iter().all(|&id| g.ents[g.find(id).unwrap()].job == Job::Attack(knight))); + // guard the treasury ring around the core + for &id in &ids { + let j = g.find(id).unwrap(); + g.ents[j].job = Job::None; + } + g.order_guard(cx - 1, cy - 1); + let guards = g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count(); + assert_eq!(guards, 3); + g.cancel_orders(); + assert_eq!(g.ents.iter().filter(|e| matches!(e.job, Job::Guard(_))).count(), 0); + } +} diff --git a/src/research.rs b/src/research.rs index fa31c76..7718d50 100644 --- a/src/research.rs +++ b/src/research.rs @@ -43,7 +43,7 @@ pub const RESEARCH: &[(usize, f32)] = &[ ]; // known from the start in free play -pub const BASE_TOOLS: &[usize] = &[0, 1, 2, 3, 4, 6, 7, 8, 9, 14, 17, 20]; +pub const BASE_TOOLS: &[usize] = &[0, 1, 2, 3, 4, 6, 7, 8, 9, 14, 17, 20, 45, 46]; // campaign: what the next missions bring may be researched early const LOOKAHEAD: usize = 4; diff --git a/src/ui.rs b/src/ui.rs index 35cf0c6..220ee8d 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -159,10 +159,11 @@ pub enum Tool { Steel, Trap(crate::traps::TrapKind), Spell(crate::spells::Spell), + Order(u8), // 0 attack, 1 guard } // New tools are appended so that existing key bindings ("tool") keep their meaning -pub const TOOLS: [Tool; 45] = [ +pub const TOOLS: [Tool; 47] = [ Tool::Dig, Tool::Hand, Tool::Room(0), @@ -208,6 +209,8 @@ pub const TOOLS: [Tool; 45] = [ Tool::Room(11), Tool::Room(12), Tool::Room(13), + Tool::Order(0), + Tool::Order(1), ]; @@ -237,6 +240,8 @@ pub fn tool_info(t: Tool) -> ToolInfo { Tool::Room(12) => ("arena", T_ARENA, Some(12)), Tool::Room(_) => ("altar", T_ALTAR, Some(13)), Tool::Trap(k) => (k.id(), I_TRAPS + crate::traps::TRAP_KINDS.iter().position(|&t| t == k).unwrap_or(0) as u32, None), + Tool::Order(0) => ("order_attack", I_ORDERS, None), + Tool::Order(_) => ("order_guard", I_ORDERS + 1, None), Tool::Spell(sp) => (sp.id(), I_SPELLS + crate::spells::SPELLS.iter().position(|&t| t == sp).unwrap_or(0) as u32, None), Tool::Tower(TowerKind::Arrow) => ("arrow", I_ARROW, None), Tool::Tower(TowerKind::Cannon) => ("cannon", I_CANNON, None), @@ -313,7 +318,7 @@ pub const CATS: [(&str, &[usize]); 7] = [ ("cat.towers", &[14, 15, 16, 20]), ("cat.traps", &[22, 23, 24, 25, 26, 27, 28, 29, 30, 31]), ("cat.spells", &[9, 10, 11, 32, 33, 34, 35, 36, 37, 38, 39]), - ("cat.orders", &[8, 7, 19]), + ("cat.orders", &[8, 45, 46, 7, 19]), ]; #[derive(Clone, Copy, PartialEq, Debug)]