Campaign mission system: 20 mission definitions, goals, unlocks, carry-over

- campaign.rs: missions as key data (size, rivers, fortresses, goals, waves, time limit, gifts);
  start_mission builds the map with the generator; tools unlock step by step; the strongest
  5 creatures and 20% of the gold are carried into the next mission; campaign progress type
- Map generator options (portals, treasure caves, hero gate) and meandering rivers with
  knight bridges
- Mission goals: shards, gate of the order, all knights, vault loot, knight towers, waves;
  time limits; waves and gate defenders only where the mission wants them
- Locked tools are shown dark and cannot be chosen; HUD shows gate/waves/time only when used
- Fix: patrol routes no longer start on tower spots
- Tests: every mission builds, its goals are possible, wave heroes reach the core

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 11:11:53 +01:00
1 parent e15b16cc60
commit 46755d68ac
8 files changed
+758 -85

No files matched your search

+442
View File
@@ -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.<key>.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<Goal>,
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<River>,
pub forts: Vec<FortDef>,
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<bool>,
pub carry: Vec<Option<Carry>>, // 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<Goal>) -> 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<MissionDef> {
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::<u32>(), "{}", 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));
}
}
+2 -1
View File
@@ -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() {
+159 -58
View File
@@ -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<Goal>,
pub tools: Vec<bool>, // 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<bool>, // tiles next to fighting heroes: knight ground there cannot be claimed
pub contested: Vec<bool>,
#[serde(default)]
pub mission: Option<MissionState>, // 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<String>) {
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);
+6 -1
View File
@@ -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 {
+110 -15
View File
@@ -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<usize>, // None: one per 64 x 64 tiles
pub treasures: Option<usize>, // guarded treasure caves; None: portals - 1
pub gate: bool, // hero gate at the far side
pub core_y: Option<i32>, // 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<usize> {
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.
+25 -10
View File
@@ -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;