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

+64 -5
View File
@@ -122,6 +122,20 @@ fn new_game(seed: u32, s: &Settings) -> (Game, Cam) {
(g, cam)
}
// a campaign mission with what the last mission handed over
fn mission_game(i: usize, progress: &campaign::Progress) -> (Game, Cam) {
let g = campaign::start_mission(i, progress.carry_into(i));
let (cx, cy) = g.map.core;
let cam = Cam::new(cx as f32 + 1.5, cy as f32 + 1.5);
(g, cam)
}
const PROGRESS_KEY: &str = "dc.campaign";
fn store_progress(p: &campaign::Progress) {
platform::storage_set(PROGRESS_KEY, &serde_json::to_string(p).unwrap_or_default());
}
fn next_seed(seed: u32) -> u32 {
seed.wrapping_mul(1103515245).wrapping_add(12345) | 1
}
@@ -223,6 +237,7 @@ struct App {
settings: Settings,
seed: u32,
status: Option<(String, f64)>,
recorded: bool, // the end of the current mission has been recorded in the campaign progress
}
impl App {
@@ -286,7 +301,8 @@ async fn main() {
let seed = (miniquad::date::now() * 1000.0) as u32 | 1;
let (game, cam) = new_game(seed, &settings);
let mut app = App { game, cam, settings, seed, status: None };
let mut app = App { game, cam, settings, seed, status: None, recorded: false };
menu.progress = platform::storage_get(PROGRESS_KEY).and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
app.game.hints = app.settings.hints;
let mut inp = Input { ldown: None, rdown: None, rlast: (0.0, 0.0), rdrag: false, mlast: None, minimap_drag: false, tlast: None, touch_world: false, inspect: None };
let mut touch = TouchInput::new();
@@ -361,10 +377,10 @@ async fn main() {
}
}
}
if matches!(menu.page, Some(Page::Saves { .. })) {
if menu.scrollable() {
let (_, wheel) = mouse_wheel();
if wheel != 0.0 {
menu.scroll_by(if wheel > 0.0 { -1 } else { 1 }, cloud.list.len());
menu.scroll_lines(if wheel > 0.0 { -1 } else { 1 });
}
}
@@ -633,6 +649,15 @@ async fn main() {
MenuAction::CloudDelete(id) => {
cloud.delete(&id);
}
MenuAction::StartMission(i) => {
(app.game, app.cam) = mission_game(i, &menu.progress);
app.game.hints = app.settings.hints;
app.recorded = false;
menu.page = None;
ui.log.clear();
ui.show_help = false;
autosave_t = 0.0;
}
MenuAction::EditName => {
if let Some(n) = platform::prompt(&i18n::tr("saves.name"), &menu.save_name) {
menu.save_name = n.chars().filter(|c| !c.is_control()).take(40).collect();
@@ -664,6 +689,25 @@ async fn main() {
(app.game, app.cam) = new_game(app.seed, &app.settings);
app.game.hints = app.settings.hints;
}
// end of a mission: next mission (won) or try again (lost)
UiAction::EndNext => {
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
if app.game.state == 1 {
menu.page = Some(if i + 1 < campaign::missions().len() { Page::Briefing { mission: i + 1, ingame: false } } else { Page::Campaign });
} else {
(app.game, app.cam) = mission_game(i, &menu.progress);
app.game.hints = app.settings.hints;
app.recorded = false;
ui.log.clear();
}
}
}
UiAction::EndCampaign => menu.page = Some(Page::Campaign),
UiAction::Briefing => {
if let Some(i) = app.game.mission.as_ref().map(|m| m.index) {
menu.open_briefing(i);
}
}
UiAction::OpenMenu => menu.page = Some(Page::Pause),
UiAction::Locked => {
app.game.msg("msg.tool_locked", vec![]);
@@ -785,7 +829,22 @@ async fn main() {
for ev in app.game.events.drain(..) {
sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now);
}
let on_title = menu.page == Some(Page::Title) || matches!(menu.page, Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame));
// a won mission goes into the campaign progress (with the carry-over)
if let (Some(i), false) = (app.game.mission.as_ref().map(|m| m.index), app.recorded) {
if app.game.state != 0 {
app.recorded = true;
if app.game.state == 1 {
menu.progress.set_done(i, campaign::carry_from(&app.game));
store_progress(&menu.progress);
}
}
}
menu.mission = app.game.mission.as_ref().map(|m| m.index);
let on_title = menu.page == Some(Page::Title)
|| matches!(
menu.page,
Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::NewGame) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. })
);
music.update(&app.game, on_title, (app.cam.tx, app.cam.tz));
for m in app.game.msgs.drain(..) {
ui.log.push((m, now));
@@ -832,7 +891,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
r3d.draw(&app.game, gfx, &app.cam, now, &ov);
let title_screen = matches!(
menu.page,
Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. })
Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }) | Some(Page::Campaign) | Some(Page::Briefing { ingame: false, .. })
);
if !title_screen {
let info = if over_ui || (touch && inp.inspect.is_none()) {
+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()]),
}
}
+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));
}
}