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

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