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:
1 parent
46755d68ac
commit
09e22b009e
5 files changed
+523
-39
No files matched your search
+222
-11
@@ -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()]),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user