// 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::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, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT}; use macroquad::prelude::*; mod bestiary; #[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) Match, // multiplayer lobby and match status Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks } 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, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse, Outlines, WallDetail, Lights, UiScale, RenderScale, GameSpeed, Hints, TouchUi, Voice, } fn tab_items(tab: usize) -> &'static [Item] { use Item::*; match tab { 0 => &[Language, Volume, MusicVolume, Mute, Voice], 1 => &[RotateSpeed, PanSpeed, 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::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::GameSpeed => "set.game_speed", Item::Hints => "set.hints", Item::TouchUi => "set.touch_ui", Item::Voice => "set.voice", }) } 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::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::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::Voice => s.voice = !s.voice, 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.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, ClaudeCode, Field(usize), DoLogin, Account, Standalone, ChangePassword, Logout, CreateUser, DeleteUser(usize), Match, MatchJoin, MatchJoinRoom(usize), MatchCreate, MatchTake(usize), MatchReady, MatchStart, MatchResume, MatchAbort, MatchLeave, MatchChat, Bestiary, BestTab(usize), BestSel(i32), BestPick(usize), BestLevel(i32), } pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft"; pub const CLAUDE_CODE_URL: &str = "https://claude.com/claude-code"; // 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] = [ "...############...", "...##.######.##...", ".################.", "...############...", "....#.#....#.#....", ]; pub fn draw_claude_mark(x: f32, y: f32, px: f32) { let c = Color::from_rgba(0xd9, 0x77, 0x57, 255); for (r, row) in CLAUDE_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), OpenRepo, OpenClaudeCode, 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, MatchTakeSide(u8), 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 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, match_ui: MatchUi::default(), 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::Match) => Some(Page::Title), Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title), Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause), 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) | 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) { self.page = Some(Page::Match); 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: 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::ClaudeCode) => MenuAction::OpenClaudeCode, 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(); 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(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::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::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(); 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::Match => self.draw_match(gfx, cloud), Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame), Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level), 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::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::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(); // "made with Claude Code" with the pixel mark, a link to Claude Code let made = tr("menu.made_with"); let px = (SMALL() * 2.0).round().max(2.0); let mark_w = 18.0 * px; let tw = gfx.measure(&made, SMALL()); let total = mark_w + CW() * 0.5 + tw; let mx0 = grid_center(sw, total); let my0 = sh - CH() * 4.0; let r = Rect::new(mx0, my0, total, GH * SMALL() + 2.0); let hot = r.contains(vec2(mx, my)); draw_claude_mark(mx0, my0 + (GH * SMALL() - 5.0 * px) * 0.5, px); gfx.text(&made, mx0 + mark_w + CW() * 0.5, my0, SMALL(), if hot { FRAME_HI } else { Color::from_rgba(0xd9, 0x77, 0x57, 255) }); self.buttons.push((r, Btn::ClaudeCode)); 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 = 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::Match, tr("menu.multiplayer")), (Btn::Settings, tr("menu.settings")), (Btn::Bestiary, tr("menu.bestiary")), (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); 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 == 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)); } 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; } 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() { 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"))); let bw = 14.0 * CW(); for (k, (b, label)) in items.iter().take(6).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() * 5.4; if !ui.rooms.is_empty() && !ui.started { gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD); yy += CH() * 1.4; for (k, room) in ui.rooms.iter().take(4).enumerate() { 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))); 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 name = if Some(s.side) == ui.local_side { 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 line = format!("{} {} {} {} {}", side_label, name, if s.connected { "●" } else { "○" }, if s.ready { "✓" } else { "—" }, if s.side == 0 { tr("net.host") } else { String::new() }); gfx.text(&line, ix, yy, SMALL(), if s.connected { TXT } else { DIM }); if !ui.started && ui.local_side.is_some() && !s.connected { let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0); let label = if Some(s.side) == ui.local_side { 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 (name, text) in ui.chat.iter().rev().take(3) { gfx.text(&format!("{}: {}", name, text), ix, yy + SMALL() * GH * 1.2, SMALL(), TXT); 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 { #[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); } }