From a54316c86ea1ecaf32df6907356b7929762760f3 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Wed, 4 Nov 2026 07:00:33 +0100 Subject: [PATCH] Research in the library: rooms, spells, towers, traps and doors are discovered in a fixed order - studying creatures collect research points (mages and owls best, higher levels faster); studying gives less mana than before - free play starts with the basics; campaign missions know what their story allows and can research what the next four missions would bring - research shown in the top bar; the library's info lists the current item and what comes next; locked tools say whether they can be researched Co-Authored-By: Claude Opus 5.5 --- lang/de.lang | 8 +++ lang/en.lang | 8 +++ src/campaign.rs | 1 + src/game.rs | 23 +++++++- src/main.rs | 4 +- src/research.rs | 140 ++++++++++++++++++++++++++++++++++++++++++++++++ src/rival.rs | 1 + src/ui.rs | 28 +++++++++- 8 files changed, 210 insertions(+), 3 deletions(-) create mode 100644 src/research.rs diff --git a/lang/de.lang b/lang/de.lang index e8e41b3..96e3791 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -789,3 +789,11 @@ best.b.frost.text = Kälte macht Helden langsam, und langsame Helden sind leicht best.b.door_wood.text = Hält Zugluft und Ritter draußen - eine Weile. best.b.door_iron.text = Schwer, genietet, stur. Bringt einen Rammbock mit, Helden. best.b.door_magic.text = Schlag zu, und sie schlägt zurück. Verschlossen ist sie das Ende jedes Ganges. + +# research +hud.research = Forschung +research.now = Erforscht: {0} ({1}%) +research.next = danach: {0} +research.done = Hier gibt es nichts mehr zu erforschen. +msg.researched = Erforscht: {0}! Jetzt im Baumenü. +msg.tool_research = Noch nicht erforscht - Kreaturen in der Bibliothek arbeiten daran. diff --git a/lang/en.lang b/lang/en.lang index bb60beb..7bab2fb 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -790,3 +790,11 @@ best.b.frost.text = Cold makes heroes slow, and slow heroes make easy targets. best.b.door_wood.text = Keeps the draught and the knights out - for a while. best.b.door_iron.text = Heavy, riveted, stubborn. Bring a battering ram, heroes. best.b.door_magic.text = Strike it and it strikes back. Locked, it is the end of every corridor. + +# research +hud.research = Research +research.now = Researching: {0} ({1}%) +research.next = then: {0} +research.done = Nothing left to research here. +msg.researched = Researched: {0}! It is in the build menu now. +msg.tool_research = Not researched yet - creatures studying in the library work on it. diff --git a/src/campaign.rs b/src/campaign.rs index 268e57f..cdf1c13 100644 --- a/src/campaign.rs +++ b/src/campaign.rs @@ -504,6 +504,7 @@ pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { g.add_rival(side, x, y, diff); } g.place_dwarf_caves(d.seed); + g.init_research(Some(i)); let heart = d.gate || d.forts.iter().any(|f| f.heart); if !heart { g.gate_hp = 0.0; diff --git a/src/game.rs b/src/game.rs index c1930b2..5782da2 100644 --- a/src/game.rs +++ b/src/game.rs @@ -462,6 +462,12 @@ pub struct Game { pub rollers: Vec, #[serde(default)] pub clouds: Vec, + #[serde(default)] + pub known: Vec, // tools known (ui::TOOLS order; empty = old savegame: everything) + #[serde(default)] + pub researchable: Vec, // tools that can be researched in the library + #[serde(default)] + pub research: f32, // research points towards the current item #[serde(skip)] pub forge_work: f32, // work done in the forge this frame #[serde(skip)] @@ -555,6 +561,7 @@ impl Game { g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0); g.guard_treasures(); g.place_dwarf_caves(seed); + g.init_research(None); g.msg("msg.welcome1", vec![]); g.msg("msg.welcome2", vec![]); g.msg("msg.welcome3", vec![]); @@ -631,6 +638,9 @@ impl Game { rollers: Vec::new(), clouds: Vec::new(), forge_work: 0.0, + known: Vec::new(), + researchable: Vec::new(), + research: 0.0, parts: Vec::new(), stuck: Vec::new(), vrng: crate::particles::vrng(), @@ -678,6 +688,9 @@ impl Game { } pub fn tool_allowed(&self, tool: usize) -> bool { + if !self.known.is_empty() { + return self.known(tool); + } self.mission.as_ref().map_or(true, |m| m.tools.get(tool).copied().unwrap_or(true)) } @@ -1069,6 +1082,7 @@ impl Game { self.update_units(dt); self.update_towers(dt); self.update_forge(dt); + self.update_research(); if !self.rivals.is_empty() { self.update_rivals(dt); } @@ -3063,7 +3077,14 @@ impl Game { } e.working = true; let rate = if e.kind == EK::Mage { 1.2 } else { 0.5 }; - self.mana = (self.mana + rate * dt).min(self.mana_max()); + self.mana = (self.mana + rate * 0.5 * dt).min(self.mana_max()); + // research: mages and owls are the scholars + let points = match e.kind { + EK::Mage => 1.6, + EK::Owl => 1.3, + _ => 0.5, + } * (1.0 + 0.12 * (e.level - 1) as f32); + self.add_research(points * dt); e.xp += 3.0 * dt; e.timer -= dt; if e.timer <= 0.0 { diff --git a/src/main.rs b/src/main.rs index e6814a6..c52a92a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod menu; mod platform; mod particles; mod props; +mod research; mod rival; mod render3d; mod save; @@ -862,7 +863,8 @@ async fn main() { } UiAction::OpenMenu => menu.page = Some(Page::Pause), UiAction::Locked => { - app.game.msg("msg.tool_locked", vec![]); + let locked = ui.last_locked.unwrap_or(usize::MAX); + app.game.msg(if app.game.can_research(locked) { "msg.tool_research" } else { "msg.tool_locked" }, vec![]); app.game.sfx_ui(game::Sfx::Error); } UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} diff --git a/src/research.rs b/src/research.rs new file mode 100644 index 0000000..d2ec89a --- /dev/null +++ b/src/research.rs @@ -0,0 +1,140 @@ +// Research: creatures studying in the library collect research points, and rooms, spells, +// towers, traps and doors are discovered one after another in a fixed order. Free play starts +// with the basics; a campaign mission knows what its story allows and may research what the +// next few missions would bring. + +use crate::game::*; + +// what is researched, in this order: (index in ui::TOOLS, research points) +pub const RESEARCH: &[(usize, f32)] = &[ + (5, 200.0), // training hall + (10, 250.0), // heal + (21, 300.0), // forge + (22, 250.0), // spike pit + (29, 250.0), // wooden door + (15, 400.0), // cannon tower + (12, 450.0), // torture chamber + (26, 300.0), // alarm bell + (11, 500.0), // summon + (13, 550.0), // temple + (23, 500.0), // poison vent + (30, 500.0), // iron door + (18, 550.0), // aerie + (16, 650.0), // magic tower + (24, 650.0), // fire jet + (25, 650.0), // stone roller + (19, 700.0), // steel walls + (28, 800.0), // frost glow + (27, 900.0), // rune mine + (31, 1100.0), // magic door +]; + +// known from the start in free play +pub const BASE_TOOLS: &[usize] = &[0, 1, 2, 3, 4, 6, 7, 8, 9, 14, 17, 20]; + +// campaign: what the next missions bring may be researched early +const LOOKAHEAD: usize = 4; + +impl Game { + // free play (None) or campaign mission i: what is known, what can be researched + pub fn init_research(&mut self, mission: Option) { + let n = crate::ui::TOOLS.len(); + match mission { + None => { + self.known = (0..n).map(|i| BASE_TOOLS.contains(&i)).collect(); + self.researchable = vec![true; n]; + } + Some(m) => { + let unlock = |i: usize| crate::campaign::TOOL_UNLOCK.get(i).copied().unwrap_or(0); + self.known = (0..n).map(|i| unlock(i) <= m).collect(); + self.researchable = (0..n).map(|i| unlock(i) <= m + LOOKAHEAD && i != crate::game::RUNE_TOOL).collect(); + } + } + self.research = 0.0; + } + + pub fn known(&self, tool: usize) -> bool { + self.known.get(tool).copied().unwrap_or(true) + } + + // the item being researched now: (index in RESEARCH) + pub fn research_target(&self) -> Option { + if self.known.is_empty() { + return None; + } + RESEARCH.iter().position(|&(t, _)| !self.known(t) && self.researchable.get(t).copied().unwrap_or(false)) + } + + // the next items after the current one (for the library's info) + pub fn research_queue(&self, n: usize) -> Vec { + RESEARCH.iter().enumerate().filter(|(_, &(t, _))| !self.known(t) && self.researchable.get(t).copied().unwrap_or(false)).map(|(k, _)| k).take(n).collect() + } + + // could this tool still be researched (for the locked-tool message)? + pub fn can_research(&self, tool: usize) -> bool { + !self.known(tool) && self.researchable.get(tool).copied().unwrap_or(false) && RESEARCH.iter().any(|&(t, _)| t == tool) + } + + pub(crate) fn add_research(&mut self, points: f32) { + if self.research_target().is_some() { + self.research += points; + } + } + + pub(crate) fn update_research(&mut self) { + let Some(k) = self.research_target() else { return }; + let (tool, cost) = RESEARCH[k]; + if self.research >= cost { + self.research -= cost; + if let Some(v) = self.known.get_mut(tool) { + *v = true; + } + let name = crate::ui::tool_info(crate::ui::TOOLS[tool]).name; + self.msg("msg.researched", vec![name]); + self.sfx_ui(Sfx::LevelUp); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::map::*; + + #[test] + fn studying_discovers_the_next_item() { + let mut g = Game::new(9, 64, 64); + assert!(!g.tool_allowed(5), "training hall must be researched"); + assert!(g.tool_allowed(2) && g.tool_allowed(6)); + let (cx, cy) = g.map.core; + for y in cy - 8..=cy - 5 { + for x in cx..=cx + 4 { + let t = &mut g.map.t[idx(x, y)]; + t.kind = Kind::Library; + t.owner = Owner::Player; + } + } + g.recount(); + for k in 0..4 { + g.spawn(EK::Mage, false, cx as f32 + 0.5 + k as f32, cy as f32 - 6.5, 3); + } + let mut found = false; + for _ in 0..(20 * 400) { + g.update(0.05); + if g.tool_allowed(5) { + found = true; + break; + } + } + assert!(found, "research points {}", g.research); + } + + #[test] + fn missions_know_their_tools_and_research_ahead() { + let g = crate::campaign::start_mission(2, None); + assert!(g.tool_allowed(5), "training hall known from mission 3"); + assert!(!g.tool_allowed(12)); + assert!(g.can_research(10), "heal comes soon and can be researched"); + assert!(!g.can_research(31), "magic doors are far away"); + } +} diff --git a/src/rival.rs b/src/rival.rs index 61bc93b..e42f744 100644 --- a/src/rival.rs +++ b/src/rival.rs @@ -85,6 +85,7 @@ impl Game { g.msg("msg.welcome1", vec![]); g.msg("msg.welcome2", vec![]); g.msg(if rivals == 0 { "msg.welcome_humans" } else if humans { "msg.welcome_both" } else { "msg.welcome_rivals" }, vec![rivals.to_string()]); + g.init_research(None); g.place_dwarf_caves(seed); g } diff --git a/src/ui.rs b/src/ui.rs index 3e73e5c..3f6beec 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -327,6 +327,7 @@ pub struct Ui { minimap_tex: Texture2D, minimap_t: f64, minimap_rect: Rect, + pub last_locked: Option, // the locked tool clicked last end_buttons: Vec<(Rect, u8)>, goals_rect: Rect, // mission goals panel (click: briefing) // end dialog: 0 new game, 1 next/again, 2 campaign lang_rects: Vec<(Rect, String)>, @@ -378,6 +379,7 @@ impl Ui { minimap_tex: tex, minimap_t: -1.0, minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.0), + last_locked: None, end_buttons: Vec::new(), goals_rect: Rect::new(0.0, 0.0, 0.0, 0.0), lang_rects: Vec::new(), @@ -433,7 +435,10 @@ impl Ui { self.open_cat = None; UiAction::OpenMenu } - TBtn::Tool(i) if !g.tool_allowed(i) => UiAction::Locked, + TBtn::Tool(i) if !g.tool_allowed(i) => { + self.last_locked = Some(i); + UiAction::Locked + } TBtn::Tool(i) => { self.open_cat = None; self.tip = Some((i, get_time())); @@ -442,6 +447,7 @@ impl Ui { TBtn::Cat(c) => { let tools = CATS[c].1; if tools.len() == 1 && !g.tool_allowed(tools[0]) { + self.last_locked = Some(tools[0]); UiAction::Locked } else if tools.len() == 1 { self.open_cat = None; @@ -518,6 +524,11 @@ impl Ui { let left = (limit - g.time).max(0.0); segs.insert(0, vec![txt(tr("hud.time_left"), if left < 120.0 { RED } else { GOLD }), Part::Gap, txt(mmss(left), TXT)]); } + if let Some(k) = g.research_target().filter(|_| g.counts[4] > 0) { + let (tool, cost) = crate::research::RESEARCH[k]; + let name: String = tool_info(TOOLS[tool]).name.chars().take(18).collect(); + segs.push(vec![txt(tr("hud.research"), BLUE), Part::Gap, txt(name, TXT), Part::Gap, Part::Bar(g.research / cost, BLUE)]); + } segs.push(vec![txt(tr("hud.payday"), if g.payday <= PAYDAY_WARN { RED } else { GOLD }), Part::Gap, txt(mmss(g.payday), TXT)]); segs.push(vec![txt(tr("hud.army"), TXT), Part::Gap, txt((g.army_value() as i32).to_string(), TXT)]); if !g.hand.is_empty() { @@ -1215,6 +1226,21 @@ pub fn hover_info(g: &Game, ent: Option, tile: Option<(i32, i32)>) -> Vec out.push((tr(ROOM_NAMES[r]), FRAME_HI)); match k { Kind::Farm => out.push((trf("farm.food", &[(t.food as i32).to_string()]), GREEN)), + Kind::Library => { + let q = g.research_queue(4); + if q.is_empty() { + out.push((tr("research.done"), DIM)); + } + for (n, k) in q.iter().enumerate() { + let (tool, cost) = crate::research::RESEARCH[*k]; + let name = tool_info(TOOLS[tool]).name; + if n == 0 { + out.push((trf("research.now", &[name, ((g.research / cost * 100.0).min(99.0) as i32).to_string()]), BLUE)); + } else { + out.push((trf("research.next", &[name]), DIM)); + } + } + } Kind::Treasury if t.gold > 0 => { let what = ["res.gold", "res.silver", "res.ruby", "res.diamond"][(t.res & 3) as usize]; out.push((trf("treasury.content", &[t.gold.to_string(), tr(what)]), GOLD));