// 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); } } }