// Menus: title screen, pause menu, savegame list (server) and the settings dialog // (tabs, values, key bindings). // Drawn as text-mode windows on top of the (paused) game world. use crate::campaign::{missions, Progress}; use crate::campaign_doc::CampaignDoc; use crate::cloud::{date_string, duration_string, Cloud}; use crate::game::Goal; use crate::gfx::*; use crate::i18n::{languages, tr, trf}; use crate::lobby::MatchUi; 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, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; use macroquad::prelude::*; mod achievements; mod bestiary; pub mod flags; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Page { Title, Pause, Settings { tab: usize, from_title: bool }, 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 CampaignEditor, // editable shareable .dccamp campaign file Briefing { mission: usize, ingame: bool }, // story and goals of a mission CustomBriefing, // title and briefing of an editor mission Login, // login to the savegame service (before the title) Account, // password, logout, users (admin) Match { from_title: bool }, // multiplayer lobby and match status Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks Achievements { from_title: bool }, } pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"]; // One adjustable value in the settings dialog #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Item { Language, Volume, MusicVolume, Mute, RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse, Outlines, WallDetail, Lights, UiScale, RenderScale, DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines, GameSpeed, Hints, Events, TouchUi, Voice, Track, } fn tab_items(tab: usize) -> &'static [Item] { use Item::*; match tab { 0 => &[Language, Volume, MusicVolume, Track, Mute, Voice], 1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], 3 => &[GameSpeed, Hints, Events, TouchUi], 4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines], _ => &[], } } fn item_label(it: Item) -> String { tr(match it { Item::Language => "set.language", Item::Volume => "set.volume", Item::MusicVolume => "set.music", Item::Mute => "set.mute", Item::RotateSpeed => "set.rotate_speed", Item::PanSpeed => "set.pan_speed", Item::ZoomSpeed => "set.zoom_speed", Item::EdgeScroll => "set.edge_scroll", Item::InvertMouse => "set.invert", Item::Outlines => "set.outlines", Item::WallDetail => "set.wall_detail", Item::Lights => "set.lights", Item::UiScale => "set.ui_scale", Item::RenderScale => "set.render_scale", Item::DebugFps => "set.debug_fps", Item::DebugFrameTime => "set.debug_frame_time", Item::DebugPing => "set.debug_ping", Item::DebugTriggerZones => "set.debug_trigger_zones", Item::DebugCommandMarkers => "set.debug_command_markers", Item::DebugPathLines => "set.debug_path_lines", Item::GameSpeed => "set.game_speed", Item::Hints => "set.hints", Item::Events => "set.events", Item::TouchUi => "set.touch_ui", Item::Voice => "set.voice", Item::Track => "set.track", }) } fn onoff(b: bool) -> String { tr(if b { "val.on" } else { "val.off" }) } fn pct(v: f32) -> String { format!("{}%", (v * 100.0).round() as i32) } fn item_value(it: Item, s: &Settings) -> String { match it { Item::Language => languages().into_iter().find(|(c, _)| *c == s.language).map(|(_, n)| n).unwrap_or_else(|| s.language.clone()), Item::Volume => pct(s.volume), Item::MusicVolume => pct(s.music_volume), Item::Mute => onoff(s.muted), Item::Voice => onoff(s.voice), Item::Track => crate::audio::track_by_id(&s.music_track).map_or_else(|| tr("val.track_auto"), crate::audio::track_title), Item::RotateSpeed => pct(s.rotate_speed), Item::PanSpeed => pct(s.pan_speed), Item::ZoomSpeed => pct(s.zoom_speed), Item::EdgeScroll => onoff(s.edge_scroll), Item::InvertMouse => onoff(s.invert_mouse), Item::Outlines => onoff(s.outlines), Item::WallDetail => tr(match s.wall_detail { 1 => "val.low", 3 => "val.high", _ => "val.medium", }), Item::Lights => s.max_lights.to_string(), Item::UiScale => { if s.ui_factor == 0 { trf("val.auto", &[crate::ui::F().to_string()]) } else { format!("{}x", s.ui_factor) } } Item::DebugFps => onoff(s.debug_fps), Item::DebugFrameTime => onoff(s.debug_frame_time), Item::DebugPing => onoff(s.debug_ping), Item::DebugTriggerZones => onoff(s.debug_trigger_zones), Item::DebugCommandMarkers => onoff(s.debug_command_markers), Item::DebugPathLines => onoff(s.debug_path_lines), Item::RenderScale => crate::i18n::trf("set.pixel_rows_val", &[s.pixel_rows.to_string()]), Item::GameSpeed => format!("{}x", s.game_speed as i32), Item::Hints => onoff(s.hints), Item::Events => onoff(s.events), Item::TouchUi => match s.touch_ui { 1 => tr("val.on"), 2 => tr("val.off"), _ => trf("val.auto_state", &[tr(if crate::ui::touch_ui() { "val.on" } else { "val.off" })]), }, } } fn cycle(opts: &[T], cur: T, d: i32) -> T { let i = opts.iter().position(|&o| o == cur).unwrap_or(0) as i32; opts[(i + d).rem_euclid(opts.len() as i32) as usize] } fn step(v: f32, d: i32, lo: f32, hi: f32, st: f32) -> f32 { ((v + d as f32 * st) * 100.0).round().clamp(lo * 100.0, hi * 100.0) / 100.0 } // Change a value; d = -1 / +1 (toggles flip either way) fn item_change(it: Item, s: &mut Settings, d: i32) { match it { Item::Language => { let codes: Vec = languages().into_iter().map(|(c, _)| c).collect(); let i = codes.iter().position(|c| *c == s.language).unwrap_or(0) as i32; s.language = codes[(i + d).rem_euclid(codes.len() as i32) as usize].clone(); } Item::Volume => s.volume = step(s.volume, d, 0.0, 1.0, 0.1), Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1), Item::Mute => s.muted = !s.muted, Item::Voice => s.voice = !s.voice, Item::Track => { // automatic, then every calm track let mut opts: Vec = vec![String::new()]; opts.extend(crate::audio::player_tracks().into_iter().map(|t| crate::audio::track_id(t).to_string())); let i = opts.iter().position(|o| *o == s.music_track).unwrap_or(0) as i32; s.music_track = opts[(i + d).rem_euclid(opts.len() as i32) as usize].clone(); } Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25), Item::PanSpeed => s.pan_speed = step(s.pan_speed, d, 0.25, 2.5, 0.25), Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25), Item::EdgeScroll => s.edge_scroll = !s.edge_scroll, Item::InvertMouse => s.invert_mouse = !s.invert_mouse, Item::Outlines => s.outlines = !s.outlines, Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d), Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d), Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d), Item::RenderScale => s.pixel_rows = cycle(&[270u16, 360, 480], if [270, 360, 480].contains(&s.pixel_rows) { s.pixel_rows } else { 360 }, d), Item::DebugFps => s.debug_fps = !s.debug_fps, Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time, Item::DebugPing => s.debug_ping = !s.debug_ping, Item::DebugTriggerZones => s.debug_trigger_zones = !s.debug_trigger_zones, Item::DebugCommandMarkers => s.debug_command_markers = !s.debug_command_markers, Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines, Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), Item::Hints => s.hints = !s.hints, Item::Events => s.events = !s.events, Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d), } } pub fn action_label(a: Action) -> String { match a { Action::Tool(i) => tool_info(TOOLS[i]).name, other => tr(&format!("key.{}", other.id())), } } #[derive(Clone, Copy, PartialEq, Debug)] enum Btn { Continue, Resume, New, Load, Save, Settings, Help, MainMenu, Back, Tab(usize), Change(usize, i32), MapW(i32), MatchSize(i32), Land, MapH(i32), Difficulty(i32), Start, Key(usize), ResetKeys, SaveNew, Slot(usize), // load / overwrite Delete(usize), Scroll(i32), FileSave, FileLoad, NameField, Campaign, CampaignEditor, CampaignNew, CampaignOpen, CampaignSave, CampaignAdd, CampaignDelete, CampaignEditName, CampaignEditTitle, CampaignEditGoals, CampaignEditNext, CampaignEditBranches, CampaignEditScript, CampaignStart, CampaignMission(usize), Mission(usize), StartMission, Goals, Repo, ClaudeCode, OpenCode, Field(usize), DoLogin, Account, Standalone, ChangePassword, Logout, CreateUser, DeleteUser(usize), Match, MatchJoin, MatchJoinRoom(usize), MatchCreate, MatchCoopPrev, MatchCoopNext, MatchCoopCreate, MatchCoopSource, // switch between the built-in campaign and the editor campaign MatchCoopSolo, // the selected co-op mission without the server, with an AI partner MatchCoopResume, // go on with the interrupted co-op run of the selected campaign MatchTake(usize), MatchAi(usize), MatchTeam(usize), // next team for that seat GoldTo, // pause menu: the next ally to receive gold Gold(u32), // pause menu: send that much gold (GOLD_ALL: everything) MatchReady, MatchStart, MatchResume, MatchAbort, MatchLeave, MatchChat, Bestiary, Achievements, Tutorial, BestTab(usize), BestSel(i32), BestPick(usize), BestLevel(i32), LangOpen, // the language dropdown field BackToEditor, } pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft"; pub const CLAUDE_CODE_URL: &str = "https://claude.com/claude-code"; pub const OPENCODE_URL: &str = "https://opencode.ai"; // the game version (file VERSION, "vYYYYMM-NNNNN"): tools/bump_version.py raises it with every // change, and the title screen and the pause menu show it const VERSION_FILE: &str = include_str!("../VERSION"); pub fn version() -> &'static str { VERSION_FILE.trim() } // the Claude Code mark as pixel art (drawn next to the "made with" note) const CLAUDE_MARK: [&str; 5] = [ "...############...", "...##.######.##...", ".################.", "...############...", "....#.#....#.#....", ]; // pixel renditions of the Qwen and the OpenCode mark (the second "made with" note) const QWEN_MARK: [&str; 5] = [ "...###...", ".##.#.##.", "#..###..#", ".##.#.##.", "...###...", ]; const OPENCODE_MARK: [&str; 5] = [ "#######", "#.....#", "#.#...#", "#..#.##", "#######", ]; const CLAUDE_COL: Color = Color { r: 0xd9 as f32 / 255.0, g: 0x77 as f32 / 255.0, b: 0x57 as f32 / 255.0, a: 1.0 }; const QWEN_COL: Color = Color { r: 0x7b as f32 / 255.0, g: 0x6c as f32 / 255.0, b: 0xf6 as f32 / 255.0, a: 1.0 }; const OPENCODE_COL: Color = Color { r: 0.86, g: 0.86, b: 0.88, a: 1.0 }; fn draw_mark(mark: &[&str], x: f32, y: f32, px: f32, c: Color) { for (r, row) in mark.iter().enumerate() { for (k, ch) in row.chars().enumerate() { if ch == '#' { draw_rectangle(x + k as f32 * px, y + r as f32 * px, px, px, c); } } } } pub enum MenuAction { None, Consumed, Continue, // load the autosave Resume, NewGame, // start a new game with the chosen map size Load, Save, Help, MainMenu, SettingsChanged, FileSave, // download a savegame file FileLoad, // choose a savegame file CloudSave(Option, String), // (id to overwrite, name) CloudLoad(String), CloudDelete(String), EditName, // the name field was tapped (touch: ask with a browser dialog) StartMission(usize), CampaignNew, CampaignOpen, CampaignSave, CampaignAdd, CampaignDelete(usize), CampaignEditName, CampaignEditTitle, CampaignEditGoals, CampaignEditNext, CampaignEditBranches, CampaignEditScript, CampaignStart(usize), OpenRepo, OpenClaudeCode, Tutorial, // start the tutorial mission BackToEditor, OpenOpenCode, Login(String, String), Logout, OpenAccount, ChangePassword(String, String), CreateUser(String, String), DeleteUser(String), DownloadStandalone, EditField(usize), // touch: ask for the text with the browser dialog OpenMatch, MatchJoin(String), MatchCreate, MatchCoopCreate(usize), MatchCoopEditorCreate(usize), // co-op match of a mission of `campaign_doc` MatchCoopSolo(Option, usize), // offline with an AI partner: editor campaign or built-in, mission MatchCoopResume(crate::campaign_doc::CampaignDoc), // an interrupted run of this campaign goes on MatchTakeSide(u8), MatchSetAi(u8, bool), MatchSetTeam(u8, u8, bool), // side, team, the own seat SendGold(u8, u32), // ally side, amount MatchReady(bool), MatchStart, MatchResume, MatchAbort, MatchLeave, MatchChat(String), MatchEditField(usize), } pub struct Menu { pub page: Option, 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, // mission being played (pause menu: goals) pub fields: [String; 4], // text fields of the login and account pages pub focus: usize, // field that gets the typed text pub error: Option, // message on the login and account pages pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page pub coop_mission: usize, // co-op campaign mission selected on the Match page match_size: usize, // the versus map size (from the settings, for drawing) pub coop_editor: bool, // the Match page offers a campaign document instead of the built-in missions pub coop_league: bool, // ... and that document is the co-op campaign "League against the Lightwatch" league_doc: Option, // the co-op campaign, built the first time it is chosen pub coop_editor_mission: usize, // mission of `campaign_doc` selected for co-op pub in_match: bool, // a match is being played; the pause menu changes its shape pub gold_allies: Vec, // allies of the local master that can get gold (pause menu) pub gold_to: usize, // the one chosen among them pub editor_test: bool, // a mission of the editor is being tested: back to the editor pub custom_briefing: (String, String), // title and text of an editor mission's briefing pub campaign_doc: CampaignDoc, pub campaign_selected: usize, pub campaign_pending: bool, confirm: Option, // overwrite / delete waiting for a second click scroll: usize, rows_visible: usize, pub profile: crate::achievements::Profile, // shown on the achievements page buttons: Vec<(Rect, Btn)>, lang_open: Option, // the language dropdown is open below/above this field lang_scroll: usize, // first language shown in the open dropdown lang_opts: Vec<(Rect, String)>, // the open dropdown's rows (language codes) lang_rows: usize, // rows the open dropdown shows at once cur_lang: String, // the language in use (for the field and the highlight) } fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) { 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.13, 0.09, 0.08, 1.0) }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent }); // a label too wide for the button gets the small font, and is cut short if that is not enough let mut s = S(); let room = r.w - 8.0; let mut text = label.to_string(); if gfx.measure(&text, s) > room { s = SMALL(); while gfx.measure(&text, s) > room && text.chars().count() > 2 { text.pop(); while text.ends_with(' ') { text.pop(); } if gfx.measure(&format!("{text}…"), s) <= room { text.push('…'); break; } } } gfx.text(&text, r.x + (r.w - gfx.measure(&text, s)) * 0.5, r.y + (r.h - GH * s) * 0.5, s, if hot { FRAME_HI } else { TXT }); } impl Menu { pub fn new() -> Menu { Menu { page: Some(Page::Title), has_autosave: false, save_name: String::new(), progress: Progress::default(), mission: None, fields: Default::default(), focus: 0, error: None, match_ui: MatchUi::default(), coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0), match_size: 128, coop_editor: false, coop_league: false, league_doc: None, coop_editor_mission: 0, in_match: false, gold_allies: Vec::new(), gold_to: 0, editor_test: false, custom_briefing: (String::new(), String::new()), campaign_doc: CampaignDoc::default(), campaign_selected: 0, campaign_pending: false, confirm: None, scroll: 0, lang_open: None, lang_scroll: 0, lang_opts: Vec::new(), lang_rows: 0, cur_lang: String::new(), rows_visible: 1, profile: Default::default(), buttons: Vec::new(), } } pub fn open(&self) -> bool { self.page.is_some() } // Esc: go one level back pub fn back(&mut self) { self.lang_open = None; self.page = match self.page { Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 5, from_title }), Some(Page::Settings { from_title: true, .. }) => Some(Page::Title), Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause), 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::CampaignEditor) => Some(Page::Title), Some(Page::Login) => Some(Page::Login), Some(Page::Account) => Some(Page::Title), Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }), Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title), Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause), Some(Page::Achievements { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }), Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause), Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign), Some(Page::CustomBriefing) => None, Some(Page::Pause) => None, Some(Page::Title) => Some(Page::Title), None => Some(Page::Pause), }; } // opens the savegame list; `name` is the suggested name for a new savegame pub fn open_saves(&mut self, save: bool, from_title: bool, name: String) { self.page = Some(Page::Saves { save, from_title }); self.save_name = name; self.confirm = None; self.scroll = 0; } // pages with text fields take keyboard input pub fn typing(&self) -> bool { matches!(self.page, Some(Page::Saves { save: true, .. }) | Some(Page::Login) | Some(Page::Account) | Some(Page::Match { .. })) } fn input(&mut self) -> &mut String { if matches!(self.page, Some(Page::Saves { .. })) { &mut self.save_name } else { &mut self.fields[self.focus.min(3)] } } pub fn type_char(&mut self, c: char) { let s = self.input(); if !c.is_control() && s.chars().count() < 40 { s.push(c); } } pub fn backspace(&mut self) { self.input().pop(); } // Tab: next text field pub fn next_field(&mut self) { let n = match self.page { Some(Page::Login) => 2, Some(Page::Account) => 4, Some(Page::Match { .. }) => 2, _ => 1, }; self.focus = (self.focus + 1) % n; } // Enter on the login page logs in, on the account page it uses the focused group pub fn submit(&mut self) -> MenuAction { match self.page { Some(Page::Login) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()), Some(Page::Match { .. }) => { let action = if self.focus == 1 { MenuAction::MatchChat(self.fields[1].clone()) } else { MenuAction::MatchJoin(self.fields[0].trim().to_string()) }; if self.focus == 1 { self.fields[1].clear(); } action } Some(Page::Account) if self.focus < 2 => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()), Some(Page::Account) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()), _ => MenuAction::None, } } pub fn open_match(&mut self, from_title: bool) { self.page = Some(Page::Match { from_title }); self.fields[1].clear(); self.focus = 0; self.error = None; self.confirm = None; self.scroll = 0; } pub fn set_match_ui(&mut self, ui: MatchUi) { self.match_ui = ui; } pub fn open_login(&mut self) { self.page = Some(Page::Login); self.fields = Default::default(); self.focus = 0; } pub fn capturing(&self) -> Option { match self.page { Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(), _ => None, } } pub fn finish_capture(&mut self) { if let Some(Page::KeyCapture { from_title, .. }) = self.page { self.page = Some(Page::Settings { tab: 5, from_title }); } } pub fn click(&mut self, x: f32, y: f32, settings: &mut Settings, cloud: &Cloud) -> MenuAction { if self.page.is_none() { return MenuAction::None; } // an open language dropdown takes the click: a language, or anywhere else closes it if self.lang_open.take().is_some() { if let Some((_, code)) = self.lang_opts.iter().find(|(r, _)| r.contains(vec2(x, y))) { settings.language = code.clone(); return MenuAction::SettingsChanged; } return MenuAction::Consumed; } let hit_rect = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(r, _)| *r); let hit = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(_, b)| *b); // overwrite and delete need a second click on the same button let confirm = self.confirm.take(); let save_mode = matches!(self.page, Some(Page::Saves { save: true, .. })); let from_title = matches!(self.page, Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. })); match hit { None => MenuAction::Consumed, Some(Btn::LangOpen) => { self.lang_open = hit_rect; // start the list so that the language in use is visible let k = languages().iter().position(|(c, _)| *c == settings.language).unwrap_or(0); self.lang_scroll = k.saturating_sub(3); MenuAction::Consumed } Some(Btn::Continue) => MenuAction::Continue, Some(Btn::Resume) => { self.page = None; MenuAction::Resume } Some(Btn::New) => { self.page = Some(Page::NewGame); MenuAction::Consumed } Some(Btn::Start) => MenuAction::NewGame, Some(Btn::MatchSize(d)) => { let sizes = crate::lobby::MATCH_SIZES; let k = sizes.iter().position(|&s| s >= settings.match_size).unwrap_or(2) as i32; settings.match_size = sizes[(k + d).clamp(0, sizes.len() as i32 - 1) as usize]; MenuAction::SettingsChanged } Some(Btn::MapW(d)) => { settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; MenuAction::SettingsChanged } Some(Btn::Difficulty(d)) => { settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8; MenuAction::SettingsChanged } Some(Btn::Land) => { settings.surface = !settings.surface; MenuAction::SettingsChanged } Some(Btn::MapH(d)) => { settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize; MenuAction::SettingsChanged } Some(Btn::Load) => MenuAction::Load, Some(Btn::Save) => MenuAction::Save, Some(Btn::Help) => MenuAction::Help, Some(Btn::MainMenu) => MenuAction::MainMenu, Some(Btn::Settings) => { self.page = Some(Page::Settings { tab: 0, from_title }); MenuAction::Consumed } Some(Btn::Back) => { self.back(); MenuAction::Consumed } Some(Btn::Tab(t)) => { self.scroll = 0; self.page = Some(Page::Settings { tab: t, from_title }); MenuAction::Consumed } Some(Btn::Change(i, d)) => { if let Some(Page::Settings { tab, .. }) = self.page { if let Some(&it) = tab_items(tab).get(i) { item_change(it, settings, d); return MenuAction::SettingsChanged; } } MenuAction::Consumed } Some(Btn::Key(a)) => { self.page = Some(Page::KeyCapture { action: a, from_title }); MenuAction::Consumed } Some(Btn::ResetKeys) => { settings.reset_keys(); MenuAction::SettingsChanged } Some(Btn::SaveNew) => { let name = self.save_name.trim().to_string(); MenuAction::CloudSave(None, name) } Some(b @ Btn::Slot(i)) => { let Some(info) = cloud.list.get(i) else { return MenuAction::Consumed }; if !save_mode { MenuAction::CloudLoad(info.id.clone()) } else if confirm == Some(b) { MenuAction::CloudSave(Some(info.id.clone()), info.name.clone()) } else { self.confirm = Some(b); MenuAction::Consumed } } Some(b @ Btn::Delete(i)) => { let Some(info) = cloud.list.get(i) else { return MenuAction::Consumed }; if confirm == Some(b) { MenuAction::CloudDelete(info.id.clone()) } else { self.confirm = Some(b); MenuAction::Consumed } } Some(Btn::Scroll(d)) => { // 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::Repo) => MenuAction::OpenRepo, Some(Btn::ClaudeCode) => MenuAction::OpenClaudeCode, Some(Btn::OpenCode) => MenuAction::OpenOpenCode, Some(Btn::BackToEditor) => MenuAction::BackToEditor, Some(Btn::Field(i)) => { self.focus = i; if matches!(self.page, Some(Page::Match { .. })) { MenuAction::MatchEditField(i) } else { MenuAction::EditField(i) } } Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()), Some(Btn::Account) => { self.page = Some(Page::Account); self.fields = Default::default(); self.focus = 0; self.error = None; MenuAction::OpenAccount } Some(Btn::Standalone) => MenuAction::DownloadStandalone, Some(Btn::ChangePassword) => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()), Some(Btn::Logout) => MenuAction::Logout, Some(Btn::CreateUser) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()), Some(b @ Btn::DeleteUser(i)) => match cloud.users.get(i) { Some(u) if confirm == Some(b) => MenuAction::DeleteUser(u.name.clone()), Some(_) => { self.confirm = Some(b); MenuAction::Consumed } None => MenuAction::Consumed, }, Some(Btn::Match) => { self.open_match(from_title); MenuAction::OpenMatch } Some(Btn::MatchJoin) => MenuAction::MatchJoin(self.fields[0].trim().to_string()), Some(Btn::MatchJoinRoom(i)) => match self.match_ui.rooms.get(i) { Some(room) => MenuAction::MatchJoin(room.code.clone()), None => MenuAction::Consumed, }, Some(Btn::MatchCreate) => MenuAction::MatchCreate, Some(b @ (Btn::MatchCoopPrev | Btn::MatchCoopNext)) => { let dir = if b == Btn::MatchCoopPrev { -1 } else { 1 }; if self.coop_editor { self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), self.coop_editor_mission, dir); } else { self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, dir); } self.confirm = None; MenuAction::Consumed } Some(Btn::MatchCoopSource) => { // built-in missions -> the league -> the editor's campaign (if it has missions) -> ... match (self.coop_editor, self.coop_league) { (false, _) => { if self.league_doc.is_none() { self.league_doc = Some(crate::editor::coop_campaign::coop_campaign()); } self.coop_editor = true; self.coop_league = true; } (true, true) if !self.campaign_doc.missions.is_empty() => self.coop_league = false, _ => { self.coop_editor = false; self.coop_league = false; } } self.coop_editor_mission = 0; if self.coop_editor { self.coop_editor_mission = crate::campaign::next_editor_coop_mission(self.coop_doc(), 0, 0); } self.confirm = None; MenuAction::Consumed } Some(Btn::MatchCoopResume) => MenuAction::MatchCoopResume(self.coop_doc().clone()), Some(Btn::MatchCoopSolo) if self.coop_editor => MenuAction::MatchCoopSolo(Some(self.coop_doc().clone()), self.coop_editor_mission), Some(Btn::MatchCoopSolo) => MenuAction::MatchCoopSolo(None, self.coop_mission), Some(Btn::MatchCoopCreate) if self.coop_editor => MenuAction::MatchCoopEditorCreate(self.coop_editor_mission), Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission), Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready), Some(Btn::MatchStart) => MenuAction::MatchStart, Some(Btn::MatchResume) => MenuAction::MatchResume, Some(b @ Btn::MatchAbort) => { if confirm == Some(b) { MenuAction::MatchAbort } else { self.confirm = Some(b); MenuAction::Consumed } } Some(Btn::MatchLeave) => MenuAction::MatchLeave, Some(Btn::MatchChat) => { let text = self.fields[1].clone(); self.fields[1].clear(); MenuAction::MatchChat(text) } Some(Btn::MatchTake(i)) => match self.match_ui.seats.get(i) { Some(s) => MenuAction::MatchTakeSide(s.side), None => MenuAction::Consumed, }, Some(Btn::MatchTeam(i)) => match self.match_ui.seats.get(i) { Some(s) => MenuAction::MatchSetTeam(s.side, (s.team + 1) % 4, Some(s.side) == self.match_ui.local_side), None => MenuAction::Consumed, }, Some(Btn::GoldTo) => { self.gold_to = (self.gold_to + 1) % self.gold_allies.len().max(1); MenuAction::Consumed } Some(Btn::Gold(amount)) => match self.gold_allies.get(self.gold_to.min(self.gold_allies.len().saturating_sub(1))) { Some(&to) => MenuAction::SendGold(to, amount), None => MenuAction::Consumed, }, Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) { Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai), _ => MenuAction::Consumed, }, Some(Btn::Tutorial) => MenuAction::Tutorial, Some(Btn::Campaign) => { self.page = Some(Page::Campaign); self.scroll = 0; MenuAction::Consumed } Some(Btn::CampaignEditor) => { self.page = Some(Page::CampaignEditor); self.scroll = 0; MenuAction::Consumed } Some(Btn::CampaignNew) => MenuAction::CampaignNew, Some(Btn::CampaignOpen) => MenuAction::CampaignOpen, Some(Btn::CampaignSave) => MenuAction::CampaignSave, Some(Btn::CampaignAdd) => MenuAction::CampaignAdd, Some(Btn::CampaignEditName) => MenuAction::CampaignEditName, Some(Btn::CampaignEditTitle) => MenuAction::CampaignEditTitle, Some(Btn::CampaignEditGoals) => MenuAction::CampaignEditGoals, Some(Btn::CampaignEditNext) => MenuAction::CampaignEditNext, Some(Btn::CampaignEditBranches) => MenuAction::CampaignEditBranches, Some(Btn::CampaignEditScript) => MenuAction::CampaignEditScript, Some(b @ Btn::CampaignDelete) => { let i = self.campaign_selected; if confirm == Some(b) { MenuAction::CampaignDelete(i) } else { self.confirm = Some(b); MenuAction::Consumed } } Some(Btn::CampaignStart) => MenuAction::CampaignStart(self.campaign_selected), Some(Btn::CampaignMission(i)) => { self.campaign_selected = i; 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::Achievements) => { self.page = Some(Page::Achievements { from_title }); self.scroll = 0; MenuAction::Consumed } Some(Btn::Bestiary) => { self.open_bestiary(from_title); MenuAction::Consumed } Some(b @ (Btn::BestTab(_) | Btn::BestSel(_) | Btn::BestPick(_) | Btn::BestLevel(_))) => { self.bestiary_click(b); MenuAction::Consumed } Some(Btn::FileSave) => MenuAction::FileSave, Some(Btn::FileLoad) => MenuAction::FileLoad, } } pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>, cloud: &Cloud) { self.buttons.clear(); self.lang_opts.clear(); self.cur_lang = settings.language.clone(); self.match_size = settings.match_size; let Some(page) = self.page else { self.lang_open = None; return; }; let sw = sw(); let sh = sh(); draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 })); match page { Page::Title => self.draw_title(gfx, cloud), Page::Pause => self.draw_pause(gfx), 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::CampaignEditor => self.draw_campaign_editor(gfx), Page::Login => self.draw_login(gfx, cloud), Page::Account => self.draw_account(gfx, cloud), Page::Match { .. } => self.draw_match(gfx, cloud), Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), Page::CustomBriefing => self.draw_custom_briefing(gfx), Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level), Page::Achievements { .. } => self.draw_achievements(gfx), Page::KeyCapture { action, .. } => { self.draw_settings(gfx, settings, 5); self.buttons.clear(); let a = all_actions()[action]; let text = trf("set.press_key", &[action_label(a)]); let cols = (text.chars().count() as i32 + 8).max(36); let x = grid_center(sw, cols as f32 * CW()); let y = (sh * 0.4 / CH()).floor() * CH(); gfx.window(x, y, cols, 5, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.98)); gfx.text(&text, x + CW() * 4.0, y + CH() * 1.5, S(), TXT); let esc = tr("set.esc_cancel"); gfx.text(&esc, x + CW() * 4.0, y + CH() * 3.0, S(), DIM); } } if let Some(anchor) = self.lang_open { self.draw_lang_list(gfx, anchor); } if let Some(st) = status { // long messages wrap onto several lines let maxc = ((sw / CW()) as usize).saturating_sub(8).clamp(10, 70); let lines = wrap(st, maxc); let w = lines.iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 4.0; let h = CH() * (lines.len() as f32 + 1.0); let x = grid_center(sw, w); let y = sh - CH() * 8.0 - h; draw_rectangle(x, y, w, h, Color::new(0.05, 0.03, 0.04, 0.95)); draw_rectangle_lines(x, y, w, h, 2.0, GOLD); for (k, l) in lines.iter().enumerate() { gfx.text(l, x + CW() * 2.0, y + CH() * (0.5 + k as f32), S(), GOLD); } } } fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) { self.column_pitch(gfx, x, y, w, items, CH() * 3.0); } fn column_pitch(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)], pitch: f32) { let (mx, my) = mouse(); for (k, (b, label)) in items.iter().enumerate() { let r = Rect::new(x, y + k as f32 * pitch, w, CH() * 2.0); button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, *b)); } } // the language dropdown field: flag, the language's own name, an arrow fn lang_field(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32) { let (mx, my) = mouse(); let r = Rect::new(x, y, w, CH() * 2.0); let hot = r.contains(vec2(mx, my)) || self.lang_open == Some(r); 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.13, 0.09, 0.08, 1.0) }); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { FRAME }); let code = self.cur_lang.clone(); let name = languages().into_iter().find(|(c, _)| *c == code).map(|(_, n)| n).unwrap_or_else(|| code.clone()); self.lang_row(gfx, &code, &name, r, if hot { FRAME_HI } else { TXT }); gfx.glyph(if self.lang_open == Some(r) { '▲' } else { '▼' }, r.x + r.w - CW() * 1.5, r.y + CH() * 0.5, S(), if hot { FRAME_HI } else { DIM }); self.buttons.push((r, Btn::LangOpen)); } // a flag and a language name inside a row; returns the x where the text ends fn lang_row(&self, gfx: &Gfx, code: &str, name: &str, r: Rect, col: Color) -> f32 { let px = S(); let fy = r.y + (r.h - flags::FLAG_H as f32 * px) * 0.5; flags::draw_flag(code, (r.x + CW() * 0.75).floor(), fy.floor(), px); let tx = r.x + CW() * 0.75 + flags::FLAG_W as f32 * px + CW(); let maxc = ((r.x + r.w - CW() * 2.0 - tx) / CW()).max(1.0) as usize; let name: String = name.chars().take(maxc).collect(); gfx.text(&name, tx, r.y + (r.h - CH()) * 0.5, S(), col); tx + gfx.measure(&name, S()) } // the open dropdown: all languages under (or, without room, above) the field; scrolls fn draw_lang_list(&mut self, gfx: &Gfx, a: Rect) { let (mx, my) = mouse(); let langs = languages(); let row_h = CH() * 2.0; let below = ((sh() - a.y - a.h - CH()) / row_h).floor().max(0.0) as usize; let above = ((a.y - CH()) / row_h).floor().max(0.0) as usize; let down = below >= langs.len().min(6) || below >= above; let rows = langs.len().min(if down { below } else { above }).max(1); self.lang_rows = rows; self.lang_scroll = self.lang_scroll.min(langs.len().saturating_sub(rows)); let w = a.w.max(CW() * 20.0); let x = a.x.min(sw() - w - CW() * 0.5).max(0.0); let y0 = if down { a.y + a.h } else { a.y - rows as f32 * row_h }; draw_rectangle(x, y0, w, rows as f32 * row_h, Color::new(0.06, 0.04, 0.05, 0.98)); for (k, (code, name)) in langs.iter().enumerate().skip(self.lang_scroll).take(rows) { let r = Rect::new(x, y0 + (k - self.lang_scroll) as f32 * row_h, w, row_h); let hot = r.contains(vec2(mx, my)); let cur = *code == self.cur_lang; if hot || cur { 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.2, 0.13, 0.08, 1.0) }); } self.lang_row(gfx, code, name, r, if hot || cur { FRAME_HI } else { TXT }); self.lang_opts.push((r, code.clone())); } // more languages than rows: arrows show that the list scrolls if self.lang_scroll > 0 { gfx.glyph('▲', x + w - CW() * 1.5, y0 + CH() * 0.5, S(), DIM); } if self.lang_scroll + rows < langs.len() { gfx.glyph('▼', x + w - CW() * 1.5, y0 + rows as f32 * row_h - CH() * 1.5, S(), DIM); } draw_rectangle_lines(x, y0, w, rows as f32 * row_h, 2.0, FRAME_HI); } // mouse wheel / swipe over an open dropdown: scrolls its list; true if it took the motion pub fn lang_wheel(&mut self, d: i32) -> bool { if self.lang_open.is_none() { return false; } let n = languages().len(); self.lang_scroll = (self.lang_scroll as i32 + d).clamp(0, n.saturating_sub(self.lang_rows) as i32) as usize; true } // the language dropdown in a corner of the title and login screens fn corner_lang_field(&mut self, gfx: &Gfx) { let w = CW() * 18.0; self.lang_field(gfx, (sw() - w - CW()).max(0.0), CH(), w); } fn draw_title(&mut self, gfx: &Gfx, cloud: &Cloud) { let sw = sw(); let sh = sh(); let big = TITLE(); let title = "DungeonCraft"; let ty = (sh * 0.12 / CH()).floor() * CH(); gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8)); gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI); let sub = tr("menu.subtitle"); gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), ty + GH * big + CH(), S(), TXT); let mut items = Vec::new(); if self.has_autosave { items.push((Btn::Continue, tr("menu.continue"))); } items.push((Btn::Campaign, tr("menu.campaign"))); items.push((Btn::Tutorial, tr("menu.tutorial"))); items.push((Btn::CampaignEditor, tr("menu.campaign_editor"))); items.push((Btn::Match, tr("menu.multiplayer"))); items.push((Btn::New, tr("menu.new"))); items.push((Btn::Load, tr("menu.load"))); items.push((Btn::Settings, tr("menu.settings"))); if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) { items.push((Btn::Account, trf("menu.account", &[s.name.clone()]))); } items.push((Btn::Standalone, tr("menu.standalone"))); items.push((Btn::Bestiary, tr("menu.bestiary"))); items.push((Btn::Achievements, tr("menu.achievements"))); items.push((Btn::Help, tr("menu.help"))); let w = 26.0 * CW(); let y = ty + GH * big + CH() * 4.0; // the buttons end above the hints and credits; on a short screen they move closer // together or into two columns let bottom = sh - CH() * 9.0; let fits = |pitch: f32| y + items.len() as f32 * pitch <= bottom; if fits(CH() * 3.0) { self.column(gfx, grid_center(sw, w), y, w, &items); } else if fits(CH() * 2.5) { self.column_pitch(gfx, grid_center(sw, w), y, w, &items, CH() * 2.5); } else { let half = (items.len() + 1) / 2; let gap = CW() * 2.0; let x0 = grid_center(sw, w * 2.0 + gap); self.column_pitch(gfx, x0, y, w, &items[..half], CH() * 2.5); self.column_pitch(gfx, x0 + w + gap, y, w, &items[half..], CH() * 2.5); } let hint = tr("menu.drop_hint"); gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM); let credit = tr("help.font"); gfx.text(&credit, grid_center(sw, gfx.measure(&credit, SMALL())), sh - CH() * 7.0, SMALL(), DIM); self.draw_license(gfx); self.corner_lang_field(gfx); } // licence and repository link at the bottom of the screen (title and login) pub fn draw_license(&mut self, gfx: &Gfx) { let (sw, sh) = (sw(), sh()); let (mx, my) = mouse(); // "made with" notes with their pixel marks, links to the tools: // Claude Code, then qwen3.8-flash-next-iq3xxs with Strata in OpenCode let px = (SMALL() * 2.0).round().max(2.0); let credits: [(&[&[&str]], &[Color], String, Color, Btn, f32); 2] = [ (&[&CLAUDE_MARK], &[CLAUDE_COL], tr("menu.made_with"), CLAUDE_COL, Btn::ClaudeCode, 4.6), (&[&QWEN_MARK, &OPENCODE_MARK], &[QWEN_COL, OPENCODE_COL], tr("menu.made_with_qwen"), QWEN_COL, Btn::OpenCode, 3.6), ]; for (marks, cols, made, col, btn, row) in credits { let widths: Vec = marks.iter().map(|m| m[0].len() as f32 * px).collect(); let mark_w: f32 = widths.iter().sum::() + px * 2.0 * (marks.len() as f32 - 1.0); let tw = gfx.measure(&made, SMALL()); let total = mark_w + CW() * 0.5 + tw; let mx0 = grid_center(sw, total); let my0 = sh - CH() * row; let r = Rect::new(mx0, my0, total, GH * SMALL() + 2.0); let hot = r.contains(vec2(mx, my)); let mut x = mx0; for (k, m) in marks.iter().enumerate() { draw_mark(m, x, my0 + (GH * SMALL() - 5.0 * px) * 0.5, px, cols[k]); x += widths[k] + px * 2.0; } gfx.text(&made, mx0 + mark_w + CW() * 0.5, my0, SMALL(), if hot { FRAME_HI } else { col }); self.buttons.push((r, btn)); } let lic = tr("menu.license"); gfx.text(&lic, grid_center(sw, gfx.measure(&lic, SMALL())), sh - CH() * 2.5, SMALL(), DIM); let ver = tr("menu.version"); let line = format!("{} {}", ver, version()); gfx.text(&line, grid_center(sw, gfx.measure(&line, SMALL())), sh - CH() * 5.5, SMALL(), DIM); let url = REPO_URL.trim_start_matches("https://"); let w = gfx.measure(url, SMALL()); let r = Rect::new(grid_center(sw, w), sh - CH() * 1.5, w, GH * SMALL() + 2.0); let hot = r.contains(vec2(mx, my)); gfx.text(url, r.x, r.y, SMALL(), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 1.0) }); draw_rectangle(r.x, r.y + GH * SMALL(), r.w, (SMALL()).max(1.0), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 0.6) }); self.buttons.push((r, Btn::Repo)); } // New game: choose the map size (width and height separately) fn draw_new_game(&mut self, gfx: &Gfx, s: &Settings) { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); let cols = 48.min((sw / CW()) as i32); let rows = 25; 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("menu.new"), FRAME_HI); let diff = tr(&format!("diff.{}", s.difficulty.min(2))); let land = tr(if s.surface { "newgame.land1" } else { "newgame.land0" }); let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2), ("newgame.land", land, 3)]; for (k, (key, val, which)) in lines.iter().enumerate() { let (key, which) = (*key, *which); let by = y + CH() * (2.0 + k as f32 * 2.5); gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT); let vw = 18.0 * CW(); let vx = x + cols as f32 * CW() - vw - CW() * 3.0; let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0); let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0); button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); let v = val.clone(); let vs = if gfx.measure(&v, S()) > vw - CW() * 5.5 { SMALL() } else { S() }; gfx.text(&v, vx + (vw - gfx.measure(&v, vs)) * 0.5, by + CH() * 0.5, vs, GOLD); let (l, r) = match which { 0 => (Btn::MapW(-1), Btn::MapW(1)), 1 => (Btn::MapH(-1), Btn::MapH(1)), 3 => (Btn::Land, Btn::Land), _ => (Btn::Difficulty(-1), Btn::Difficulty(1)), }; self.buttons.push((left, l)); self.buttons.push((right, r)); } // what the size means for the game let area = (s.map_w * s.map_h) as f32 / 4096.0; let portals = (area.round() as usize).max(1); let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]); gfx.text(&info, x + CW() * 3.0, y + CH() * 12.5, S(), DIM); let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" }); let maxc = (cols - 6) as usize; for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() { gfx.text(l, x + CW() * 3.0, y + CH() * (13.5 + k as f32), S(), DIM); } let w = (cols - 6) as f32 * CW(); self.column(gfx, x + CW() * 3.0, y + CH() * 16.5, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]); } fn draw_pause(&mut self, gfx: &Gfx) { let sw = sw(); let sh = sh(); let mut items = vec![(Btn::Resume, tr("menu.resume"))]; if self.editor_test { items.push((Btn::BackToEditor, tr("menu.back_editor"))); } if self.mission.is_some() { items.push((Btn::Goals, tr("menu.goals"))); } if self.in_match { items.extend([ (Btn::Settings, tr("menu.settings")), (Btn::Bestiary, tr("menu.bestiary")), (Btn::Achievements, tr("menu.achievements")), (Btn::Help, tr("menu.help")), (Btn::MainMenu, tr("net.leave")), ]); } else { items.extend([ (Btn::Save, tr("menu.save")), (Btn::Load, tr("menu.load")), (Btn::Match, tr("menu.multiplayer")), (Btn::Settings, tr("menu.settings")), (Btn::Bestiary, tr("menu.bestiary")), (Btn::Achievements, tr("menu.achievements")), (Btn::Help, tr("menu.help")), (Btn::MainMenu, tr("menu.main")), ]); } // a match with allies: gold for one of them (the ally button switches between them) let gold = self.in_match && !self.gold_allies.is_empty(); let cols = if gold { 40 } else { 32 }; let rows = items.len() as i32 * 3 + 4 + if gold { 6 } else { 0 }; let x = grid_center(sw, cols as f32 * CW()); 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("menu.paused"), FRAME_HI); let w = (cols - 6) as f32 * CW(); self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items); if gold { let (mx, my) = mouse(); self.gold_to = self.gold_to.min(self.gold_allies.len() - 1); let to = self.gold_allies[self.gold_to]; let gy = y + CH() * (2.0 + items.len() as f32 * 3.0); let label = trf("gold.send_to", &[tr(&format!("net.side{to}"))]); let r = Rect::new(x + CW() * 3.0, gy, w, CH() * 2.0); button(gfx, r, &label, r.contains(vec2(mx, my)), side_color(Some(to))); self.buttons.push((r, Btn::GoldTo)); let bw = (w - CW() * 3.0) / 4.0; for (k, amount) in crate::netplay::GOLD_AMOUNTS.iter().enumerate() { let label = if *amount == crate::netplay::GOLD_ALL { tr("gold.all") } else { amount.to_string() }; let r = Rect::new(x + CW() * 3.0 + k as f32 * (bw + CW()), gy + CH() * 2.6, bw, CH() * 2.0); button(gfx, r, &label, r.contains(vec2(mx, my)), GOLD); self.buttons.push((r, Btn::Gold(*amount))); } } let ver = tr("menu.version"); let line = format!("{} {}", ver, version()); gfx.text(&line, x + (cols as f32 * CW() - gfx.measure(&line, SMALL())) * 0.5, y + (rows as f32 - 1.7) * CH(), SMALL(), DIM); } fn draw_settings(&mut self, gfx: &Gfx, s: &Settings, tab: usize) { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); let cols = ((sw / CW()) as i32 - 2).min(66); let rows = ((sh / CH()) as i32 - 2).min(34); 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("menu.settings"), FRAME_HI); // tabs let mut tx = x + CW() * 2.0; let ty = y + CH() * 1.5; for (i, key) in TABS.iter().enumerate() { let label = tr(key); let w = gfx.measure(&label, S()) + CW() * 2.0; let r = Rect::new(tx, ty, w, CH() * 2.0); let hot = r.contains(vec2(mx, my)); draw_rectangle(r.x, r.y, r.w, r.h, if i == tab { 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 i == tab || hot { FRAME_HI } else { FRAME }); gfx.text(&label, r.x + CW(), r.y + CH() * 0.5, S(), if i == tab { FRAME_HI } else { TXT }); self.buttons.push((r, Btn::Tab(i))); tx += w + CW(); } let top = ty + CH() * 3.5; if tab == 5 { // key bindings in two columns let acts = all_actions(); let half = (acts.len() + 1) / 2; let colw = (cols as f32 - 6.0) * CW() * 0.5; let rowh = CH(); // on small screens the two columns scroll (mouse wheel, swipe) let vis = (((y + (rows - 6) as f32 * CH() - top) / rowh).floor() as usize).max(1).min(half); self.rows_visible = vis; self.scroll = self.scroll.min(half - vis); for (k, a) in acts.iter().enumerate() { let c = k / half; let r = k % half; if r < self.scroll || r >= self.scroll + vis { continue; } let r = r - self.scroll; let bx = x + CW() * 2.0 + c as f32 * (colw + CW() * 2.0); let by = top + r as f32 * rowh; let rect = Rect::new(bx, by - 2.0, colw, rowh - 2.0); let hot = rect.contains(vec2(mx, my)); if hot { draw_rectangle(rect.x, rect.y, rect.w, rect.h, Color::new(0.25, 0.16, 0.08, 1.0)); } let key = format!("[{}]", key_name(s.key(*a))); let maxc = ((colw - gfx.measure(&key, S()) - CW()) / CW()).max(4.0) as usize; let label: String = action_label(*a).chars().take(maxc).collect(); gfx.text(&label, bx + CW() * 0.5, by, S(), TXT); gfx.text(&key, bx + colw - gfx.measure(&key, S()) - CW() * 0.5, by, S(), GOLD); self.buttons.push((rect, Btn::Key(k))); } if vis < half { let pos = format!("{}-{}/{}", self.scroll + 1, self.scroll + vis, half); gfx.text(&pos, x + cols as f32 * CW() - gfx.measure(&pos, SMALL()) - CW() * 2.0, top - CH() * 1.2, SMALL(), DIM); } let hint = tr("set.keys_hint"); gfx.text(&hint, x + CW() * 2.0, top + vis as f32 * rowh + CH() * 0.5, SMALL(), DIM); let rw = gfx.measure(&tr("set.reset_keys"), S()) + CW() * 4.0; let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.0); button(gfx, rr, &tr("set.reset_keys"), rr.contains(vec2(mx, my)), FRAME); self.buttons.push((rr, Btn::ResetKeys)); } else { for (i, &it) in tab_items(tab).iter().enumerate() { let by = top + i as f32 * CH() * 2.5; gfx.text(&item_label(it), x + CW() * 3.0, by + CH() * 0.5, S(), TXT); // value with < > buttons let vw = 18.0 * CW(); let vx = x + cols as f32 * CW() - vw - CW() * 3.0; if it == Item::Language { self.lang_field(gfx, vx, by, vw); continue; } let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0); let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0); button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME); button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME); let val = item_value(it, s); gfx.text(&val, vx + (vw - gfx.measure(&val, S())) * 0.5, by + CH() * 0.5, S(), GOLD); let mid = Rect::new(vx + CW() * 2.5, by, vw - CW() * 5.0, CH() * 2.0); self.buttons.push((left, Btn::Change(i, -1))); self.buttons.push((right, Btn::Change(i, 1))); self.buttons.push((mid, Btn::Change(i, 1))); } } let bw = gfx.measure(&tr("menu.back"), S()) + CW() * 6.0; let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.0); button(gfx, br, &tr("menu.back"), br.contains(vec2(mx, my)), FRAME); self.buttons.push((br, Btn::Back)); } // Savegame list on the server: save mode (name field, new / overwrite) or load mode fn draw_saves(&mut self, gfx: &Gfx, cloud: &Cloud, save: bool) { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); let cols = ((sw / CW()) as i32 - 2).min(72); 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); let title = tr(if save { "menu.save" } else { "menu.load" }); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); let inner_x = x + CW() * 2.0; let inner_w = (cols - 4) as f32 * CW(); let mut top = y + CH() * 2.0; // name field and "save as new" if save { let label = tr("saves.name"); gfx.text(&label, inner_x, top + CH() * 0.5, S(), TXT); let bl = tr("saves.new"); let bw = gfx.measure(&bl, S()) + CW() * 4.0; let fx = inner_x + gfx.measure(&label, S()) + CW(); let fw = (inner_w - (fx - inner_x) - bw - CW()).max(CW() * 8.0); draw_rectangle(fx, top, fw, CH() * 2.0, Color::new(0.02, 0.01, 0.02, 1.0)); draw_rectangle_lines(fx, top, fw, CH() * 2.0, 2.0, FRAME_HI); self.buttons.push((Rect::new(fx, top, fw, CH() * 2.0), Btn::NameField)); let blink = if (get_time() * 2.0) as i64 % 2 == 0 { "_" } else { " " }; let maxc = ((fw - CW() * 2.0) / CW()).max(1.0) as usize; let shown: String = { let all: Vec = self.save_name.chars().collect(); all[all.len().saturating_sub(maxc - 1)..].iter().collect() }; gfx.text(&format!("{}{}", shown, blink), fx + CW(), top + CH() * 0.5, S(), GOLD); let br = Rect::new(fx + fw + CW(), top, bw, CH() * 2.0); button(gfx, br, &bl, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME); self.buttons.push((br, Btn::SaveNew)); top += CH() * 3.0; } // list let bottom = y + (rows - 5) as f32 * CH(); let row_h = CH() * 4.5; self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1); let n = cloud.list.len(); self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible)); if n == 0 { let msg = tr(if !cloud.listed { "saves.loading" } else { "saves.empty" }); gfx.text(&msg, inner_x + CW(), top + CH(), S(), DIM); } let act_label = tr(if save { "saves.overwrite" } else { "saves.load" }); let del_label = tr("saves.delete"); let sure = tr("saves.sure"); let bw = [&act_label, &del_label, &sure].iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 3.0; let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 }; for (k, info) in cloud.list.iter().enumerate().skip(self.scroll).take(self.rows_visible) { let ry = top + (k - self.scroll) as f32 * row_h; let rr = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5); if rr.contains(vec2(mx, my)) { draw_rectangle(rr.x, rr.y, rr.w, rr.h, Color::new(0.2, 0.13, 0.07, 1.0)); } let text_w = rr.w - bw * 2.0 - CW() * 3.0; let maxc = (text_w / CW()).max(4.0) as usize; let name: String = info.name.chars().take(maxc).collect(); gfx.text(&name, inner_x + CW(), ry + CH() * 0.25, S(), GOLD); let meta = trf( "saves.meta", &[(info.wave + 1).to_string(), format!("{}x{}", info.map_w, info.map_h), duration_string(info.time), date_string(info.saved_at as i64)], ); let meta: String = meta.chars().take(maxc).collect(); gfx.text(&meta, inner_x + CW(), ry + CH() * 2.0, S(), DIM); let b1 = Rect::new(rr.x + rr.w - bw * 2.0 - CW(), ry + CH(), bw, CH() * 2.0); let b2 = Rect::new(rr.x + rr.w - bw, ry + CH(), bw, CH() * 2.0); let c1 = self.confirm == Some(Btn::Slot(k)); let c2 = self.confirm == Some(Btn::Delete(k)); button(gfx, b1, if c1 { &sure } else { &act_label }, b1.contains(vec2(mx, my)) || c1, FRAME); button(gfx, b2, if c2 { &sure } else { &del_label }, b2.contains(vec2(mx, my)) || c2, Color::new(0.6, 0.2, 0.15, 1.0)); self.buttons.push((b1, Btn::Slot(k))); self.buttons.push((b2, Btn::Delete(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 pos = format!("{}-{}/{}", self.scroll + 1, (self.scroll + self.rows_visible).min(n), n); gfx.text(&pos, sx + CW() * 1.25 - gfx.measure(&pos, SMALL()) * 0.5, (top + bottom) * 0.5 - CH() * 0.5, SMALL(), DIM); } if cloud.busy() { let w = tr("saves.wait"); gfx.text(&w, inner_x + inner_w - gfx.measure(&w, SMALL()), bottom + CH() * 0.25, SMALL(), DIM); } // back, and the savegame files as before let by = y + (rows - 4) as f32 * CH(); let back = tr("menu.back"); let bkw = gfx.measure(&back, S()) + CW() * 6.0; let br = Rect::new(inner_x, by, bkw, CH() * 2.0); button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME); self.buttons.push((br, Btn::Back)); let fl = tr(if save { "saves.file_save" } else { "saves.file_load" }); let fw = gfx.measure(&fl, S()) + CW() * 4.0; let fr = Rect::new(inner_x + inner_w - fw, by, fw, CH() * 2.0); 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)); } // First campaign-editor step: load/save shareable missions and show their branching structure. fn draw_campaign_editor(&mut self, gfx: &Gfx) { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); let cols = ((sw / CW()) as i32 - 2).min(72); let rows = ((sh / CH()) as i32 - 2).min(34); 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("campaign.title"), FRAME_HI); let campaign = self.campaign_doc.clone(); let errors = campaign.validate(); let name = if campaign.name.trim().is_empty() { tr("campaign.new") } else { campaign.name.clone() }; let file = campaign.file_name(); let n = campaign.missions.len(); let inner_x = x + CW() * 2.0; let inner_w = (cols - 4) as f32 * CW(); let head = trf("campaign.name", &[name]); let head_w = (gfx.measure(&head, S()) + CW()).max(CW() * 8.0); let head_rect = Rect::new(inner_x, y + CH() * 1.5, head_w, GH * S() + CH() * 0.5); let hot_name = head_rect.contains(vec2(mx, my)); gfx.text(&head, inner_x, y + CH() * 1.5, S(), if hot_name { FRAME_HI } else { GOLD }); if !campaign.author.trim().is_empty() { let author = trf("campaign.author", &[campaign.author.clone()]); gfx.text(&author, inner_x + head_w + CW() * 2.0, y + CH() * 1.5, S(), DIM); } self.buttons.push((head_rect, Btn::CampaignEditName)); let top = if n > 0 { y + CH() * 5.0 } else { y + CH() * 3.5 }; if n > 0 { let field_items = [(Btn::CampaignEditTitle, "T"), (Btn::CampaignEditGoals, "G"), (Btn::CampaignEditNext, "N"), (Btn::CampaignEditBranches, "B"), (Btn::CampaignEditScript, "S")]; let widths: Vec = field_items.iter().map(|(_, l)| (gfx.measure(l, S()) + CW() * 2.5).max(CW() * 3.5)).collect(); let total_w = widths.iter().sum::() + (field_items.len() as f32 - 1.0) * CW() * 0.5; let mut fx = x + cols as f32 * CW() - CW() * 2.0 - total_w; for (k, (b, l)) in field_items.iter().enumerate() { let r = Rect::new(fx, y + CH() * 2.8, widths[k], CH() * 1.8); button(gfx, r, l, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, *b)); fx += widths[k] + CW() * 0.5; } } let bottom = y + (rows - 5) as f32 * CH(); let row_h = CH() * 3.0; 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 }; if n == 0 { gfx.text(&tr("campaign.empty"), inner_x, top, S(), DIM); } for (k, mission) in campaign.missions.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, row_h - CH() * 0.5); let selected = k == self.campaign_selected; let hot = r.contains(vec2(mx, my)); draw_rectangle(r.x, r.y, r.w, r.h, if selected { Color::new(0.32, 0.21, 0.1, 1.0) } else if hot { Color::new(0.18, 0.12, 0.08, 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 selected { GOLD } else if hot { FRAME_HI } else { FRAME }); let title = if mission.title.trim().is_empty() { mission.key.clone() } else { mission.title.clone() }; let line = trf("campaign.mission_no", &[(k + 1).to_string(), 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 selected { GOLD } else { TXT }); let next = mission.next.clone().unwrap_or_default(); let branch_count = mission.branches.len(); let detail = if branch_count > 0 { trf("campaign.branches", &[branch_count.to_string()]) } else if !next.is_empty() { trf("campaign.next", &[next]) } else if !mission.goals.is_empty() { trf("campaign.goals", &[mission.goals.join(", ")]) } else { tr("campaign.empty") }; let script_count = mission.script.as_ref().map(|s| if s.source.trim().is_empty() { 0 } else { 1 }).unwrap_or(0); let zone_count = mission.zones.len(); let detail = if zone_count > 0 || script_count > 0 { format!("{} · {} · {}", detail, trf("campaign.zones", &[zone_count.to_string()]), trf("campaign.script", &[script_count.to_string()])) } else { detail }; let maxc = ((r.w - CW() * 2.0) / GW.max(1.0) / (SMALL().max(1.0))) as usize; let detail: String = detail.chars().take(maxc.max(20)).collect(); gfx.text(&detail, r.x + CW(), r.y + CH() * 1.6, SMALL(), DIM); self.buttons.push((r, Btn::CampaignMission(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 count = trf("campaign.missions", &[n.to_string()]); gfx.text(&count, inner_x, bottom + CH() * 0.5, SMALL(), DIM); let file_line = format!("{} · {}", file, tr("menu.drop_hint")); let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize; let file_line: String = file_line.chars().take(maxc.max(20)).collect(); gfx.text(&file_line, x + cols as f32 * CW() - gfx.measure(&file_line, SMALL()) - CW() * 2.0, bottom + CH() * 0.5, SMALL(), DIM); if !errors.is_empty() { let err = errors.iter().map(|e| e.as_str()).collect::>().join(" | "); let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize; for l in wrap(&err, maxc).iter().take(3) { gfx.text(l, inner_x, bottom + CH() * 1.5, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0)); } } let by = y + (rows as f32 - 3.5) * CH(); let right = x + cols as f32 * CW() - CW() * 2.0; let mut bx = inner_x; let mut actions = vec![(Btn::CampaignNew, tr("campaign.new")), (Btn::CampaignAdd, tr("campaign.add"))]; if n > 0 { actions.push((Btn::CampaignStart, tr("campaign.start"))); actions.push((Btn::CampaignDelete, tr("campaign.delete"))); } actions.extend([(Btn::CampaignOpen, tr("campaign.open")), (Btn::CampaignSave, tr("campaign.save")), (Btn::Back, tr("menu.back"))]); for (b, label) in actions { let w = (gfx.measure(&label, S()) + CW() * 4.0).max(CW() * 6.0); let r = Rect::new(bx, by, w, CH() * 2.0); if r.x + r.w <= right { button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, b)); } bx += w + CW(); } } // 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 = 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) // the briefing of an editor mission: its text (scrolls) and a button to begin fn draw_custom_briefing(&mut self, gfx: &Gfx) { let (sw, sh) = (sw(), sh()); let cols = ((sw / CW()) as i32 - 2).min(64); let rows = ((sh / CH()) as i32 - 2).min(28); 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, story) = self.custom_briefing.clone(); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI); let maxc = (cols - 6) as usize; let mut lines: Vec = Vec::new(); for p in story.split('\n') { lines.extend(wrap(p, maxc)); if wrap(p, maxc).is_empty() { lines.push(String::new()); } } self.rows_visible = (rows as usize).saturating_sub(7).max(1); self.scroll = self.scroll.min(lines.len().saturating_sub(self.rows_visible)); for (k, l) in lines.iter().skip(self.scroll).take(self.rows_visible).enumerate() { gfx.text(l, x + CW() * 3.0, y + CH() * (2.0 + k as f32), S(), TXT); } let label = tr("camp.start"); let w = gfx.measure(&label, S()) + CW() * 6.0; self.column(gfx, x + (cols as f32 * CW() - w) * 0.5, y + (rows - 4) as f32 * CH(), w, &[(Btn::Resume, label)]); } // the campaign document the co-op create offers: the league or the editor's campaign pub fn coop_doc(&self) -> &CampaignDoc { match (&self.league_doc, self.coop_league) { (Some(d), true) => d, _ => &self.campaign_doc, } } pub fn scrollable(&self) -> bool { matches!( self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }) | Some(Page::CustomBriefing) | Some(Page::Settings { tab: 5, .. }) | Some(Page::Match { .. }) | Some(Page::Account) | Some(Page::Achievements { .. }) ) } // mouse wheel or a swipe: scrolls what is under it (an open dropdown first); d lines down pub fn wheel(&mut self, d: i32) { if self.lang_wheel(d) { return; } if matches!(self.page, Some(Page::Bestiary { .. })) { self.bestiary_wheel(d); } else if self.scrollable() { self.scroll_lines(d); } } pub fn scroll_lines(&mut self, d: i32) { self.scroll = (self.scroll as i32 + d).max(0) as usize; } // a text field; passwords are shown as stars fn field(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, label: &str, i: usize, secret: bool) { gfx.text(label, x, y + CH() * 0.5, S(), TXT); let lw = 16.0 * CW(); let r = Rect::new(x + lw, y, w - lw, CH() * 2.0); let focused = self.focus == i; draw_rectangle(r.x, r.y, r.w, r.h, Color::new(0.02, 0.01, 0.02, 1.0)); draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if focused { FRAME_HI } else { FRAME }); let shown: String = if secret { "*".repeat(self.fields[i].chars().count()) } else { self.fields[i].clone() }; let maxc = ((r.w - CW() * 2.0) / CW()).max(1.0) as usize; let all: Vec = shown.chars().collect(); let mut text: String = all[all.len().saturating_sub(maxc - 1)..].iter().collect(); if focused && (get_time() * 2.0) as i64 % 2 == 0 { text.push('_'); } gfx.text(&text, r.x + CW(), r.y + CH() * 0.5, S(), GOLD); self.buttons.push((r, Btn::Field(i))); } // Login to the server (the first login creates the admin account) fn draw_login(&mut self, gfx: &Gfx, cloud: &Cloud) { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); let big = TITLE(); let title = "DungeonCraft"; let ty = (sh * 0.12 / CH()).floor() * CH(); gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8)); gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI); let cols = ((sw / CW()) as i32 - 2).min(50); let x = grid_center(sw, cols as f32 * CW()).max(0.0); let y = ty + GH * big + CH() * 3.0; let w = (cols - 6) as f32 * CW(); let label = tr(if cloud.setup { "login.create_admin" } else { "login.button" }); let standalone_label = tr("menu.standalone"); let bw = gfx.measure(&label, S()) + CW() * 6.0; let standalone_bw = (gfx.measure(&standalone_label, S()) + CW() * 4.0).min(w); let side_by_side = standalone_bw + bw + CW() * 1.5 <= w; let rows = if side_by_side { 13 } else { 15 }; let head = tr(if cloud.setup { "login.setup" } else { "login.title" }); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI); if cloud.setup { gfx.text(&tr("login.setup_hint"), x + CW() * 3.0, y + CH() * 1.5, SMALL(), DIM); } self.field(gfx, x + CW() * 3.0, y + CH() * 3.0, w, &tr("login.name"), 0, false); self.field(gfx, x + CW() * 3.0, y + CH() * 5.5, w, &tr("login.password"), 1, true); if let Some(e) = &self.error { let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize; for (k, l) in wrap(e, maxc).iter().take(2).enumerate() { gfx.text(l, x + CW() * 3.0, y + CH() * 7.9 + k as f32 * GH * SMALL() * 1.2, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0)); } } if side_by_side { let sr = Rect::new(x + CW() * 3.0, y + CH() * 9.5, standalone_bw, CH() * 2.0); button(gfx, sr, &standalone_label, sr.contains(vec2(mx, my)) && !cloud.busy(), FRAME); self.buttons.push((sr, Btn::Standalone)); let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 9.5, bw, CH() * 2.0); button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI); self.buttons.push((br, Btn::DoLogin)); } else { let sr = Rect::new(x + CW() * 3.0, y + CH() * 9.5, w, CH() * 2.0); button(gfx, sr, &standalone_label, sr.contains(vec2(mx, my)) && !cloud.busy(), FRAME); self.buttons.push((sr, Btn::Standalone)); let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 12.5, bw, CH() * 2.0); button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI); self.buttons.push((br, Btn::DoLogin)); } self.draw_license(gfx); self.corner_lang_field(gfx); } // Account: change the password, log out; the admin manages the users fn draw_account(&mut self, gfx: &Gfx, cloud: &Cloud) { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); let admin = cloud.session.as_ref().map_or(false, |s| s.admin); let cols = ((sw / CW()) as i32 - 2).min(60); let rows = ((sh / CH()) as i32 - 2).min(if admin { 38 } else { 18 }); 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 who = cloud.session.as_ref().map(|s| s.name.clone()).unwrap_or_default(); let head = trf(if admin { "account.title_admin" } else { "account.title" }, &[who]); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI); let ix = x + CW() * 3.0; let w = (cols - 6) as f32 * CW(); gfx.text(&tr("account.change_pw"), ix, y + CH() * 2.0, S(), GOLD); self.field(gfx, ix, y + CH() * 3.5, w, &tr("account.old_pw"), 0, true); self.field(gfx, ix, y + CH() * 6.0, w, &tr("account.new_pw"), 1, true); let cl = tr("account.save_pw"); let cw2 = gfx.measure(&cl, S()) + CW() * 4.0; let cr = Rect::new(ix + w - cw2, y + CH() * 8.5, cw2, CH() * 2.0); button(gfx, cr, &cl, cr.contains(vec2(mx, my)), FRAME); self.buttons.push((cr, Btn::ChangePassword)); let mut yy = y + CH() * 11.5; if admin { gfx.text(&tr("account.users"), ix, yy, S(), GOLD); yy += CH() * 1.5; self.field(gfx, ix, yy, w, &tr("account.user_name"), 2, false); self.field(gfx, ix, yy + CH() * 2.5, w, &tr("account.user_pw"), 3, true); let nl = tr("account.create"); let nw = gfx.measure(&nl, S()) + CW() * 4.0; let nr = Rect::new(ix + w - nw, yy + CH() * 5.0, nw, CH() * 2.0); button(gfx, nr, &nl, nr.contains(vec2(mx, my)), FRAME); self.buttons.push((nr, Btn::CreateUser)); yy += CH() * 8.0; let del = tr("saves.delete"); let sure = tr("saves.sure"); let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0; let bottom = y + (rows - 5) as f32 * CH(); // more users than fit: the list scrolls (mouse wheel, swipe) let vis = (((bottom - yy) / (CH() * 2.5)).floor() as usize).max(1); self.rows_visible = vis; self.scroll = self.scroll.min(cloud.users.len().saturating_sub(vis)); if cloud.users.len() > vis { let pos = format!("{}-{}/{}", self.scroll + 1, (self.scroll + vis).min(cloud.users.len()), cloud.users.len()); gfx.text(&pos, ix + w - gfx.measure(&pos, SMALL()), yy - CH() * 1.2, SMALL(), DIM); } for (k, u) in cloud.users.iter().enumerate().skip(self.scroll).take(vis) { let ry = yy + (k - self.scroll) as f32 * CH() * 2.5; let label = if u.admin { format!("{} (admin)", u.name) } else { u.name.clone() }; gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT); if !u.admin { let r = Rect::new(ix + w - dw, ry, dw, CH() * 2.0); let c = self.confirm == Some(Btn::DeleteUser(k)); button(gfx, r, if c { &sure } else { &del }, r.contains(vec2(mx, my)) || c, Color::new(0.6, 0.2, 0.15, 1.0)); self.buttons.push((r, Btn::DeleteUser(k))); } } } let _ = yy; if let Some(e) = &self.error { gfx.text(e, ix, y + (rows - 5) as f32 * CH() + CH() * 0.5, S(), Color::new(0.95, 0.4, 0.35, 1.0)); } let by = y + (rows - 4) as f32 * CH(); let back = tr("menu.back"); let br = Rect::new(ix - CW(), 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)); let lo = tr("account.logout"); let lw = gfx.measure(&lo, S()) + CW() * 4.0; let lr = Rect::new(ix + w - lw, by, lw, CH() * 2.0); button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME); self.buttons.push((lr, Btn::Logout)); } fn draw_match(&mut self, gfx: &Gfx, _cloud: &Cloud) { let sw = sw(); let sh = sh(); let (mx, my) = mouse(); let ui = self.match_ui.clone(); let cols = ((sw / CW()) as i32 - 2).min(72); 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 ix = x + CW() * 3.0; let w = (cols - 6) as f32 * CW(); gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("net.title"), FRAME_HI); let head = match ui.phase { crate::lobby::Phase::Offline => tr("net.status_offline"), crate::lobby::Phase::Lobby => tr("net.status_lobby"), crate::lobby::Phase::InLobby => trf("net.status_lobby_room", &[ui.code.clone().unwrap_or_default()]), crate::lobby::Phase::Playing => trf("net.status_playing", &[ui.code.clone().unwrap_or_default()]), crate::lobby::Phase::Closed => trf("net.status_closed", &[ui.status.clone().unwrap_or_else(|| "?".into())]), }; gfx.text(&head, ix, y + CH() * 1.6, S(), GOLD); let state = format!("{} · {} · {}", ui.name, ui.turn.to_string(), if ui.paused { tr("net.paused") } else if ui.missing { tr("net.missing") } else { String::new() }); gfx.text(&state, ix, y + CH() * 3.0, SMALL(), DIM); let mut yy = y + CH() * 4.5; if let Some(e) = &self.error { gfx.text(e, ix, yy, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0)); yy += CH() * 1.6; } else if let Some(s) = &ui.status { gfx.text(s, ix, yy, SMALL(), DIM); yy += CH() * 1.6; } // the editor campaign can only be offered while it has missions (it may have been replaced) let doc = self.coop_doc().clone(); let editor_coop = self.coop_editor && !doc.missions.is_empty(); let editor_list = if editor_coop { crate::campaign::editor_coop_indexes(&doc) } else { Vec::new() }; if editor_coop && !editor_list.contains(&self.coop_editor_mission) { self.coop_editor_mission = editor_list.first().copied().unwrap_or(0); } if !ui.started && !ui.room_open() && editor_coop { let line = match doc.missions.get(self.coop_editor_mission).filter(|_| !editor_list.is_empty()) { Some(m) => { let name = if doc.name.trim().is_empty() { "?".to_string() } else { doc.name.clone() }; let title = if m.title.trim().is_empty() { m.key.clone() } else { m.title.clone() }; trf("net.coop_editor_mission", &[name, (self.coop_editor_mission + 1).to_string(), title]) } None => tr("net.coop_editor_none"), }; let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize; for text in wrap(&line, maxc).iter().take(2) { gfx.text(text, ix, yy, SMALL(), GOLD); yy += GH * SMALL() * 1.35; } } else if !ui.started && !ui.room_open() { if let Some(d) = missions().get(self.coop_mission) { let line = trf("net.coop_mission", &[(self.coop_mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]); let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize; for (_k, text) in wrap(&line, maxc).iter().take(2).enumerate() { gfx.text(text, ix, yy, SMALL(), GOLD); yy += GH * SMALL() * 1.35; } } } // a new versus match: the map size and with it the number of seats if !ui.started && !ui.room_open() { let n = self.match_size; let seats = crate::lobby::seats_for(n).len(); let r = Rect::new(ix, yy, 4.0 * CW(), CH() * 2.0); button(gfx, r, "<", r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchSize(-1))); gfx.text(&trf("net.map_size", &[n.to_string(), n.to_string(), seats.to_string()]), ix + 5.0 * CW(), yy + CH() * 0.5, S(), TXT); let r = Rect::new(ix + 30.0 * CW(), yy, 4.0 * CW(), CH() * 2.0); button(gfx, r, ">", r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchSize(1))); yy += CH() * 2.4; } self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false); self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false); yy += CH() * 4.8; let mut items: Vec<(Btn, String)> = Vec::new(); if !ui.started { items.push((Btn::MatchCreate, tr("net.create"))); items.push((Btn::MatchJoin, tr("net.join"))); if !ui.room_open() { if !editor_coop || !editor_list.is_empty() { items.push((Btn::MatchCoopPrev, "<".to_string())); items.push((Btn::MatchCoopCreate, tr("net.coop_create"))); items.push((Btn::MatchCoopNext, ">".to_string())); items.push((Btn::MatchCoopSolo, tr("net.coop_solo"))); } // an interrupted run of the shown campaign can go on (online, or offline with the AI) if editor_coop { if let Some((m, _, _)) = crate::coop_resume_of(&doc) { items.push((Btn::MatchCoopResume, trf("net.coop_resume", &[(m + 1).to_string()]))); } } // the button names what comes next let next = match (editor_coop, self.coop_league) { (false, _) => "net.coop_league", (true, true) if !self.campaign_doc.missions.is_empty() => "net.coop_editor", _ => "net.coop_builtin", }; items.push((Btn::MatchCoopSource, tr(next))); } if ui.room_open() { items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" }))); if ui.is_host { items.push((Btn::MatchStart, tr("net.start"))); } } } else { items.push((Btn::MatchChat, tr("net.send"))); if ui.is_host { if ui.paused || ui.missing { items.push((Btn::MatchResume, tr("net.resume"))); } items.push((Btn::MatchAbort, tr("net.abort"))); } } if ui.room_open() { items.push((Btn::MatchLeave, tr("net.leave"))); } items.push((Btn::Back, tr("menu.back"))); // three buttons a row over the whole width let bw = ((w - 2.0 * CW()) / 3.0).max(14.0 * CW()); let shown = items.iter().take(9).count(); for (k, (b, label)) in items.iter().take(shown).enumerate() { let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0); button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, *b)); } yy += CH() * 2.4 * ((shown + 2) / 3) as f32; if !ui.rooms.is_empty() && !ui.started { gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD); yy += CH() * 1.4; // more than four open matches: the list scrolls (mouse wheel, swipe) self.rows_visible = 4; self.scroll = self.scroll.min(ui.rooms.len().saturating_sub(4)); if ui.rooms.len() > 4 { let pos = format!("{}-{}/{}", self.scroll + 1, self.scroll + 4, ui.rooms.len()); gfx.text(&pos, ix + 25.0 * CW(), yy - CH() * 1.4, SMALL(), DIM); } for (k, room) in ui.rooms.iter().enumerate().skip(self.scroll).take(4) { let label = format!("{} {}{}{}", room.code, room.players, if room.started { " *" } else { "" }, if room.paused { " p" } else { "" }); let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0); button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchJoinRoom(k))); // what the room plays: an editor campaign (name and mission), a co-op mission or versus let what = match (&room.campaign, room.mission) { (Some(name), _) => { let name = if name.trim().is_empty() { "?".to_string() } else { name.clone() }; match &room.title { Some(t) if !t.trim().is_empty() => format!("{name}: {t}"), _ => name, } } (None, Some(m)) => missions().get(m).map_or_else(|| tr("net.coop_create"), |d| tr(&format!("camp.{}.title", d.key))), (None, None) => tr("net.versus"), }; let maxc = ((w - 25.0 * CW()) / (GW * SMALL())).max(4.0) as usize; let what: String = what.chars().take(maxc).collect(); gfx.text(&what, ix + 25.0 * CW(), yy + (CH() * 2.0 - GH * SMALL()) * 0.5, SMALL(), if room.campaign.is_some() { GOLD } else { DIM }); yy += CH() * 2.2; } } else if ui.rooms.is_empty() && !ui.started { gfx.text(&tr("net.no_rooms"), ix, yy, SMALL(), DIM); yy += CH() * 2.0; } if ui.room_open() { gfx.text(&tr("net.seats"), ix, yy, S(), GOLD); yy += CH() * 1.4; let seat_cols = (cols - 6) as usize; for (k, s) in ui.seats.iter().take(4).enumerate() { let is_local = Some(s.side) == ui.local_side; let name = if is_local { format!("{} ({})", s.name, tr("net.you")) } else { s.name.clone() }; let side_label = match s.side { 0 => tr("net.side0"), 2 => tr("net.side2"), 3 => tr("net.side3"), 4 => tr("net.side4"), _ => s.side.to_string(), }; let status = if s.connected { "●" } else if s.ai { "AI" } else { "○" }; let ready = if s.ready || s.ai { "✓" } else { "—" }; let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() }); gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM }); // versus: every seat chooses its team (the host also for free and AI seats) if !ui.coop { let team = trf("net.team", &[((b'A' + s.team.min(3)) as char).to_string()]); let r = Rect::new(ix + 49.0 * CW(), yy, 10.0 * CW(), CH() * 2.0); let may = !ui.started && (is_local || (ui.is_host && !s.connected)); if may { button(gfx, r, &team, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchTeam(k))); } else { gfx.text(&team, r.x + CW(), yy + CH() * 0.5, SMALL(), DIM); } } if !ui.started && ui.local_side.is_some() && !s.connected { if ui.is_host && s.side != 0 { if s.ai { let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); let label = tr("net.ai_off"); button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchAi(k))); } else { let join_label = if is_local { tr("net.you") } else { tr("net.join") }; let r = Rect::new(ix + 22.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); button(gfx, r, &join_label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchTake(k))); let ai_label = tr("net.ai"); let r = Rect::new(ix + 36.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); button(gfx, r, &ai_label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchAi(k))); } } else if !s.ai { let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); let label = if is_local { tr("net.you") } else { tr("net.join") }; button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, Btn::MatchTake(k))); } } yy += CH() * 2.0; } let _ = seat_cols; } if !ui.chat.is_empty() { yy = (y + (rows - 5) as f32 * CH()).max(yy); gfx.text(&tr("net.chat"), ix, yy, SMALL(), DIM); for (side, name, text) in ui.chat.iter().rev().take(3) { gfx.text(&format!("{}: {}", name, text), ix, yy + SMALL() * GH * 1.2, SMALL(), side_color(*side)); yy += SMALL() * GH * 1.2; } } } } // 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()]), Goal::Rivals => tr("goal.rivals"), Goal::Springs(n) => trf("goal.springs", &[n.to_string()]), Goal::Freed(n) => trf("goal.freed", &[n.to_string()]), } } #[cfg(test)] mod tests { use super::{Menu, Page}; #[test] fn the_multiplayer_page_remembers_where_it_came_from() { let mut from_pause = Menu::new(); from_pause.page = Some(Page::Pause); from_pause.open_match(false); from_pause.back(); assert_eq!(from_pause.page, Some(Page::Pause), "back from a match menu goes back to the pause menu"); let mut from_title = Menu::new(); from_title.open_match(true); from_title.back(); assert_eq!(from_title.page, Some(Page::Title), "back from the title multiplayer page goes to the title"); } #[test] fn the_version_follows_the_scheme() { let rest = super::version().strip_prefix('v').expect("the version starts with v"); let (month, number) = rest.split_once('-').expect("the version is vYYYYMM-NNNNN"); assert_eq!(month.len(), 6); assert!(month.chars().all(|c| c.is_ascii_digit())); assert_eq!(number.len(), 5); assert!(number.parse::().expect("a number") >= 1); } }