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

+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.