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:
1 parent
e15b16cc60
commit
46755d68ac
8 files changed
+758
-85
No files matched your search
+159
-58
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user