Campaign menu and story: mission list, briefings, goals panel, mission end flow

- Title screen: Campaign with the list of the 20 missions (done / open / locked)
- Briefing per mission: story told by old Crookfinger (EN/DE), goals, new tools,
  creatures and gold carried over; start button
- In the game: goals panel with progress (click/tap opens the briefing again), the pause menu
  has a Briefing entry to read the whole briefing again
- Mission end: next mission / try again and back to the campaign; won missions and the
  carry-over are stored in the browser; epilogue after the last mission

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:23:44 +01:00
1 parent 46755d68ac
commit 09e22b009e
5 files changed
+523 -39

No files matched your search

+93 -23
View File
@@ -254,6 +254,9 @@ enum TBtn {
pub enum UiAction {
None,
EndNext, // mission over: next mission (won) or try again (lost)
EndCampaign, // mission over: back to the campaign
Briefing, // show the briefing of the current mission again
Locked, // a tool that is not available yet (campaign)
OpenMenu,
SelectTool(Tool),
@@ -274,7 +277,8 @@ pub struct Ui {
minimap_tex: Texture2D,
minimap_t: f64,
minimap_rect: Rect,
end_button: Rect,
end_buttons: Vec<(Rect, u8)>,
goals_rect: Rect, // mission goals panel (click: briefing) // end dialog: 0 new game, 1 next/again, 2 campaign
lang_rects: Vec<(Rect, String)>,
pub hand_anim: Option<(u8, f64)>, // 1 = backhand, 2 = punch, start time
// touch interface
@@ -284,7 +288,7 @@ pub struct Ui {
hud_h: f32,
}
fn wrap(s: &str, cols: usize) -> Vec<String> {
pub fn wrap(s: &str, cols: usize) -> Vec<String> {
let mut out = Vec::new();
let mut line = String::new();
for word in s.split(' ') {
@@ -324,7 +328,8 @@ impl Ui {
minimap_tex: tex,
minimap_t: -1.0,
minimap_rect: Rect::new(0.0, 0.0, 0.0, 0.0),
end_button: Rect::new(0.0, 0.0, 0.0, 0.0),
end_buttons: Vec::new(),
goals_rect: Rect::new(0.0, 0.0, 0.0, 0.0),
lang_rects: Vec::new(),
hand_anim: None,
open_cat: None,
@@ -358,8 +363,14 @@ impl Ui {
return UiAction::Consumed;
}
if g.state != 0 {
if self.end_button.contains(p) {
return UiAction::NewGame;
for &(r, code) in &self.end_buttons {
if r.contains(p) {
return match code {
0 => UiAction::NewGame,
1 => UiAction::EndNext,
_ => UiAction::EndCampaign,
};
}
}
return UiAction::Consumed;
}
@@ -395,6 +406,9 @@ impl Ui {
}
// a tap elsewhere closes an open category row
self.open_cat = None;
if g.mission.is_some() && self.goals_rect.contains(p) {
return UiAction::Briefing;
}
if self.minimap_rect.contains(p) {
let (wx, wy) = self.minimap_world(x, y);
return UiAction::MinimapJump(wx, wy);
@@ -652,6 +666,35 @@ impl Ui {
draw_line(pa.x, pa.y, pb.x, pb.y, px, WHITE);
}
// ---------------- mission goals (campaign)
if let Some(m) = &g.mission {
let gcols = 34.min(cols - 2);
let mut lines: Vec<(String, Color)> = Vec::new();
for &goal in &m.goals {
let done = g.goal_done(goal);
let progress = match goal {
Goal::Shards => format!(" {}/{}", g.shards_taken.min(m.shards), m.shards),
Goal::Heart => format!(" {}%", (g.gate_hp / GATE_HP * 100.0).ceil().max(0.0) as i32),
Goal::KillAll => trf("goal.left", &[g.ents.iter().filter(|e| e.alive && e.active_hero()).count().to_string()]),
Goal::Loot(n) => format!(" {}/{}", (g.loot as u32).min(n), n),
Goal::Towers => trf("goal.left", &[g.towers.iter().filter(|t| t.hero).count().to_string()]),
Goal::Survive(n) => format!(" {}/{}", g.wave.min(n), n),
};
let text = format!("{} {}{}", if done { "\u{221a}" } else { "\u{25ba}" }, crate::menu::goal_text(goal), progress);
for l in wrap(&text, gcols as usize - 4) {
lines.push((l, if done { GREEN } else { TXT }));
}
}
let grows = lines.len() as i32 + 2;
gfx.window_titled(0.0, self.hud_h, gcols, grows, S(), false, FRAME, BG, &tr("camp.goals_panel"), GOLD);
let r = Rect::new(0.0, self.hud_h, gcols as f32 * CW(), grows as f32 * CH());
self.rects.push(r);
self.goals_rect = r;
for (k, (l, c)) in lines.iter().enumerate() {
gfx.text(l, CW() * 2.0, self.hud_h + CH() * (k as f32 + 1.0), S(), *c);
}
}
// ---------------- info panel
if !info.is_empty() {
let icols = 30.min(cols - 2);
@@ -852,29 +895,56 @@ impl Ui {
let sh = sh();
draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.5));
let win = g.state == 1;
let cols = 44;
let rows = 11;
let mission = g.mission.as_ref().map(|m| m.index);
let last = mission.map_or(false, |i| i + 1 == crate::campaign::missions().len());
let cols = 50.min((sw / CW()) as i32);
let tw = (cols - 6) as usize;
let (title, sub) = match (mission, win) {
(Some(_), true) => ("end.mission_won", if last { "camp.epilogue" } else { "end.mission_won_sub" }),
(Some(_), false) => ("end.mission_lost", "end.mission_lost_sub"),
(None, true) => ("end.victory", "end.victory_sub"),
(None, false) => ("end.defeat", "end.defeat_sub"),
};
let mut lines: Vec<String> = Vec::new();
for (k, para) in tr(sub).split(" | ").enumerate() {
if k > 0 {
lines.push(String::new());
}
lines.extend(wrap(para, tw));
}
let rows = 9 + lines.len() as i32;
let x = grid_center(sw, cols as f32 * CW());
let y = grid_center_y(sh, rows as f32 * CH());
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
let col = if win { GOLD } else { RED };
gfx.window(x, y, cols, rows, S(), true, col, Color::new(0.05, 0.03, 0.04, 0.97));
let title = tr(if win { "end.victory" } else { "end.defeat" });
gfx.text(&title, grid_center(sw, gfx.measure(&title, TITLE())), y + CH(), TITLE(), col);
let sub = tr(if win { "end.victory_sub" } else { "end.defeat_sub" });
gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), y + CH() * 4.5, S(), TXT);
let title = tr(title);
let big = if gfx.measure(&title, TITLE()) < cols as f32 * CW() - CW() * 4.0 { TITLE() } else { S() };
gfx.text(&title, grid_center(sw, gfx.measure(&title, big)), y + CH(), big, col);
for (k, l) in lines.iter().enumerate() {
gfx.text(l, x + CW() * 3.0, y + CH() * (4.0 + k as f32), S(), TXT);
}
let st = trf("end.stats", &[mmss(g.time), g.wave.to_string(), (g.army_value() as i32).to_string()]);
gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * 6.0, S(), DIM);
let b = format!(" {} ", tr("end.new"));
let b = b.as_str();
let bw = gfx.measure(b, S()) + CW() * 2.0;
let bx = grid_center(sw, bw);
let by = y + CH() * 7.75;
self.end_button = Rect::new(bx, by, bw, CH() * 2.0);
gfx.text(&st, grid_center(sw, gfx.measure(&st, S())), y + CH() * (4.5 + lines.len() as f32), S(), DIM);
// buttons: free game: new game; mission: next / again and back to the campaign
let labels: Vec<(String, u8)> = match mission {
None => vec![(tr("end.new"), 0)],
Some(_) => vec![(tr(if !win { "end.again" } else if last { "end.to_campaign" } else { "end.next" }), 1), (tr("end.campaign"), 2)],
};
let by = y + CH() * (6.0 + lines.len() as f32);
let widths: Vec<f32> = labels.iter().map(|(l, _)| gfx.measure(l, S()) + CW() * 4.0).collect();
let total: f32 = widths.iter().sum::<f32>() + CW() * 2.0 * (labels.len() as f32 - 1.0);
let mut bx = grid_center(sw, total);
let (mx, my) = mouse();
let hov = self.end_button.contains(vec2(mx, my));
draw_rectangle(bx, by, bw, CH() * 2.0, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) });
draw_rectangle_lines(bx, by, bw, CH() * 2.0, 2.0, col);
gfx.text(b, bx + CW(), by + CH() * 0.5, S(), TXT);
self.end_buttons.clear();
for ((label, code), w) in labels.iter().zip(widths) {
let r = Rect::new(bx, by, w, CH() * 2.0);
let hov = r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hov { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.15, 0.1, 0.08, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, col);
gfx.text(label, r.x + CW() * 2.0, r.y + CH() * 0.5, S(), TXT);
self.end_buttons.push((r, *code));
bx += w + CW() * 2.0;
}
self.rects.push(Rect::new(0.0, 0.0, sw, sh));
}
}