- tabs: creatures, humans (incl. cave dwarves), dungeon buildings and towers, ranks - every unit at every level: sprite with gear, rank badge, health, attack, range, speed, wage or bounty, what attracts it - Crookfinger's texts: loving about the creatures, disgusted about the humans (EN/DE) - projectiles, hit sparks and splashes grow with the attacker's level, with halos from Lv 5 and glow at Lv 9+ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1240 lines
56 KiB
Rust
1240 lines
56 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::map::{MAX_SIZE, MIN_SIZE};
|
|
use crate::settings::{all_actions, key_name, Action, Settings};
|
|
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
|
|
use macroquad::prelude::*;
|
|
|
|
mod bestiary;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Page {
|
|
Title,
|
|
Pause,
|
|
Settings { tab: usize, from_title: bool },
|
|
NewGame,
|
|
KeyCapture { action: usize, from_title: bool },
|
|
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
|
|
Campaign, // list of the campaign missions
|
|
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
|
|
Login, // login to the savegame service (before the title)
|
|
Account, // password, logout, users (admin)
|
|
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,
|
|
ZoomSpeed,
|
|
EdgeScroll,
|
|
InvertMouse,
|
|
Outlines,
|
|
WallDetail,
|
|
Lights,
|
|
UiScale,
|
|
RenderScale,
|
|
GameSpeed,
|
|
Hints,
|
|
TouchUi,
|
|
}
|
|
|
|
fn tab_items(tab: usize) -> &'static [Item] {
|
|
use Item::*;
|
|
match tab {
|
|
0 => &[Language, Volume, MusicVolume, Mute],
|
|
1 => &[RotateSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
|
|
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale],
|
|
3 => &[GameSpeed, Hints, TouchUi],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
fn item_label(it: Item) -> String {
|
|
tr(match it {
|
|
Item::Language => "set.language",
|
|
Item::Volume => "set.volume",
|
|
Item::MusicVolume => "set.music",
|
|
Item::Mute => "set.mute",
|
|
Item::RotateSpeed => "set.rotate_speed",
|
|
Item::ZoomSpeed => "set.zoom_speed",
|
|
Item::EdgeScroll => "set.edge_scroll",
|
|
Item::InvertMouse => "set.invert",
|
|
Item::Outlines => "set.outlines",
|
|
Item::WallDetail => "set.wall_detail",
|
|
Item::Lights => "set.lights",
|
|
Item::UiScale => "set.ui_scale",
|
|
Item::RenderScale => "set.render_scale",
|
|
Item::GameSpeed => "set.game_speed",
|
|
Item::Hints => "set.hints",
|
|
Item::TouchUi => "set.touch_ui",
|
|
})
|
|
}
|
|
|
|
fn onoff(b: bool) -> String {
|
|
tr(if b { "val.on" } else { "val.off" })
|
|
}
|
|
|
|
fn pct(v: f32) -> String {
|
|
format!("{}%", (v * 100.0).round() as i32)
|
|
}
|
|
|
|
fn item_value(it: Item, s: &Settings) -> String {
|
|
match it {
|
|
Item::Language => languages().into_iter().find(|(c, _)| *c == s.language).map(|(_, n)| n).unwrap_or_else(|| s.language.clone()),
|
|
Item::Volume => pct(s.volume),
|
|
Item::MusicVolume => pct(s.music_volume),
|
|
Item::Mute => onoff(s.muted),
|
|
Item::RotateSpeed => pct(s.rotate_speed),
|
|
Item::ZoomSpeed => pct(s.zoom_speed),
|
|
Item::EdgeScroll => onoff(s.edge_scroll),
|
|
Item::InvertMouse => onoff(s.invert_mouse),
|
|
Item::Outlines => onoff(s.outlines),
|
|
Item::WallDetail => tr(match s.wall_detail {
|
|
1 => "val.low",
|
|
3 => "val.high",
|
|
_ => "val.medium",
|
|
}),
|
|
Item::Lights => s.max_lights.to_string(),
|
|
Item::UiScale => {
|
|
if s.ui_factor == 0 {
|
|
trf("val.auto", &[crate::ui::F().to_string()])
|
|
} else {
|
|
format!("{}x", s.ui_factor)
|
|
}
|
|
}
|
|
Item::RenderScale => {
|
|
// the setting is for a 1080 pixel high window; show what applies to this one
|
|
let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh());
|
|
if now as u8 == s.render_scale.max(1) {
|
|
format!("1/{}", s.render_scale.max(1))
|
|
} else {
|
|
format!("1/{} (1/{})", s.render_scale.max(1), now)
|
|
}
|
|
}
|
|
Item::GameSpeed => format!("{}x", s.game_speed as i32),
|
|
Item::Hints => onoff(s.hints),
|
|
Item::TouchUi => match s.touch_ui {
|
|
1 => tr("val.on"),
|
|
2 => tr("val.off"),
|
|
_ => trf("val.auto_state", &[tr(if crate::ui::touch_ui() { "val.on" } else { "val.off" })]),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn cycle<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::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25),
|
|
Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25),
|
|
Item::EdgeScroll => s.edge_scroll = !s.edge_scroll,
|
|
Item::InvertMouse => s.invert_mouse = !s.invert_mouse,
|
|
Item::Outlines => s.outlines = !s.outlines,
|
|
Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d),
|
|
Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d),
|
|
Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d),
|
|
Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
|
|
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
|
|
Item::Hints => s.hints = !s.hints,
|
|
Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d),
|
|
}
|
|
}
|
|
|
|
pub fn action_label(a: Action) -> String {
|
|
match a {
|
|
Action::Tool(i) => tool_info(TOOLS[i]).name,
|
|
other => tr(&format!("key.{}", other.id())),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
|
enum Btn {
|
|
Continue,
|
|
Resume,
|
|
New,
|
|
Load,
|
|
Save,
|
|
Settings,
|
|
Help,
|
|
MainMenu,
|
|
Back,
|
|
Tab(usize),
|
|
Change(usize, i32),
|
|
MapW(i32),
|
|
MapH(i32),
|
|
Difficulty(i32),
|
|
Start,
|
|
Key(usize),
|
|
ResetKeys,
|
|
SaveNew,
|
|
Slot(usize), // load / overwrite
|
|
Delete(usize),
|
|
Scroll(i32),
|
|
FileSave,
|
|
FileLoad,
|
|
NameField,
|
|
Campaign,
|
|
Mission(usize),
|
|
StartMission,
|
|
Goals,
|
|
Repo,
|
|
ClaudeCode,
|
|
Field(usize),
|
|
DoLogin,
|
|
Account,
|
|
Standalone,
|
|
ChangePassword,
|
|
Logout,
|
|
CreateUser,
|
|
DeleteUser(usize),
|
|
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 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
|
|
}
|
|
|
|
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
|
|
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,
|
|
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::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))
|
|
}
|
|
|
|
fn input(&mut self) -> &mut String {
|
|
if matches!(self.page, Some(Page::Saves { .. })) {
|
|
&mut self.save_name
|
|
} else {
|
|
&mut self.fields[self.focus.min(3)]
|
|
}
|
|
}
|
|
|
|
pub fn type_char(&mut self, c: char) {
|
|
let s = self.input();
|
|
if !c.is_control() && s.chars().count() < 40 {
|
|
s.push(c);
|
|
}
|
|
}
|
|
|
|
pub fn backspace(&mut self) {
|
|
self.input().pop();
|
|
}
|
|
|
|
// Tab: next text field
|
|
pub fn next_field(&mut self) {
|
|
let n = match self.page {
|
|
Some(Page::Login) => 2,
|
|
Some(Page::Account) => 4,
|
|
_ => 1,
|
|
};
|
|
self.focus = (self.focus + 1) % n;
|
|
}
|
|
|
|
// Enter on the login page logs in, on the account page it uses the focused group
|
|
pub fn submit(&mut self) -> MenuAction {
|
|
match self.page {
|
|
Some(Page::Login) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
|
|
Some(Page::Account) if self.focus < 2 => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
|
|
Some(Page::Account) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
|
|
_ => MenuAction::None,
|
|
}
|
|
}
|
|
|
|
pub fn open_login(&mut self) {
|
|
self.page = Some(Page::Login);
|
|
self.fields = Default::default();
|
|
self.focus = 0;
|
|
}
|
|
|
|
pub fn capturing(&self) -> Option<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;
|
|
MenuAction::EditField(i)
|
|
}
|
|
Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
|
|
Some(Btn::Account) => {
|
|
self.page = Some(Page::Account);
|
|
self.fields = Default::default();
|
|
self.focus = 0;
|
|
self.error = None;
|
|
MenuAction::OpenAccount
|
|
}
|
|
Some(Btn::Standalone) => MenuAction::DownloadStandalone,
|
|
Some(Btn::ChangePassword) => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
|
|
Some(Btn::Logout) => MenuAction::Logout,
|
|
Some(Btn::CreateUser) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
|
|
Some(b @ Btn::DeleteUser(i)) => match cloud.users.get(i) {
|
|
Some(u) if confirm == Some(b) => MenuAction::DeleteUser(u.name.clone()),
|
|
Some(_) => {
|
|
self.confirm = Some(b);
|
|
MenuAction::Consumed
|
|
}
|
|
None => MenuAction::Consumed,
|
|
},
|
|
Some(Btn::Campaign) => {
|
|
self.page = Some(Page::Campaign);
|
|
self.scroll = 0;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Mission(i)) => {
|
|
self.page = Some(Page::Briefing { mission: i, ingame: false });
|
|
self.scroll = 0;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::StartMission) => match self.page {
|
|
Some(Page::Briefing { mission, .. }) => MenuAction::StartMission(mission),
|
|
_ => MenuAction::Consumed,
|
|
},
|
|
Some(Btn::Goals) => {
|
|
if let Some(m) = self.mission {
|
|
self.page = Some(Page::Briefing { mission: m, ingame: true });
|
|
self.scroll = 0;
|
|
}
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::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::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::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 url = REPO_URL.trim_start_matches("https://");
|
|
let w = gfx.measure(url, SMALL());
|
|
let r = Rect::new(grid_center(sw, w), sh - CH() * 1.5, w, GH * SMALL() + 2.0);
|
|
let hot = r.contains(vec2(mx, my));
|
|
gfx.text(url, r.x, r.y, SMALL(), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 1.0) });
|
|
draw_rectangle(r.x, r.y + GH * SMALL(), r.w, (SMALL()).max(1.0), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 0.6) });
|
|
self.buttons.push((r, Btn::Repo));
|
|
}
|
|
|
|
// New game: choose the map size (width and height separately)
|
|
fn draw_new_game(&mut self, gfx: &Gfx, s: &Settings) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let cols = 48.min((sw / CW()) as i32);
|
|
let rows = 22;
|
|
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI);
|
|
let diff = tr(&format!("diff.{}", s.difficulty.min(2)));
|
|
let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2)];
|
|
for (k, (key, val, which)) in lines.iter().enumerate() {
|
|
let (key, which) = (*key, *which);
|
|
let by = y + CH() * (2.0 + k as f32 * 2.5);
|
|
gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
|
|
let vw = 14.0 * CW();
|
|
let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
|
|
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
|
|
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
|
|
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
|
|
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
|
|
let v = val.clone();
|
|
gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD);
|
|
let (l, r) = match which {
|
|
0 => (Btn::MapW(-1), Btn::MapW(1)),
|
|
1 => (Btn::MapH(-1), Btn::MapH(1)),
|
|
_ => (Btn::Difficulty(-1), Btn::Difficulty(1)),
|
|
};
|
|
self.buttons.push((left, l));
|
|
self.buttons.push((right, r));
|
|
}
|
|
// what the size means for the game
|
|
let area = (s.map_w * s.map_h) as f32 / 4096.0;
|
|
let portals = (area.round() as usize).max(1);
|
|
let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]);
|
|
gfx.text(&info, x + CW() * 3.0, y + CH() * 10.0, S(), DIM);
|
|
let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" });
|
|
let maxc = (cols - 6) as usize;
|
|
for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() {
|
|
gfx.text(l, x + CW() * 3.0, y + CH() * (11.0 + k as f32), S(), DIM);
|
|
}
|
|
let w = (cols - 6) as f32 * CW();
|
|
self.column(gfx, x + CW() * 3.0, y + CH() * 14.0, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]);
|
|
}
|
|
|
|
fn draw_pause(&mut self, gfx: &Gfx) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let mut items = vec![(Btn::Resume, tr("menu.resume"))];
|
|
if self.mission.is_some() {
|
|
items.push((Btn::Goals, tr("menu.goals")));
|
|
}
|
|
items.extend([
|
|
(Btn::Save, tr("menu.save")),
|
|
(Btn::Load, tr("menu.load")),
|
|
(Btn::Settings, tr("menu.settings")),
|
|
(Btn::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);
|
|
}
|
|
|
|
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));
|
|
}
|
|
}
|
|
|
|
// 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()]),
|
|
}
|
|
}
|