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
+64
-5
@@ -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
@@ -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()]),
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user