Co-op campaign "League against the Lightwatch": 20 missions, selectable on the Match page

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-05 20:07:04 +01:00
1 parent 405f8f60b2
commit 4e49c2fdc9
9 files changed
+240 -13

No files matched your search

+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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
+1
View File
@@ -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é
+1
View File
@@ -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 = Повторять, пока не
+186
View File
@@ -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<Spec> {
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);
}
}
}
+1
View File
@@ -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;
+3 -2
View File
@@ -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"));
}
+45 -11
View File
@@ -421,7 +421,9 @@ pub struct Menu {
pub error: Option<String>, // 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<CampaignDoc>, // 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<u8>, // 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" })));