From 4e49c2fdc949088fe7e3c8b9dd4b7e23bbffb2a1 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Thu, 5 Nov 2026 20:07:04 +0100 Subject: [PATCH] Co-op campaign "League against the Lightwatch": 20 missions, selectable on the Match page Co-Authored-By: Claude Opus 5.5 --- lang/de.lang | 1 + lang/en.lang | 1 + lang/es.lang | 1 + lang/it.lang | 1 + lang/ru.lang | 1 + src/editor/coop_campaign.rs | 186 ++++++++++++++++++++++++++++++++++++ src/editor/mod.rs | 1 + src/main.rs | 5 +- src/menu.rs | 56 ++++++++--- 9 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/editor/coop_campaign.rs diff --git a/lang/de.lang b/lang/de.lang index 7f9e182..04215b0 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -660,6 +660,7 @@ net.coop_mission = Koop-Kampagne: Mission {0} ({1}) net.coop_create = Koop-Kampagne net.coop_editor = Editor-Kampagne net.coop_builtin = Eingebaute Kampagne +net.coop_league = Bund gegen die Lichtwacht net.coop_editor_mission = Editor-Kampagne {0}: Mission {1} ({2}) net.coop_editor_none = Keine Mission der Editor-Kampagne passt für Koop (Karte mindestens 72x72, ein Ziel außer Rivalen-Meistern) net.join = Beitreten diff --git a/lang/en.lang b/lang/en.lang index b744807..44fe35b 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -661,6 +661,7 @@ net.coop_mission = Co-op campaign: mission {0} ({1}) net.coop_create = Co-op campaign net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign +net.coop_league = League against the Lightwatch net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) net.join = Join diff --git a/lang/es.lang b/lang/es.lang index 16d3675..6df146c 100644 --- a/lang/es.lang +++ b/lang/es.lang @@ -1330,6 +1330,7 @@ demo.m2.start = Un mapa aleatorio. Ni siquiera yo lo había visto antes. demo.m2.wave = Han pasado dos minutos: llegan los héroes. net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign +net.coop_league = Liga contra la Guardia Luminosa net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) blk.until = Repetir hasta diff --git a/lang/it.lang b/lang/it.lang index b366331..d2d8c5f 100644 --- a/lang/it.lang +++ b/lang/it.lang @@ -1330,6 +1330,7 @@ demo.m2.start = Una mappa casuale. Nemmeno io l'avevo mai vista. demo.m2.wave = Sono passati due minuti: arrivano gli eroi. net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign +net.coop_league = Lega contro la Guardia della Luce net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) blk.until = Ripeti finché diff --git a/lang/ru.lang b/lang/ru.lang index db47025..197f7ab 100644 --- a/lang/ru.lang +++ b/lang/ru.lang @@ -1330,6 +1330,7 @@ demo.m2.start = Случайная карта. Даже я её раньше н demo.m2.wave = Две минуты прошли: идут герои. net.coop_editor = Editor campaign net.coop_builtin = Built-in campaign +net.coop_league = Союз против Светлого Дозора net.coop_editor_mission = Editor campaign {0}: mission {1} ({2}) net.coop_editor_none = No mission of the editor campaign fits co-op (map at least 72x72, a goal besides rival masters) blk.until = Повторять, пока не diff --git a/src/editor/coop_campaign.rs b/src/editor/coop_campaign.rs new file mode 100644 index 0000000..44a9783 --- /dev/null +++ b/src/editor/coop_campaign.rs @@ -0,0 +1,186 @@ +// The built-in co-op campaign "League against the Lightwatch": 20 missions for two to four masters, +// made as an editor campaign (painted maps, goals, branches, triggers). Crookfinger brings young +// masters together: the Lightwatch has allied with Mossgrim to smoke out every dungeon. The masters +// take fortresses, free creatures, and finally bring down the traitor Mossgrim and the High Marshal. +// In co-op the players hold the master seats, so Mossgrim's creatures fight on the heroes' side. +// +// Texts: coop.* keys in the language files; the campaign is built in the player's language. + +use crate::blocks::{Block, Place, Val}; +use crate::campaign_doc::*; +use crate::game::EK; +use crate::i18n::tr; + +struct Spec { + size: (usize, usize), + seed: u32, + gold: f32, + gen: (u8, u8, u8, u8), // rivers, lava rivers, forts, difficulty + goals: &'static [&'static str], + waves: (bool, u32, f32), // hero waves, how many, first after + islands: &'static [((f32, f32), bool, bool, i32)], // at, lava, mana spring, treasure + caves: &'static [((f32, f32), Option<(EK, u32)>, i32)], // at, chained creature, treasure + raids: &'static [(f64, bool, &'static str, u32, u32, usize)], // after/every secs, repeat, kind, count, level, zone + boss: Option<(&'static str, u32, &'static str, usize)>, // kind, level, name, zone + branch: Option<(&'static str, usize)>, // goal, mission number it leads to +} + +// zones where Mossgrim's creatures and the bosses appear (map fractions) +const ZONES: [(&str, (f32, f32)); 3] = [("east", (0.86, 0.5)), ("north", (0.7, 0.16)), ("south", (0.7, 0.84))]; + +const fn s(size: (usize, usize), seed: u32, gold: f32, gen: (u8, u8, u8, u8), goals: &'static [&'static str]) -> Spec { + Spec { size, seed, gold, gen, goals, waves: (false, 0, 0.0), islands: &[], caves: &[], raids: &[], boss: None, branch: None } +} + +fn specs() -> Vec { + use EK::*; + vec![ + // ---- Act I: the league + Spec { raids: &[(150.0, false, "Knight", 3, 1, 0)], ..s((80, 80), 9101, 3000.0, (0, 0, 0, 0), &["KillAll"]) }, + s((80, 80), 9102, 3000.0, (1, 0, 1, 0), &["Towers"]), + Spec { waves: (true, 3, 240.0), raids: &[(120.0, false, "Ogre", 2, 2, 1)], ..s((80, 80), 9103, 3500.0, (1, 0, 0, 0), &["Survive(3)"]) }, + Spec { islands: &[((0.5, 0.2), false, true, 0), ((0.5, 0.8), false, true, 1500)], branch: Some(("Loot(2000)", 6)), ..s((88, 80), 9104, 3000.0, (1, 0, 1, 0), &["Springs(2)"]) }, + Spec { caves: &[((0.3, 0.2), Some((Ogre, 5)), 500), ((0.3, 0.8), Some((Mage, 5)), 500)], raids: &[(200.0, false, "Spider", 4, 3, 0)], ..s((88, 80), 9105, 3500.0, (0, 1, 1, 0), &["Freed(2)"]) }, + // ---- Act II: against the Lightwatch + s((88, 88), 9106, 4000.0, (2, 0, 1, 1), &["Towers"]), + Spec { raids: &[(180.0, true, "Knight", 2, 3, 0)], ..s((96, 80), 9107, 4000.0, (0, 2, 1, 1), &["Loot(4000)"]) }, + Spec { raids: &[(60.0, true, "Priest", 1, 3, 0)], ..s((88, 88), 9108, 4000.0, (1, 0, 1, 1), &["KillAll"]) }, + Spec { boss: Some(("Paladin", 7, "ambassador", 0)), raids: &[(150.0, true, "Ogre", 2, 4, 1)], ..s((96, 88), 9109, 4500.0, (1, 1, 1, 1), &[]) }, + s((96, 96), 9110, 5000.0, (1, 0, 2, 1), &["Towers"]), + Spec { waves: (true, 5, 240.0), raids: &[(300.0, true, "Gargoyle", 2, 4, 2)], ..s((96, 88), 9111, 5000.0, (1, 0, 0, 1), &["Survive(5)"]) }, + Spec { waves: (true, 0, 300.0), ..s((96, 96), 9112, 5000.0, (2, 0, 1, 1), &["Heart"]) }, + // ---- Act III: the traitor + Spec { caves: &[((0.5, 0.15), None, 2500), ((0.5, 0.85), None, 2500)], raids: &[(120.0, true, "Spider", 3, 4, 0)], ..s((96, 96), 9113, 5000.0, (0, 1, 1, 2), &["Loot(5000)"]) }, + Spec { raids: &[(90.0, true, "Revenant", 2, 5, 1), (90.0, true, "Revenant", 2, 5, 2)], ..s((104, 96), 9114, 5500.0, (2, 0, 1, 2), &["KillAll"]) }, + Spec { caves: &[((0.35, 0.2), Some((Chainbreaker, 6)), 1000)], branch: Some(("Loot(5000)", 17)), ..s((104, 96), 9115, 5500.0, (1, 1, 1, 2), &["Freed(1)", "Towers"]) }, + Spec { islands: &[((0.5, 0.5), true, true, 3000)], raids: &[(150.0, true, "Mage", 2, 5, 0)], ..s((104, 104), 9116, 6000.0, (0, 2, 1, 2), &["Springs(1)", "KillAll"]) }, + Spec { raids: &[(60.0, true, "Ogre", 2, 6, 0), (120.0, true, "Wyrm", 1, 5, 1)], ..s((104, 104), 9117, 6000.0, (1, 1, 1, 2), &["KillAll"]) }, + s((112, 104), 9118, 6500.0, (2, 1, 3, 2), &["Towers"]), + Spec { boss: Some(("Paladin", 10, "marshal", 0)), raids: &[(120.0, true, "Knight", 3, 6, 1), (120.0, true, "Archer", 3, 6, 2)], ..s((112, 112), 9119, 7000.0, (2, 0, 2, 2), &[]) }, + Spec { boss: Some(("Wyrm", 10, "mossgrim", 0)), raids: &[(90.0, true, "Ogre", 3, 7, 1), (90.0, true, "Gargoyle", 2, 7, 2)], ..s((112, 112), 9120, 8000.0, (1, 2, 2, 2), &["KillAll"]) }, + ] +} + +fn n(x: f64) -> Val { + Val::num(x) +} + +fn key(i: usize, part: &str) -> String { + format!("coop.m{:02}.{}", i + 1, part) +} + +fn say(k: String) -> Block { + Block::Message { text: tr(&k) } +} + +// the co-op campaign, built in the player's language +pub fn coop_campaign() -> CampaignDoc { + let mut doc = CampaignDoc { name: tr("coop.name"), author: "DungeonCraft".into(), language: crate::i18n::language(), ..CampaignDoc::default() }; + let saved = (crate::map::mw(), crate::map::mh()); + let specs = specs(); + for (i, sp) in specs.iter().enumerate() { + let mut m = MissionDoc { + key: format!("c{:02}", i + 1), + title: tr(&key(i, "title")), + story: tr(&key(i, "story")).replace("\\n", "\n"), + size: sp.size, + seed: sp.seed, + gold: sp.gold, + portals: 2, + goals: sp.goals.iter().map(|g| g.to_string()).collect(), + waves: sp.waves.0, + wave_limit: sp.waves.1, + first_wave: if sp.waves.0 { sp.waves.2 } else { 300.0 }, + gen: GenDoc { rivers: sp.gen.0, lava: sp.gen.1, forts: sp.gen.2, rivals: 0, difficulty: sp.gen.3 }, + next: (i + 1 < specs.len()).then(|| format!("c{:02}", i + 2)), + pos: (40.0 + (i % 5) as f32 * 220.0, 40.0 + (i / 5) as f32 * 140.0), + ..MissionDoc::default() + }; + if let Some((goal, to)) = sp.branch { + m.branches.push(BranchDoc { id: "detour".into(), goal: goal.into(), title: tr(&key(i, "branch")), next: format!("c{:02}", to), priority: Some(1) }); + } + // the map: generated, then fixed with the islands, caves and the places where enemies appear + let mut g = crate::campaign::start_editor_mission(&m, i, sp.seed); + for (k, &(at, lava, spring, treasure)) in sp.islands.iter().enumerate() { + crate::campaign::editor_island(&mut g, at, lava, spring, treasure, sp.seed + k as u32); + } + for &(at, chained, treasure) in sp.caves.iter() { + crate::campaign::editor_cave(&mut g, at, chained, treasure); + } + let (w, h) = (g.map.w as i32, g.map.h as i32); + for (name, (fx, fy)) in ZONES { + let (cx, cy) = (((fx * w as f32) as i32).clamp(4, w - 5), ((fy * h as f32) as i32).clamp(4, h - 5)); + for y in cy - 2..=cy + 2 { + for x in cx - 2..=cx + 2 { + let t = &mut g.map.t[crate::map::idx(x, y)]; + if !matches!(t.kind, crate::map::Kind::Core | crate::map::Kind::Portal | crate::map::Kind::Gate | crate::map::Kind::Tower) { + t.kind = crate::map::Kind::Floor; + t.owner = crate::map::Owner::Hero; + } + } + } + m.zones.push(ZoneDoc { name: name.into(), shape: "rect".into(), at: None, radius: None, tiles: None, from: Some(((cx - 2) as f32, (cy - 2) as f32)), to: Some(((cx + 2) as f32, (cy + 2) as f32)), width: None }); + } + g.recount(); + g.place_all_torches(); + g.init_fog(); + m.map = MapDoc::Fixed { game: serde_json::to_string(&g).unwrap_or_default() }; + + // the scripts: Crookfinger's start, the objective, story lines, raids and the boss + let sc = &mut m.scripts; + sc.start = vec![say(key(i, "start")), Block::Objective { text: tr(&key(i, "goal")) }]; + sc.triggers.push(TriggerDoc { name: "story1".into(), kind: TriggerKind::Timer { secs: 90.0, every: false }, once: true, enabled: true, script: vec![Block::Hint { text: tr(&key(i, "t1")) }] }); + sc.triggers.push(TriggerDoc { name: "story2".into(), kind: TriggerKind::Timer { secs: 360.0, every: false }, once: true, enabled: true, script: vec![Block::Hint { text: tr(&key(i, "t2")) }] }); + for (k, &(secs, every, kind, count, level, zone)) in sp.raids.iter().enumerate() { + sc.triggers.push(TriggerDoc { + name: format!("raid{}", k + 1), + kind: TriggerKind::Timer { secs, every }, + once: !every, + enabled: true, + script: vec![ + Block::Message { text: tr("coop.raid") }, + Block::Spawn { side: 1, kind: kind.into(), count: n(count as f64), level: n(level as f64), place: Place::Zone(ZONES[zone].0.into()), name: String::new() }, + ], + }); + } + if let Some((kind, level, name, zone)) = sp.boss { + sc.start.push(Block::Spawn { side: 1, kind: kind.into(), count: n(1.0), level: n(level as f64), place: Place::Zone(ZONES[zone].0.into()), name: name.into() }); + // the boss falls: the mission is won once the goals (if any) are done too + let win = if sp.goals.is_empty() { vec![say(key(i, "boss")), Block::Win] } else { vec![say(key(i, "boss")), Block::SetVar { name: "boss".into(), value: n(1.0) }] }; + sc.triggers.push(TriggerDoc { name: "boss".into(), kind: TriggerKind::UnitDies { side: 1, kind: String::new(), name: name.into() }, once: true, enabled: true, script: win }); + if !sp.goals.is_empty() { + // KillAll covers the boss as well: nothing more to do + sc.victory = vec![]; + } + } + doc.missions.push(m); + } + crate::map::set_dims(saved.0, saved.1); + doc +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_coop_campaign_is_complete_and_valid() { + let doc = coop_campaign(); + assert_eq!(doc.missions.len(), 20); + assert!(doc.validate().is_empty(), "{:?}", doc.validate()); + for m in &doc.missions { + assert!(m.size.0 >= 72 && m.size.1 >= 72, "{} fits the co-op master areas", m.key); + assert!(!m.goals.is_empty() || m.scripts.triggers.iter().any(|t| t.name == "boss"), "{} can be won", m.key); + assert!(matches!(m.map, MapDoc::Fixed { .. })); + } + // every mission starts and its scripts run + for (i, m) in doc.missions.iter().enumerate().step_by(5) { + let mut g = crate::campaign::start_editor_mission(m, i, 3); + for _ in 0..(20 * 5) { + g.update(0.05); + } + assert!(g.script.as_ref().unwrap().error.is_none()); + assert_eq!(g.state, 0, "{} is not decided at once", m.key); + } + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs index 7f786c4..08c9f69 100644 --- a/src/editor/mod.rs +++ b/src/editor/mod.rs @@ -6,6 +6,7 @@ // handles what it returns (test play, files, the savegame service). pub mod blocks_ui; +pub mod coop_campaign; pub mod map; pub mod templates; pub mod widgets; diff --git a/src/main.rs b/src/main.rs index beb5e71..dc975b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -336,8 +336,9 @@ fn account_action(a: MenuAction, cloud: &mut Cloud, menu: &mut Menu, app: &mut A } } MenuAction::MatchCoopEditorCreate(mission) => { - if menu.campaign_doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) { - app.client.create_coop_doc(&menu.campaign_doc, mission, &[0, 2, 3, 4], app.settings.difficulty); + let doc = menu.coop_doc().clone(); + if doc.missions.get(mission).map_or(false, campaign::is_editor_coop_mission) { + app.client.create_coop_doc(&doc, mission, &[0, 2, 3, 4], app.settings.difficulty); } else { menu.error = Some(i18n::tr("net.coop_editor_none")); } diff --git a/src/menu.rs b/src/menu.rs index 56f3e81..b010c03 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -421,7 +421,9 @@ pub struct Menu { pub error: Option, // message on the login and account pages pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page pub coop_mission: usize, // co-op campaign mission selected on the Match page - pub coop_editor: bool, // the Match page offers the editor campaign instead of the built-in one + pub coop_editor: bool, // the Match page offers a campaign document instead of the built-in missions + pub coop_league: bool, // ... and that document is the co-op campaign "League against the Lightwatch" + league_doc: Option, // the co-op campaign, built the first time it is chosen pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op pub in_match: bool, // a match is being played; the pause menu changes its shape pub gold_allies: Vec, // allies of the local master that can get gold (pause menu) @@ -463,6 +465,8 @@ impl Menu { match_ui: MatchUi::default(), coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), coop_editor: false, + coop_league: false, + league_doc: None, coop_editor_mission: 0, in_match: false, gold_allies: Vec::new(), @@ -764,7 +768,7 @@ impl Menu { Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => { let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 }; if self.coop_editor { - self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, dir); + self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), self.coop_editor_mission, dir); } else { self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir); } @@ -772,8 +776,25 @@ impl Menu { MenuAction::Consumed } Some(Btn::MatchCoopSource) => { - self.coop_editor = !self.coop_editor && !self.campaign_doc.missions.is_empty(); - self.coop_editor_mission = crate::campaign::next_editor_coop_mission(&self.campaign_doc, self.coop_editor_mission, 0); + // built-in missions -> the league -> the editor's campaign (if it has missions) -> ... + match (self.coop_editor, self.coop_league) { + (false, _) => { + if self.league_doc.is_none() { + self.league_doc = Some(crate::editor::coop_campaign::coop_campaign()); + } + self.coop_editor = true; + self.coop_league = true; + } + (true, true) if !self.campaign_doc.missions.is_empty() => self.coop_league = false, + _ => { + self.coop_editor = false; + self.coop_league = false; + } + } + self.coop_editor_mission = 0; + if self.coop_editor { + self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), 0, 0); + } self.confirm = None; MenuAction::Consumed } @@ -1717,6 +1738,14 @@ impl Menu { self.column(gfx, x + (cols as f32 * CW() - w) * 0.5, y + (rows - 4) as f32 * CH(), w, &[(Btn::Resume, label)]); } + // the campaign document the co-op create offers: the league or the editor's campaign + pub fn coop_doc(&self) -> &CampaignDoc { + match (&self.league_doc, self.coop_league) { + (Some(d), true) => d, + _ => &self.campaign_doc, + } + } + pub fn scrollable(&self) -> bool { matches!( self.page, @@ -1920,15 +1949,16 @@ impl Menu { } // the editor campaign can only be offered while it has missions (it may have been replaced) - let editor_coop = self.coop_editor && !self.campaign_doc.missions.is_empty(); - let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&self.campaign_doc) } else { Vec::new() }; + let doc = self.coop_doc().clone(); + let editor_coop = self.coop_editor && !doc.missions.is_empty(); + let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&doc) } else { Vec::new() }; if editor_coop && !editor_list.contains(&self.coop_editor_mission) { self.coop_editor_mission = editor_list.first().copied().unwrap_or(0); } if !ui.started && !ui.room_open() && editor_coop { - let line = match self.campaign_doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) { + let line = match doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) { Some(m) => { - let name = if self.campaign_doc.name.trim().is_empty() { "?".to_string() } else { self.campaign_doc.name.clone() }; + let name = if doc.name.trim().is_empty() { "?".to_string() } else { doc.name.clone() }; let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() }; trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title]) } @@ -1963,9 +1993,13 @@ impl Menu { items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); items.push((Btn::MatchCoopNext, ">".to_string())); } - if !self.campaign_doc.missions.is_empty() { - items.push((Btn::MatchCoopSource, tr(if editor_coop { "net.coop_builtin" } else { "net.coop_editor" }))); - } + // the button names what comes next + let next = match (editor_coop, self.coop_league) { + (false, _) => "net.coop_league", + (true, true) if !self.campaign_doc.missions.is_empty() => "net.coop_editor", + _ => "net.coop_builtin", + }; + items.push((Btn::MatchCoopSource, tr(next))); } if ui.room_open() { items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));