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:
1 parent
405f8f60b2
commit
4e49c2fdc9
9 files changed
+240
-13
No files matched your search
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user