// Menus: title screen, pause menu, savegame list (server) and the settings dialog // (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, wrap, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; use macroquad::prelude::*; #[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 Briefing { mission: usize, ingame: bool }, // story and goals of a mission Login, // login to the savegame service (before the title) Account, // password, logout, users (admin) } pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"]; // One adjustable value in the settings dialog #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Item { Language, Volume, MusicVolume, Mute, RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse, Outlines, WallDetail, Lights, UiScale, RenderScale, GameSpeed, Hints, TouchUi, } fn tab_items(tab: usize) -> &'static [Item] { use Item::*; match tab { 0 => &[Language, Volume, MusicVolume, Mute], 1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse], 2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale], 3 => &[GameSpeed, Hints, TouchUi], _ => &[], } } 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::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::GameSpeed => "set.game_speed", Item::Hints => "set.hints", Item::TouchUi => "set.touch_ui", }) } 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::RotateSpeed => pct(s.rotate_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::RenderScale => { // the setting is for a 1080 pixel high window; show what applies to this one let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh()); if now as u8 == s.render_scale.max(1) { format!("1/{}", s.render_scale.max(1)) } else { format!("1/{} (1/{})", s.render_scale.max(1), now) } } Item::GameSpeed => format!("{}x", s.game_speed as i32), Item::Hints => onoff(s.hints), 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::RotateSpeed => s.rotate_speed = step(s.rotate_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.render_scale = cycle(&(1u8..=16).collect::>(), s.render_scale.clamp(1, 16), d), Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d), Item::Hints => s.hints = !s.hints, 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), MapH(i32), Difficulty(i32), Start, Key(usize), ResetKeys, SaveNew, Slot(usize), // load / overwrite Delete(usize), Scroll(i32), FileSave, FileLoad, NameField, Campaign, Mission(usize), StartMission, Goals, Repo, Field(usize), DoLogin, Account, Standalone, ChangePassword, Logout, CreateUser, DeleteUser(usize), } pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft"; 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), OpenRepo, 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 } 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 confirm: Option, // overwrite / delete waiting for a second click scroll: usize, rows_visible: usize, buttons: Vec<(Rect, Btn)>, } 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 }); let s = S(); gfx.text(label, r.x + (r.w - gfx.measure(label, 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, confirm: None, scroll: 0, rows_visible: 1, buttons: Vec::new(), } } pub fn open(&self) -> bool { self.page.is_some() } // Esc: go one level back pub fn back(&mut self) { self.page = match self.page { Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 4, 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::Login) => Some(Page::Login), Some(Page::Account) => 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), }; } // 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)) } 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, _ => 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::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_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: 4, 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; } 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::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::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::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.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::Field(i)) => { self.focus = i; 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::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, } } pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>, cloud: &Cloud) { self.buttons.clear(); let Some(page) = self.page else { 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::Login => self.draw_login(gfx, cloud), Page::Account => self.draw_account(gfx, cloud), Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), Page::KeyCapture { action, .. } => { self.draw_settings(gfx, settings, 4); 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(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)]) { let (mx, my) = mouse(); for (k, (b, label)) in items.iter().enumerate() { let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.0); button(gfx, r, label, r.contains(vec2(mx, my)), FRAME); self.buttons.push((r, *b)); } } 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::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::Help, tr("menu.help"))); let w = 26.0 * CW(); let y = ty + GH * big + CH() * 4.0; self.column(gfx, grid_center(sw, w), y, w, &items); 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); } // 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(); let lic = tr("menu.license"); gfx.text(&lic, grid_center(sw, gfx.measure(&lic, SMALL())), sh - CH() * 2.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 = 22; 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 lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2)]; 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 = 14.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(); gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD); let (l, r) = match which { 0 => (Btn::MapW(-1), Btn::MapW(1)), 1 => (Btn::MapH(-1), Btn::MapH(1)), _ => (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() * 10.0, 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() * (11.0 + k as f32), S(), DIM); } let w = (cols - 6) as f32 * CW(); self.column(gfx, x + CW() * 3.0, y + CH() * 14.0, 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.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()); 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); } 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 == 4 { // 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(); for (k, a) in acts.iter().enumerate() { let c = k / half; let r = k % half; 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))); } let hint = tr("set.keys_hint"); gfx.text(&hint, x + CW() * 2.0, top + half 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; 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)); } // 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) 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; } // 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 rows = 13; let x = grid_center(sw, cols as f32 * CW()).max(0.0); let y = ty + GH * big + CH() * 3.0; 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); let w = (cols - 6) as f32 * CW(); 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)); } } let label = tr(if cloud.setup { "login.create_admin" } else { "login.button" }); let bw = gfx.measure(&label, S()) + CW() * 6.0; 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)); self.draw_license(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(); for (k, u) in cloud.users.iter().enumerate() { let ry = yy + k as f32 * CH() * 2.5; if ry + CH() * 2.0 > bottom { break; } 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)); } } // 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()]), } }