diff --git a/lang/de.lang b/lang/de.lang index 61b36ab..743807c 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -423,3 +423,10 @@ msg.bridge_torn = Die Ritter haben eine deiner Brücken eingerissen! job.tear_bridge = Reißt eine Brücke ein job.patrol = Auf Patrouille job.guard = Hält Wache + +# campaign +msg.mission_start = Mission: {0} +msg.mission_won = Mission erfüllt! +msg.time_up = Die Zeit ist um - die Ritter haben ihre Schätze in Sicherheit gebracht. +hud.time_left = Zeit +msg.tool_locked = Das steht in dieser Mission noch nicht zur Verfügung. diff --git a/lang/en.lang b/lang/en.lang index 191d577..539b040 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -424,3 +424,10 @@ msg.bridge_torn = The knights have torn down one of your bridges! job.tear_bridge = Tearing down a bridge job.patrol = On patrol job.guard = On guard + +# campaign +msg.mission_start = Mission: {0} +msg.mission_won = Mission accomplished! +msg.time_up = Time is up - the knights have secured their treasures. +hud.time_left = Time +msg.tool_locked = This is not available yet in this mission. diff --git a/src/campaign.rs b/src/campaign.rs new file mode 100644 index 0000000..6ffa626 --- /dev/null +++ b/src/campaign.rs @@ -0,0 +1,442 @@ +// Campaign "The Broken Core": 20 missions. Each mission is a set of key data (size, rivers, +// fortresses, goals, available tools); the map generator builds the map from it. +// Texts (title, story, hints) come from the language files: camp..title / .story + +use crate::fortress::FortSpec; +use crate::game::*; +use crate::map::*; +use serde::{Deserialize, Serialize}; + +// From which mission (0-based) on each tool is available (same order as ui::TOOLS): +// dig, hand, treasury, lair, farm, training, library, sell, flag, lightning, heal, imp, +// torture, temple, arrow tower, cannon tower, magic tower, bridge +pub const TOOL_UNLOCK: [usize; 18] = [0, 0, 0, 0, 0, 2, 4, 0, 0, 3, 4, 0, 6, 9, 3, 5, 9, 1]; + +pub struct FortDef { + pub at: (f32, f32), // centre as a fraction of the map size + pub size: (i32, i32), + pub doors: &'static [usize], + pub towers: &'static [TowerKind], + pub tower_level: u32, + pub guards: u32, // number of guards + pub patrols: u32, // patrol groups of two + pub level: u32, // level of the knights + pub boss: Option<(EK, u32)>, + pub vaults: u32, + pub vault_value: i32, + pub shard: bool, + pub heart: bool, +} + +pub struct MissionDef { + pub key: &'static str, + pub size: (usize, usize), + pub seed: u32, + pub gold: f32, + pub portals: usize, + pub treasures: usize, + pub goals: Vec, + pub waves: bool, // hero waves (from the gate) + pub wave_limit: u32, // 0 = endless + pub first_wave: f32, // seconds until the first wave + pub gate: bool, // free-game hero gate at the far side (wave missions) + pub gate_defenders: bool, + pub time_limit: f32, + pub rivers: Vec, + pub forts: Vec, + pub gift: Vec<(EK, u32)>, // creatures that join at the start +} + +// carried over from the last won mission: the strongest creatures and part of the gold +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Carry { + pub creatures: Vec<(EK, u32, f32)>, // kind, level, experience + pub gold: f32, +} + +pub const CARRY_CREATURES: usize = 5; +pub const CARRY_GOLD: f32 = 0.2; + +// progress of the campaign (stored in the browser) +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Progress { + pub done: Vec, + pub carry: Vec>, // carry[i] = taken from mission i into mission i + 1 +} + +impl Progress { + pub fn unlocked(&self, i: usize) -> bool { + i == 0 || self.done.get(i - 1).copied().unwrap_or(false) || self.done.get(i).copied().unwrap_or(false) + } + pub fn is_done(&self, i: usize) -> bool { + self.done.get(i).copied().unwrap_or(false) + } + pub fn set_done(&mut self, i: usize, carry: Carry) { + let n = missions().len(); + self.done.resize(n, false); + self.carry.resize(n, None); + self.done[i] = true; + self.carry[i] = Some(carry); + } + // what the player brings into mission i + pub fn carry_into(&self, i: usize) -> Option<&Carry> { + if i == 0 { + return None; + } + self.carry.get(i - 1).and_then(|c| c.as_ref()) + } +} + +use EK::*; +use TowerKind::{Arrow, Cannon, Magic}; + +fn river(lava: bool, vertical: bool, pos: f32, width: i32, bridges: u32) -> River { + River { lava, vertical, pos, width, bridges } +} + +#[allow(clippy::too_many_arguments)] +fn fort(at: (f32, f32), size: (i32, i32), doors: &'static [usize], towers: &'static [TowerKind], tower_level: u32, guards: u32, patrols: u32, level: u32, vaults: u32, vault_value: i32) -> FortDef { + FortDef { at, size, doors, towers, tower_level, guards, patrols, level, boss: None, vaults, vault_value, shard: false, heart: false } +} + +impl FortDef { + fn shard(mut self) -> FortDef { + self.shard = true; + self + } + fn heart(mut self) -> FortDef { + self.heart = true; + self + } + fn boss(mut self, k: EK, level: u32) -> FortDef { + self.boss = Some((k, level)); + self + } +} + +fn base(key: &'static str, size: (usize, usize), seed: u32, goals: Vec) -> MissionDef { + MissionDef { + key, + size, + seed, + gold: 3000.0, + portals: 1, + treasures: 0, + goals, + waves: false, + wave_limit: 0, + first_wave: 300.0, + gate: false, + gate_defenders: false, + time_limit: 0.0, + rivers: Vec::new(), + forts: Vec::new(), + gift: Vec::new(), + } +} + +pub fn missions() -> Vec { + let mut v = Vec::new(); + // ---- Act I: awakening + v.push(MissionDef { gold: 2500.0, forts: vec![fort((0.74, 0.5), (15, 13), &[3], &[], 1, 4, 0, 1, 1, 1200).shard()], ..base("m01", (64, 64), 101, vec![Goal::Shards]) }); + v.push(MissionDef { + gold: 2500.0, + rivers: vec![river(false, true, 0.47, 3, 0)], + forts: vec![fort((0.8, 0.5), (15, 15), &[3], &[Arrow], 1, 4, 1, 1, 1, 1500).shard()], + ..base("m02", (80, 64), 202, vec![Goal::Shards]) + }); + v.push(MissionDef { forts: vec![fort((0.74, 0.5), (19, 17), &[3, 0], &[Arrow, Arrow], 1, 6, 2, 1, 1, 1500)], ..base("m03", (80, 80), 303, vec![Goal::KillAll]) }); + v.push(MissionDef { + portals: 2, + treasures: 1, + waves: true, + wave_limit: 4, + first_wave: 270.0, + gate: true, + ..base("m04", (80, 64), 404, vec![Goal::Survive(4)]) + }); + v.push(MissionDef { + rivers: vec![river(false, true, 0.42, 3, 1)], + forts: vec![fort((0.76, 0.5), (21, 21), &[3, 2], &[Arrow, Arrow, Arrow], 1, 6, 2, 2, 4, 2000)], + ..base("m05", (96, 80), 505, vec![Goal::Loot(6000)]) + }); + // ---- Act II: the rivers + v.push(MissionDef { + rivers: vec![river(false, true, 0.52, 5, 1)], + forts: vec![fort((0.82, 0.5), (21, 19), &[3], &[Arrow, Cannon, Arrow, Cannon], 1, 8, 2, 2, 2, 1500).heart()], + ..base("m06", (96, 80), 606, vec![Goal::Heart]) + }); + v.push(MissionDef { + rivers: vec![river(true, true, 0.45, 3, 0)], + forts: vec![fort((0.79, 0.45), (19, 19), &[3], &[Arrow, Cannon], 2, 7, 2, 2, 2, 1800).shard()], + ..base("m07", (96, 96), 707, vec![Goal::Shards]) + }); + v.push(MissionDef { + time_limit: 900.0, + gift: vec![(Ogre, 3), (Kobold, 3), (Kobold, 3), (Spider, 2)], + forts: vec![ + fort((0.6, 0.27), (17, 15), &[3, 2], &[Arrow], 1, 4, 1, 2, 3, 1500), + fort((0.78, 0.72), (17, 15), &[3, 0], &[Arrow, Cannon], 1, 4, 1, 2, 3, 1500), + ], + ..base("m08", (96, 80), 808, vec![Goal::Loot(8000)]) + }); + v.push(MissionDef { + rivers: vec![river(false, false, 0.5, 3, 2)], + forts: vec![ + fort((0.76, 0.24), (19, 17), &[3, 2], &[Arrow, Cannon], 2, 7, 2, 2, 1, 1500).shard(), + fort((0.76, 0.76), (19, 17), &[3, 0], &[Arrow, Cannon], 2, 7, 2, 2, 1, 1500).shard(), + ], + ..base("m09", (112, 96), 909, vec![Goal::Shards]) + }); + v.push(MissionDef { + gold: 4000.0, + portals: 2, + treasures: 1, + waves: true, + wave_limit: 6, + first_wave: 240.0, + gate: true, + ..base("m10", (96, 80), 1010, vec![Goal::Survive(6)]) + }); + v.push(MissionDef { + forts: vec![fort((0.72, 0.5), (25, 25), &[3, 0, 2], &[Arrow, Cannon, Magic, Arrow, Cannon], 2, 8, 4, 3, 2, 2000)], + ..base("m11", (112, 96), 1111, vec![Goal::KillAll]) + }); + v.push(MissionDef { + rivers: vec![river(false, true, 0.4, 3, 1), river(true, true, 0.62, 3, 1)], + forts: vec![fort((0.85, 0.5), (21, 25), &[3], &[Arrow, Cannon, Magic, Arrow], 2, 10, 2, 3, 2, 2000).heart().shard()], + ..base("m12", (112, 96), 1212, vec![Goal::Heart, Goal::Shards]) + }); + // ---- Act III: the citadel + v.push(MissionDef { + rivers: vec![river(false, false, 0.22, 3, 1)], + forts: vec![fort((0.75, 0.55), (27, 23), &[3, 0], &[Arrow, Cannon, Magic, Arrow, Cannon, Magic], 2, 12, 3, 3, 4, 3500)], + ..base("m13", (112, 96), 1313, vec![Goal::Loot(13000)]) + }); + v.push(MissionDef { + forts: vec![fort((0.7, 0.5), (25, 25), &[3, 1], &[Arrow, Cannon, Magic, Arrow, Cannon, Magic, Arrow, Cannon], 3, 8, 2, 3, 2, 2000)], + ..base("m14", (112, 112), 1414, vec![Goal::Towers]) + }); + v.push(MissionDef { + portals: 0, + gold: 5000.0, + gift: vec![(Ogre, 5), (Ogre, 4), (Mage, 5), (Mage, 4), (Spider, 4), (Kobold, 4), (Kobold, 4), (Mage, 3)], + rivers: vec![river(false, true, 0.42, 4, 1)], + forts: vec![fort((0.74, 0.5), (21, 21), &[3, 0, 2], &[Arrow, Cannon, Magic], 2, 10, 2, 3, 2, 2000)], + ..base("m15", (112, 96), 1515, vec![Goal::KillAll]) + }); + v.push(MissionDef { + waves: true, + first_wave: 360.0, + gate_defenders: true, + rivers: vec![river(true, true, 0.55, 3, 2)], + forts: vec![fort((0.82, 0.5), (23, 23), &[3], &[Arrow, Cannon, Magic, Arrow], 2, 10, 2, 3, 2, 2000).heart()], + ..base("m16", (112, 96), 1616, vec![Goal::Heart]) + }); + v.push(MissionDef { + portals: 2, + rivers: vec![river(false, true, 0.36, 3, 2), river(false, true, 0.56, 3, 2), river(false, false, 0.5, 3, 2)], + forts: vec![ + fort((0.8, 0.24), (19, 17), &[3, 2], &[Arrow, Cannon, Magic], 2, 8, 2, 3, 2, 2000).shard(), + fort((0.8, 0.76), (19, 17), &[3, 0], &[Arrow, Cannon, Magic], 2, 8, 2, 3, 2, 2000).shard(), + ], + ..base("m17", (128, 112), 1717, vec![Goal::Shards, Goal::Loot(6000)]) + }); + v.push(MissionDef { + portals: 2, + rivers: vec![river(true, true, 0.5, 4, 1)], + forts: vec![fort((0.82, 0.5), (25, 25), &[3], &[Arrow, Cannon, Magic, Arrow, Cannon, Magic], 3, 14, 3, 4, 3, 2500).heart().boss(Paladin, 5)], + ..base("m18", (128, 112), 1818, vec![Goal::Heart]) + }); + v.push(MissionDef { + portals: 2, + rivers: vec![river(false, true, 0.42, 3, 2), river(true, false, 0.5, 3, 2)], + forts: vec![ + fort((0.62, 0.22), (19, 17), &[3, 2], &[Arrow, Cannon, Magic], 3, 8, 2, 4, 1, 2500).shard(), + fort((0.86, 0.5), (19, 19), &[3], &[Arrow, Cannon, Magic, Arrow], 3, 10, 2, 4, 1, 2500).shard(), + fort((0.62, 0.8), (19, 17), &[3, 0], &[Arrow, Cannon, Magic], 3, 8, 2, 4, 1, 2500).shard(), + ], + ..base("m19", (144, 128), 1919, vec![Goal::Shards]) + }); + v.push(MissionDef { + portals: 3, + gold: 5000.0, + waves: true, + first_wave: 420.0, + gate_defenders: true, + rivers: vec![river(false, true, 0.6, 6, 2)], + forts: vec![fort((0.83, 0.5), (29, 29), &[3], &[Arrow, Cannon, Magic, Arrow, Cannon, Magic, Arrow, Magic], 3, 16, 4, 5, 4, 3000) + .heart() + .shard() + .boss(Paladin, 10)], + ..base("m20", (144, 144), 2020, vec![Goal::Heart, Goal::Shards]) + }); + v +} + +// the knights of a fortress: mostly knights and archers, some priests and dwarves +fn knights(n: u32, level: u32) -> Vec<(EK, u32)> { + let mix = [Knight, Archer, Knight, Priest, Dwarf, Archer, Knight]; + (0..n).map(|k| (mix[k as usize % mix.len()], level)).collect() +} + +impl FortDef { + fn spec(&self, w: usize, h: usize) -> FortSpec { + let (fw, fh) = self.size; + let x = ((self.at.0 * w as f32) as i32 - fw / 2).clamp(3, w as i32 - fw - 3); + let y = ((self.at.1 * h as f32) as i32 - fh / 2).clamp(3, h as i32 - fh - 3); + let mut guards = knights(self.guards, self.level); + if let Some(b) = self.boss { + guards.insert(0, b); + } + let patrols = (0..self.patrols).map(|k| vec![(if k % 2 == 0 { Knight } else { Archer }, self.level), (Knight, self.level)]).collect(); + FortSpec { + x, + y, + w: fw, + h: fh, + doors: self.doors.to_vec(), + towers: self.towers.to_vec(), + tower_level: self.tower_level, + guards, + patrols, + vaults: self.vaults, + vault_value: self.vault_value, + shard: self.shard, + heart: self.heart, + } + } +} + +// Builds the game of mission i (carry: what the last mission handed over) +pub fn start_mission(i: usize, carry: Option<&Carry>) -> Game { + let all = missions(); + let d = &all[i.min(all.len() - 1)]; + let (w, h) = d.size; + let opts = GenOpts { portals: Some(d.portals), treasures: Some(d.treasures), gate: d.gate, core_y: None }; + let map = Map::generate_with(d.seed, w, h, &opts); + let mut g = Game::from_map(map, d.seed); + g.gold = d.gold; + for (k, r) in d.rivers.iter().enumerate() { + let seed = d.seed.wrapping_mul(31).wrapping_add(k as u32 * 977); + g.map.carve_river(r, seed); + } + let mut shards = 0; + for f in &d.forts { + g.build_fortress(&f.spec(w, h)); + shards += f.shard as u32; + } + let heart = d.gate || d.forts.iter().any(|f| f.heart); + if !heart { + g.gate_hp = 0.0; + } + g.guard_treasures(); + g.place_all_torches(); + g.mission = Some(MissionState { + index: i, + goals: d.goals.clone(), + tools: TOOL_UNLOCK.iter().map(|&u| u <= i).collect(), + waves: d.waves, + wave_limit: d.wave_limit, + gate_defenders: d.gate_defenders, + heart, + time_limit: d.time_limit, + shards, + }); + g.wave_timer = d.first_wave; + // new creatures stand around the core + let (cx, cy) = g.map.core; + let mut joiners: Vec<(EK, u32, f32)> = d.gift.iter().map(|&(k, l)| (k, l, 0.0)).collect(); + if let Some(c) = carry { + joiners.extend(c.creatures.iter().copied()); + g.gold += c.gold; + } + for (k, &(kind, level, xp)) in joiners.iter().enumerate() { + let a = k as f32 * 0.9; + let (x, y) = (cx as f32 + 1.5 + a.cos() * 3.2, cy as f32 + 1.5 + a.sin() * 3.2); + let id = g.spawn(kind, false, x, y, level.max(1)); + if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) { + e.xp = xp; + e.happy = 1.0; + } + } + g.msg("msg.mission_start", vec![format!("@camp.{}.title", d.key)]); + g +} + +// the strongest creatures and a fifth of the gold go along into the next mission +pub fn carry_from(g: &Game) -> Carry { + let mut c: Vec<&Ent> = g.ents.iter().filter(|e| e.alive && e.creature()).collect(); + let power = |e: &Ent| e.max_hp * e.atk() / st(e.kind).interval; + c.sort_by(|a, b| power(b).partial_cmp(&power(a)).unwrap_or(std::cmp::Ordering::Equal)); + Carry { creatures: c.iter().take(CARRY_CREATURES).map(|e| (e.kind, e.level, e.xp)).collect(), gold: (g.gold * CARRY_GOLD).floor() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_missions_build() { + for (i, d) in missions().iter().enumerate() { + let g = start_mission(i, None); + let m = g.mission.as_ref().unwrap(); + let heroes = g.ents.iter().filter(|e| e.hero).count(); + println!("{} {}x{}: {} knights, {} knight towers, {} shards, goals {:?}", d.key, d.size.0, d.size.1, heroes, g.towers.iter().filter(|t| t.hero).count(), m.shards, m.goals); + // every patrol group got its round + let patrols = g.ents.iter().filter(|e| !e.patrol.is_empty()).count() as u32; + assert_eq!(patrols, d.forts.iter().map(|f| f.patrols * 2).sum::(), "{}", d.key); + // the core area is intact + let (cx, cy) = g.map.core; + assert_eq!(g.map.t[idx(cx + 1, cy + 1)].kind, Kind::Core, "{}", d.key); + // goals that need something on the map have it + for goal in &m.goals { + match goal { + Goal::Shards => assert!(m.shards > 0, "{}", d.key), + Goal::Heart => assert!(m.heart && g.gate_hp > 0.0, "{}", d.key), + Goal::Towers => assert!(g.towers.iter().any(|t| t.hero), "{}", d.key), + Goal::KillAll => assert!(heroes > 0, "{}", d.key), + Goal::Loot(n) => { + let vaults: i32 = g.map.t.iter().filter(|t| t.kind == Kind::Treasury && t.owner == Owner::Hero).map(|t| t.gold).sum(); + assert!(vaults as u32 >= *n, "{}: vaults hold {}", d.key, vaults); + } + Goal::Survive(_) => assert!(m.waves, "{}", d.key), + } + } + // wave missions: the heroes can reach the core + if m.waves { + let (gx, gy) = g.map.gate; + assert!(g.map.hero_path_to_core(idx(gx + 1, gy + 1)).is_some(), "{}: no hero path", d.key); + } + // every shard and the heart can be reached by digging and bridging from the core + let reach = g.map.search(idx(cx + 1, cy - 2).max(0), |i| { + let t = &g.map.t[i]; + if t.kind == Kind::Rock || t.kind == Kind::Core || t.kind == Kind::Tower { + None + } else { + Some(1) + } + }, |i| g.map.t[i].kind == Kind::Shard || g.map.t[i].kind == Kind::Gate || g.map.t[i].owner == Owner::Hero, true); + assert!(reach.is_some() || heroes == 0, "{}: fortress unreachable", d.key); + } + } + + #[test] + fn carry_over_takes_the_strongest() { + let mut g = start_mission(0, None); + for (k, l) in [(Kobold, 1), (Ogre, 5), (Spider, 2), (Mage, 4), (Kobold, 2), (Ogre, 2), (Spider, 1)] { + g.spawn(k, false, 20.0, 20.0, l); + } + g.gold = 5000.0; + let c = carry_from(&g); + assert_eq!(c.creatures.len(), CARRY_CREATURES); + assert_eq!(c.creatures[0].0, Ogre); + assert_eq!(c.gold, 1000.0); + let g2 = start_mission(1, Some(&c)); + assert_eq!(g2.ents.iter().filter(|e| e.creature()).count(), CARRY_CREATURES); + assert_eq!(g2.gold, 2500.0 + 1000.0); + // the tools of mission 2: bridges yes, training no + assert!(g2.tool_allowed(17)); + assert!(!g2.tool_allowed(5)); + } +} diff --git a/src/fortress.rs b/src/fortress.rs index de7082f..a1bbe37 100644 --- a/src/fortress.rs +++ b/src/fortress.rs @@ -157,7 +157,8 @@ impl Game { e.home = Some((hx, hy)); } // patrols: round through the courtyard along the inside of the walls - let route = vec![(x0 + 2, cy), (cx, y0 + 2), (x1 - 2, cy), (cx, y1 - 2)]; + // (next to the tower spots in the middle of the walls) + let route = vec![(x0 + 3, cy + 2), (cx + 2, y0 + 3), (x1 - 3, cy - 2), (cx - 2, y1 - 3)]; let route: Vec<(i32, i32)> = route.into_iter().filter(|&(x, y)| self.map.walk(x, y)).collect(); for (g, group) in f.patrols.iter().enumerate() { if route.is_empty() { diff --git a/src/game.rs b/src/game.rs index 491d624..898088f 100644 --- a/src/game.rs +++ b/src/game.rs @@ -272,6 +272,30 @@ impl Ent { } } +// Goals of a campaign mission (all must be reached) +#[derive(Clone, Copy, PartialEq, Debug, Serialize, Deserialize)] +pub enum Goal { + Shards, // take back all shards of the soul core + Heart, // destroy the gate of the order + KillAll, // defeat every knight (prisoners count as defeated) + Loot(u32), // take treasure worth this much from knight vaults + Towers, // raze all knight towers + Survive(u32), // beat this many waves +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MissionState { + pub index: usize, // number of the mission in the campaign + pub goals: Vec, + pub tools: Vec, // allowed tools (ui::TOOLS order) + pub waves: bool, + pub wave_limit: u32, // 0 = endless + pub gate_defenders: bool, + pub heart: bool, // the map has a gate of the order + pub time_limit: f32, // seconds, 0 = none + pub shards: u32, // shards on the map +} + #[derive(Clone, Copy)] pub struct Fx { pub kind: u8, // 0 projectile, 1 lightning, 2 hit, 3 smoke, 4 heal, 5 gold, 6 dust, 7 level up, 8 claim @@ -340,7 +364,9 @@ pub struct Game { #[serde(default)] pub towers_razed: u32, // knight towers destroyed #[serde(skip)] - pub contested: Vec, // tiles next to fighting heroes: knight ground there cannot be claimed + pub contested: Vec, + #[serde(default)] + pub mission: Option, // campaign mission (None: free game) // tiles next to fighting heroes: knight ground there cannot be claimed #[serde(skip)] pub hints: bool, // show hint messages (setting) #[serde(skip, default = "hint_times")] @@ -403,7 +429,38 @@ fn sgn(v: f32) -> f32 { impl Game { pub fn new(seed: u32, w: usize, h: usize) -> Game { - let map = Map::generate(seed, w, h); + let mut g = Game::from_map(Map::generate(seed, w, h), seed); + // heroes take longer to reach the core on big maps: longer grace period + let (cx, cy) = g.map.core; + let (gx, gy) = g.map.gate; + let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32; + g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0); + g.guard_treasures(); + g.msg("msg.welcome1", vec![]); + g.msg("msg.welcome2", vec![]); + g.msg("msg.welcome3", vec![]); + g.msg("msg.welcome4", vec![]); + g + } + + // treasure caves are guarded by heroes + pub fn guard_treasures(&mut self) { + for k in 0..self.map.treasures.len() { + let (tx, ty) = self.map.treasures[k]; + let squad = [EK::Knight, EK::Archer, EK::Dwarf, if k % 2 == 0 { EK::Priest } else { EK::Knight }]; + for (j, &kind) in squad.iter().enumerate() { + let x = tx as f32 + 0.5 + (j as f32 - 1.5) * 0.6; + let id = self.spawn(kind, true, x, ty as f32 + 0.5, 2); + if let Some(e) = self.ents.iter_mut().find(|e| e.id == id) { + e.guard = true; + e.home = Some((tx as f32 + 0.5, ty as f32 + 0.5)); + } + } + } + } + + // game state around a generated map: treasury ring around the core, four imps, torches + pub fn from_map(map: Map, seed: u32) -> Game { let mut g = Game { map, ents: Vec::new(), @@ -445,6 +502,7 @@ impl Game { shards_taken: 0, towers_razed: 0, contested: Vec::new(), + mission: None, }; let (cx, cy) = g.map.core; // the soul core starts surrounded by a one tile wide ring of treasury @@ -461,35 +519,46 @@ impl Game { g.spawn(EK::Worker, false, cx as f32 - 0.5, cy as f32 + k as f32 * 0.7 + 0.3, 1); } g.recount(); - for i in 0..n() { - if g.map.t[i].owner == Owner::Player { - g.place_torches(i); - } - } - // heroes take longer to reach the core on big maps: longer grace period - let (gx, gy) = g.map.gate; - let dist = ((gx - cx).abs() + (gy - cy).abs()) as f32; - g.wave_timer = FIRST_WAVE + ((dist - 45.0) * 1.5).clamp(0.0, 300.0); - // treasure caves are guarded by heroes - for k in 0..g.map.treasures.len() { - let (tx, ty) = g.map.treasures[k]; - let squad = [EK::Knight, EK::Archer, EK::Dwarf, if k % 2 == 0 { EK::Priest } else { EK::Knight }]; - for (j, &kind) in squad.iter().enumerate() { - let x = tx as f32 + 0.5 + (j as f32 - 1.5) * 0.6; - let id = g.spawn(kind, true, x, ty as f32 + 0.5, 2); - if let Some(e) = g.ents.iter_mut().find(|e| e.id == id) { - e.guard = true; - e.home = Some((tx as f32 + 0.5, ty as f32 + 0.5)); - } - } - } - g.msg("msg.welcome1", vec![]); - g.msg("msg.welcome2", vec![]); - g.msg("msg.welcome3", vec![]); - g.msg("msg.welcome4", vec![]); + g.place_all_torches(); g } + pub fn place_all_torches(&mut self) { + // torches on walls that are gone (rivers, fortresses) are removed + let map = &self.map; + self.torches.retain(|t| map.t[t.wall].solid()); + for i in 0..n() { + if self.map.t[i].owner == Owner::Player { + self.place_torches(i); + } + } + } + + // ---------------------------------------------------------------- campaign missions + + pub fn tool_allowed(&self, tool: usize) -> bool { + self.mission.as_ref().map_or(true, |m| m.tools.get(tool).copied().unwrap_or(true)) + } + + pub fn goal_done(&self, goal: Goal) -> bool { + match goal { + Goal::Shards => self.shards_taken >= self.mission.as_ref().map_or(0, |m| m.shards), + Goal::Heart => self.gate_hp <= 0.0, + Goal::KillAll => !self.ents.iter().any(|e| e.alive && e.active_hero()), + Goal::Loot(n) => self.loot >= n as f32, + Goal::Towers => !self.towers.iter().any(|t| t.hero), + Goal::Survive(n) => self.wave >= n && !self.ents.iter().any(|e| e.alive && e.active_hero() && !e.guard && e.patrol.is_empty()), + } + } + + pub fn waves_on(&self) -> bool { + self.mission.as_ref().map_or(true, |m| m.waves && (m.wave_limit == 0 || self.wave < m.wave_limit)) + } + + pub fn has_gate(&self) -> bool { + self.mission.as_ref().map_or(true, |m| m.heart) + } + pub fn msg(&mut self, key: &'static str, args: Vec) { self.msgs.push(Msg { key, args }); if self.msgs.len() > 50 { @@ -788,6 +857,59 @@ impl Game { self.do_payday(); } + if self.waves_on() { + self.wave_tick(dt); + } + if self.time - self.gate_hit_t > 10.0 && self.has_gate() { + self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP); + } + + self.update_contested(); + for i in 0..self.ents.len() { + if !self.ents[i].alive || self.ents[i].held { + continue; + } + let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy()); + self.update_ent(&mut e, dt); + self.ents[i] = e; + } + self.ents.retain(|e| e.alive); + self.update_towers(dt); + self.check_end(); + } + + fn check_end(&mut self) { + if self.core_hp <= 0.0 { + self.core_hp = 0.0; + self.state = 2; + self.msg("msg.defeat", vec![]); + self.sfx_ui(Sfx::Defeat); + return; + } + if let Some(m) = &self.mission { + if m.time_limit > 0.0 && self.time > m.time_limit { + self.state = 2; + self.msg("msg.time_up", vec![]); + self.sfx_ui(Sfx::Defeat); + return; + } + let goals = m.goals.clone(); + if !goals.is_empty() && goals.iter().all(|&g| self.goal_done(g)) { + self.state = 1; + self.msg("msg.mission_won", vec![]); + self.sfx_ui(Sfx::Victory); + } + return; + } + if self.gate_hp <= 0.0 { + self.gate_hp = 0.0; + self.state = 1; + self.msg("msg.victory", vec![]); + self.sfx_ui(Sfx::Victory); + } + } + + fn wave_tick(&mut self, dt: f32) { self.wave_timer -= dt; if !self.warned && self.wave_timer < 30.0 { self.warned = true; @@ -814,33 +936,6 @@ impl Game { self.wave_timer = WAVE_GAP + self.rng.f() * 40.0; self.warned = false; } - if self.time - self.gate_hit_t > 10.0 { - self.gate_hp = (self.gate_hp + 8.0 * dt).min(GATE_HP); - } - - self.update_contested(); - for i in 0..self.ents.len() { - if !self.ents[i].alive || self.ents[i].held { - continue; - } - let mut e = std::mem::replace(&mut self.ents[i], Ent::dummy()); - self.update_ent(&mut e, dt); - self.ents[i] = e; - } - self.ents.retain(|e| e.alive); - self.update_towers(dt); - - if self.core_hp <= 0.0 { - self.core_hp = 0.0; - self.state = 2; - self.msg("msg.defeat", vec![]); - self.sfx_ui(Sfx::Defeat); - } else if self.gate_hp <= 0.0 { - self.gate_hp = 0.0; - self.state = 1; - self.msg("msg.victory", vec![]); - self.sfx_ui(Sfx::Victory); - } } fn try_portal(&mut self) { @@ -1082,7 +1177,9 @@ impl Game { } self.sfx(Sfx::GateHit, gx, gy); self.hint(10, "hint.gate_attack"); - self.check_gate_stage(); + if self.mission.as_ref().map_or(true, |m| m.gate_defenders) { + self.check_gate_stage(); + } } fn update_ent(&mut self, e: &mut Ent, dt: f32) { @@ -3835,14 +3932,18 @@ mod tests { patrols: vec![vec![(EK::Archer, 1)]], ..spec(x, y) }); + // no imps around (the patrol would shoot them through the door) + g.ents.retain(|e| e.hero); let pj = g.ents.iter().position(|e| !e.patrol.is_empty()).unwrap(); let pid = g.ents[pj].id; let start = (g.ents[pj].x, g.ents[pj].y); + let mut far = 0.0f32; for _ in 0..200 { g.update(0.05); + let p = g.ents.iter().find(|e| e.id == pid).unwrap(); + far = far.max((p.x - start.0).abs() + (p.y - start.1).abs()); } - let p = g.ents.iter().find(|e| e.id == pid).unwrap(); - assert!((p.x - start.0).abs() + (p.y - start.1).abs() > 3.0, "the patrol moves"); + assert!(far > 6.0, "the patrol walks its round"); // an intruder next to one guard: the others join let (hx, hy) = g.ents.iter().find(|e| e.guard).unwrap().home.unwrap(); g.spawn(EK::Ogre, false, hx + 1.0, hy, 8); diff --git a/src/main.rs b/src/main.rs index 8ee4f55..78bc56e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ // DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). mod audio; +mod campaign; mod cloud; mod fortress; mod game; @@ -413,7 +414,7 @@ async fn main() { // ---------------- keyboard (only while playing) if playing { for i in 0..TOOLS.len() { - if is_key_pressed(key(Action::Tool(i))) { + if is_key_pressed(key(Action::Tool(i))) && app.game.tool_allowed(i) { ui.tool = TOOLS[i]; inp.ldown = None; } @@ -664,6 +665,10 @@ async fn main() { app.game.hints = app.settings.hints; } UiAction::OpenMenu => menu.page = Some(Page::Pause), + UiAction::Locked => { + app.game.msg("msg.tool_locked", vec![]); + app.game.sfx_ui(game::Sfx::Error); + } UiAction::CloseHelp | UiAction::SelectLanguage(_) | UiAction::Consumed => {} UiAction::None => { if touch_mode { diff --git a/src/map.rs b/src/map.rs index e7560c2..e6aeeb0 100644 --- a/src/map.rs +++ b/src/map.rs @@ -59,6 +59,31 @@ pub enum Kind { Diamond, // diamond vein: very rare, very valuable } +// options for the map generator (campaign missions) +#[derive(Clone, Debug)] +pub struct GenOpts { + pub portals: Option, // None: one per 64 x 64 tiles + pub treasures: Option, // guarded treasure caves; None: portals - 1 + pub gate: bool, // hero gate at the far side + pub core_y: Option, // row of the core (None: about the middle) +} + +impl Default for GenOpts { + fn default() -> GenOpts { + GenOpts { portals: None, treasures: None, gate: true, core_y: None } + } +} + +// An underground river across the map +#[derive(Clone, Debug)] +pub struct River { + pub lava: bool, + pub vertical: bool, // runs from top to bottom (else from left to right) + pub pos: f32, // position across the map (0..1) + pub width: i32, + pub bridges: u32, // fixed bridges of the knights +} + // order matters for the packed savegame codec: only append new kinds const KINDS: [Kind; 23] = [ Kind::Rock, @@ -274,21 +299,25 @@ impl Map { } pub fn generate(seed: u32, w: usize, h: usize) -> Map { + Map::generate_with(seed, w, h, &GenOpts::default()) + } + + pub fn generate_with(seed: u32, w: usize, h: usize, opts: &GenOpts) -> Map { let w = w.clamp(MIN_SIZE, MAX_SIZE); let h = h.clamp(MIN_SIZE, MAX_SIZE); set_dims(w, h); let mut s = seed; loop { - let m = Map::try_generate(s, w, h); + let m = Map::try_generate(s, w, h, opts); // make sure heroes can reach the core - if m.hero_path_to_core(idx(m.gate.0 + 1, m.gate.1 + 1)).is_some() { + if !opts.gate || m.hero_path_to_core(idx(m.gate.0 + 1, m.gate.1 + 1)).is_some() { return m; } s = s.wrapping_mul(747796405).wrapping_add(2891336453) | 1; } } - fn try_generate(seed: u32, w: usize, h: usize) -> Map { + fn try_generate(seed: u32, w: usize, h: usize, opts: &GenOpts) -> Map { let (wi, hi) = (w as i32, h as i32); let mut rng = Rng(seed | 1); for _ in 0..10 { @@ -297,8 +326,8 @@ impl Map { // content scales with the area; one portal per 64x64 tiles let area = (w * h) as f32 / 4096.0; let scale = |n: f32| (n * area).round() as i32; - let n_portals = (area.round() as usize).max(1); - let n_treasures = n_portals - 1; + let n_portals = opts.portals.unwrap_or((area.round() as usize).max(1)); + let n_treasures = opts.treasures.unwrap_or(n_portals.saturating_sub(1)); let mut t = vec![Tile::new(Kind::Earth); w * h]; for (i, tile) in t.iter_mut().enumerate() { @@ -310,7 +339,7 @@ impl Map { } } let inner = |x: i32, y: i32| x >= 2 && y >= 2 && x < wi - 2 && y < hi - 2; - let core = (11, hi / 2 + rng.range(-5, 3)); + let core = (11, opts.core_y.unwrap_or(hi / 2 + rng.range(-5, 3))); let gate = (wi - 13 + rng.range(-2, 2), rng.range(10, hi - 14)); // places features far apart: best of several random candidates @@ -332,7 +361,7 @@ impl Map { // the first portal is close to the start let first = (core.0 + rng.range(13, 19), (core.1 + if rng.f() < 0.5 { rng.range(-14, -10) } else { rng.range(10, 14) }).clamp(5, hi - 9)); taken.push(first); - let mut portals = vec![first]; + let mut portals = if n_portals > 0 { vec![first] } else { Vec::new() }; for _ in 1..n_portals { portals.push(place(&mut rng, &mut taken, 6)); } @@ -448,15 +477,20 @@ impl Map { } } } - // hero gate - room(&mut t, gate.0 - 2, gate.1 - 2, gate.0 + 4, gate.1 + 4, Owner::Hero); - for y in 0..3 { - for x in 0..3 { - let tl = &mut t[(gate.1 + y) as usize * w + (gate.0 + x) as usize]; - tl.kind = Kind::Gate; - tl.owner = Owner::Hero; + // hero gate (campaign maps place the gate of the order in a fortress instead) + let gate = if opts.gate { + room(&mut t, gate.0 - 2, gate.1 - 2, gate.0 + 4, gate.1 + 4, Owner::Hero); + for y in 0..3 { + for x in 0..3 { + let tl = &mut t[(gate.1 + y) as usize * w + (gate.0 + x) as usize]; + tl.kind = Kind::Gate; + tl.owner = Owner::Hero; + } } - } + gate + } else { + (0, 0) + }; for tl in t.iter_mut() { if tl.vein().is_some() { tl.hp = 6.0; @@ -465,6 +499,67 @@ impl Map { Map { w, h, t, core, portals, legacy_portal: None, gate, treasures } } + // Carves a meandering river; the player's area, the core and portals stay dry. + // Returns the tiles of the knight bridges. + pub fn carve_river(&mut self, r: &River, seed: u32) -> Vec { + let mut rng = Rng(seed | 1); + let (w, h) = (self.w as i32, self.h as i32); + let len = if r.vertical { h } else { w }; + let across = if r.vertical { w } else { h }; + let base = (r.pos * across as f32) as i32; + let (f1, f2) = (0.05 + rng.f() * 0.06, 0.13 + rng.f() * 0.08); + let (p1, p2) = (rng.f() * 6.0, rng.f() * 6.0); + let amp = (across as f32 * 0.06).min(6.0); + let mut centre = Vec::with_capacity(len as usize); + for s in 0..len { + let c = base as f32 + (s as f32 * f1 + p1).sin() * amp + (s as f32 * f2 + p2).sin() * amp * 0.4; + centre.push(c.round() as i32); + } + let at = |s: i32, o: i32| if r.vertical { (o, s) } else { (s, o) }; + let kind = if r.lava { Kind::Lava } else { Kind::Water }; + for s in 1..len - 1 { + for k in 0..r.width { + let o = centre[s as usize] - r.width / 2 + k; + let (x, y) = at(s, o); + if x < 1 || y < 1 || x >= w - 1 || y >= h - 1 { + continue; + } + let t = &mut self.t[idx(x, y)]; + if t.owner == Owner::Player || matches!(t.kind, Kind::Core | Kind::Portal | Kind::Gate) { + continue; + } + t.kind = kind; + t.owner = Owner::None; + t.gold = 0; + t.marked = false; + t.reinforced = false; + } + } + // knight bridges, evenly spread, with a little landing at both ends + let mut out = Vec::new(); + for b in 0..r.bridges { + let s = ((b as f32 + 0.5) / r.bridges as f32 * (len - 6) as f32) as i32 + 3; + for k in -1..=r.width { + let o = centre[s as usize] - r.width / 2 + k; + let (x, y) = at(s, o); + if !inb(x, y) || x < 1 || y < 1 || x >= w - 1 || y >= h - 1 { + continue; + } + let t = &mut self.t[idx(x, y)]; + if t.liquid() { + t.kind = if r.lava { Kind::LavaBridge } else { Kind::Bridge }; + t.owner = Owner::Hero; + out.push(idx(x, y)); + } else if t.solid() && t.kind != Kind::Rock { + t.kind = Kind::Floor; + t.owner = Owner::None; + t.gold = 0; + } + } + } + out + } + // Generic Dijkstra search. // cost(i) -> None = impassable, Some(c) = cost of entering. // goal(i) -> true = goal reached. diff --git a/src/ui.rs b/src/ui.rs index 8abd24b..0e7662f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -254,6 +254,7 @@ enum TBtn { pub enum UiAction { None, + Locked, // a tool that is not available yet (campaign) OpenMenu, SelectTool(Tool), MinimapJump(f32, f32), @@ -371,6 +372,7 @@ impl Ui { self.open_cat = None; UiAction::OpenMenu } + TBtn::Tool(i) if !g.tool_allowed(i) => UiAction::Locked, TBtn::Tool(i) => { self.open_cat = None; self.tip = Some((i, get_time())); @@ -378,7 +380,9 @@ impl Ui { } TBtn::Cat(c) => { let tools = CATS[c].1; - if tools.len() == 1 { + if tools.len() == 1 && !g.tool_allowed(tools[0]) { + UiAction::Locked + } else if tools.len() == 1 { self.open_cat = None; self.tip = Some((tools[0], get_time())); UiAction::SelectTool(TOOLS[tools[0]]) @@ -430,13 +434,22 @@ impl Ui { gold_seg, vec![txt(tr("hud.mana"), MANA), Part::Gap, Part::Bar(g.mana / MANA_MAX, MANA), Part::Gap, txt(format!("{:<3}", g.mana as i32), TXT)], vec![txt(tr("hud.core"), RED), Part::Gap, Part::Bar(g.core_hp / CORE_HP, RED)], - vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)], vec![txt(tr("hud.creatures"), TXT), Part::Gap, txt(format!("{}/{}", g.creature_count(), cap), TXT)], vec![txt(tr("hud.workers"), TXT), Part::Gap, txt(g.worker_count().to_string(), TXT)], - vec![txt(trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col), Part::Gap, txt(mmss(g.wave_timer), TXT)], - vec![txt(tr("hud.payday"), if g.payday <= PAYDAY_WARN { RED } else { GOLD }), Part::Gap, txt(mmss(g.payday), TXT)], - vec![txt(tr("hud.army"), TXT), Part::Gap, txt((g.army_value() as i32).to_string(), TXT)], ]; + // campaign missions: gate, waves and time limit only where the mission has them + if g.has_gate() { + segs.insert(3, vec![txt(tr("hud.gate"), BLUE), Part::Gap, Part::Bar(g.gate_hp / GATE_HP, BLUE)]); + } + if g.waves_on() { + segs.push(vec![txt(trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col), Part::Gap, txt(mmss(g.wave_timer), TXT)]); + } + if let Some(limit) = g.mission.as_ref().map(|m| m.time_limit).filter(|&l| l > 0.0) { + 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)]); + } + 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() { segs.push(vec![txt(tr("hud.hand"), FRAME_HI), Part::Gap, txt(format!("{}/8", g.hand.len()), TXT)]); } @@ -499,7 +512,8 @@ impl Ui { let px = K(); // one pixel of the font, times the factor let pulse = (now * 2.5) as i64 % 2 == 0; // one toolbar button: icon, optional key, label in small text - let tool_button = |bx: f32, by: f32, bw: f32, icon_id: u32, key: &str, label: &str, sel: bool, hovered: bool| { + // locked tools (campaign: not yet available) are drawn dark + let tool_button = |bx: f32, by: f32, bw: f32, icon_id: u32, key: &str, label: &str, sel: bool, hovered: bool, locked: bool| { let r = Rect::new(bx + px, by, bw - 2.0 * px, 3.0 * CH()); draw_rectangle(r.x, r.y, r.w, r.h, if sel { Color::new(0.42, 0.26, 0.1, 1.0) } else if hovered { Color::new(0.18, 0.13, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) }); // the active tool: pulsing frame @@ -507,7 +521,7 @@ impl Ui { draw_rectangle_lines(r.x, r.y, r.w, r.h, if sel { 3.0 * px } else { 2.0 * px }, frame); // icon: the 16 px atlas cell at 2*F, centred on the grid let icon = 32.0 * px; - gfx.draw_cell(icon_id, bx + (bw - icon) * 0.5, by + 4.0 * px, icon, icon, WHITE); + gfx.draw_cell(icon_id, bx + (bw - icon) * 0.5, by + 4.0 * px, icon, icon, if locked { Color::new(0.25, 0.22, 0.2, 1.0) } else { WHITE }); if !key.is_empty() { gfx.text(key, bx + 3.0 * px, by + 3.0 * px, SMALL(), if sel { FRAME_HI } else { DIM }); } @@ -516,7 +530,7 @@ impl Ui { let label: String = label.chars().take(maxc.min(11)).collect(); let lw = gfx.measure(&label, SMALL()); let lx = bx + ((bw - lw) / (GW * SMALL()) / 2.0).floor() * GW * SMALL(); - gfx.text(&label, lx, by + 2.0 * CH() + 4.0 * px, SMALL(), if sel { FRAME_HI } else { TXT }); + gfx.text(&label, lx, by + 2.0 * CH() + 4.0 * px, SMALL(), if sel { FRAME_HI } else if locked { DIM } else { TXT }); r }; let mut popup_top = tb_y; @@ -540,7 +554,8 @@ impl Ui { if hovered && tools.len() == 1 { hover_tool = Some(tools[0]); } - let r = tool_button(bx, by, bw, ti.icon, key, &label, has, self.open_cat == Some(c) || hovered); + let locked = !tools.iter().any(|&t| g.tool_allowed(t)); + let r = tool_button(bx, by, bw, ti.icon, key, &label, has, self.open_cat == Some(c) || hovered, locked); if has { gfx.glyph(0x1F, bx + (bw - CW()) * 0.5, tb_y, S(), if pulse { WHITE } else { FRAME_HI }); } @@ -701,7 +716,7 @@ impl Ui { hover_tool = Some(i); } let key = if touch_ui() { "" } else { keys[i].as_str() }; - let r = tool_button(bx, by, bw, ti.icon, key, &ti.label, self.tool == TOOLS[i], hovered); + let r = tool_button(bx, by, bw, ti.icon, key, &ti.label, self.tool == TOOLS[i], hovered, !g.tool_allowed(i)); self.touch_btns.push((r, TBtn::Tool(i))); } popup_top = py0;