Files
dungeoncraft/src/menu.rs
T

1846 lines
84 KiB
Rust

// 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::campaign_doc::CampaignDoc;
use crate::cloud::{date_string, duration_string, Cloud};
use crate::game::Goal;
use crate::gfx::*;
use crate::i18n::{languages, tr, trf};
use crate::lobby::MatchUi;
use crate::map::{MAX_SIZE, MIN_SIZE};
use crate::settings::{all_actions, key_name, Action, Settings};
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
use macroquad::prelude::*;
mod 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
CampaignEditor, // editable shareable .dccamp campaign file
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 { from_title: bool }, // multiplayer lobby and match status
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
}
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"];
// One adjustable value in the settings dialog
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Item {
Language,
Volume,
MusicVolume,
Mute,
RotateSpeed,
PanSpeed,
ZoomSpeed,
EdgeScroll,
InvertMouse,
Outlines,
WallDetail,
Lights,
UiScale,
RenderScale,
DebugFps,
DebugFrameTime,
DebugPing,
DebugTriggerZones,
DebugCommandMarkers,
DebugPathLines,
GameSpeed,
Hints,
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],
4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines],
_ => &[],
}
}
fn item_label(it: Item) -> String {
tr(match it {
Item::Language => "set.language",
Item::Volume => "set.volume",
Item::MusicVolume => "set.music",
Item::Mute => "set.mute",
Item::RotateSpeed => "set.rotate_speed",
Item::PanSpeed => "set.pan_speed",
Item::ZoomSpeed => "set.zoom_speed",
Item::EdgeScroll => "set.edge_scroll",
Item::InvertMouse => "set.invert",
Item::Outlines => "set.outlines",
Item::WallDetail => "set.wall_detail",
Item::Lights => "set.lights",
Item::UiScale => "set.ui_scale",
Item::RenderScale => "set.render_scale",
Item::DebugFps => "set.debug_fps",
Item::DebugFrameTime => "set.debug_frame_time",
Item::DebugPing => "set.debug_ping",
Item::DebugTriggerZones => "set.debug_trigger_zones",
Item::DebugCommandMarkers => "set.debug_command_markers",
Item::DebugPathLines => "set.debug_path_lines",
Item::GameSpeed => "set.game_speed",
Item::Hints => "set.hints",
Item::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::DebugFps => onoff(s.debug_fps),
Item::DebugFrameTime => onoff(s.debug_frame_time),
Item::DebugPing => onoff(s.debug_ping),
Item::DebugTriggerZones => onoff(s.debug_trigger_zones),
Item::DebugCommandMarkers => onoff(s.debug_command_markers),
Item::DebugPathLines => onoff(s.debug_path_lines),
Item::RenderScale => {
// 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<T: PartialEq + Copy>(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<String> = 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::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
Item::DebugFps => s.debug_fps = !s.debug_fps,
Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time,
Item::DebugPing => s.debug_ping = !s.debug_ping,
Item::DebugTriggerZones => s.debug_trigger_zones = !s.debug_trigger_zones,
Item::DebugCommandMarkers => s.debug_command_markers = !s.debug_command_markers,
Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines,
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
Item::Hints => s.hints = !s.hints,
Item::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,
CampaignEditor,
CampaignNew,
CampaignOpen,
CampaignSave,
CampaignAdd,
CampaignDelete,
CampaignEditName,
CampaignEditTitle,
CampaignEditGoals,
CampaignEditNext,
CampaignEditBranches,
CampaignEditScript,
CampaignStart,
CampaignMission(usize),
Mission(usize),
StartMission,
Goals,
Repo,
ClaudeCode,
OpenCode,
Field(usize),
DoLogin,
Account,
Standalone,
ChangePassword,
Logout,
CreateUser,
DeleteUser(usize),
Match,
MatchJoin,
MatchJoinRoom(usize),
MatchCreate,
MatchCoopPrev,
MatchCoopNext,
MatchCoopCreate,
MatchTake(usize),
MatchAi(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";
pub const OPENCODE_URL: &str = "https://opencode.ai";
// the game version (file VERSION, "vYYYYMM-NNNNN"): tools/bump_version.py raises it with every
// change, and the title screen and the pause menu show it
const VERSION_FILE: &str = include_str!("../VERSION");
pub fn version() -> &'static str {
VERSION_FILE.trim()
}
// the Claude Code mark as pixel art (drawn next to the "made with" note)
const CLAUDE_MARK: [&str; 5] = [
"...############...",
"...##.######.##...",
".################.",
"...############...",
"....#.#....#.#....",
];
// pixel renditions of the Qwen and the OpenCode mark (the second "made with" note)
const QWEN_MARK: [&str; 5] = [
"...###...",
".##.#.##.",
"#..###..#",
".##.#.##.",
"...###...",
];
const OPENCODE_MARK: [&str; 5] = [
"#######",
"#.....#",
"#.#...#",
"#..#.##",
"#######",
];
const CLAUDE_COL: Color = Color { r: 0xd9 as f32 / 255.0, g: 0x77 as f32 / 255.0, b: 0x57 as f32 / 255.0, a: 1.0 };
const QWEN_COL: Color = Color { r: 0x7b as f32 / 255.0, g: 0x6c as f32 / 255.0, b: 0xf6 as f32 / 255.0, a: 1.0 };
const OPENCODE_COL: Color = Color { r: 0.86, g: 0.86, b: 0.88, a: 1.0 };
fn draw_mark(mark: &[&str], x: f32, y: f32, px: f32, c: Color) {
for (r, row) in mark.iter().enumerate() {
for (k, ch) in row.chars().enumerate() {
if ch == '#' {
draw_rectangle(x + k as f32 * px, y + r as f32 * px, px, px, c);
}
}
}
}
pub enum MenuAction {
None,
Consumed,
Continue, // load the autosave
Resume,
NewGame, // start a new game with the chosen map size
Load,
Save,
Help,
MainMenu,
SettingsChanged,
FileSave, // download a savegame file
FileLoad, // choose a savegame file
CloudSave(Option<String>, String), // (id to overwrite, name)
CloudLoad(String),
CloudDelete(String),
EditName, // the name field was tapped (touch: ask with a browser dialog)
StartMission(usize),
CampaignNew,
CampaignOpen,
CampaignSave,
CampaignAdd,
CampaignDelete(usize),
CampaignEditName,
CampaignEditTitle,
CampaignEditGoals,
CampaignEditNext,
CampaignEditBranches,
CampaignEditScript,
CampaignStart(usize),
OpenRepo,
OpenClaudeCode,
OpenOpenCode,
Login(String, String),
Logout,
OpenAccount,
ChangePassword(String, String),
CreateUser(String, String),
DeleteUser(String),
DownloadStandalone,
EditField(usize), // touch: ask for the text with the browser dialog
OpenMatch,
MatchJoin(String),
MatchCreate,
MatchCoopCreate(usize),
MatchTakeSide(u8),
MatchSetAi(u8, bool),
MatchReady(bool),
MatchStart,
MatchResume,
MatchAbort,
MatchLeave,
MatchChat(String),
MatchEditField(usize),
}
pub struct Menu {
pub page: Option<Page>,
pub has_autosave: bool,
pub save_name: String, // name for a new savegame (typed on the savegame page)
pub progress: Progress, // campaign progress
pub mission: Option<usize>, // mission being played (pause menu: goals)
pub fields: [String; 4], // text fields of the login and account pages
pub focus: usize, // field that gets the typed text
pub error: Option<String>, // message on the login and account pages
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
pub coop_mission: usize, // co-op campaign mission selected on the Match page
pub in_match: bool, // a match is being played; the pause menu changes its shape
pub campaign_doc: CampaignDoc,
pub campaign_selected: usize,
pub campaign_pending: bool,
confirm: Option<Btn>, // 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(),
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
in_match: false,
campaign_doc: CampaignDoc::default(),
campaign_selected: 0,
campaign_pending: false,
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: 5, from_title }),
Some(Page::Settings { from_title: true, .. }) => Some(Page::Title),
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
Some(Page::NewGame) => Some(Page::Title),
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Campaign) => Some(Page::Title),
Some(Page::CampaignEditor) => Some(Page::Title),
Some(Page::Login) => Some(Page::Login),
Some(Page::Account) => Some(Page::Title),
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
Some(Page::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, from_title: bool) {
self.page = Some(Page::Match { from_title });
self.fields[1].clear();
self.focus = 0;
self.error = None;
self.confirm = None;
self.scroll = 0;
}
pub fn set_match_ui(&mut self, ui: MatchUi) {
self.match_ui = ui;
}
pub fn open_login(&mut self) {
self.page = Some(Page::Login);
self.fields = Default::default();
self.focus = 0;
}
pub fn capturing(&self) -> Option<Action> {
match self.page {
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
_ => None,
}
}
pub fn finish_capture(&mut self) {
if let Some(Page::KeyCapture { from_title, .. }) = self.page {
self.page = Some(Page::Settings { tab: 5, from_title });
}
}
pub fn click(&mut self, x: f32, y: f32, settings: &mut Settings, cloud: &Cloud) -> MenuAction {
if self.page.is_none() {
return MenuAction::None;
}
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::OpenCode) => MenuAction::OpenOpenCode,
Some(Btn::Field(i)) => {
self.focus = i;
if matches!(self.page, Some(Page::Match { .. })) {
MenuAction::MatchEditField(i)
} else {
MenuAction::EditField(i)
}
}
Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
Some(Btn::Account) => {
self.page = Some(Page::Account);
self.fields = Default::default();
self.focus = 0;
self.error = None;
MenuAction::OpenAccount
}
Some(Btn::Standalone) => MenuAction::DownloadStandalone,
Some(Btn::ChangePassword) => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
Some(Btn::Logout) => MenuAction::Logout,
Some(Btn::CreateUser) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
Some(b @ Btn::DeleteUser(i)) => match cloud.users.get(i) {
Some(u) if confirm == Some(b) => MenuAction::DeleteUser(u.name.clone()),
Some(_) => {
self.confirm = Some(b);
MenuAction::Consumed
}
None => MenuAction::Consumed,
},
Some(Btn::Match) => {
self.open_match(from_title);
MenuAction::OpenMatch
}
Some(Btn::MatchJoin) => MenuAction::MatchJoin(self.fields[0].trim().to_string()),
Some(Btn::MatchJoinRoom(i)) => match self.match_ui.rooms.get(i) {
Some(room) => MenuAction::MatchJoin(room.code.clone()),
None => MenuAction::Consumed,
},
Some(Btn::MatchCreate) => MenuAction::MatchCreate,
Some(Btn::MatchCoopPrev) => {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, -1);
self.confirm = None;
MenuAction::Consumed
}
Some(Btn::MatchCoopNext) => {
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, 1);
self.confirm = None;
MenuAction::Consumed
}
Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission),
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
Some(Btn::MatchStart) => MenuAction::MatchStart,
Some(Btn::MatchResume) => MenuAction::MatchResume,
Some(b @ Btn::MatchAbort) => {
if confirm == Some(b) {
MenuAction::MatchAbort
} else {
self.confirm = Some(b);
MenuAction::Consumed
}
}
Some(Btn::MatchLeave) => MenuAction::MatchLeave,
Some(Btn::MatchChat) => {
let text = self.fields[1].clone();
self.fields[1].clear();
MenuAction::MatchChat(text)
}
Some(Btn::MatchTake(i)) => match self.match_ui.seats.get(i) {
Some(s) => MenuAction::MatchTakeSide(s.side),
None => MenuAction::Consumed,
},
Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) {
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
_ => MenuAction::Consumed,
},
Some(Btn::Campaign) => {
self.page = Some(Page::Campaign);
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::CampaignEditor) => {
self.page = Some(Page::CampaignEditor);
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::CampaignNew) => MenuAction::CampaignNew,
Some(Btn::CampaignOpen) => MenuAction::CampaignOpen,
Some(Btn::CampaignSave) => MenuAction::CampaignSave,
Some(Btn::CampaignAdd) => MenuAction::CampaignAdd,
Some(Btn::CampaignEditName) => MenuAction::CampaignEditName,
Some(Btn::CampaignEditTitle) => MenuAction::CampaignEditTitle,
Some(Btn::CampaignEditGoals) => MenuAction::CampaignEditGoals,
Some(Btn::CampaignEditNext) => MenuAction::CampaignEditNext,
Some(Btn::CampaignEditBranches) => MenuAction::CampaignEditBranches,
Some(Btn::CampaignEditScript) => MenuAction::CampaignEditScript,
Some(b @ Btn::CampaignDelete) => {
let i = self.campaign_selected;
if confirm == Some(b) {
MenuAction::CampaignDelete(i)
} else {
self.confirm = Some(b);
MenuAction::Consumed
}
}
Some(Btn::CampaignStart) => MenuAction::CampaignStart(self.campaign_selected),
Some(Btn::CampaignMission(i)) => {
self.campaign_selected = i;
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::Mission(i)) => {
self.page = Some(Page::Briefing { mission: i, ingame: false });
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::StartMission) => match self.page {
Some(Page::Briefing { mission, .. }) => MenuAction::StartMission(mission),
_ => MenuAction::Consumed,
},
Some(Btn::Goals) => {
if let Some(m) = self.mission {
self.page = Some(Page::Briefing { mission: m, ingame: true });
self.scroll = 0;
}
MenuAction::Consumed
}
Some(Btn::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::CampaignEditor => self.draw_campaign_editor(gfx),
Page::Login => self.draw_login(gfx, cloud),
Page::Account => self.draw_account(gfx, cloud),
Page::Match { .. } => self.draw_match(gfx, cloud),
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
Page::KeyCapture { action, .. } => {
self.draw_settings(gfx, settings, 5);
self.buttons.clear();
let a = all_actions()[action];
let text = trf("set.press_key", &[action_label(a)]);
let cols = (text.chars().count() as i32 + 8).max(36);
let x = grid_center(sw, cols as f32 * CW());
let y = (sh * 0.4 / CH()).floor() * CH();
gfx.window(x, y, cols, 5, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.98));
gfx.text(&text, x + CW() * 4.0, y + CH() * 1.5, S(), TXT);
let esc = tr("set.esc_cancel");
gfx.text(&esc, x + CW() * 4.0, y + CH() * 3.0, S(), DIM);
}
}
if let Some(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::CampaignEditor, tr("menu.campaign_editor")));
items.push((Btn::Match, tr("menu.multiplayer")));
items.push((Btn::New, tr("menu.new")));
items.push((Btn::Load, tr("menu.load")));
items.push((Btn::Settings, tr("menu.settings")));
if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) {
items.push((Btn::Account, trf("menu.account", &[s.name.clone()])));
}
items.push((Btn::Standalone, tr("menu.standalone")));
items.push((Btn::Bestiary, tr("menu.bestiary")));
items.push((Btn::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" notes with their pixel marks, links to the tools:
// Claude Code, then qwen3.8-flash-next-iq3xxs with Strata in OpenCode
let px = (SMALL() * 2.0).round().max(2.0);
let credits: [(&[&[&str]], &[Color], String, Color, Btn, f32); 2] = [
(&[&CLAUDE_MARK], &[CLAUDE_COL], tr("menu.made_with"), CLAUDE_COL, Btn::ClaudeCode, 4.6),
(&[&QWEN_MARK, &OPENCODE_MARK], &[QWEN_COL, OPENCODE_COL], tr("menu.made_with_qwen"), QWEN_COL, Btn::OpenCode, 3.6),
];
for (marks, cols, made, col, btn, row) in credits {
let widths: Vec<f32> = marks.iter().map(|m| m[0].len() as f32 * px).collect();
let mark_w: f32 = widths.iter().sum::<f32>() + px * 2.0 * (marks.len() as f32 - 1.0);
let tw = gfx.measure(&made, SMALL());
let total = mark_w + CW() * 0.5 + tw;
let mx0 = grid_center(sw, total);
let my0 = sh - CH() * row;
let r = Rect::new(mx0, my0, total, GH * SMALL() + 2.0);
let hot = r.contains(vec2(mx, my));
let mut x = mx0;
for (k, m) in marks.iter().enumerate() {
draw_mark(m, x, my0 + (GH * SMALL() - 5.0 * px) * 0.5, px, cols[k]);
x += widths[k] + px * 2.0;
}
gfx.text(&made, mx0 + mark_w + CW() * 0.5, my0, SMALL(), if hot { FRAME_HI } else { col });
self.buttons.push((r, btn));
}
let lic = tr("menu.license");
gfx.text(&lic, grid_center(sw, gfx.measure(&lic, SMALL())), sh - CH() * 2.5, SMALL(), DIM);
let ver = tr("menu.version");
let line = format!("{} {}", ver, version());
gfx.text(&line, grid_center(sw, gfx.measure(&line, SMALL())), sh - CH() * 5.5, SMALL(), DIM);
let url = REPO_URL.trim_start_matches("https://");
let w = gfx.measure(url, SMALL());
let r = Rect::new(grid_center(sw, w), sh - CH() * 1.5, w, GH * SMALL() + 2.0);
let hot = r.contains(vec2(mx, my));
gfx.text(url, r.x, r.y, SMALL(), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 1.0) });
draw_rectangle(r.x, r.y + GH * SMALL(), r.w, (SMALL()).max(1.0), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 0.6) });
self.buttons.push((r, Btn::Repo));
}
// New game: choose the map size (width and height separately)
fn draw_new_game(&mut self, gfx: &Gfx, s: &Settings) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let cols = 48.min((sw / CW()) as i32);
let rows = 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")));
}
if self.in_match {
items.extend([
(Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")),
(Btn::Help, tr("menu.help")),
(Btn::MainMenu, tr("net.leave")),
]);
} else {
items.extend([
(Btn::Save, tr("menu.save")),
(Btn::Load, tr("menu.load")),
(Btn::Match, tr("menu.multiplayer")),
(Btn::Settings, tr("menu.settings")),
(Btn::Bestiary, tr("menu.bestiary")),
(Btn::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 == 5 {
// key bindings in two columns
let acts = all_actions();
let half = (acts.len() + 1) / 2;
let colw = (cols as f32 - 6.0) * CW() * 0.5;
let rowh = CH();
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<char> = self.save_name.chars().collect();
all[all.len().saturating_sub(maxc - 1)..].iter().collect()
};
gfx.text(&format!("{}{}", shown, blink), fx + CW(), top + CH() * 0.5, S(), GOLD);
let br = Rect::new(fx + fw + CW(), top, bw, CH() * 2.0);
button(gfx, br, &bl, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME);
self.buttons.push((br, Btn::SaveNew));
top += CH() * 3.0;
}
// list
let bottom = y + (rows - 5) as f32 * CH();
let row_h = CH() * 4.5;
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
let n = cloud.list.len();
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
if n == 0 {
let msg = tr(if !cloud.listed { "saves.loading" } else { "saves.empty" });
gfx.text(&msg, inner_x + CW(), top + CH(), S(), DIM);
}
let act_label = tr(if save { "saves.overwrite" } else { "saves.load" });
let del_label = tr("saves.delete");
let sure = tr("saves.sure");
let bw = [&act_label, &del_label, &sure].iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 3.0;
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
for (k, info) in cloud.list.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let rr = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5);
if rr.contains(vec2(mx, my)) {
draw_rectangle(rr.x, rr.y, rr.w, rr.h, Color::new(0.2, 0.13, 0.07, 1.0));
}
let text_w = rr.w - bw * 2.0 - CW() * 3.0;
let maxc = (text_w / CW()).max(4.0) as usize;
let name: String = info.name.chars().take(maxc).collect();
gfx.text(&name, inner_x + CW(), ry + CH() * 0.25, S(), GOLD);
let meta = trf(
"saves.meta",
&[(info.wave + 1).to_string(), format!("{}x{}", info.map_w, info.map_h), duration_string(info.time), date_string(info.saved_at as i64)],
);
let meta: String = meta.chars().take(maxc).collect();
gfx.text(&meta, inner_x + CW(), ry + CH() * 2.0, S(), DIM);
let b1 = Rect::new(rr.x + rr.w - bw * 2.0 - CW(), ry + CH(), bw, CH() * 2.0);
let b2 = Rect::new(rr.x + rr.w - bw, ry + CH(), bw, CH() * 2.0);
let c1 = self.confirm == Some(Btn::Slot(k));
let c2 = self.confirm == Some(Btn::Delete(k));
button(gfx, b1, if c1 { &sure } else { &act_label }, b1.contains(vec2(mx, my)) || c1, FRAME);
button(gfx, b2, if c2 { &sure } else { &del_label }, b2.contains(vec2(mx, my)) || c2, Color::new(0.6, 0.2, 0.15, 1.0));
self.buttons.push((b1, Btn::Slot(k)));
self.buttons.push((b2, Btn::Delete(k)));
}
if scroll_w > 0.0 {
let sx = inner_x + inner_w - CW() * 2.5;
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
self.buttons.push((up, Btn::Scroll(-1)));
self.buttons.push((down, Btn::Scroll(1)));
let pos = format!("{}-{}/{}", self.scroll + 1, (self.scroll + self.rows_visible).min(n), n);
gfx.text(&pos, sx + CW() * 1.25 - gfx.measure(&pos, SMALL()) * 0.5, (top + bottom) * 0.5 - CH() * 0.5, SMALL(), DIM);
}
if cloud.busy() {
let w = tr("saves.wait");
gfx.text(&w, inner_x + inner_w - gfx.measure(&w, SMALL()), bottom + CH() * 0.25, SMALL(), DIM);
}
// back, and the savegame files as before
let by = y + (rows - 4) as f32 * CH();
let back = tr("menu.back");
let bkw = gfx.measure(&back, S()) + CW() * 6.0;
let br = Rect::new(inner_x, by, bkw, CH() * 2.0);
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
self.buttons.push((br, Btn::Back));
let fl = tr(if save { "saves.file_save" } else { "saves.file_load" });
let fw = gfx.measure(&fl, S()) + CW() * 4.0;
let fr = Rect::new(inner_x + inner_w - fw, by, fw, CH() * 2.0);
button(gfx, fr, &fl, fr.contains(vec2(mx, my)), FRAME);
self.buttons.push((fr, if save { Btn::FileSave } else { Btn::FileLoad }));
}
// Campaign: list of the missions (done, open, locked)
fn draw_campaign(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let cols = ((sw / CW()) as i32 - 2).min(60);
let rows = ((sh / CH()) as i32 - 2).min(36);
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("camp.title"), FRAME_HI);
let inner_x = x + CW() * 2.0;
let inner_w = (cols - 4) as f32 * CW();
let top = y + CH() * 2.0;
let bottom = y + (rows - 5) as f32 * CH();
let row_h = CH() * 2.5;
let all = missions();
let n = all.len();
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
for (k, d) in all.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, CH() * 2.0);
let open = self.progress.unlocked(k);
let done = self.progress.is_done(k);
let hot = open && r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else if open { FRAME } else { Color::new(0.25, 0.2, 0.15, 1.0) });
let mark = if done { "\u{221a}" } else if open { "\u{25ba}" } else { "-" };
let title = if open { tr(&format!("camp.{}.title", d.key)) } else { tr("camp.locked") };
let line = format!("{} {:02} {}", mark, k + 1, title);
let maxc = ((r.w - CW() * 2.0) / CW()) as usize;
let line: String = line.chars().take(maxc).collect();
gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if done { GOLD } else if open { TXT } else { DIM });
if open {
self.buttons.push((r, Btn::Mission(k)));
}
}
if scroll_w > 0.0 {
let sx = inner_x + inner_w - CW() * 2.5;
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
self.buttons.push((up, Btn::Scroll(-1)));
self.buttons.push((down, Btn::Scroll(1)));
}
let done = (0..n).filter(|&k| self.progress.is_done(k)).count();
let st = trf("camp.progress", &[done.to_string(), n.to_string()]);
gfx.text(&st, inner_x, bottom + CH() * 0.5, SMALL(), DIM);
let by = y + (rows - 4) as f32 * CH();
let back = tr("menu.back");
let br = Rect::new(inner_x, by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
self.buttons.push((br, Btn::Back));
}
// First campaign-editor step: load/save shareable missions and show their branching structure.
fn draw_campaign_editor(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let cols = ((sw / CW()) as i32 - 2).min(72);
let rows = ((sh / CH()) as i32 - 2).min(34);
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("campaign.title"), FRAME_HI);
let campaign = self.campaign_doc.clone();
let errors = campaign.validate();
let name = if campaign.name.trim().is_empty() { tr("campaign.new") } else { campaign.name.clone() };
let file = campaign.file_name();
let n = campaign.missions.len();
let inner_x = x + CW() * 2.0;
let inner_w = (cols - 4) as f32 * CW();
let head = trf("campaign.name", &[name]);
let head_w = (gfx.measure(&head, S()) + CW()).max(CW() * 8.0);
let head_rect = Rect::new(inner_x, y + CH() * 1.5, head_w, GH * S() + CH() * 0.5);
let hot_name = head_rect.contains(vec2(mx, my));
gfx.text(&head, inner_x, y + CH() * 1.5, S(), if hot_name { FRAME_HI } else { GOLD });
if !campaign.author.trim().is_empty() {
let author = trf("campaign.author", &[campaign.author.clone()]);
gfx.text(&author, inner_x + head_w + CW() * 2.0, y + CH() * 1.5, S(), DIM);
}
self.buttons.push((head_rect, Btn::CampaignEditName));
let top = if n > 0 { y + CH() * 5.0 } else { y + CH() * 3.5 };
if n > 0 {
let field_items = [(Btn::CampaignEditTitle, "T"), (Btn::CampaignEditGoals, "G"), (Btn::CampaignEditNext, "N"), (Btn::CampaignEditBranches, "B"), (Btn::CampaignEditScript, "S")];
let widths: Vec<f32> = field_items.iter().map(|(_, l)| (gfx.measure(l, S()) + CW() * 2.5).max(CW() * 3.5)).collect();
let total_w = widths.iter().sum::<f32>() + (field_items.len() as f32 - 1.0) * CW() * 0.5;
let mut fx = x + cols as f32 * CW() - CW() * 2.0 - total_w;
for (k, (b, l)) in field_items.iter().enumerate() {
let r = Rect::new(fx, y + CH() * 2.8, widths[k], CH() * 1.8);
button(gfx, r, l, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b));
fx += widths[k] + CW() * 0.5;
}
}
let bottom = y + (rows - 5) as f32 * CH();
let row_h = CH() * 3.0;
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
if n == 0 {
gfx.text(&tr("campaign.empty"), inner_x, top, S(), DIM);
}
for (k, mission) in campaign.missions.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
let ry = top + (k - self.scroll) as f32 * row_h;
let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5);
let selected = k == self.campaign_selected;
let hot = r.contains(vec2(mx, my));
draw_rectangle(r.x, r.y, r.w, r.h, if selected { Color::new(0.32, 0.21, 0.1, 1.0) } else if hot { Color::new(0.18, 0.12, 0.08, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if selected { GOLD } else if hot { FRAME_HI } else { FRAME });
let title = if mission.title.trim().is_empty() { mission.key.clone() } else { mission.title.clone() };
let line = trf("campaign.mission_no", &[(k + 1).to_string(), title]);
let maxc = ((r.w - CW() * 2.0) / CW()) as usize;
let line: String = line.chars().take(maxc).collect();
gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if selected { GOLD } else { TXT });
let next = mission.next.clone().unwrap_or_default();
let branch_count = mission.branches.len();
let detail = if branch_count > 0 {
trf("campaign.branches", &[branch_count.to_string()])
} else if !next.is_empty() {
trf("campaign.next", &[next])
} else if !mission.goals.is_empty() {
trf("campaign.goals", &[mission.goals.join(", ")])
} else {
tr("campaign.empty")
};
let script_count = mission.script.as_ref().map(|s| if s.source.trim().is_empty() { 0 } else { 1 }).unwrap_or(0);
let zone_count = mission.zones.len();
let detail = if zone_count > 0 || script_count > 0 {
format!("{} · {} · {}", detail, trf("campaign.zones", &[zone_count.to_string()]), trf("campaign.script", &[script_count.to_string()]))
} else {
detail
};
let maxc = ((r.w - CW() * 2.0) / GW.max(1.0) / (SMALL().max(1.0))) as usize;
let detail: String = detail.chars().take(maxc.max(20)).collect();
gfx.text(&detail, r.x + CW(), r.y + CH() * 1.6, SMALL(), DIM);
self.buttons.push((r, Btn::CampaignMission(k)));
}
if scroll_w > 0.0 {
let sx = inner_x + inner_w - CW() * 2.5;
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
self.buttons.push((up, Btn::Scroll(-1)));
self.buttons.push((down, Btn::Scroll(1)));
}
let count = trf("campaign.missions", &[n.to_string()]);
gfx.text(&count, inner_x, bottom + CH() * 0.5, SMALL(), DIM);
let file_line = format!("{} · {}", file, tr("menu.drop_hint"));
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
let file_line: String = file_line.chars().take(maxc.max(20)).collect();
gfx.text(&file_line, x + cols as f32 * CW() - gfx.measure(&file_line, SMALL()) - CW() * 2.0, bottom + CH() * 0.5, SMALL(), DIM);
if !errors.is_empty() {
let err = errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join(" | ");
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
for l in wrap(&err, maxc).iter().take(3) {
gfx.text(l, inner_x, bottom + CH() * 1.5, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
}
}
let by = y + (rows as f32 - 3.5) * CH();
let right = x + cols as f32 * CW() - CW() * 2.0;
let mut bx = inner_x;
let mut actions = vec![(Btn::CampaignNew, tr("campaign.new")), (Btn::CampaignAdd, tr("campaign.add"))];
if n > 0 {
actions.push((Btn::CampaignStart, tr("campaign.start")));
actions.push((Btn::CampaignDelete, tr("campaign.delete")));
}
actions.extend([(Btn::CampaignOpen, tr("campaign.open")), (Btn::CampaignSave, tr("campaign.save")), (Btn::Back, tr("menu.back"))]);
for (b, label) in actions {
let w = (gfx.measure(&label, S()) + CW() * 4.0).max(CW() * 6.0);
let r = Rect::new(bx, by, w, CH() * 2.0);
if r.x + r.w <= right {
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, b));
}
bx += w + CW();
}
}
// Briefing: the old imp tells the story, then the goals and what comes along
fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let all = missions();
let Some(d) = all.get(mission) else { return };
let cols = ((sw / CW()) as i32 - 2).min(64);
let rows = ((sh / CH()) as i32 - 2).min(38);
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
let title = trf("camp.mission_title", &[(mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI);
// all lines: story paragraphs (" | " separates them), goals, carry-over
let tw = (cols - 6) as usize;
let mut lines: Vec<(String, Color)> = Vec::new();
for (k, para) in tr(&format!("camp.{}.story", d.key)).split(" | ").enumerate() {
if k > 0 {
lines.push((String::new(), TXT));
}
for l in wrap(para, tw) {
lines.push((l, TXT));
}
}
lines.push((String::new(), TXT));
lines.push((tr("camp.goals"), GOLD));
for g in &d.goals {
for l in wrap(&format!("\u{25ba} {}", goal_text(*g)), tw) {
lines.push((l, FRAME_HI));
}
}
if d.time_limit > 0.0 {
lines.push((format!("\u{25ba} {}", trf("camp.time_limit", &[duration_string(d.time_limit as f64)])), FRAME_HI));
}
// newly available tools
let new_tools: Vec<String> = crate::campaign::TOOL_UNLOCK.iter().enumerate().filter(|&(_, &u)| u == mission && mission > 0).map(|(i, _)| tool_info(TOOLS[i]).name).collect();
if !new_tools.is_empty() {
lines.push((String::new(), TXT));
for l in wrap(&trf("camp.new_tools", &[new_tools.join(", ")]), tw) {
lines.push((l, GOLD));
}
}
if !ingame {
if let Some(c) = self.progress.carry_into(mission) {
lines.push((String::new(), TXT));
for l in wrap(&trf("camp.carry", &[c.creatures.len().to_string(), (c.gold as i32).to_string()]), tw) {
lines.push((l, DIM));
}
}
}
let top = y + CH() * 2.0;
let bottom = y + (rows - 5) as f32 * CH();
self.rows_visible = (((bottom - top) / CH()).floor() as usize).max(1);
self.scroll = self.scroll.min(lines.len().saturating_sub(self.rows_visible));
for (k, (l, c)) in lines.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
gfx.text(l, x + CW() * 3.0, top + (k - self.scroll) as f32 * CH(), S(), *c);
}
if lines.len() > self.rows_visible {
let sx = x + (cols - 4) as f32 * CW();
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
let down = Rect::new(sx, bottom - CH() * 2.0, CW() * 2.5, CH() * 2.0);
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
self.buttons.push((up, Btn::Scroll(-1)));
self.buttons.push((down, Btn::Scroll(1)));
}
let by = y + (rows - 4) as f32 * CH();
let back = tr("menu.back");
let br = Rect::new(x + CW() * 2.0, by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
self.buttons.push((br, Btn::Back));
if !ingame {
let start = tr("camp.start");
let w = gfx.measure(&start, S()) + CW() * 6.0;
let sr = Rect::new(x + cols as f32 * CW() - w - CW() * 2.0, by, w, CH() * 2.0);
button(gfx, sr, &start, sr.contains(vec2(mx, my)), FRAME_HI);
self.buttons.push((sr, Btn::StartMission));
}
}
// the briefing of the running mission, again (story and goals)
pub fn open_briefing(&mut self, mission: usize) {
self.page = Some(Page::Briefing { mission, ingame: true });
self.scroll = 0;
}
// scrolling on pages with lists (mouse wheel)
pub fn scrollable(&self) -> bool {
matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | 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<char> = shown.chars().collect();
let mut text: String = all[all.len().saturating_sub(maxc - 1)..].iter().collect();
if focused && (get_time() * 2.0) as i64 % 2 == 0 {
text.push('_');
}
gfx.text(&text, r.x + CW(), r.y + CH() * 0.5, S(), GOLD);
self.buttons.push((r, Btn::Field(i)));
}
// Login to the server (the first login creates the admin account)
fn draw_login(&mut self, gfx: &Gfx, cloud: &Cloud) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let big = TITLE();
let title = "DungeonCraft";
let ty = (sh * 0.12 / CH()).floor() * CH();
gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8));
gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI);
let cols = ((sw / CW()) as i32 - 2).min(50);
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = ty + GH * big + CH() * 3.0;
let w = (cols - 6) as f32 * CW();
let label = tr(if cloud.setup { "login.create_admin" } else { "login.button" });
let standalone_label = tr("menu.standalone");
let bw = gfx.measure(&label, S()) + CW() * 6.0;
let standalone_bw = (gfx.measure(&standalone_label, S()) + CW() * 4.0).min(w);
let side_by_side = standalone_bw + bw + CW() * 1.5 <= w;
let rows = if side_by_side { 13 } else { 15 };
let head = tr(if cloud.setup { "login.setup" } else { "login.title" });
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI);
if cloud.setup {
gfx.text(&tr("login.setup_hint"), x + CW() * 3.0, y + CH() * 1.5, SMALL(), DIM);
}
self.field(gfx, x + CW() * 3.0, y + CH() * 3.0, w, &tr("login.name"), 0, false);
self.field(gfx, x + CW() * 3.0, y + CH() * 5.5, w, &tr("login.password"), 1, true);
if let Some(e) = &self.error {
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
for (k, l) in wrap(e, maxc).iter().take(2).enumerate() {
gfx.text(l, x + CW() * 3.0, y + CH() * 7.9 + k as f32 * GH * SMALL() * 1.2, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
}
}
if side_by_side {
let sr = Rect::new(x + CW() * 3.0, y + CH() * 9.5, standalone_bw, CH() * 2.0);
button(gfx, sr, &standalone_label, sr.contains(vec2(mx, my)) && !cloud.busy(), FRAME);
self.buttons.push((sr, Btn::Standalone));
let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 9.5, bw, CH() * 2.0);
button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI);
self.buttons.push((br, Btn::DoLogin));
} else {
let sr = Rect::new(x + CW() * 3.0, y + CH() * 9.5, w, CH() * 2.0);
button(gfx, sr, &standalone_label, sr.contains(vec2(mx, my)) && !cloud.busy(), FRAME);
self.buttons.push((sr, Btn::Standalone));
let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 12.5, bw, CH() * 2.0);
button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI);
self.buttons.push((br, Btn::DoLogin));
}
self.draw_license(gfx);
}
// 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;
}
if !ui.started && !ui.room_open() {
if let Some(d) = missions().get(self.coop_mission) {
let line = trf("net.coop_mission", &[(self.coop_mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]);
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
for (_k, text) in wrap(&line, maxc).iter().take(2).enumerate() {
gfx.text(text, ix, yy, SMALL(), GOLD);
yy += GH * SMALL() * 1.35;
}
}
}
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::MatchCoopPrev, "<".to_string()));
items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
items.push((Btn::MatchCoopNext, ">".to_string()));
}
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();
let shown = items.iter().take(9).count();
for (k, (b, label)) in items.iter().take(shown).enumerate() {
let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0);
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b));
}
yy += CH() * 2.4 * ((shown + 2) / 3) as f32;
if !ui.rooms.is_empty() && !ui.started {
gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD);
yy += CH() * 1.4;
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 is_local = Some(s.side) == ui.local_side;
let name = if is_local {
format!("{} ({})", s.name, tr("net.you"))
} else {
s.name.clone()
};
let side_label = match s.side {
0 => tr("net.side0"),
2 => tr("net.side2"),
3 => tr("net.side3"),
4 => tr("net.side4"),
_ => s.side.to_string(),
};
let status = if s.connected { "●" } else if s.ai { "AI" } else { "○" };
let ready = if s.ready || s.ai { "✓" } else { "—" };
let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() });
gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM });
if !ui.started && ui.local_side.is_some() && !s.connected {
if ui.is_host && s.side != 0 {
if s.ai {
let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
let label = tr("net.ai_off");
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchAi(k)));
} else {
let join_label = if is_local { tr("net.you") } else { tr("net.join") };
let r = Rect::new(ix + 22.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
button(gfx, r, &join_label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchTake(k)));
let ai_label = tr("net.ai");
let r = Rect::new(ix + 36.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
button(gfx, r, &ai_label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchAi(k)));
}
} else if !s.ai {
let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
let label = if is_local { tr("net.you") } else { tr("net.join") };
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, Btn::MatchTake(k)));
}
}
yy += CH() * 2.0;
}
let _ = seat_cols;
}
if !ui.chat.is_empty() {
yy = (y + (rows - 5) as f32 * CH()).max(yy);
gfx.text(&tr("net.chat"), ix, yy, SMALL(), DIM);
for (side, name, text) in ui.chat.iter().rev().take(3) {
gfx.text(&format!("{}: {}", name, text), ix, yy + SMALL() * GH * 1.2, SMALL(), side_color(*side));
yy += SMALL() * GH * 1.2;
}
}
}
}
// description of a mission goal (without progress)
pub fn goal_text(g: Goal) -> String {
match g {
Goal::Shards => tr("goal.shards"),
Goal::Heart => tr("goal.heart"),
Goal::KillAll => tr("goal.killall"),
Goal::Loot(n) => trf("goal.loot", &[n.to_string()]),
Goal::Towers => tr("goal.towers"),
Goal::Survive(n) => trf("goal.survive", &[n.to_string()]),
Goal::Rivals => tr("goal.rivals"),
Goal::Springs(n) => trf("goal.springs", &[n.to_string()]),
Goal::Freed(n) => trf("goal.freed", &[n.to_string()]),
}
}
#[cfg(test)]
mod tests {
use super::{Menu, Page};
#[test]
fn the_multiplayer_page_remembers_where_it_came_from() {
let mut from_pause = Menu::new();
from_pause.page = Some(Page::Pause);
from_pause.open_match(false);
from_pause.back();
assert_eq!(from_pause.page, Some(Page::Pause), "back from a match menu goes back to the pause menu");
let mut from_title = Menu::new();
from_title.open_match(true);
from_title.back();
assert_eq!(from_title.page, Some(Page::Title), "back from the title multiplayer page goes to the title");
}
#[test]
fn the_version_follows_the_scheme() {
let rest = super::version().strip_prefix('v').expect("the version starts with v");
let (month, number) = rest.split_once('-').expect("the version is vYYYYMM-NNNNN");
assert_eq!(month.len(), 6);
assert!(month.chars().all(|c| c.is_ascii_digit()));
assert_eq!(number.len(), 5);
assert!(number.parse::<u32>().expect("a number") >= 1);
}
}