Files
dungeoncraft/src/menu.rs
T

1574 lines
71 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::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
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; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"];
// One adjustable value in the settings dialog
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Item {
Language,
Volume,
MusicVolume,
Mute,
RotateSpeed,
PanSpeed,
ZoomSpeed,
EdgeScroll,
InvertMouse,
Outlines,
WallDetail,
Lights,
UiScale,
RenderScale,
DebugFps,
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, DebugFps],
_ => &[],
}
}
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::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::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::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
Item::Hints => s.hints = !s.hints,
Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d),
}
}
pub fn action_label(a: Action) -> String {
match a {
Action::Tool(i) => tool_info(TOOLS[i]).name,
other => tr(&format!("key.{}", other.id())),
}
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Btn {
Continue,
Resume,
New,
Load,
Save,
Settings,
Help,
MainMenu,
Back,
Tab(usize),
Change(usize, i32),
MapW(i32),
MapH(i32),
Difficulty(i32),
Start,
Key(usize),
ResetKeys,
SaveNew,
Slot(usize), // load / overwrite
Delete(usize),
Scroll(i32),
FileSave,
FileLoad,
NameField,
Campaign,
Mission(usize),
StartMission,
Goals,
Repo,
ClaudeCode,
Field(usize),
DoLogin,
Account,
Standalone,
ChangePassword,
Logout,
CreateUser,
DeleteUser(usize),
Match,
MatchJoin,
MatchJoinRoom(usize),
MatchCreate,
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";
// the game version (file VERSION, "vYYYYMM-NNNNN"): tools/bump_version.py raises it with every
// change, and the title screen and the pause menu show it
const VERSION_FILE: &str = include_str!("../VERSION");
pub fn version() -> &'static str {
VERSION_FILE.trim()
}
// the Claude Code mark as pixel art (drawn next to the "made with" note)
const CLAUDE_MARK: [&str; 5] = [
"...############...",
"...##.######.##...",
".################.",
"...############...",
"....#.#....#.#....",
];
pub fn draw_claude_mark(x: f32, y: f32, px: f32) {
let c = Color::from_rgba(0xd9, 0x77, 0x57, 255);
for (r, row) in CLAUDE_MARK.iter().enumerate() {
for (k, ch) in row.chars().enumerate() {
if ch == '#' {
draw_rectangle(x + k as f32 * px, y + r as f32 * px, px, px, c);
}
}
}
}
pub enum MenuAction {
None,
Consumed,
Continue, // load the autosave
Resume,
NewGame, // start a new game with the chosen map size
Load,
Save,
Help,
MainMenu,
SettingsChanged,
FileSave, // download a savegame file
FileLoad, // choose a savegame file
CloudSave(Option<String>, String), // (id to overwrite, name)
CloudLoad(String),
CloudDelete(String),
EditName, // the name field was tapped (touch: ask with a browser dialog)
StartMission(usize),
OpenRepo,
OpenClaudeCode,
Login(String, String),
Logout,
OpenAccount,
ChangePassword(String, String),
CreateUser(String, String),
DeleteUser(String),
DownloadStandalone,
EditField(usize), // touch: ask for the text with the browser dialog
OpenMatch,
MatchJoin(String),
MatchCreate,
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
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,
confirm: None,
scroll: 0,
rows_visible: 1,
buttons: Vec::new(),
}
}
pub fn open(&self) -> bool {
self.page.is_some()
}
// Esc: go one level back
pub fn back(&mut self) {
self.page = match self.page {
Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 4, from_title }),
Some(Page::Settings { from_title: true, .. }) => Some(Page::Title),
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
Some(Page::NewGame) => Some(Page::Title),
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
Some(Page::Campaign) => Some(Page::Title),
Some(Page::Login) => Some(Page::Login),
Some(Page::Account) => Some(Page::Title),
Some(Page::Match { 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: 4, from_title });
}
}
pub fn click(&mut self, x: f32, y: f32, settings: &mut Settings, cloud: &Cloud) -> MenuAction {
if self.page.is_none() {
return MenuAction::None;
}
let hit = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(_, b)| *b);
// overwrite and delete need a second click on the same button
let confirm = self.confirm.take();
let save_mode = matches!(self.page, Some(Page::Saves { save: true, .. }));
let from_title = matches!(self.page, Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }));
match hit {
None => MenuAction::Consumed,
Some(Btn::Continue) => MenuAction::Continue,
Some(Btn::Resume) => {
self.page = None;
MenuAction::Resume
}
Some(Btn::New) => {
self.page = Some(Page::NewGame);
MenuAction::Consumed
}
Some(Btn::Start) => MenuAction::NewGame,
Some(Btn::MapW(d)) => {
settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
MenuAction::SettingsChanged
}
Some(Btn::Difficulty(d)) => {
settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8;
MenuAction::SettingsChanged
}
Some(Btn::MapH(d)) => {
settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
MenuAction::SettingsChanged
}
Some(Btn::Load) => MenuAction::Load,
Some(Btn::Save) => MenuAction::Save,
Some(Btn::Help) => MenuAction::Help,
Some(Btn::MainMenu) => MenuAction::MainMenu,
Some(Btn::Settings) => {
self.page = Some(Page::Settings { tab: 0, from_title });
MenuAction::Consumed
}
Some(Btn::Back) => {
self.back();
MenuAction::Consumed
}
Some(Btn::Tab(t)) => {
self.page = Some(Page::Settings { tab: t, from_title });
MenuAction::Consumed
}
Some(Btn::Change(i, d)) => {
if let Some(Page::Settings { tab, .. }) = self.page {
if let Some(&it) = tab_items(tab).get(i) {
item_change(it, settings, d);
return MenuAction::SettingsChanged;
}
}
MenuAction::Consumed
}
Some(Btn::Key(a)) => {
self.page = Some(Page::KeyCapture { action: a, from_title });
MenuAction::Consumed
}
Some(Btn::ResetKeys) => {
settings.reset_keys();
MenuAction::SettingsChanged
}
Some(Btn::SaveNew) => {
let name = self.save_name.trim().to_string();
MenuAction::CloudSave(None, name)
}
Some(b @ Btn::Slot(i)) => {
let Some(info) = cloud.list.get(i) else { return MenuAction::Consumed };
if !save_mode {
MenuAction::CloudLoad(info.id.clone())
} else if confirm == Some(b) {
MenuAction::CloudSave(Some(info.id.clone()), info.name.clone())
} else {
self.confirm = Some(b);
MenuAction::Consumed
}
}
Some(b @ Btn::Delete(i)) => {
let Some(info) = cloud.list.get(i) else { return MenuAction::Consumed };
if confirm == Some(b) {
MenuAction::CloudDelete(info.id.clone())
} else {
self.confirm = Some(b);
MenuAction::Consumed
}
}
Some(Btn::Scroll(d)) => {
// each page clamps the scroll position when it is drawn
self.scroll_lines(d * self.rows_visible.max(1) as i32);
MenuAction::Consumed
}
Some(Btn::NameField) => MenuAction::EditName,
Some(Btn::Repo) => MenuAction::OpenRepo,
Some(Btn::ClaudeCode) => MenuAction::OpenClaudeCode,
Some(Btn::Field(i)) => {
self.focus = i;
if matches!(self.page, Some(Page::Match { .. })) {
MenuAction::MatchEditField(i)
} else {
MenuAction::EditField(i)
}
}
Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
Some(Btn::Account) => {
self.page = Some(Page::Account);
self.fields = Default::default();
self.focus = 0;
self.error = None;
MenuAction::OpenAccount
}
Some(Btn::Standalone) => MenuAction::DownloadStandalone,
Some(Btn::ChangePassword) => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
Some(Btn::Logout) => MenuAction::Logout,
Some(Btn::CreateUser) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
Some(b @ Btn::DeleteUser(i)) => match cloud.users.get(i) {
Some(u) if confirm == Some(b) => MenuAction::DeleteUser(u.name.clone()),
Some(_) => {
self.confirm = Some(b);
MenuAction::Consumed
}
None => MenuAction::Consumed,
},
Some(Btn::Match) => {
self.open_match(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::Mission(i)) => {
self.page = Some(Page::Briefing { mission: i, ingame: false });
self.scroll = 0;
MenuAction::Consumed
}
Some(Btn::StartMission) => match self.page {
Some(Page::Briefing { mission, .. }) => MenuAction::StartMission(mission),
_ => MenuAction::Consumed,
},
Some(Btn::Goals) => {
if let Some(m) = self.mission {
self.page = Some(Page::Briefing { mission: m, ingame: true });
self.scroll = 0;
}
MenuAction::Consumed
}
Some(Btn::Bestiary) => {
self.open_bestiary(from_title);
MenuAction::Consumed
}
Some(b @ (Btn::BestTab(_) | Btn::BestSel(_) | Btn::BestPick(_) | Btn::BestLevel(_))) => {
self.bestiary_click(b);
MenuAction::Consumed
}
Some(Btn::FileSave) => MenuAction::FileSave,
Some(Btn::FileLoad) => MenuAction::FileLoad,
}
}
pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>, cloud: &Cloud) {
self.buttons.clear();
let Some(page) = self.page else { return };
let sw = sw();
let sh = sh();
draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 }));
match page {
Page::Title => self.draw_title(gfx, cloud),
Page::Pause => self.draw_pause(gfx),
Page::NewGame => self.draw_new_game(gfx, settings),
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
Page::Campaign => self.draw_campaign(gfx),
Page::Login => self.draw_login(gfx, cloud),
Page::Account => self.draw_account(gfx, cloud),
Page::Match { .. } => self.draw_match(gfx, cloud),
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
Page::KeyCapture { action, .. } => {
self.draw_settings(gfx, settings, 4);
self.buttons.clear();
let a = all_actions()[action];
let text = trf("set.press_key", &[action_label(a)]);
let cols = (text.chars().count() as i32 + 8).max(36);
let x = grid_center(sw, cols as f32 * CW());
let y = (sh * 0.4 / CH()).floor() * CH();
gfx.window(x, y, cols, 5, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.98));
gfx.text(&text, x + CW() * 4.0, y + CH() * 1.5, S(), TXT);
let esc = tr("set.esc_cancel");
gfx.text(&esc, x + CW() * 4.0, y + CH() * 3.0, S(), DIM);
}
}
if let Some(st) = status {
// long messages wrap onto several lines
let maxc = ((sw / CW()) as usize).saturating_sub(8).clamp(10, 70);
let lines = wrap(st, maxc);
let w = lines.iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 4.0;
let h = CH() * (lines.len() as f32 + 1.0);
let x = grid_center(sw, w);
let y = sh - CH() * 8.0 - h;
draw_rectangle(x, y, w, h, Color::new(0.05, 0.03, 0.04, 0.95));
draw_rectangle_lines(x, y, w, h, 2.0, GOLD);
for (k, l) in lines.iter().enumerate() {
gfx.text(l, x + CW() * 2.0, y + CH() * (0.5 + k as f32), S(), GOLD);
}
}
}
fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) {
let (mx, my) = mouse();
for (k, (b, label)) in items.iter().enumerate() {
let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.0);
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
self.buttons.push((r, *b));
}
}
fn draw_title(&mut self, gfx: &Gfx, cloud: &Cloud) {
let sw = sw();
let sh = sh();
let big = TITLE();
let title = "DungeonCraft";
let ty = (sh * 0.12 / CH()).floor() * CH();
gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8));
gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI);
let sub = tr("menu.subtitle");
gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), ty + GH * big + CH(), S(), TXT);
let mut items = Vec::new();
if self.has_autosave {
items.push((Btn::Continue, tr("menu.continue")));
}
items.push((Btn::Campaign, tr("menu.campaign")));
items.push((Btn::Match, tr("menu.multiplayer")));
items.push((Btn::New, tr("menu.new")));
items.push((Btn::Load, tr("menu.load")));
items.push((Btn::Settings, tr("menu.settings")));
if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) {
items.push((Btn::Account, trf("menu.account", &[s.name.clone()])));
items.push((Btn::Standalone, tr("menu.standalone")));
}
items.push((Btn::Bestiary, tr("menu.bestiary")));
items.push((Btn::Help, tr("menu.help")));
let w = 26.0 * CW();
let y = ty + GH * big + CH() * 4.0;
self.column(gfx, grid_center(sw, w), y, w, &items);
let hint = tr("menu.drop_hint");
gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM);
let credit = tr("help.font");
gfx.text(&credit, grid_center(sw, gfx.measure(&credit, SMALL())), sh - CH() * 7.0, SMALL(), DIM);
self.draw_license(gfx);
}
// licence and repository link at the bottom of the screen (title and login)
pub fn draw_license(&mut self, gfx: &Gfx) {
let (sw, sh) = (sw(), sh());
let (mx, my) = mouse();
// "made with Claude Code" with the pixel mark, a link to Claude Code
let made = tr("menu.made_with");
let px = (SMALL() * 2.0).round().max(2.0);
let mark_w = 18.0 * px;
let tw = gfx.measure(&made, SMALL());
let total = mark_w + CW() * 0.5 + tw;
let mx0 = grid_center(sw, total);
let my0 = sh - CH() * 4.0;
let r = Rect::new(mx0, my0, total, GH * SMALL() + 2.0);
let hot = r.contains(vec2(mx, my));
draw_claude_mark(mx0, my0 + (GH * SMALL() - 5.0 * px) * 0.5, px);
gfx.text(&made, mx0 + mark_w + CW() * 0.5, my0, SMALL(), if hot { FRAME_HI } else { Color::from_rgba(0xd9, 0x77, 0x57, 255) });
self.buttons.push((r, Btn::ClaudeCode));
let lic = tr("menu.license");
gfx.text(&lic, grid_center(sw, gfx.measure(&lic, SMALL())), sh - CH() * 2.5, SMALL(), DIM);
let ver = tr("menu.version");
let line = format!("{} {}", ver, version());
gfx.text(&line, grid_center(sw, gfx.measure(&line, SMALL())), sh - CH() * 5.5, SMALL(), DIM);
let url = REPO_URL.trim_start_matches("https://");
let w = gfx.measure(url, SMALL());
let r = Rect::new(grid_center(sw, w), sh - CH() * 1.5, w, GH * SMALL() + 2.0);
let hot = r.contains(vec2(mx, my));
gfx.text(url, r.x, r.y, SMALL(), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 1.0) });
draw_rectangle(r.x, r.y + GH * SMALL(), r.w, (SMALL()).max(1.0), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 0.6) });
self.buttons.push((r, Btn::Repo));
}
// New game: choose the map size (width and height separately)
fn draw_new_game(&mut self, gfx: &Gfx, s: &Settings) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let cols = 48.min((sw / CW()) as i32);
let rows = 22;
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI);
let diff = tr(&format!("diff.{}", s.difficulty.min(2)));
let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2)];
for (k, (key, val, which)) in lines.iter().enumerate() {
let (key, which) = (*key, *which);
let by = y + CH() * (2.0 + k as f32 * 2.5);
gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
let vw = 14.0 * CW();
let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
let v = val.clone();
gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD);
let (l, r) = match which {
0 => (Btn::MapW(-1), Btn::MapW(1)),
1 => (Btn::MapH(-1), Btn::MapH(1)),
_ => (Btn::Difficulty(-1), Btn::Difficulty(1)),
};
self.buttons.push((left, l));
self.buttons.push((right, r));
}
// what the size means for the game
let area = (s.map_w * s.map_h) as f32 / 4096.0;
let portals = (area.round() as usize).max(1);
let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]);
gfx.text(&info, x + CW() * 3.0, y + CH() * 10.0, S(), DIM);
let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" });
let maxc = (cols - 6) as usize;
for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() {
gfx.text(l, x + CW() * 3.0, y + CH() * (11.0 + k as f32), S(), DIM);
}
let w = (cols - 6) as f32 * CW();
self.column(gfx, x + CW() * 3.0, y + CH() * 14.0, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]);
}
fn draw_pause(&mut self, gfx: &Gfx) {
let sw = sw();
let sh = sh();
let mut items = vec![(Btn::Resume, tr("menu.resume"))];
if self.mission.is_some() {
items.push((Btn::Goals, tr("menu.goals")));
}
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 == 4 {
// key bindings in two columns
let acts = all_actions();
let half = (acts.len() + 1) / 2;
let colw = (cols as f32 - 6.0) * CW() * 0.5;
let rowh = CH();
for (k, a) in acts.iter().enumerate() {
let c = k / half;
let r = k % half;
let bx = x + CW() * 2.0 + c as f32 * (colw + CW() * 2.0);
let by = top + r as f32 * rowh;
let rect = Rect::new(bx, by - 2.0, colw, rowh - 2.0);
let hot = rect.contains(vec2(mx, my));
if hot {
draw_rectangle(rect.x, rect.y, rect.w, rect.h, Color::new(0.25, 0.16, 0.08, 1.0));
}
let key = format!("[{}]", key_name(s.key(*a)));
let maxc = ((colw - gfx.measure(&key, S()) - CW()) / CW()).max(4.0) as usize;
let label: String = action_label(*a).chars().take(maxc).collect();
gfx.text(&label, bx + CW() * 0.5, by, S(), TXT);
gfx.text(&key, bx + colw - gfx.measure(&key, S()) - CW() * 0.5, by, S(), GOLD);
self.buttons.push((rect, Btn::Key(k)));
}
let hint = tr("set.keys_hint");
gfx.text(&hint, x + CW() * 2.0, top + half as f32 * rowh + CH() * 0.5, SMALL(), DIM);
let rw = gfx.measure(&tr("set.reset_keys"), S()) + CW() * 4.0;
let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.0);
button(gfx, rr, &tr("set.reset_keys"), rr.contains(vec2(mx, my)), FRAME);
self.buttons.push((rr, Btn::ResetKeys));
} else {
for (i, &it) in tab_items(tab).iter().enumerate() {
let by = top + i as f32 * CH() * 2.5;
gfx.text(&item_label(it), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
// value with < > buttons
let vw = 18.0 * CW();
let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
let val = item_value(it, s);
gfx.text(&val, vx + (vw - gfx.measure(&val, S())) * 0.5, by + CH() * 0.5, S(), GOLD);
let mid = Rect::new(vx + CW() * 2.5, by, vw - CW() * 5.0, CH() * 2.0);
self.buttons.push((left, Btn::Change(i, -1)));
self.buttons.push((right, Btn::Change(i, 1)));
self.buttons.push((mid, Btn::Change(i, 1)));
}
}
let bw = gfx.measure(&tr("menu.back"), S()) + CW() * 6.0;
let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.0);
button(gfx, br, &tr("menu.back"), br.contains(vec2(mx, my)), FRAME);
self.buttons.push((br, Btn::Back));
}
// Savegame list on the server: save mode (name field, new / overwrite) or load mode
fn draw_saves(&mut self, gfx: &Gfx, cloud: &Cloud, save: bool) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let cols = ((sw / CW()) as i32 - 2).min(72);
let rows = ((sh / CH()) as i32 - 2).min(36);
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
let title = tr(if save { "menu.save" } else { "menu.load" });
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI);
let inner_x = x + CW() * 2.0;
let inner_w = (cols - 4) as f32 * CW();
let mut top = y + CH() * 2.0;
// name field and "save as new"
if save {
let label = tr("saves.name");
gfx.text(&label, inner_x, top + CH() * 0.5, S(), TXT);
let bl = tr("saves.new");
let bw = gfx.measure(&bl, S()) + CW() * 4.0;
let fx = inner_x + gfx.measure(&label, S()) + CW();
let fw = (inner_w - (fx - inner_x) - bw - CW()).max(CW() * 8.0);
draw_rectangle(fx, top, fw, CH() * 2.0, Color::new(0.02, 0.01, 0.02, 1.0));
draw_rectangle_lines(fx, top, fw, CH() * 2.0, 2.0, FRAME_HI);
self.buttons.push((Rect::new(fx, top, fw, CH() * 2.0), Btn::NameField));
let blink = if (get_time() * 2.0) as i64 % 2 == 0 { "_" } else { " " };
let maxc = ((fw - CW() * 2.0) / CW()).max(1.0) as usize;
let shown: String = {
let all: Vec<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));
}
// 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::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 rows = 13;
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = ty + GH * big + CH() * 3.0;
let head = tr(if cloud.setup { "login.setup" } else { "login.title" });
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI);
let w = (cols - 6) as f32 * CW();
if cloud.setup {
gfx.text(&tr("login.setup_hint"), x + CW() * 3.0, y + CH() * 1.5, SMALL(), DIM);
}
self.field(gfx, x + CW() * 3.0, y + CH() * 3.0, w, &tr("login.name"), 0, false);
self.field(gfx, x + CW() * 3.0, y + CH() * 5.5, w, &tr("login.password"), 1, true);
if let Some(e) = &self.error {
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
for (k, l) in wrap(e, maxc).iter().take(2).enumerate() {
gfx.text(l, x + CW() * 3.0, y + CH() * 7.9 + k as f32 * GH * SMALL() * 1.2, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
}
}
let label = tr(if cloud.setup { "login.create_admin" } else { "login.button" });
let bw = gfx.measure(&label, S()) + CW() * 6.0;
let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 9.5, bw, CH() * 2.0);
button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI);
self.buttons.push((br, Btn::DoLogin));
self.draw_license(gfx);
}
// Account: change the password, log out; the admin manages the users
fn draw_account(&mut self, gfx: &Gfx, cloud: &Cloud) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let admin = cloud.session.as_ref().map_or(false, |s| s.admin);
let cols = ((sw / CW()) as i32 - 2).min(60);
let rows = ((sh / CH()) as i32 - 2).min(if admin { 38 } else { 18 });
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
let who = cloud.session.as_ref().map(|s| s.name.clone()).unwrap_or_default();
let head = trf(if admin { "account.title_admin" } else { "account.title" }, &[who]);
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI);
let ix = x + CW() * 3.0;
let w = (cols - 6) as f32 * CW();
gfx.text(&tr("account.change_pw"), ix, y + CH() * 2.0, S(), GOLD);
self.field(gfx, ix, y + CH() * 3.5, w, &tr("account.old_pw"), 0, true);
self.field(gfx, ix, y + CH() * 6.0, w, &tr("account.new_pw"), 1, true);
let cl = tr("account.save_pw");
let cw2 = gfx.measure(&cl, S()) + CW() * 4.0;
let cr = Rect::new(ix + w - cw2, y + CH() * 8.5, cw2, CH() * 2.0);
button(gfx, cr, &cl, cr.contains(vec2(mx, my)), FRAME);
self.buttons.push((cr, Btn::ChangePassword));
let mut yy = y + CH() * 11.5;
if admin {
gfx.text(&tr("account.users"), ix, yy, S(), GOLD);
yy += CH() * 1.5;
self.field(gfx, ix, yy, w, &tr("account.user_name"), 2, false);
self.field(gfx, ix, yy + CH() * 2.5, w, &tr("account.user_pw"), 3, true);
let nl = tr("account.create");
let nw = gfx.measure(&nl, S()) + CW() * 4.0;
let nr = Rect::new(ix + w - nw, yy + CH() * 5.0, nw, CH() * 2.0);
button(gfx, nr, &nl, nr.contains(vec2(mx, my)), FRAME);
self.buttons.push((nr, Btn::CreateUser));
yy += CH() * 8.0;
let del = tr("saves.delete");
let sure = tr("saves.sure");
let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0;
let bottom = y + (rows - 5) as f32 * CH();
for (k, u) in cloud.users.iter().enumerate() {
let ry = yy + k as f32 * CH() * 2.5;
if ry + CH() * 2.0 > bottom {
break;
}
let label = if u.admin { format!("{} (admin)", u.name) } else { u.name.clone() };
gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT);
if !u.admin {
let r = Rect::new(ix + w - dw, ry, dw, CH() * 2.0);
let c = self.confirm == Some(Btn::DeleteUser(k));
button(gfx, r, if c { &sure } else { &del }, r.contains(vec2(mx, my)) || c, Color::new(0.6, 0.2, 0.15, 1.0));
self.buttons.push((r, Btn::DeleteUser(k)));
}
}
}
let _ = yy;
if let Some(e) = &self.error {
gfx.text(e, ix, y + (rows - 5) as f32 * CH() + CH() * 0.5, S(), Color::new(0.95, 0.4, 0.35, 1.0));
}
let by = y + (rows - 4) as f32 * CH();
let back = tr("menu.back");
let br = Rect::new(ix - CW(), by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
self.buttons.push((br, Btn::Back));
let lo = tr("account.logout");
let lw = gfx.measure(&lo, S()) + CW() * 4.0;
let lr = Rect::new(ix + w - lw, by, lw, CH() * 2.0);
button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME);
self.buttons.push((lr, Btn::Logout));
}
fn draw_match(&mut self, gfx: &Gfx, _cloud: &Cloud) {
let sw = sw();
let sh = sh();
let (mx, my) = mouse();
let ui = self.match_ui.clone();
let cols = ((sw / CW()) as i32 - 2).min(72);
let rows = ((sh / CH()) as i32 - 2).min(38);
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
let ix = x + CW() * 3.0;
let w = (cols - 6) as f32 * CW();
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("net.title"), FRAME_HI);
let head = match ui.phase {
crate::lobby::Phase::Offline => tr("net.status_offline"),
crate::lobby::Phase::Lobby => tr("net.status_lobby"),
crate::lobby::Phase::InLobby => trf("net.status_lobby_room", &[ui.code.clone().unwrap_or_default()]),
crate::lobby::Phase::Playing => trf("net.status_playing", &[ui.code.clone().unwrap_or_default()]),
crate::lobby::Phase::Closed => trf("net.status_closed", &[ui.status.clone().unwrap_or_else(|| "?".into())]),
};
gfx.text(&head, ix, y + CH() * 1.6, S(), GOLD);
let state = format!("{} · {} · {}", ui.name, ui.turn.to_string(), if ui.paused { tr("net.paused") } else if ui.missing { tr("net.missing") } else { String::new() });
gfx.text(&state, ix, y + CH() * 3.0, SMALL(), DIM);
let mut yy = y + CH() * 4.5;
if let Some(e) = &self.error {
gfx.text(e, ix, yy, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
yy += CH() * 1.6;
} else if let Some(s) = &ui.status {
gfx.text(s, ix, yy, SMALL(), DIM);
yy += CH() * 1.6;
}
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);
}
}