Files
dungeoncraft/src/menu.rs
T

2297 lines
109 KiB
Rust

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