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

+222 -11
View File
@@ -2,12 +2,14 @@
// (tabs, values, key bindings).
// Drawn as CP437 windows on top of the (paused) game world.
use crate::campaign::{missions, Progress};
use crate::cloud::{date_string, duration_string, Cloud};
use crate::game::Goal;
use crate::gfx::*;
use crate::i18n::{languages, tr, trf};
use crate::map::{MAX_SIZE, MIN_SIZE};
use crate::settings::{all_actions, key_name, Action, Settings};
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
use macroquad::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
@@ -18,6 +20,8 @@ pub enum Page {
NewGame,
KeyCapture { action: usize, from_title: bool },
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
Campaign, // list of the campaign missions
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
}
pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"];
@@ -185,6 +189,10 @@ enum Btn {
FileSave,
FileLoad,
NameField,
Campaign,
Mission(usize),
StartMission,
Goals,
}
pub enum MenuAction {
@@ -204,12 +212,15 @@ pub enum MenuAction {
CloudLoad(String),
CloudDelete(String),
EditName, // the name field was tapped (touch: ask with a browser dialog)
StartMission(usize),
}
pub struct Menu {
pub page: Option<Page>,
pub has_autosave: bool,
pub save_name: String, // name for a new savegame (typed on the savegame page)
pub progress: Progress, // campaign progress
pub mission: Option<usize>, // mission being played (pause menu: goals)
confirm: Option<Btn>, // overwrite / delete waiting for a second click
scroll: usize,
rows_visible: usize,
@@ -225,7 +236,17 @@ fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) {
impl Menu {
pub fn new() -> Menu {
Menu { page: Some(Page::Title), has_autosave: false, save_name: String::new(), confirm: None, scroll: 0, rows_visible: 1, buttons: Vec::new() }
Menu {
page: Some(Page::Title),
has_autosave: false,
save_name: String::new(),
progress: Progress::default(),
mission: None,
confirm: None,
scroll: 0,
rows_visible: 1,
buttons: Vec::new(),
}
}
pub fn open(&self) -> bool {
@@ -241,6 +262,9 @@ impl Menu {
Some(Page::NewGame) => Some(Page::Title),
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Campaign) => Some(Page::Title),
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
Some(Page::Pause) => None,
Some(Page::Title) => Some(Page::Title),
None => Some(Page::Pause),
@@ -266,11 +290,6 @@ impl Menu {
}
}
pub fn scroll_by(&mut self, d: i32, total: usize) {
let max = total.saturating_sub(self.rows_visible);
self.scroll = (self.scroll as i32 + d).clamp(0, max as i32) as usize;
}
pub fn capturing(&self) -> Option<Action> {
match self.page {
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
@@ -371,10 +390,32 @@ impl Menu {
}
}
Some(Btn::Scroll(d)) => {
self.scroll_by(d * self.rows_visible.max(1) as i32, cloud.list.len());
// each page clamps the scroll position when it is drawn
self.scroll_lines(d * self.rows_visible.max(1) as i32);
MenuAction::Consumed
}
Some(Btn::NameField) => MenuAction::EditName,
Some(Btn::Campaign) => {
self.page = Some(Page::Campaign);
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::Mission(i)) => {
self.page = Some(Page::Briefing { mission: i, ingame: false });
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::StartMission) => match self.page {
Some(Page::Briefing { mission, .. }) => MenuAction::StartMission(mission),
_ => MenuAction::Consumed,
},
Some(Btn::Goals) => {
if let Some(m) = self.mission {
self.page = Some(Page::Briefing { mission: m, ingame: true });
self.scroll = 0;
}
MenuAction::Consumed
}
Some(Btn::FileSave) => MenuAction::FileSave,
Some(Btn::FileLoad) => MenuAction::FileLoad,
}
@@ -392,6 +433,8 @@ impl Menu {
Page::NewGame => self.draw_new_game(gfx, settings),
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
Page::Campaign => self.draw_campaign(gfx),
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::KeyCapture { action, .. } => {
self.draw_settings(gfx, settings, 4);
self.buttons.clear();
@@ -439,6 +482,7 @@ impl Menu {
if self.has_autosave {
items.push((Btn::Continue, tr("menu.continue")));
}
items.push((Btn::Campaign, tr("menu.campaign")));
items.push((Btn::New, tr("menu.new")));
items.push((Btn::Load, tr("menu.load")));
items.push((Btn::Settings, tr("menu.settings")));
@@ -490,14 +534,17 @@ impl Menu {
fn draw_pause(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let items = vec![
(Btn::Resume, tr("menu.resume")),
let mut items = vec![(Btn::Resume, tr("menu.resume"))];
if self.mission.is_some() {
items.push((Btn::Goals, tr("menu.goals")));
}
items.extend([
(Btn::Save, tr("menu.save")),
(Btn::Load, tr("menu.load")),
(Btn::Settings, tr("menu.settings")),
(Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("menu.main")),
];
]);
let cols = 32;
let rows = items.len() as i32 * 3 + 4;
let x = grid_center(sw, cols as f32 * CW());
@@ -693,4 +740,168 @@ impl Menu {
button(gfx, fr, &fl, fr.contains(vec2(mx, my)), FRAME);
self.buttons.push((fr, if save { Btn::FileSave } else { Btn::FileLoad }));
}
// Campaign: list of the missions (done, open, locked)
fn draw_campaign(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let cols = ((sw / CW()) as i32 - 2).min(60);
let rows = ((sh / CH()) as i32 - 2).min(36);
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("camp.title"), FRAME_HI);
let inner_x = x + CW() * 2.0;
let inner_w = (cols - 4) as f32 * CW();
let top = y + CH() * 2.0;
let bottom = y + (rows - 5) as f32 * CH();
let row_h = CH() * 2.5;
let all = missions();
let n = all.len();
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
for (k, d) in all.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, CH() * 2.0);
let open = self.progress.unlocked(k);
let done = self.progress.is_done(k);
let hot = open && r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else if open { FRAME } else { Color::new(0.25, 0.2, 0.15, 1.0) });
let mark = if done { "\u{221a}" } else if open { "\u{25ba}" } else { "-" };
let title = if open { tr(&format!("camp.{}.title", d.key)) } else { tr("camp.locked") };
let line = format!("{} {:02} {}", mark, k + 1, title);
let maxc = ((r.w - CW() * 2.0) / CW()) as usize;
let line: String = line.chars().take(maxc).collect();
gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if done { GOLD } else if open { TXT } else { DIM });
if open {
self.buttons.push((r, Btn::Mission(k)));
}
}
if scroll_w > 0.0 {
let sx = inner_x + inner_w - CW() * 2.5;
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
self.buttons.push((up, Btn::Scroll(-1)));
self.buttons.push((down, Btn::Scroll(1)));
}
let done = (0..n).filter(|&k| self.progress.is_done(k)).count();
let st = trf("camp.progress", &[done.to_string(), n.to_string()]);
gfx.text(&st, inner_x, bottom + CH() * 0.5, SMALL(), DIM);
let by = y + (rows - 4) as f32 * CH();
let back = tr("menu.back");
let br = Rect::new(inner_x, by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
self.buttons.push((br, Btn::Back));
}
// Briefing: the old imp tells the story, then the goals and what comes along
fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let all = missions();
let Some(d) = all.get(mission) else { return };
let cols = ((sw / CW()) as i32 - 2).min(64);
let rows = ((sh / CH()) as i32 - 2).min(38);
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
let title = trf("camp.mission_title", &[(mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI);
// all lines: story paragraphs (" | " separates them), goals, carry-over
let tw = (cols - 6) as usize;
let mut lines: Vec<(String, Color)> = Vec::new();
for (k, para) in tr(&format!("camp.{}.story", d.key)).split(" | ").enumerate() {
if k > 0 {
lines.push((String::new(), TXT));
}
for l in wrap(para, tw) {
lines.push((l, TXT));
}
}
lines.push((String::new(), TXT));
lines.push((tr("camp.goals"), GOLD));
for g in &d.goals {
for l in wrap(&format!("\u{25ba} {}", goal_text(*g)), tw) {
lines.push((l, FRAME_HI));
}
}
if d.time_limit > 0.0 {
lines.push((format!("\u{25ba} {}", trf("camp.time_limit", &[duration_string(d.time_limit as f64)])), FRAME_HI));
}
// newly available tools
let new_tools: Vec<String> = crate::campaign::TOOL_UNLOCK.iter().enumerate().filter(|&(_, &u)| u == mission && mission > 0).map(|(i, _)| tool_info(TOOLS[i]).name).collect();
if !new_tools.is_empty() {
lines.push((String::new(), TXT));
for l in wrap(&trf("camp.new_tools", &[new_tools.join(", ")]), tw) {
lines.push((l, GOLD));
}
}
if !ingame {
if let Some(c) = self.progress.carry_into(mission) {
lines.push((String::new(), TXT));
for l in wrap(&trf("camp.carry", &[c.creatures.len().to_string(), (c.gold as i32).to_string()]), tw) {
lines.push((l, DIM));
}
}
}
let top = y + CH() * 2.0;
let bottom = y + (rows - 5) as f32 * CH();
self.rows_visible = (((bottom - top) / CH()).floor() as usize).max(1);
self.scroll = self.scroll.min(lines.len().saturating_sub(self.rows_visible));
for (k, (l, c)) in lines.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
gfx.text(l, x + CW() * 3.0, top + (k - self.scroll) as f32 * CH(), S(), *c);
}
if lines.len() > self.rows_visible {
let sx = x + (cols - 4) as f32 * CW();
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
let down = Rect::new(sx, bottom - CH() * 2.0, CW() * 2.5, CH() * 2.0);
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
self.buttons.push((up, Btn::Scroll(-1)));
self.buttons.push((down, Btn::Scroll(1)));
}
let by = y + (rows - 4) as f32 * CH();
let back = tr("menu.back");
let br = Rect::new(x + CW() * 2.0, by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
self.buttons.push((br, Btn::Back));
if !ingame {
let start = tr("camp.start");
let w = gfx.measure(&start, S()) + CW() * 6.0;
let sr = Rect::new(x + cols as f32 * CW() - w - CW() * 2.0, by, w, CH() * 2.0);
button(gfx, sr, &start, sr.contains(vec2(mx, my)), FRAME_HI);
self.buttons.push((sr, Btn::StartMission));
}
}
// the briefing of the running mission, again (story and goals)
pub fn open_briefing(&mut self, mission: usize) {
self.page = Some(Page::Briefing { mission, ingame: true });
self.scroll = 0;
}
// scrolling on pages with lists (mouse wheel)
pub fn scrollable(&self) -> bool {
matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::Briefing { .. }))
}
pub fn scroll_lines(&mut self, d: i32) {
self.scroll = (self.scroll as i32 + d).max(0) as usize;
}
}
// description of a mission goal (without progress)
pub fn goal_text(g: Goal) -> String {
match g {
Goal::Shards => tr("goal.shards"),
Goal::Heart => tr("goal.heart"),
Goal::KillAll => tr("goal.killall"),
Goal::Loot(n) => trf("goal.loot", &[n.to_string()]),
Goal::Towers => tr("goal.towers"),
Goal::Survive(n) => trf("goal.survive", &[n.to_string()]),
}
}