1846 lines
84 KiB
Rust
1846 lines
84 KiB
Rust
// Menus: title screen, pause menu, savegame list (server) and the settings dialog
|
|
// (tabs, values, key bindings).
|
|
// Drawn as CP437 windows on top of the (paused) game world.
|
|
|
|
use crate::campaign::{missions, Progress};
|
|
use crate::campaign_doc::CampaignDoc;
|
|
use crate::cloud::{date_string, duration_string, Cloud};
|
|
use crate::game::Goal;
|
|
use crate::gfx::*;
|
|
use crate::i18n::{languages, tr, trf};
|
|
use crate::lobby::MatchUi;
|
|
use crate::map::{MAX_SIZE, MIN_SIZE};
|
|
use crate::settings::{all_actions, key_name, Action, Settings};
|
|
use crate::ui::{grid_center, grid_center_y, mouse, sh, sw, tool_info, wrap, side_color, CH, CW, DIM, FRAME, FRAME_HI, GOLD, S, SMALL, TITLE, TOOLS, TXT};
|
|
use macroquad::prelude::*;
|
|
|
|
mod bestiary;
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Page {
|
|
Title,
|
|
Pause,
|
|
Settings { tab: usize, from_title: bool },
|
|
NewGame,
|
|
KeyCapture { action: usize, from_title: bool },
|
|
Saves { save: bool, from_title: bool }, // savegame list on the server (save or load mode)
|
|
Campaign, // list of the campaign missions
|
|
CampaignEditor, // editable shareable .dccamp campaign file
|
|
Briefing { mission: usize, ingame: bool }, // story and goals of a mission
|
|
Login, // login to the savegame service (before the title)
|
|
Account, // password, logout, users (admin)
|
|
Match { from_title: bool }, // multiplayer lobby and match status
|
|
Bestiary { tab: usize, sel: usize, level: u32, from_title: bool }, // units, buildings, ranks
|
|
}
|
|
|
|
pub const TABS: [&str; 6] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.debug", "set.tab.keys"];
|
|
|
|
// One adjustable value in the settings dialog
|
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
pub enum Item {
|
|
Language,
|
|
Volume,
|
|
MusicVolume,
|
|
Mute,
|
|
RotateSpeed,
|
|
PanSpeed,
|
|
ZoomSpeed,
|
|
EdgeScroll,
|
|
InvertMouse,
|
|
Outlines,
|
|
WallDetail,
|
|
Lights,
|
|
UiScale,
|
|
RenderScale,
|
|
DebugFps,
|
|
DebugFrameTime,
|
|
DebugPing,
|
|
DebugTriggerZones,
|
|
DebugCommandMarkers,
|
|
DebugPathLines,
|
|
GameSpeed,
|
|
Hints,
|
|
TouchUi,
|
|
Voice,
|
|
}
|
|
|
|
fn tab_items(tab: usize) -> &'static [Item] {
|
|
use Item::*;
|
|
match tab {
|
|
0 => &[Language, Volume, MusicVolume, Mute, Voice],
|
|
1 => &[RotateSpeed, PanSpeed, ZoomSpeed, EdgeScroll, InvertMouse],
|
|
2 => &[Outlines, WallDetail, Lights, UiScale, RenderScale],
|
|
3 => &[GameSpeed, Hints, TouchUi],
|
|
4 => &[DebugFps, DebugFrameTime, DebugPing, DebugTriggerZones, DebugCommandMarkers, DebugPathLines],
|
|
_ => &[],
|
|
}
|
|
}
|
|
|
|
fn item_label(it: Item) -> String {
|
|
tr(match it {
|
|
Item::Language => "set.language",
|
|
Item::Volume => "set.volume",
|
|
Item::MusicVolume => "set.music",
|
|
Item::Mute => "set.mute",
|
|
Item::RotateSpeed => "set.rotate_speed",
|
|
Item::PanSpeed => "set.pan_speed",
|
|
Item::ZoomSpeed => "set.zoom_speed",
|
|
Item::EdgeScroll => "set.edge_scroll",
|
|
Item::InvertMouse => "set.invert",
|
|
Item::Outlines => "set.outlines",
|
|
Item::WallDetail => "set.wall_detail",
|
|
Item::Lights => "set.lights",
|
|
Item::UiScale => "set.ui_scale",
|
|
Item::RenderScale => "set.render_scale",
|
|
Item::DebugFps => "set.debug_fps",
|
|
Item::DebugFrameTime => "set.debug_frame_time",
|
|
Item::DebugPing => "set.debug_ping",
|
|
Item::DebugTriggerZones => "set.debug_trigger_zones",
|
|
Item::DebugCommandMarkers => "set.debug_command_markers",
|
|
Item::DebugPathLines => "set.debug_path_lines",
|
|
Item::GameSpeed => "set.game_speed",
|
|
Item::Hints => "set.hints",
|
|
Item::TouchUi => "set.touch_ui",
|
|
Item::Voice => "set.voice",
|
|
})
|
|
}
|
|
|
|
fn onoff(b: bool) -> String {
|
|
tr(if b { "val.on" } else { "val.off" })
|
|
}
|
|
|
|
fn pct(v: f32) -> String {
|
|
format!("{}%", (v * 100.0).round() as i32)
|
|
}
|
|
|
|
fn item_value(it: Item, s: &Settings) -> String {
|
|
match it {
|
|
Item::Language => languages().into_iter().find(|(c, _)| *c == s.language).map(|(_, n)| n).unwrap_or_else(|| s.language.clone()),
|
|
Item::Volume => pct(s.volume),
|
|
Item::MusicVolume => pct(s.music_volume),
|
|
Item::Mute => onoff(s.muted),
|
|
Item::Voice => onoff(s.voice),
|
|
Item::RotateSpeed => pct(s.rotate_speed),
|
|
Item::PanSpeed => pct(s.pan_speed),
|
|
Item::ZoomSpeed => pct(s.zoom_speed),
|
|
Item::EdgeScroll => onoff(s.edge_scroll),
|
|
Item::InvertMouse => onoff(s.invert_mouse),
|
|
Item::Outlines => onoff(s.outlines),
|
|
Item::WallDetail => tr(match s.wall_detail {
|
|
1 => "val.low",
|
|
3 => "val.high",
|
|
_ => "val.medium",
|
|
}),
|
|
Item::Lights => s.max_lights.to_string(),
|
|
Item::UiScale => {
|
|
if s.ui_factor == 0 {
|
|
trf("val.auto", &[crate::ui::F().to_string()])
|
|
} else {
|
|
format!("{}x", s.ui_factor)
|
|
}
|
|
}
|
|
Item::DebugFps => onoff(s.debug_fps),
|
|
Item::DebugFrameTime => onoff(s.debug_frame_time),
|
|
Item::DebugPing => onoff(s.debug_ping),
|
|
Item::DebugTriggerZones => onoff(s.debug_trigger_zones),
|
|
Item::DebugCommandMarkers => onoff(s.debug_command_markers),
|
|
Item::DebugPathLines => onoff(s.debug_path_lines),
|
|
Item::RenderScale => {
|
|
// the setting is for a 1080 pixel high window; show what applies to this one
|
|
let now = crate::render3d::effective_div(s.render_scale.max(1) as u32, crate::ui::sh());
|
|
if now as u8 == s.render_scale.max(1) {
|
|
format!("1/{}", s.render_scale.max(1))
|
|
} else {
|
|
format!("1/{} (1/{})", s.render_scale.max(1), now)
|
|
}
|
|
}
|
|
Item::GameSpeed => format!("{}x", s.game_speed as i32),
|
|
Item::Hints => onoff(s.hints),
|
|
Item::TouchUi => match s.touch_ui {
|
|
1 => tr("val.on"),
|
|
2 => tr("val.off"),
|
|
_ => trf("val.auto_state", &[tr(if crate::ui::touch_ui() { "val.on" } else { "val.off" })]),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn cycle<T: PartialEq + Copy>(opts: &[T], cur: T, d: i32) -> T {
|
|
let i = opts.iter().position(|&o| o == cur).unwrap_or(0) as i32;
|
|
opts[(i + d).rem_euclid(opts.len() as i32) as usize]
|
|
}
|
|
|
|
fn step(v: f32, d: i32, lo: f32, hi: f32, st: f32) -> f32 {
|
|
((v + d as f32 * st) * 100.0).round().clamp(lo * 100.0, hi * 100.0) / 100.0
|
|
}
|
|
|
|
// Change a value; d = -1 / +1 (toggles flip either way)
|
|
fn item_change(it: Item, s: &mut Settings, d: i32) {
|
|
match it {
|
|
Item::Language => {
|
|
let codes: Vec<String> = languages().into_iter().map(|(c, _)| c).collect();
|
|
let i = codes.iter().position(|c| *c == s.language).unwrap_or(0) as i32;
|
|
s.language = codes[(i + d).rem_euclid(codes.len() as i32) as usize].clone();
|
|
}
|
|
Item::Volume => s.volume = step(s.volume, d, 0.0, 1.0, 0.1),
|
|
Item::MusicVolume => s.music_volume = step(s.music_volume, d, 0.0, 1.0, 0.1),
|
|
Item::Mute => s.muted = !s.muted,
|
|
Item::Voice => s.voice = !s.voice,
|
|
Item::RotateSpeed => s.rotate_speed = step(s.rotate_speed, d, 0.25, 2.5, 0.25),
|
|
Item::PanSpeed => s.pan_speed = step(s.pan_speed, d, 0.25, 2.5, 0.25),
|
|
Item::ZoomSpeed => s.zoom_speed = step(s.zoom_speed, d, 0.25, 2.5, 0.25),
|
|
Item::EdgeScroll => s.edge_scroll = !s.edge_scroll,
|
|
Item::InvertMouse => s.invert_mouse = !s.invert_mouse,
|
|
Item::Outlines => s.outlines = !s.outlines,
|
|
Item::WallDetail => s.wall_detail = cycle(&[1u8, 2, 3], s.wall_detail, d),
|
|
Item::Lights => s.max_lights = cycle(&[8u8, 16, 24], s.max_lights, d),
|
|
Item::UiScale => s.ui_factor = cycle(&[0u8, 1, 2, 3, 4], s.ui_factor, d),
|
|
Item::RenderScale => s.render_scale = cycle(&(1u8..=16).collect::<Vec<u8>>(), s.render_scale.clamp(1, 16), d),
|
|
Item::DebugFps => s.debug_fps = !s.debug_fps,
|
|
Item::DebugFrameTime => s.debug_frame_time = !s.debug_frame_time,
|
|
Item::DebugPing => s.debug_ping = !s.debug_ping,
|
|
Item::DebugTriggerZones => s.debug_trigger_zones = !s.debug_trigger_zones,
|
|
Item::DebugCommandMarkers => s.debug_command_markers = !s.debug_command_markers,
|
|
Item::DebugPathLines => s.debug_path_lines = !s.debug_path_lines,
|
|
Item::GameSpeed => s.game_speed = cycle(&[1.0f32, 2.0], s.game_speed, d),
|
|
Item::Hints => s.hints = !s.hints,
|
|
Item::TouchUi => s.touch_ui = cycle(&[0u8, 1, 2], s.touch_ui, d),
|
|
}
|
|
}
|
|
|
|
pub fn action_label(a: Action) -> String {
|
|
match a {
|
|
Action::Tool(i) => tool_info(TOOLS[i]).name,
|
|
other => tr(&format!("key.{}", other.id())),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Debug)]
|
|
enum Btn {
|
|
Continue,
|
|
Resume,
|
|
New,
|
|
Load,
|
|
Save,
|
|
Settings,
|
|
Help,
|
|
MainMenu,
|
|
Back,
|
|
Tab(usize),
|
|
Change(usize, i32),
|
|
MapW(i32),
|
|
MapH(i32),
|
|
Difficulty(i32),
|
|
Start,
|
|
Key(usize),
|
|
ResetKeys,
|
|
SaveNew,
|
|
Slot(usize), // load / overwrite
|
|
Delete(usize),
|
|
Scroll(i32),
|
|
FileSave,
|
|
FileLoad,
|
|
NameField,
|
|
Campaign,
|
|
CampaignEditor,
|
|
CampaignNew,
|
|
CampaignOpen,
|
|
CampaignSave,
|
|
CampaignAdd,
|
|
CampaignDelete,
|
|
CampaignEditName,
|
|
CampaignEditTitle,
|
|
CampaignEditGoals,
|
|
CampaignEditNext,
|
|
CampaignEditBranches,
|
|
CampaignEditScript,
|
|
CampaignStart,
|
|
CampaignMission(usize),
|
|
Mission(usize),
|
|
StartMission,
|
|
Goals,
|
|
Repo,
|
|
ClaudeCode,
|
|
OpenCode,
|
|
Field(usize),
|
|
DoLogin,
|
|
Account,
|
|
Standalone,
|
|
ChangePassword,
|
|
Logout,
|
|
CreateUser,
|
|
DeleteUser(usize),
|
|
Match,
|
|
MatchJoin,
|
|
MatchJoinRoom(usize),
|
|
MatchCreate,
|
|
MatchCoopPrev,
|
|
MatchCoopNext,
|
|
MatchCoopCreate,
|
|
MatchTake(usize),
|
|
MatchAi(usize),
|
|
MatchReady,
|
|
MatchStart,
|
|
MatchResume,
|
|
MatchAbort,
|
|
MatchLeave,
|
|
MatchChat,
|
|
Bestiary,
|
|
BestTab(usize),
|
|
BestSel(i32),
|
|
BestPick(usize),
|
|
BestLevel(i32),
|
|
}
|
|
|
|
pub const REPO_URL: &str = "https://git.familie-zink.org/zink/dungeoncraft";
|
|
pub const CLAUDE_CODE_URL: &str = "https://claude.com/claude-code";
|
|
pub const OPENCODE_URL: &str = "https://opencode.ai";
|
|
|
|
// the game version (file VERSION, "vYYYYMM-NNNNN"): tools/bump_version.py raises it with every
|
|
// change, and the title screen and the pause menu show it
|
|
const VERSION_FILE: &str = include_str!("../VERSION");
|
|
|
|
pub fn version() -> &'static str {
|
|
VERSION_FILE.trim()
|
|
}
|
|
|
|
// the Claude Code mark as pixel art (drawn next to the "made with" note)
|
|
const CLAUDE_MARK: [&str; 5] = [
|
|
"...############...",
|
|
"...##.######.##...",
|
|
".################.",
|
|
"...############...",
|
|
"....#.#....#.#....",
|
|
];
|
|
|
|
// pixel renditions of the Qwen and the OpenCode mark (the second "made with" note)
|
|
const QWEN_MARK: [&str; 5] = [
|
|
"...###...",
|
|
".##.#.##.",
|
|
"#..###..#",
|
|
".##.#.##.",
|
|
"...###...",
|
|
];
|
|
const OPENCODE_MARK: [&str; 5] = [
|
|
"#######",
|
|
"#.....#",
|
|
"#.#...#",
|
|
"#..#.##",
|
|
"#######",
|
|
];
|
|
const CLAUDE_COL: Color = Color { r: 0xd9 as f32 / 255.0, g: 0x77 as f32 / 255.0, b: 0x57 as f32 / 255.0, a: 1.0 };
|
|
const QWEN_COL: Color = Color { r: 0x7b as f32 / 255.0, g: 0x6c as f32 / 255.0, b: 0xf6 as f32 / 255.0, a: 1.0 };
|
|
const OPENCODE_COL: Color = Color { r: 0.86, g: 0.86, b: 0.88, a: 1.0 };
|
|
|
|
fn draw_mark(mark: &[&str], x: f32, y: f32, px: f32, c: Color) {
|
|
for (r, row) in mark.iter().enumerate() {
|
|
for (k, ch) in row.chars().enumerate() {
|
|
if ch == '#' {
|
|
draw_rectangle(x + k as f32 * px, y + r as f32 * px, px, px, c);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
pub enum MenuAction {
|
|
None,
|
|
Consumed,
|
|
Continue, // load the autosave
|
|
Resume,
|
|
NewGame, // start a new game with the chosen map size
|
|
Load,
|
|
Save,
|
|
Help,
|
|
MainMenu,
|
|
SettingsChanged,
|
|
FileSave, // download a savegame file
|
|
FileLoad, // choose a savegame file
|
|
CloudSave(Option<String>, String), // (id to overwrite, name)
|
|
CloudLoad(String),
|
|
CloudDelete(String),
|
|
EditName, // the name field was tapped (touch: ask with a browser dialog)
|
|
StartMission(usize),
|
|
CampaignNew,
|
|
CampaignOpen,
|
|
CampaignSave,
|
|
CampaignAdd,
|
|
CampaignDelete(usize),
|
|
CampaignEditName,
|
|
CampaignEditTitle,
|
|
CampaignEditGoals,
|
|
CampaignEditNext,
|
|
CampaignEditBranches,
|
|
CampaignEditScript,
|
|
CampaignStart(usize),
|
|
OpenRepo,
|
|
OpenClaudeCode,
|
|
OpenOpenCode,
|
|
Login(String, String),
|
|
Logout,
|
|
OpenAccount,
|
|
ChangePassword(String, String),
|
|
CreateUser(String, String),
|
|
DeleteUser(String),
|
|
DownloadStandalone,
|
|
EditField(usize), // touch: ask for the text with the browser dialog
|
|
OpenMatch,
|
|
MatchJoin(String),
|
|
MatchCreate,
|
|
MatchCoopCreate(usize),
|
|
MatchTakeSide(u8),
|
|
MatchSetAi(u8, bool),
|
|
MatchReady(bool),
|
|
MatchStart,
|
|
MatchResume,
|
|
MatchAbort,
|
|
MatchLeave,
|
|
MatchChat(String),
|
|
MatchEditField(usize),
|
|
}
|
|
|
|
pub struct Menu {
|
|
pub page: Option<Page>,
|
|
pub has_autosave: bool,
|
|
pub save_name: String, // name for a new savegame (typed on the savegame page)
|
|
pub progress: Progress, // campaign progress
|
|
pub mission: Option<usize>, // mission being played (pause menu: goals)
|
|
pub fields: [String; 4], // text fields of the login and account pages
|
|
pub focus: usize, // field that gets the typed text
|
|
pub error: Option<String>, // message on the login and account pages
|
|
pub match_ui: MatchUi, // copied state of the multiplayer relay, shown on the Match page
|
|
pub coop_mission: usize, // co-op campaign mission selected on the Match page
|
|
pub in_match: bool, // a match is being played; the pause menu changes its shape
|
|
pub campaign_doc: CampaignDoc,
|
|
pub campaign_selected: usize,
|
|
pub campaign_pending: bool,
|
|
confirm: Option<Btn>, // overwrite / delete waiting for a second click
|
|
scroll: usize,
|
|
rows_visible: usize,
|
|
buttons: Vec<(Rect, Btn)>,
|
|
}
|
|
|
|
fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) {
|
|
draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.13, 0.09, 0.08, 1.0) });
|
|
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else { accent });
|
|
let s = S();
|
|
gfx.text(label, r.x + (r.w - gfx.measure(label, s)) * 0.5, r.y + (r.h - GH * s) * 0.5, s, if hot { FRAME_HI } else { TXT });
|
|
}
|
|
|
|
impl Menu {
|
|
pub fn new() -> Menu {
|
|
Menu {
|
|
page: Some(Page::Title),
|
|
has_autosave: false,
|
|
save_name: String::new(),
|
|
progress: Progress::default(),
|
|
mission: None,
|
|
fields: Default::default(),
|
|
focus: 0,
|
|
error: None,
|
|
match_ui: MatchUi::default(),
|
|
coop_mission: crate::campaign::coop_mission_indexes().first().copied().unwrap_or(0),
|
|
in_match: false,
|
|
campaign_doc: CampaignDoc::default(),
|
|
campaign_selected: 0,
|
|
campaign_pending: false,
|
|
confirm: None,
|
|
scroll: 0,
|
|
rows_visible: 1,
|
|
buttons: Vec::new(),
|
|
}
|
|
}
|
|
|
|
pub fn open(&self) -> bool {
|
|
self.page.is_some()
|
|
}
|
|
|
|
// Esc: go one level back
|
|
pub fn back(&mut self) {
|
|
self.page = match self.page {
|
|
Some(Page::KeyCapture { from_title, .. }) => Some(Page::Settings { tab: 5, from_title }),
|
|
Some(Page::Settings { from_title: true, .. }) => Some(Page::Title),
|
|
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
|
|
Some(Page::NewGame) => Some(Page::Title),
|
|
Some(Page::Saves { from_title: true, .. }) => Some(Page::Title),
|
|
Some(Page::Saves { from_title: false, .. }) => Some(Page::Pause),
|
|
Some(Page::Campaign) => Some(Page::Title),
|
|
Some(Page::CampaignEditor) => Some(Page::Title),
|
|
Some(Page::Login) => Some(Page::Login),
|
|
Some(Page::Account) => Some(Page::Title),
|
|
Some(Page::Match { from_title }) => Some(if from_title { Page::Title } else { Page::Pause }),
|
|
Some(Page::Bestiary { from_title: true, .. }) => Some(Page::Title),
|
|
Some(Page::Bestiary { from_title: false, .. }) => Some(Page::Pause),
|
|
Some(Page::Briefing { ingame: true, .. }) => Some(Page::Pause),
|
|
Some(Page::Briefing { ingame: false, .. }) => Some(Page::Campaign),
|
|
Some(Page::Pause) => None,
|
|
Some(Page::Title) => Some(Page::Title),
|
|
None => Some(Page::Pause),
|
|
};
|
|
}
|
|
|
|
// opens the savegame list; `name` is the suggested name for a new savegame
|
|
pub fn open_saves(&mut self, save: bool, from_title: bool, name: String) {
|
|
self.page = Some(Page::Saves { save, from_title });
|
|
self.save_name = name;
|
|
self.confirm = None;
|
|
self.scroll = 0;
|
|
}
|
|
|
|
// pages with text fields take keyboard input
|
|
pub fn typing(&self) -> bool {
|
|
matches!(self.page, Some(Page::Saves { save: true, .. }) | Some(Page::Login) | Some(Page::Account) | Some(Page::Match { .. }))
|
|
}
|
|
|
|
fn input(&mut self) -> &mut String {
|
|
if matches!(self.page, Some(Page::Saves { .. })) {
|
|
&mut self.save_name
|
|
} else {
|
|
&mut self.fields[self.focus.min(3)]
|
|
}
|
|
}
|
|
|
|
pub fn type_char(&mut self, c: char) {
|
|
let s = self.input();
|
|
if !c.is_control() && s.chars().count() < 40 {
|
|
s.push(c);
|
|
}
|
|
}
|
|
|
|
pub fn backspace(&mut self) {
|
|
self.input().pop();
|
|
}
|
|
|
|
// Tab: next text field
|
|
pub fn next_field(&mut self) {
|
|
let n = match self.page {
|
|
Some(Page::Login) => 2,
|
|
Some(Page::Account) => 4,
|
|
Some(Page::Match { .. }) => 2,
|
|
_ => 1,
|
|
};
|
|
self.focus = (self.focus + 1) % n;
|
|
}
|
|
|
|
// Enter on the login page logs in, on the account page it uses the focused group
|
|
pub fn submit(&mut self) -> MenuAction {
|
|
match self.page {
|
|
Some(Page::Login) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
|
|
Some(Page::Match { .. }) => {
|
|
let action = if self.focus == 1 { MenuAction::MatchChat(self.fields[1].clone()) } else { MenuAction::MatchJoin(self.fields[0].trim().to_string()) };
|
|
if self.focus == 1 {
|
|
self.fields[1].clear();
|
|
}
|
|
action
|
|
}
|
|
Some(Page::Account) if self.focus < 2 => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
|
|
Some(Page::Account) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
|
|
_ => MenuAction::None,
|
|
}
|
|
}
|
|
|
|
pub fn open_match(&mut self, from_title: bool) {
|
|
self.page = Some(Page::Match { from_title });
|
|
self.fields[1].clear();
|
|
self.focus = 0;
|
|
self.error = None;
|
|
self.confirm = None;
|
|
self.scroll = 0;
|
|
}
|
|
|
|
pub fn set_match_ui(&mut self, ui: MatchUi) {
|
|
self.match_ui = ui;
|
|
}
|
|
|
|
pub fn open_login(&mut self) {
|
|
self.page = Some(Page::Login);
|
|
self.fields = Default::default();
|
|
self.focus = 0;
|
|
}
|
|
|
|
pub fn capturing(&self) -> Option<Action> {
|
|
match self.page {
|
|
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn finish_capture(&mut self) {
|
|
if let Some(Page::KeyCapture { from_title, .. }) = self.page {
|
|
self.page = Some(Page::Settings { tab: 5, from_title });
|
|
}
|
|
}
|
|
|
|
pub fn click(&mut self, x: f32, y: f32, settings: &mut Settings, cloud: &Cloud) -> MenuAction {
|
|
if self.page.is_none() {
|
|
return MenuAction::None;
|
|
}
|
|
let hit = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(_, b)| *b);
|
|
// overwrite and delete need a second click on the same button
|
|
let confirm = self.confirm.take();
|
|
let save_mode = matches!(self.page, Some(Page::Saves { save: true, .. }));
|
|
let from_title = matches!(self.page, Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }));
|
|
match hit {
|
|
None => MenuAction::Consumed,
|
|
Some(Btn::Continue) => MenuAction::Continue,
|
|
Some(Btn::Resume) => {
|
|
self.page = None;
|
|
MenuAction::Resume
|
|
}
|
|
Some(Btn::New) => {
|
|
self.page = Some(Page::NewGame);
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Start) => MenuAction::NewGame,
|
|
Some(Btn::MapW(d)) => {
|
|
settings.map_w = (settings.map_w as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
|
|
MenuAction::SettingsChanged
|
|
}
|
|
Some(Btn::Difficulty(d)) => {
|
|
settings.difficulty = (settings.difficulty as i32 + d).rem_euclid(3) as u8;
|
|
MenuAction::SettingsChanged
|
|
}
|
|
Some(Btn::MapH(d)) => {
|
|
settings.map_h = (settings.map_h as i32 + d * 32).clamp(MIN_SIZE as i32, MAX_SIZE as i32) as usize;
|
|
MenuAction::SettingsChanged
|
|
}
|
|
Some(Btn::Load) => MenuAction::Load,
|
|
Some(Btn::Save) => MenuAction::Save,
|
|
Some(Btn::Help) => MenuAction::Help,
|
|
Some(Btn::MainMenu) => MenuAction::MainMenu,
|
|
Some(Btn::Settings) => {
|
|
self.page = Some(Page::Settings { tab: 0, from_title });
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Back) => {
|
|
self.back();
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Tab(t)) => {
|
|
self.page = Some(Page::Settings { tab: t, from_title });
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Change(i, d)) => {
|
|
if let Some(Page::Settings { tab, .. }) = self.page {
|
|
if let Some(&it) = tab_items(tab).get(i) {
|
|
item_change(it, settings, d);
|
|
return MenuAction::SettingsChanged;
|
|
}
|
|
}
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Key(a)) => {
|
|
self.page = Some(Page::KeyCapture { action: a, from_title });
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::ResetKeys) => {
|
|
settings.reset_keys();
|
|
MenuAction::SettingsChanged
|
|
}
|
|
Some(Btn::SaveNew) => {
|
|
let name = self.save_name.trim().to_string();
|
|
MenuAction::CloudSave(None, name)
|
|
}
|
|
Some(b @ Btn::Slot(i)) => {
|
|
let Some(info) = cloud.list.get(i) else { return MenuAction::Consumed };
|
|
if !save_mode {
|
|
MenuAction::CloudLoad(info.id.clone())
|
|
} else if confirm == Some(b) {
|
|
MenuAction::CloudSave(Some(info.id.clone()), info.name.clone())
|
|
} else {
|
|
self.confirm = Some(b);
|
|
MenuAction::Consumed
|
|
}
|
|
}
|
|
Some(b @ Btn::Delete(i)) => {
|
|
let Some(info) = cloud.list.get(i) else { return MenuAction::Consumed };
|
|
if confirm == Some(b) {
|
|
MenuAction::CloudDelete(info.id.clone())
|
|
} else {
|
|
self.confirm = Some(b);
|
|
MenuAction::Consumed
|
|
}
|
|
}
|
|
Some(Btn::Scroll(d)) => {
|
|
// each page clamps the scroll position when it is drawn
|
|
self.scroll_lines(d * self.rows_visible.max(1) as i32);
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::NameField) => MenuAction::EditName,
|
|
Some(Btn::Repo) => MenuAction::OpenRepo,
|
|
Some(Btn::ClaudeCode) => MenuAction::OpenClaudeCode,
|
|
Some(Btn::OpenCode) => MenuAction::OpenOpenCode,
|
|
Some(Btn::Field(i)) => {
|
|
self.focus = i;
|
|
if matches!(self.page, Some(Page::Match { .. })) {
|
|
MenuAction::MatchEditField(i)
|
|
} else {
|
|
MenuAction::EditField(i)
|
|
}
|
|
}
|
|
Some(Btn::DoLogin) => MenuAction::Login(self.fields[0].trim().to_string(), self.fields[1].clone()),
|
|
Some(Btn::Account) => {
|
|
self.page = Some(Page::Account);
|
|
self.fields = Default::default();
|
|
self.focus = 0;
|
|
self.error = None;
|
|
MenuAction::OpenAccount
|
|
}
|
|
Some(Btn::Standalone) => MenuAction::DownloadStandalone,
|
|
Some(Btn::ChangePassword) => MenuAction::ChangePassword(self.fields[0].clone(), self.fields[1].clone()),
|
|
Some(Btn::Logout) => MenuAction::Logout,
|
|
Some(Btn::CreateUser) => MenuAction::CreateUser(self.fields[2].trim().to_string(), self.fields[3].clone()),
|
|
Some(b @ Btn::DeleteUser(i)) => match cloud.users.get(i) {
|
|
Some(u) if confirm == Some(b) => MenuAction::DeleteUser(u.name.clone()),
|
|
Some(_) => {
|
|
self.confirm = Some(b);
|
|
MenuAction::Consumed
|
|
}
|
|
None => MenuAction::Consumed,
|
|
},
|
|
Some(Btn::Match) => {
|
|
self.open_match(from_title);
|
|
MenuAction::OpenMatch
|
|
}
|
|
Some(Btn::MatchJoin) => MenuAction::MatchJoin(self.fields[0].trim().to_string()),
|
|
Some(Btn::MatchJoinRoom(i)) => match self.match_ui.rooms.get(i) {
|
|
Some(room) => MenuAction::MatchJoin(room.code.clone()),
|
|
None => MenuAction::Consumed,
|
|
},
|
|
Some(Btn::MatchCreate) => MenuAction::MatchCreate,
|
|
Some(Btn::MatchCoopPrev) => {
|
|
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, -1);
|
|
self.confirm = None;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::MatchCoopNext) => {
|
|
self.coop_mission = crate::campaign::next_coop_mission(self.coop_mission, 1);
|
|
self.confirm = None;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::MatchCoopCreate) => MenuAction::MatchCoopCreate(self.coop_mission),
|
|
Some(Btn::MatchReady) => MenuAction::MatchReady(!self.match_ui.ready),
|
|
Some(Btn::MatchStart) => MenuAction::MatchStart,
|
|
Some(Btn::MatchResume) => MenuAction::MatchResume,
|
|
Some(b @ Btn::MatchAbort) => {
|
|
if confirm == Some(b) {
|
|
MenuAction::MatchAbort
|
|
} else {
|
|
self.confirm = Some(b);
|
|
MenuAction::Consumed
|
|
}
|
|
}
|
|
Some(Btn::MatchLeave) => MenuAction::MatchLeave,
|
|
Some(Btn::MatchChat) => {
|
|
let text = self.fields[1].clone();
|
|
self.fields[1].clear();
|
|
MenuAction::MatchChat(text)
|
|
}
|
|
Some(Btn::MatchTake(i)) => match self.match_ui.seats.get(i) {
|
|
Some(s) => MenuAction::MatchTakeSide(s.side),
|
|
None => MenuAction::Consumed,
|
|
},
|
|
Some(Btn::MatchAi(i)) => match self.match_ui.seats.get(i) {
|
|
Some(s) if s.side != 0 => MenuAction::MatchSetAi(s.side, !s.ai),
|
|
_ => MenuAction::Consumed,
|
|
},
|
|
Some(Btn::Campaign) => {
|
|
self.page = Some(Page::Campaign);
|
|
self.scroll = 0;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::CampaignEditor) => {
|
|
self.page = Some(Page::CampaignEditor);
|
|
self.scroll = 0;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::CampaignNew) => MenuAction::CampaignNew,
|
|
Some(Btn::CampaignOpen) => MenuAction::CampaignOpen,
|
|
Some(Btn::CampaignSave) => MenuAction::CampaignSave,
|
|
Some(Btn::CampaignAdd) => MenuAction::CampaignAdd,
|
|
Some(Btn::CampaignEditName) => MenuAction::CampaignEditName,
|
|
Some(Btn::CampaignEditTitle) => MenuAction::CampaignEditTitle,
|
|
Some(Btn::CampaignEditGoals) => MenuAction::CampaignEditGoals,
|
|
Some(Btn::CampaignEditNext) => MenuAction::CampaignEditNext,
|
|
Some(Btn::CampaignEditBranches) => MenuAction::CampaignEditBranches,
|
|
Some(Btn::CampaignEditScript) => MenuAction::CampaignEditScript,
|
|
Some(b @ Btn::CampaignDelete) => {
|
|
let i = self.campaign_selected;
|
|
if confirm == Some(b) {
|
|
MenuAction::CampaignDelete(i)
|
|
} else {
|
|
self.confirm = Some(b);
|
|
MenuAction::Consumed
|
|
}
|
|
}
|
|
Some(Btn::CampaignStart) => MenuAction::CampaignStart(self.campaign_selected),
|
|
Some(Btn::CampaignMission(i)) => {
|
|
self.campaign_selected = i;
|
|
self.scroll = 0;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Mission(i)) => {
|
|
self.page = Some(Page::Briefing { mission: i, ingame: false });
|
|
self.scroll = 0;
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::StartMission) => match self.page {
|
|
Some(Page::Briefing { mission, .. }) => MenuAction::StartMission(mission),
|
|
_ => MenuAction::Consumed,
|
|
},
|
|
Some(Btn::Goals) => {
|
|
if let Some(m) = self.mission {
|
|
self.page = Some(Page::Briefing { mission: m, ingame: true });
|
|
self.scroll = 0;
|
|
}
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::Bestiary) => {
|
|
self.open_bestiary(from_title);
|
|
MenuAction::Consumed
|
|
}
|
|
Some(b @ (Btn::BestTab(_) | Btn::BestSel(_) | Btn::BestPick(_) | Btn::BestLevel(_))) => {
|
|
self.bestiary_click(b);
|
|
MenuAction::Consumed
|
|
}
|
|
Some(Btn::FileSave) => MenuAction::FileSave,
|
|
Some(Btn::FileLoad) => MenuAction::FileLoad,
|
|
}
|
|
}
|
|
|
|
pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>, cloud: &Cloud) {
|
|
self.buttons.clear();
|
|
let Some(page) = self.page else { return };
|
|
let sw = sw();
|
|
let sh = sh();
|
|
draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, if page == Page::Title { 0.55 } else { 0.45 }));
|
|
match page {
|
|
Page::Title => self.draw_title(gfx, cloud),
|
|
Page::Pause => self.draw_pause(gfx),
|
|
Page::NewGame => self.draw_new_game(gfx, settings),
|
|
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
|
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
|
|
Page::Campaign => self.draw_campaign(gfx),
|
|
Page::CampaignEditor => self.draw_campaign_editor(gfx),
|
|
Page::Login => self.draw_login(gfx, cloud),
|
|
Page::Account => self.draw_account(gfx, cloud),
|
|
Page::Match { .. } => self.draw_match(gfx, cloud),
|
|
Page::Briefing { mission, ingame } => self.draw_briefing(gfx, mission, ingame),
|
|
Page::Bestiary { tab, sel, level, .. } => self.draw_bestiary(gfx, tab, sel, level),
|
|
Page::KeyCapture { action, .. } => {
|
|
self.draw_settings(gfx, settings, 5);
|
|
self.buttons.clear();
|
|
let a = all_actions()[action];
|
|
let text = trf("set.press_key", &[action_label(a)]);
|
|
let cols = (text.chars().count() as i32 + 8).max(36);
|
|
let x = grid_center(sw, cols as f32 * CW());
|
|
let y = (sh * 0.4 / CH()).floor() * CH();
|
|
gfx.window(x, y, cols, 5, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.98));
|
|
gfx.text(&text, x + CW() * 4.0, y + CH() * 1.5, S(), TXT);
|
|
let esc = tr("set.esc_cancel");
|
|
gfx.text(&esc, x + CW() * 4.0, y + CH() * 3.0, S(), DIM);
|
|
}
|
|
}
|
|
if let Some(st) = status {
|
|
// long messages wrap onto several lines
|
|
let maxc = ((sw / CW()) as usize).saturating_sub(8).clamp(10, 70);
|
|
let lines = wrap(st, maxc);
|
|
let w = lines.iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 4.0;
|
|
let h = CH() * (lines.len() as f32 + 1.0);
|
|
let x = grid_center(sw, w);
|
|
let y = sh - CH() * 8.0 - h;
|
|
draw_rectangle(x, y, w, h, Color::new(0.05, 0.03, 0.04, 0.95));
|
|
draw_rectangle_lines(x, y, w, h, 2.0, GOLD);
|
|
for (k, l) in lines.iter().enumerate() {
|
|
gfx.text(l, x + CW() * 2.0, y + CH() * (0.5 + k as f32), S(), GOLD);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn column(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, items: &[(Btn, String)]) {
|
|
let (mx, my) = mouse();
|
|
for (k, (b, label)) in items.iter().enumerate() {
|
|
let r = Rect::new(x, y + k as f32 * CH() * 3.0, w, CH() * 2.0);
|
|
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, *b));
|
|
}
|
|
}
|
|
|
|
fn draw_title(&mut self, gfx: &Gfx, cloud: &Cloud) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let big = TITLE();
|
|
let title = "DungeonCraft";
|
|
let ty = (sh * 0.12 / CH()).floor() * CH();
|
|
gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8));
|
|
gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI);
|
|
let sub = tr("menu.subtitle");
|
|
gfx.text(&sub, grid_center(sw, gfx.measure(&sub, S())), ty + GH * big + CH(), S(), TXT);
|
|
let mut items = Vec::new();
|
|
if self.has_autosave {
|
|
items.push((Btn::Continue, tr("menu.continue")));
|
|
}
|
|
items.push((Btn::Campaign, tr("menu.campaign")));
|
|
items.push((Btn::CampaignEditor, tr("menu.campaign_editor")));
|
|
items.push((Btn::Match, tr("menu.multiplayer")));
|
|
items.push((Btn::New, tr("menu.new")));
|
|
items.push((Btn::Load, tr("menu.load")));
|
|
items.push((Btn::Settings, tr("menu.settings")));
|
|
if let Some(s) = cloud.session.as_ref().filter(|_| cloud.available()) {
|
|
items.push((Btn::Account, trf("menu.account", &[s.name.clone()])));
|
|
}
|
|
items.push((Btn::Standalone, tr("menu.standalone")));
|
|
items.push((Btn::Bestiary, tr("menu.bestiary")));
|
|
items.push((Btn::Help, tr("menu.help")));
|
|
let w = 26.0 * CW();
|
|
let y = ty + GH * big + CH() * 4.0;
|
|
self.column(gfx, grid_center(sw, w), y, w, &items);
|
|
let hint = tr("menu.drop_hint");
|
|
gfx.text(&hint, grid_center(sw, gfx.measure(&hint, SMALL())), sh - CH() * 8.0, SMALL(), DIM);
|
|
let credit = tr("help.font");
|
|
gfx.text(&credit, grid_center(sw, gfx.measure(&credit, SMALL())), sh - CH() * 7.0, SMALL(), DIM);
|
|
self.draw_license(gfx);
|
|
}
|
|
|
|
// licence and repository link at the bottom of the screen (title and login)
|
|
pub fn draw_license(&mut self, gfx: &Gfx) {
|
|
let (sw, sh) = (sw(), sh());
|
|
let (mx, my) = mouse();
|
|
// "made with" notes with their pixel marks, links to the tools:
|
|
// Claude Code, then qwen3.8-flash-next-iq3xxs with Strata in OpenCode
|
|
let px = (SMALL() * 2.0).round().max(2.0);
|
|
let credits: [(&[&[&str]], &[Color], String, Color, Btn, f32); 2] = [
|
|
(&[&CLAUDE_MARK], &[CLAUDE_COL], tr("menu.made_with"), CLAUDE_COL, Btn::ClaudeCode, 4.6),
|
|
(&[&QWEN_MARK, &OPENCODE_MARK], &[QWEN_COL, OPENCODE_COL], tr("menu.made_with_qwen"), QWEN_COL, Btn::OpenCode, 3.6),
|
|
];
|
|
for (marks, cols, made, col, btn, row) in credits {
|
|
let widths: Vec<f32> = marks.iter().map(|m| m[0].len() as f32 * px).collect();
|
|
let mark_w: f32 = widths.iter().sum::<f32>() + px * 2.0 * (marks.len() as f32 - 1.0);
|
|
let tw = gfx.measure(&made, SMALL());
|
|
let total = mark_w + CW() * 0.5 + tw;
|
|
let mx0 = grid_center(sw, total);
|
|
let my0 = sh - CH() * row;
|
|
let r = Rect::new(mx0, my0, total, GH * SMALL() + 2.0);
|
|
let hot = r.contains(vec2(mx, my));
|
|
let mut x = mx0;
|
|
for (k, m) in marks.iter().enumerate() {
|
|
draw_mark(m, x, my0 + (GH * SMALL() - 5.0 * px) * 0.5, px, cols[k]);
|
|
x += widths[k] + px * 2.0;
|
|
}
|
|
gfx.text(&made, mx0 + mark_w + CW() * 0.5, my0, SMALL(), if hot { FRAME_HI } else { col });
|
|
self.buttons.push((r, btn));
|
|
}
|
|
let lic = tr("menu.license");
|
|
gfx.text(&lic, grid_center(sw, gfx.measure(&lic, SMALL())), sh - CH() * 2.5, SMALL(), DIM);
|
|
let ver = tr("menu.version");
|
|
let line = format!("{} {}", ver, version());
|
|
gfx.text(&line, grid_center(sw, gfx.measure(&line, SMALL())), sh - CH() * 5.5, SMALL(), DIM);
|
|
let url = REPO_URL.trim_start_matches("https://");
|
|
let w = gfx.measure(url, SMALL());
|
|
let r = Rect::new(grid_center(sw, w), sh - CH() * 1.5, w, GH * SMALL() + 2.0);
|
|
let hot = r.contains(vec2(mx, my));
|
|
gfx.text(url, r.x, r.y, SMALL(), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 1.0) });
|
|
draw_rectangle(r.x, r.y + GH * SMALL(), r.w, (SMALL()).max(1.0), if hot { FRAME_HI } else { Color::new(0.5, 0.7, 1.0, 0.6) });
|
|
self.buttons.push((r, Btn::Repo));
|
|
}
|
|
|
|
// New game: choose the map size (width and height separately)
|
|
fn draw_new_game(&mut self, gfx: &Gfx, s: &Settings) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let cols = 48.min((sw / CW()) as i32);
|
|
let rows = 22;
|
|
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.new"), FRAME_HI);
|
|
let diff = tr(&format!("diff.{}", s.difficulty.min(2)));
|
|
let lines = [("newgame.width", s.map_w.to_string(), 0), ("newgame.height", s.map_h.to_string(), 1), ("newgame.difficulty", diff, 2)];
|
|
for (k, (key, val, which)) in lines.iter().enumerate() {
|
|
let (key, which) = (*key, *which);
|
|
let by = y + CH() * (2.0 + k as f32 * 2.5);
|
|
gfx.text(&tr(key), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
|
|
let vw = 14.0 * CW();
|
|
let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
|
|
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
|
|
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
|
|
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
|
|
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
|
|
let v = val.clone();
|
|
gfx.text(&v, vx + (vw - gfx.measure(&v, S())) * 0.5, by + CH() * 0.5, S(), GOLD);
|
|
let (l, r) = match which {
|
|
0 => (Btn::MapW(-1), Btn::MapW(1)),
|
|
1 => (Btn::MapH(-1), Btn::MapH(1)),
|
|
_ => (Btn::Difficulty(-1), Btn::Difficulty(1)),
|
|
};
|
|
self.buttons.push((left, l));
|
|
self.buttons.push((right, r));
|
|
}
|
|
// what the size means for the game
|
|
let area = (s.map_w * s.map_h) as f32 / 4096.0;
|
|
let portals = (area.round() as usize).max(1);
|
|
let info = trf("newgame.info", &[portals.to_string(), (portals - 1).to_string()]);
|
|
gfx.text(&info, x + CW() * 3.0, y + CH() * 10.0, S(), DIM);
|
|
let opp = tr(if s.map_w * s.map_h > 128 * 128 { "newgame.opponents2" } else { "newgame.opponents1" });
|
|
let maxc = (cols - 6) as usize;
|
|
for (k, l) in wrap(&opp, maxc).iter().take(2).enumerate() {
|
|
gfx.text(l, x + CW() * 3.0, y + CH() * (11.0 + k as f32), S(), DIM);
|
|
}
|
|
let w = (cols - 6) as f32 * CW();
|
|
self.column(gfx, x + CW() * 3.0, y + CH() * 14.0, w, &[(Btn::Start, tr("newgame.start")), (Btn::Back, tr("menu.back"))]);
|
|
}
|
|
|
|
fn draw_pause(&mut self, gfx: &Gfx) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let mut items = vec![(Btn::Resume, tr("menu.resume"))];
|
|
if self.mission.is_some() {
|
|
items.push((Btn::Goals, tr("menu.goals")));
|
|
}
|
|
if self.in_match {
|
|
items.extend([
|
|
(Btn::Settings, tr("menu.settings")),
|
|
(Btn::Bestiary, tr("menu.bestiary")),
|
|
(Btn::Help, tr("menu.help")),
|
|
(Btn::MainMenu, tr("net.leave")),
|
|
]);
|
|
} else {
|
|
items.extend([
|
|
(Btn::Save, tr("menu.save")),
|
|
(Btn::Load, tr("menu.load")),
|
|
(Btn::Match, tr("menu.multiplayer")),
|
|
(Btn::Settings, tr("menu.settings")),
|
|
(Btn::Bestiary, tr("menu.bestiary")),
|
|
(Btn::Help, tr("menu.help")),
|
|
(Btn::MainMenu, tr("menu.main")),
|
|
]);
|
|
}
|
|
let cols = 32;
|
|
let rows = items.len() as i32 * 3 + 4;
|
|
let x = grid_center(sw, cols as f32 * CW());
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.paused"), FRAME_HI);
|
|
let w = (cols - 6) as f32 * CW();
|
|
self.column(gfx, x + CW() * 3.0, y + CH() * 2.0, w, &items);
|
|
let ver = tr("menu.version");
|
|
let line = format!("{} {}", ver, version());
|
|
gfx.text(&line, x + (cols as f32 * CW() - gfx.measure(&line, SMALL())) * 0.5, y + (rows as f32 - 1.7) * CH(), SMALL(), DIM);
|
|
}
|
|
|
|
fn draw_settings(&mut self, gfx: &Gfx, s: &Settings, tab: usize) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let cols = ((sw / CW()) as i32 - 2).min(66);
|
|
let rows = ((sh / CH()) as i32 - 2).min(34);
|
|
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("menu.settings"), FRAME_HI);
|
|
// tabs
|
|
let mut tx = x + CW() * 2.0;
|
|
let ty = y + CH() * 1.5;
|
|
for (i, key) in TABS.iter().enumerate() {
|
|
let label = tr(key);
|
|
let w = gfx.measure(&label, S()) + CW() * 2.0;
|
|
let r = Rect::new(tx, ty, w, CH() * 2.0);
|
|
let hot = r.contains(vec2(mx, my));
|
|
draw_rectangle(r.x, r.y, r.w, r.h, if i == tab { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
|
|
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if i == tab || hot { FRAME_HI } else { FRAME });
|
|
gfx.text(&label, r.x + CW(), r.y + CH() * 0.5, S(), if i == tab { FRAME_HI } else { TXT });
|
|
self.buttons.push((r, Btn::Tab(i)));
|
|
tx += w + CW();
|
|
}
|
|
let top = ty + CH() * 3.5;
|
|
if tab == 5 {
|
|
// key bindings in two columns
|
|
let acts = all_actions();
|
|
let half = (acts.len() + 1) / 2;
|
|
let colw = (cols as f32 - 6.0) * CW() * 0.5;
|
|
let rowh = CH();
|
|
for (k, a) in acts.iter().enumerate() {
|
|
let c = k / half;
|
|
let r = k % half;
|
|
let bx = x + CW() * 2.0 + c as f32 * (colw + CW() * 2.0);
|
|
let by = top + r as f32 * rowh;
|
|
let rect = Rect::new(bx, by - 2.0, colw, rowh - 2.0);
|
|
let hot = rect.contains(vec2(mx, my));
|
|
if hot {
|
|
draw_rectangle(rect.x, rect.y, rect.w, rect.h, Color::new(0.25, 0.16, 0.08, 1.0));
|
|
}
|
|
let key = format!("[{}]", key_name(s.key(*a)));
|
|
let maxc = ((colw - gfx.measure(&key, S()) - CW()) / CW()).max(4.0) as usize;
|
|
let label: String = action_label(*a).chars().take(maxc).collect();
|
|
gfx.text(&label, bx + CW() * 0.5, by, S(), TXT);
|
|
gfx.text(&key, bx + colw - gfx.measure(&key, S()) - CW() * 0.5, by, S(), GOLD);
|
|
self.buttons.push((rect, Btn::Key(k)));
|
|
}
|
|
let hint = tr("set.keys_hint");
|
|
gfx.text(&hint, x + CW() * 2.0, top + half as f32 * rowh + CH() * 0.5, SMALL(), DIM);
|
|
let rw = gfx.measure(&tr("set.reset_keys"), S()) + CW() * 4.0;
|
|
let rr = Rect::new(x + cols as f32 * CW() - rw - CW() * 2.0, y + (rows - 4) as f32 * CH(), rw, CH() * 2.0);
|
|
button(gfx, rr, &tr("set.reset_keys"), rr.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((rr, Btn::ResetKeys));
|
|
} else {
|
|
for (i, &it) in tab_items(tab).iter().enumerate() {
|
|
let by = top + i as f32 * CH() * 2.5;
|
|
gfx.text(&item_label(it), x + CW() * 3.0, by + CH() * 0.5, S(), TXT);
|
|
// value with < > buttons
|
|
let vw = 18.0 * CW();
|
|
let vx = x + cols as f32 * CW() - vw - CW() * 3.0;
|
|
let left = Rect::new(vx, by, CW() * 2.5, CH() * 2.0);
|
|
let right = Rect::new(vx + vw - CW() * 2.5, by, CW() * 2.5, CH() * 2.0);
|
|
button(gfx, left, "<", left.contains(vec2(mx, my)), FRAME);
|
|
button(gfx, right, ">", right.contains(vec2(mx, my)), FRAME);
|
|
let val = item_value(it, s);
|
|
gfx.text(&val, vx + (vw - gfx.measure(&val, S())) * 0.5, by + CH() * 0.5, S(), GOLD);
|
|
let mid = Rect::new(vx + CW() * 2.5, by, vw - CW() * 5.0, CH() * 2.0);
|
|
self.buttons.push((left, Btn::Change(i, -1)));
|
|
self.buttons.push((right, Btn::Change(i, 1)));
|
|
self.buttons.push((mid, Btn::Change(i, 1)));
|
|
}
|
|
}
|
|
let bw = gfx.measure(&tr("menu.back"), S()) + CW() * 6.0;
|
|
let br = Rect::new(x + CW() * 2.0, y + (rows - 4) as f32 * CH(), bw, CH() * 2.0);
|
|
button(gfx, br, &tr("menu.back"), br.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((br, Btn::Back));
|
|
}
|
|
|
|
// Savegame list on the server: save mode (name field, new / overwrite) or load mode
|
|
fn draw_saves(&mut self, gfx: &Gfx, cloud: &Cloud, save: bool) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let cols = ((sw / CW()) as i32 - 2).min(72);
|
|
let rows = ((sh / CH()) as i32 - 2).min(36);
|
|
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
let title = tr(if save { "menu.save" } else { "menu.load" });
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI);
|
|
let inner_x = x + CW() * 2.0;
|
|
let inner_w = (cols - 4) as f32 * CW();
|
|
let mut top = y + CH() * 2.0;
|
|
|
|
// name field and "save as new"
|
|
if save {
|
|
let label = tr("saves.name");
|
|
gfx.text(&label, inner_x, top + CH() * 0.5, S(), TXT);
|
|
let bl = tr("saves.new");
|
|
let bw = gfx.measure(&bl, S()) + CW() * 4.0;
|
|
let fx = inner_x + gfx.measure(&label, S()) + CW();
|
|
let fw = (inner_w - (fx - inner_x) - bw - CW()).max(CW() * 8.0);
|
|
draw_rectangle(fx, top, fw, CH() * 2.0, Color::new(0.02, 0.01, 0.02, 1.0));
|
|
draw_rectangle_lines(fx, top, fw, CH() * 2.0, 2.0, FRAME_HI);
|
|
self.buttons.push((Rect::new(fx, top, fw, CH() * 2.0), Btn::NameField));
|
|
let blink = if (get_time() * 2.0) as i64 % 2 == 0 { "_" } else { " " };
|
|
let maxc = ((fw - CW() * 2.0) / CW()).max(1.0) as usize;
|
|
let shown: String = {
|
|
let all: Vec<char> = self.save_name.chars().collect();
|
|
all[all.len().saturating_sub(maxc - 1)..].iter().collect()
|
|
};
|
|
gfx.text(&format!("{}{}", shown, blink), fx + CW(), top + CH() * 0.5, S(), GOLD);
|
|
let br = Rect::new(fx + fw + CW(), top, bw, CH() * 2.0);
|
|
button(gfx, br, &bl, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME);
|
|
self.buttons.push((br, Btn::SaveNew));
|
|
top += CH() * 3.0;
|
|
}
|
|
|
|
// list
|
|
let bottom = y + (rows - 5) as f32 * CH();
|
|
let row_h = CH() * 4.5;
|
|
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
|
|
let n = cloud.list.len();
|
|
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
|
|
if n == 0 {
|
|
let msg = tr(if !cloud.listed { "saves.loading" } else { "saves.empty" });
|
|
gfx.text(&msg, inner_x + CW(), top + CH(), S(), DIM);
|
|
}
|
|
let act_label = tr(if save { "saves.overwrite" } else { "saves.load" });
|
|
let del_label = tr("saves.delete");
|
|
let sure = tr("saves.sure");
|
|
let bw = [&act_label, &del_label, &sure].iter().map(|l| gfx.measure(l, S())).fold(0.0, f32::max) + CW() * 3.0;
|
|
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
|
|
for (k, info) in cloud.list.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
|
|
let ry = top + (k - self.scroll) as f32 * row_h;
|
|
let rr = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5);
|
|
if rr.contains(vec2(mx, my)) {
|
|
draw_rectangle(rr.x, rr.y, rr.w, rr.h, Color::new(0.2, 0.13, 0.07, 1.0));
|
|
}
|
|
let text_w = rr.w - bw * 2.0 - CW() * 3.0;
|
|
let maxc = (text_w / CW()).max(4.0) as usize;
|
|
let name: String = info.name.chars().take(maxc).collect();
|
|
gfx.text(&name, inner_x + CW(), ry + CH() * 0.25, S(), GOLD);
|
|
let meta = trf(
|
|
"saves.meta",
|
|
&[(info.wave + 1).to_string(), format!("{}x{}", info.map_w, info.map_h), duration_string(info.time), date_string(info.saved_at as i64)],
|
|
);
|
|
let meta: String = meta.chars().take(maxc).collect();
|
|
gfx.text(&meta, inner_x + CW(), ry + CH() * 2.0, S(), DIM);
|
|
let b1 = Rect::new(rr.x + rr.w - bw * 2.0 - CW(), ry + CH(), bw, CH() * 2.0);
|
|
let b2 = Rect::new(rr.x + rr.w - bw, ry + CH(), bw, CH() * 2.0);
|
|
let c1 = self.confirm == Some(Btn::Slot(k));
|
|
let c2 = self.confirm == Some(Btn::Delete(k));
|
|
button(gfx, b1, if c1 { &sure } else { &act_label }, b1.contains(vec2(mx, my)) || c1, FRAME);
|
|
button(gfx, b2, if c2 { &sure } else { &del_label }, b2.contains(vec2(mx, my)) || c2, Color::new(0.6, 0.2, 0.15, 1.0));
|
|
self.buttons.push((b1, Btn::Slot(k)));
|
|
self.buttons.push((b2, Btn::Delete(k)));
|
|
}
|
|
if scroll_w > 0.0 {
|
|
let sx = inner_x + inner_w - CW() * 2.5;
|
|
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
|
|
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
|
|
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
|
|
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((up, Btn::Scroll(-1)));
|
|
self.buttons.push((down, Btn::Scroll(1)));
|
|
let pos = format!("{}-{}/{}", self.scroll + 1, (self.scroll + self.rows_visible).min(n), n);
|
|
gfx.text(&pos, sx + CW() * 1.25 - gfx.measure(&pos, SMALL()) * 0.5, (top + bottom) * 0.5 - CH() * 0.5, SMALL(), DIM);
|
|
}
|
|
if cloud.busy() {
|
|
let w = tr("saves.wait");
|
|
gfx.text(&w, inner_x + inner_w - gfx.measure(&w, SMALL()), bottom + CH() * 0.25, SMALL(), DIM);
|
|
}
|
|
|
|
// back, and the savegame files as before
|
|
let by = y + (rows - 4) as f32 * CH();
|
|
let back = tr("menu.back");
|
|
let bkw = gfx.measure(&back, S()) + CW() * 6.0;
|
|
let br = Rect::new(inner_x, by, bkw, CH() * 2.0);
|
|
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((br, Btn::Back));
|
|
let fl = tr(if save { "saves.file_save" } else { "saves.file_load" });
|
|
let fw = gfx.measure(&fl, S()) + CW() * 4.0;
|
|
let fr = Rect::new(inner_x + inner_w - fw, by, fw, CH() * 2.0);
|
|
button(gfx, fr, &fl, fr.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((fr, if save { Btn::FileSave } else { Btn::FileLoad }));
|
|
}
|
|
|
|
// Campaign: list of the missions (done, open, locked)
|
|
fn draw_campaign(&mut self, gfx: &Gfx) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let cols = ((sw / CW()) as i32 - 2).min(60);
|
|
let rows = ((sh / CH()) as i32 - 2).min(36);
|
|
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("camp.title"), FRAME_HI);
|
|
let inner_x = x + CW() * 2.0;
|
|
let inner_w = (cols - 4) as f32 * CW();
|
|
let top = y + CH() * 2.0;
|
|
let bottom = y + (rows - 5) as f32 * CH();
|
|
let row_h = CH() * 2.5;
|
|
let all = missions();
|
|
let n = all.len();
|
|
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
|
|
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
|
|
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
|
|
for (k, d) in all.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
|
|
let ry = top + (k - self.scroll) as f32 * row_h;
|
|
let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, CH() * 2.0);
|
|
let open = self.progress.unlocked(k);
|
|
let done = self.progress.is_done(k);
|
|
let hot = open && r.contains(vec2(mx, my));
|
|
draw_rectangle(r.x, r.y, r.w, r.h, if hot { Color::new(0.35, 0.22, 0.1, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
|
|
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if hot { FRAME_HI } else if open { FRAME } else { Color::new(0.25, 0.2, 0.15, 1.0) });
|
|
let mark = if done { "\u{221a}" } else if open { "\u{25ba}" } else { "-" };
|
|
let title = if open { tr(&format!("camp.{}.title", d.key)) } else { tr("camp.locked") };
|
|
let line = format!("{} {:02} {}", mark, k + 1, title);
|
|
let maxc = ((r.w - CW() * 2.0) / CW()) as usize;
|
|
let line: String = line.chars().take(maxc).collect();
|
|
gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if done { GOLD } else if open { TXT } else { DIM });
|
|
if open {
|
|
self.buttons.push((r, Btn::Mission(k)));
|
|
}
|
|
}
|
|
if scroll_w > 0.0 {
|
|
let sx = inner_x + inner_w - CW() * 2.5;
|
|
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
|
|
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
|
|
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
|
|
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((up, Btn::Scroll(-1)));
|
|
self.buttons.push((down, Btn::Scroll(1)));
|
|
}
|
|
let done = (0..n).filter(|&k| self.progress.is_done(k)).count();
|
|
let st = trf("camp.progress", &[done.to_string(), n.to_string()]);
|
|
gfx.text(&st, inner_x, bottom + CH() * 0.5, SMALL(), DIM);
|
|
let by = y + (rows - 4) as f32 * CH();
|
|
let back = tr("menu.back");
|
|
let br = Rect::new(inner_x, by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
|
|
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((br, Btn::Back));
|
|
}
|
|
|
|
// First campaign-editor step: load/save shareable missions and show their branching structure.
|
|
fn draw_campaign_editor(&mut self, gfx: &Gfx) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let cols = ((sw / CW()) as i32 - 2).min(72);
|
|
let rows = ((sh / CH()) as i32 - 2).min(34);
|
|
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("campaign.title"), FRAME_HI);
|
|
|
|
let campaign = self.campaign_doc.clone();
|
|
let errors = campaign.validate();
|
|
let name = if campaign.name.trim().is_empty() { tr("campaign.new") } else { campaign.name.clone() };
|
|
let file = campaign.file_name();
|
|
let n = campaign.missions.len();
|
|
let inner_x = x + CW() * 2.0;
|
|
let inner_w = (cols - 4) as f32 * CW();
|
|
let head = trf("campaign.name", &[name]);
|
|
let head_w = (gfx.measure(&head, S()) + CW()).max(CW() * 8.0);
|
|
let head_rect = Rect::new(inner_x, y + CH() * 1.5, head_w, GH * S() + CH() * 0.5);
|
|
let hot_name = head_rect.contains(vec2(mx, my));
|
|
gfx.text(&head, inner_x, y + CH() * 1.5, S(), if hot_name { FRAME_HI } else { GOLD });
|
|
if !campaign.author.trim().is_empty() {
|
|
let author = trf("campaign.author", &[campaign.author.clone()]);
|
|
gfx.text(&author, inner_x + head_w + CW() * 2.0, y + CH() * 1.5, S(), DIM);
|
|
}
|
|
self.buttons.push((head_rect, Btn::CampaignEditName));
|
|
|
|
let top = if n > 0 { y + CH() * 5.0 } else { y + CH() * 3.5 };
|
|
if n > 0 {
|
|
let field_items = [(Btn::CampaignEditTitle, "T"), (Btn::CampaignEditGoals, "G"), (Btn::CampaignEditNext, "N"), (Btn::CampaignEditBranches, "B"), (Btn::CampaignEditScript, "S")];
|
|
let widths: Vec<f32> = field_items.iter().map(|(_, l)| (gfx.measure(l, S()) + CW() * 2.5).max(CW() * 3.5)).collect();
|
|
let total_w = widths.iter().sum::<f32>() + (field_items.len() as f32 - 1.0) * CW() * 0.5;
|
|
let mut fx = x + cols as f32 * CW() - CW() * 2.0 - total_w;
|
|
for (k, (b, l)) in field_items.iter().enumerate() {
|
|
let r = Rect::new(fx, y + CH() * 2.8, widths[k], CH() * 1.8);
|
|
button(gfx, r, l, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, *b));
|
|
fx += widths[k] + CW() * 0.5;
|
|
}
|
|
}
|
|
|
|
let bottom = y + (rows - 5) as f32 * CH();
|
|
let row_h = CH() * 3.0;
|
|
self.rows_visible = (((bottom - top) / row_h).floor() as usize).max(1);
|
|
self.scroll = self.scroll.min(n.saturating_sub(self.rows_visible));
|
|
let scroll_w = if n > self.rows_visible { CW() * 3.0 } else { 0.0 };
|
|
|
|
if n == 0 {
|
|
gfx.text(&tr("campaign.empty"), inner_x, top, S(), DIM);
|
|
}
|
|
|
|
for (k, mission) in campaign.missions.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
|
|
let ry = top + (k - self.scroll) as f32 * row_h;
|
|
let r = Rect::new(inner_x, ry, inner_w - scroll_w - CW() * 0.5, row_h - CH() * 0.5);
|
|
let selected = k == self.campaign_selected;
|
|
let hot = r.contains(vec2(mx, my));
|
|
draw_rectangle(r.x, r.y, r.w, r.h, if selected { Color::new(0.32, 0.21, 0.1, 1.0) } else if hot { Color::new(0.18, 0.12, 0.08, 1.0) } else { Color::new(0.1, 0.07, 0.07, 1.0) });
|
|
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if selected { GOLD } else if hot { FRAME_HI } else { FRAME });
|
|
let title = if mission.title.trim().is_empty() { mission.key.clone() } else { mission.title.clone() };
|
|
let line = trf("campaign.mission_no", &[(k + 1).to_string(), title]);
|
|
let maxc = ((r.w - CW() * 2.0) / CW()) as usize;
|
|
let line: String = line.chars().take(maxc).collect();
|
|
gfx.text(&line, r.x + CW(), r.y + CH() * 0.5, S(), if selected { GOLD } else { TXT });
|
|
|
|
let next = mission.next.clone().unwrap_or_default();
|
|
let branch_count = mission.branches.len();
|
|
let detail = if branch_count > 0 {
|
|
trf("campaign.branches", &[branch_count.to_string()])
|
|
} else if !next.is_empty() {
|
|
trf("campaign.next", &[next])
|
|
} else if !mission.goals.is_empty() {
|
|
trf("campaign.goals", &[mission.goals.join(", ")])
|
|
} else {
|
|
tr("campaign.empty")
|
|
};
|
|
let script_count = mission.script.as_ref().map(|s| if s.source.trim().is_empty() { 0 } else { 1 }).unwrap_or(0);
|
|
let zone_count = mission.zones.len();
|
|
let detail = if zone_count > 0 || script_count > 0 {
|
|
format!("{} · {} · {}", detail, trf("campaign.zones", &[zone_count.to_string()]), trf("campaign.script", &[script_count.to_string()]))
|
|
} else {
|
|
detail
|
|
};
|
|
let maxc = ((r.w - CW() * 2.0) / GW.max(1.0) / (SMALL().max(1.0))) as usize;
|
|
let detail: String = detail.chars().take(maxc.max(20)).collect();
|
|
gfx.text(&detail, r.x + CW(), r.y + CH() * 1.6, SMALL(), DIM);
|
|
self.buttons.push((r, Btn::CampaignMission(k)));
|
|
}
|
|
|
|
if scroll_w > 0.0 {
|
|
let sx = inner_x + inner_w - CW() * 2.5;
|
|
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
|
|
let down = Rect::new(sx, bottom - CH() * 2.5, CW() * 2.5, CH() * 2.0);
|
|
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
|
|
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((up, Btn::Scroll(-1)));
|
|
self.buttons.push((down, Btn::Scroll(1)));
|
|
}
|
|
|
|
let count = trf("campaign.missions", &[n.to_string()]);
|
|
gfx.text(&count, inner_x, bottom + CH() * 0.5, SMALL(), DIM);
|
|
let file_line = format!("{} · {}", file, tr("menu.drop_hint"));
|
|
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
|
|
let file_line: String = file_line.chars().take(maxc.max(20)).collect();
|
|
gfx.text(&file_line, x + cols as f32 * CW() - gfx.measure(&file_line, SMALL()) - CW() * 2.0, bottom + CH() * 0.5, SMALL(), DIM);
|
|
|
|
if !errors.is_empty() {
|
|
let err = errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join(" | ");
|
|
let maxc = ((cols - 6) as f32 * CW() / (GW.max(1.0) * SMALL().max(1.0))) as usize;
|
|
for l in wrap(&err, maxc).iter().take(3) {
|
|
gfx.text(l, inner_x, bottom + CH() * 1.5, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
|
|
}
|
|
}
|
|
|
|
let by = y + (rows as f32 - 3.5) * CH();
|
|
let right = x + cols as f32 * CW() - CW() * 2.0;
|
|
let mut bx = inner_x;
|
|
let mut actions = vec![(Btn::CampaignNew, tr("campaign.new")), (Btn::CampaignAdd, tr("campaign.add"))];
|
|
if n > 0 {
|
|
actions.push((Btn::CampaignStart, tr("campaign.start")));
|
|
actions.push((Btn::CampaignDelete, tr("campaign.delete")));
|
|
}
|
|
actions.extend([(Btn::CampaignOpen, tr("campaign.open")), (Btn::CampaignSave, tr("campaign.save")), (Btn::Back, tr("menu.back"))]);
|
|
for (b, label) in actions {
|
|
let w = (gfx.measure(&label, S()) + CW() * 4.0).max(CW() * 6.0);
|
|
let r = Rect::new(bx, by, w, CH() * 2.0);
|
|
if r.x + r.w <= right {
|
|
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, b));
|
|
}
|
|
bx += w + CW();
|
|
}
|
|
}
|
|
|
|
// Briefing: the old imp tells the story, then the goals and what comes along
|
|
fn draw_briefing(&mut self, gfx: &Gfx, mission: usize, ingame: bool) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let all = missions();
|
|
let Some(d) = all.get(mission) else { return };
|
|
let cols = ((sw / CW()) as i32 - 2).min(64);
|
|
let rows = ((sh / CH()) as i32 - 2).min(38);
|
|
let x = (grid_center(sw, cols as f32 * CW())).max(0.0);
|
|
let y = (grid_center_y(sh, rows as f32 * CH())).max(0.0);
|
|
let title = trf("camp.mission_title", &[(mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &title, FRAME_HI);
|
|
// all lines: story paragraphs (" | " separates them), goals, carry-over
|
|
let tw = (cols - 6) as usize;
|
|
let mut lines: Vec<(String, Color)> = Vec::new();
|
|
for (k, para) in tr(&format!("camp.{}.story", d.key)).split(" | ").enumerate() {
|
|
if k > 0 {
|
|
lines.push((String::new(), TXT));
|
|
}
|
|
for l in wrap(para, tw) {
|
|
lines.push((l, TXT));
|
|
}
|
|
}
|
|
lines.push((String::new(), TXT));
|
|
lines.push((tr("camp.goals"), GOLD));
|
|
for g in &d.goals {
|
|
for l in wrap(&format!("\u{25ba} {}", goal_text(*g)), tw) {
|
|
lines.push((l, FRAME_HI));
|
|
}
|
|
}
|
|
if d.time_limit > 0.0 {
|
|
lines.push((format!("\u{25ba} {}", trf("camp.time_limit", &[duration_string(d.time_limit as f64)])), FRAME_HI));
|
|
}
|
|
// newly available tools
|
|
let new_tools: Vec<String> = crate::campaign::TOOL_UNLOCK.iter().enumerate().filter(|&(_, &u)| u == mission && mission > 0).map(|(i, _)| tool_info(TOOLS[i]).name).collect();
|
|
if !new_tools.is_empty() {
|
|
lines.push((String::new(), TXT));
|
|
for l in wrap(&trf("camp.new_tools", &[new_tools.join(", ")]), tw) {
|
|
lines.push((l, GOLD));
|
|
}
|
|
}
|
|
if !ingame {
|
|
if let Some(c) = self.progress.carry_into(mission) {
|
|
lines.push((String::new(), TXT));
|
|
for l in wrap(&trf("camp.carry", &[c.creatures.len().to_string(), (c.gold as i32).to_string()]), tw) {
|
|
lines.push((l, DIM));
|
|
}
|
|
}
|
|
}
|
|
let top = y + CH() * 2.0;
|
|
let bottom = y + (rows - 5) as f32 * CH();
|
|
self.rows_visible = (((bottom - top) / CH()).floor() as usize).max(1);
|
|
self.scroll = self.scroll.min(lines.len().saturating_sub(self.rows_visible));
|
|
for (k, (l, c)) in lines.iter().enumerate().skip(self.scroll).take(self.rows_visible) {
|
|
gfx.text(l, x + CW() * 3.0, top + (k - self.scroll) as f32 * CH(), S(), *c);
|
|
}
|
|
if lines.len() > self.rows_visible {
|
|
let sx = x + (cols - 4) as f32 * CW();
|
|
let up = Rect::new(sx, top, CW() * 2.5, CH() * 2.0);
|
|
let down = Rect::new(sx, bottom - CH() * 2.0, CW() * 2.5, CH() * 2.0);
|
|
button(gfx, up, "\u{25b2}", up.contains(vec2(mx, my)), FRAME);
|
|
button(gfx, down, "\u{25bc}", down.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((up, Btn::Scroll(-1)));
|
|
self.buttons.push((down, Btn::Scroll(1)));
|
|
}
|
|
let by = y + (rows - 4) as f32 * CH();
|
|
let back = tr("menu.back");
|
|
let br = Rect::new(x + CW() * 2.0, by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
|
|
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((br, Btn::Back));
|
|
if !ingame {
|
|
let start = tr("camp.start");
|
|
let w = gfx.measure(&start, S()) + CW() * 6.0;
|
|
let sr = Rect::new(x + cols as f32 * CW() - w - CW() * 2.0, by, w, CH() * 2.0);
|
|
button(gfx, sr, &start, sr.contains(vec2(mx, my)), FRAME_HI);
|
|
self.buttons.push((sr, Btn::StartMission));
|
|
}
|
|
}
|
|
|
|
// the briefing of the running mission, again (story and goals)
|
|
pub fn open_briefing(&mut self, mission: usize) {
|
|
self.page = Some(Page::Briefing { mission, ingame: true });
|
|
self.scroll = 0;
|
|
}
|
|
|
|
// scrolling on pages with lists (mouse wheel)
|
|
pub fn scrollable(&self) -> bool {
|
|
matches!(self.page, Some(Page::Saves { .. }) | Some(Page::Campaign) | Some(Page::CampaignEditor) | Some(Page::Briefing { .. }))
|
|
}
|
|
|
|
pub fn scroll_lines(&mut self, d: i32) {
|
|
self.scroll = (self.scroll as i32 + d).max(0) as usize;
|
|
}
|
|
|
|
// a text field; passwords are shown as stars
|
|
fn field(&mut self, gfx: &Gfx, x: f32, y: f32, w: f32, label: &str, i: usize, secret: bool) {
|
|
gfx.text(label, x, y + CH() * 0.5, S(), TXT);
|
|
let lw = 16.0 * CW();
|
|
let r = Rect::new(x + lw, y, w - lw, CH() * 2.0);
|
|
let focused = self.focus == i;
|
|
draw_rectangle(r.x, r.y, r.w, r.h, Color::new(0.02, 0.01, 0.02, 1.0));
|
|
draw_rectangle_lines(r.x, r.y, r.w, r.h, 2.0, if focused { FRAME_HI } else { FRAME });
|
|
let shown: String = if secret { "*".repeat(self.fields[i].chars().count()) } else { self.fields[i].clone() };
|
|
let maxc = ((r.w - CW() * 2.0) / CW()).max(1.0) as usize;
|
|
let all: Vec<char> = shown.chars().collect();
|
|
let mut text: String = all[all.len().saturating_sub(maxc - 1)..].iter().collect();
|
|
if focused && (get_time() * 2.0) as i64 % 2 == 0 {
|
|
text.push('_');
|
|
}
|
|
gfx.text(&text, r.x + CW(), r.y + CH() * 0.5, S(), GOLD);
|
|
self.buttons.push((r, Btn::Field(i)));
|
|
}
|
|
|
|
// Login to the server (the first login creates the admin account)
|
|
fn draw_login(&mut self, gfx: &Gfx, cloud: &Cloud) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let big = TITLE();
|
|
let title = "DungeonCraft";
|
|
let ty = (sh * 0.12 / CH()).floor() * CH();
|
|
gfx.text(title, grid_center(sw, gfx.measure(title, big)) + big, ty + big, big, Color::new(0.0, 0.0, 0.0, 0.8));
|
|
gfx.text(title, grid_center(sw, gfx.measure(title, big)), ty, big, FRAME_HI);
|
|
let cols = ((sw / CW()) as i32 - 2).min(50);
|
|
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
|
let y = ty + GH * big + CH() * 3.0;
|
|
let w = (cols - 6) as f32 * CW();
|
|
let label = tr(if cloud.setup { "login.create_admin" } else { "login.button" });
|
|
let standalone_label = tr("menu.standalone");
|
|
let bw = gfx.measure(&label, S()) + CW() * 6.0;
|
|
let standalone_bw = (gfx.measure(&standalone_label, S()) + CW() * 4.0).min(w);
|
|
let side_by_side = standalone_bw + bw + CW() * 1.5 <= w;
|
|
let rows = if side_by_side { 13 } else { 15 };
|
|
let head = tr(if cloud.setup { "login.setup" } else { "login.title" });
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI);
|
|
if cloud.setup {
|
|
gfx.text(&tr("login.setup_hint"), x + CW() * 3.0, y + CH() * 1.5, SMALL(), DIM);
|
|
}
|
|
self.field(gfx, x + CW() * 3.0, y + CH() * 3.0, w, &tr("login.name"), 0, false);
|
|
self.field(gfx, x + CW() * 3.0, y + CH() * 5.5, w, &tr("login.password"), 1, true);
|
|
if let Some(e) = &self.error {
|
|
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
|
|
for (k, l) in wrap(e, maxc).iter().take(2).enumerate() {
|
|
gfx.text(l, x + CW() * 3.0, y + CH() * 7.9 + k as f32 * GH * SMALL() * 1.2, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
|
|
}
|
|
}
|
|
if side_by_side {
|
|
let sr = Rect::new(x + CW() * 3.0, y + CH() * 9.5, standalone_bw, CH() * 2.0);
|
|
button(gfx, sr, &standalone_label, sr.contains(vec2(mx, my)) && !cloud.busy(), FRAME);
|
|
self.buttons.push((sr, Btn::Standalone));
|
|
let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 9.5, bw, CH() * 2.0);
|
|
button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI);
|
|
self.buttons.push((br, Btn::DoLogin));
|
|
} else {
|
|
let sr = Rect::new(x + CW() * 3.0, y + CH() * 9.5, w, CH() * 2.0);
|
|
button(gfx, sr, &standalone_label, sr.contains(vec2(mx, my)) && !cloud.busy(), FRAME);
|
|
self.buttons.push((sr, Btn::Standalone));
|
|
let br = Rect::new(x + cols as f32 * CW() - bw - CW() * 3.0, y + CH() * 12.5, bw, CH() * 2.0);
|
|
button(gfx, br, &label, br.contains(vec2(mx, my)) && !cloud.busy(), FRAME_HI);
|
|
self.buttons.push((br, Btn::DoLogin));
|
|
}
|
|
self.draw_license(gfx);
|
|
}
|
|
|
|
// Account: change the password, log out; the admin manages the users
|
|
fn draw_account(&mut self, gfx: &Gfx, cloud: &Cloud) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let admin = cloud.session.as_ref().map_or(false, |s| s.admin);
|
|
let cols = ((sw / CW()) as i32 - 2).min(60);
|
|
let rows = ((sh / CH()) as i32 - 2).min(if admin { 38 } else { 18 });
|
|
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
|
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
|
|
let who = cloud.session.as_ref().map(|s| s.name.clone()).unwrap_or_default();
|
|
let head = trf(if admin { "account.title_admin" } else { "account.title" }, &[who]);
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &head, FRAME_HI);
|
|
let ix = x + CW() * 3.0;
|
|
let w = (cols - 6) as f32 * CW();
|
|
gfx.text(&tr("account.change_pw"), ix, y + CH() * 2.0, S(), GOLD);
|
|
self.field(gfx, ix, y + CH() * 3.5, w, &tr("account.old_pw"), 0, true);
|
|
self.field(gfx, ix, y + CH() * 6.0, w, &tr("account.new_pw"), 1, true);
|
|
let cl = tr("account.save_pw");
|
|
let cw2 = gfx.measure(&cl, S()) + CW() * 4.0;
|
|
let cr = Rect::new(ix + w - cw2, y + CH() * 8.5, cw2, CH() * 2.0);
|
|
button(gfx, cr, &cl, cr.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((cr, Btn::ChangePassword));
|
|
let mut yy = y + CH() * 11.5;
|
|
if admin {
|
|
gfx.text(&tr("account.users"), ix, yy, S(), GOLD);
|
|
yy += CH() * 1.5;
|
|
self.field(gfx, ix, yy, w, &tr("account.user_name"), 2, false);
|
|
self.field(gfx, ix, yy + CH() * 2.5, w, &tr("account.user_pw"), 3, true);
|
|
let nl = tr("account.create");
|
|
let nw = gfx.measure(&nl, S()) + CW() * 4.0;
|
|
let nr = Rect::new(ix + w - nw, yy + CH() * 5.0, nw, CH() * 2.0);
|
|
button(gfx, nr, &nl, nr.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((nr, Btn::CreateUser));
|
|
yy += CH() * 8.0;
|
|
let del = tr("saves.delete");
|
|
let sure = tr("saves.sure");
|
|
let dw = gfx.measure(&del, S()).max(gfx.measure(&sure, S())) + CW() * 3.0;
|
|
let bottom = y + (rows - 5) as f32 * CH();
|
|
for (k, u) in cloud.users.iter().enumerate() {
|
|
let ry = yy + k as f32 * CH() * 2.5;
|
|
if ry + CH() * 2.0 > bottom {
|
|
break;
|
|
}
|
|
let label = if u.admin { format!("{} (admin)", u.name) } else { u.name.clone() };
|
|
gfx.text(&label, ix, ry + CH() * 0.5, S(), TXT);
|
|
if !u.admin {
|
|
let r = Rect::new(ix + w - dw, ry, dw, CH() * 2.0);
|
|
let c = self.confirm == Some(Btn::DeleteUser(k));
|
|
button(gfx, r, if c { &sure } else { &del }, r.contains(vec2(mx, my)) || c, Color::new(0.6, 0.2, 0.15, 1.0));
|
|
self.buttons.push((r, Btn::DeleteUser(k)));
|
|
}
|
|
}
|
|
}
|
|
let _ = yy;
|
|
if let Some(e) = &self.error {
|
|
gfx.text(e, ix, y + (rows - 5) as f32 * CH() + CH() * 0.5, S(), Color::new(0.95, 0.4, 0.35, 1.0));
|
|
}
|
|
let by = y + (rows - 4) as f32 * CH();
|
|
let back = tr("menu.back");
|
|
let br = Rect::new(ix - CW(), by, gfx.measure(&back, S()) + CW() * 6.0, CH() * 2.0);
|
|
button(gfx, br, &back, br.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((br, Btn::Back));
|
|
let lo = tr("account.logout");
|
|
let lw = gfx.measure(&lo, S()) + CW() * 4.0;
|
|
let lr = Rect::new(ix + w - lw, by, lw, CH() * 2.0);
|
|
button(gfx, lr, &lo, lr.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((lr, Btn::Logout));
|
|
}
|
|
|
|
fn draw_match(&mut self, gfx: &Gfx, _cloud: &Cloud) {
|
|
let sw = sw();
|
|
let sh = sh();
|
|
let (mx, my) = mouse();
|
|
let ui = self.match_ui.clone();
|
|
let cols = ((sw / CW()) as i32 - 2).min(72);
|
|
let rows = ((sh / CH()) as i32 - 2).min(38);
|
|
let x = grid_center(sw, cols as f32 * CW()).max(0.0);
|
|
let y = grid_center_y(sh, rows as f32 * CH()).max(0.0);
|
|
let ix = x + CW() * 3.0;
|
|
let w = (cols - 6) as f32 * CW();
|
|
gfx.window_titled(x, y, cols, rows, S(), true, FRAME_HI, Color::new(0.05, 0.03, 0.04, 0.97), &tr("net.title"), FRAME_HI);
|
|
|
|
let head = match ui.phase {
|
|
crate::lobby::Phase::Offline => tr("net.status_offline"),
|
|
crate::lobby::Phase::Lobby => tr("net.status_lobby"),
|
|
crate::lobby::Phase::InLobby => trf("net.status_lobby_room", &[ui.code.clone().unwrap_or_default()]),
|
|
crate::lobby::Phase::Playing => trf("net.status_playing", &[ui.code.clone().unwrap_or_default()]),
|
|
crate::lobby::Phase::Closed => trf("net.status_closed", &[ui.status.clone().unwrap_or_else(|| "?".into())]),
|
|
};
|
|
gfx.text(&head, ix, y + CH() * 1.6, S(), GOLD);
|
|
let state = format!("{} · {} · {}", ui.name, ui.turn.to_string(), if ui.paused { tr("net.paused") } else if ui.missing { tr("net.missing") } else { String::new() });
|
|
gfx.text(&state, ix, y + CH() * 3.0, SMALL(), DIM);
|
|
|
|
let mut yy = y + CH() * 4.5;
|
|
if let Some(e) = &self.error {
|
|
gfx.text(e, ix, yy, SMALL(), Color::new(0.95, 0.4, 0.35, 1.0));
|
|
yy += CH() * 1.6;
|
|
} else if let Some(s) = &ui.status {
|
|
gfx.text(s, ix, yy, SMALL(), DIM);
|
|
yy += CH() * 1.6;
|
|
}
|
|
|
|
if !ui.started && !ui.room_open() {
|
|
if let Some(d) = missions().get(self.coop_mission) {
|
|
let line = trf("net.coop_mission", &[(self.coop_mission + 1).to_string(), tr(&format!("camp.{}.title", d.key))]);
|
|
let maxc = ((cols - 6) as f32 * CW() / (GW * SMALL())) as usize;
|
|
for (_k, text) in wrap(&line, maxc).iter().take(2).enumerate() {
|
|
gfx.text(text, ix, yy, SMALL(), GOLD);
|
|
yy += GH * SMALL() * 1.35;
|
|
}
|
|
}
|
|
}
|
|
self.field(gfx, ix, yy, w, &tr("net.join_hint"), 0, false);
|
|
self.field(gfx, ix, yy + CH() * 2.4, w, &tr("net.chat"), 1, false);
|
|
yy += CH() * 4.8;
|
|
|
|
let mut items: Vec<(Btn, String)> = Vec::new();
|
|
if !ui.started {
|
|
items.push((Btn::MatchCreate, tr("net.create")));
|
|
items.push((Btn::MatchJoin, tr("net.join")));
|
|
if !ui.room_open() {
|
|
items.push((Btn::MatchCoopPrev, "<".to_string()));
|
|
items.push((Btn::MatchCoopCreate, tr("net.coop_create")));
|
|
items.push((Btn::MatchCoopNext, ">".to_string()));
|
|
}
|
|
if ui.room_open() {
|
|
items.push((Btn::MatchReady, tr(if ui.ready { "net.unready" } else { "net.ready" })));
|
|
if ui.is_host {
|
|
items.push((Btn::MatchStart, tr("net.start")));
|
|
}
|
|
}
|
|
} else {
|
|
items.push((Btn::MatchChat, tr("net.send")));
|
|
if ui.is_host {
|
|
if ui.paused || ui.missing {
|
|
items.push((Btn::MatchResume, tr("net.resume")));
|
|
}
|
|
items.push((Btn::MatchAbort, tr("net.abort")));
|
|
}
|
|
}
|
|
if ui.room_open() {
|
|
items.push((Btn::MatchLeave, tr("net.leave")));
|
|
}
|
|
items.push((Btn::Back, tr("menu.back")));
|
|
let bw = 14.0 * CW();
|
|
let shown = items.iter().take(9).count();
|
|
for (k, (b, label)) in items.iter().take(shown).enumerate() {
|
|
let r = Rect::new(ix + (k % 3) as f32 * (bw + CW()), yy + (k / 3) as f32 * CH() * 2.4, bw, CH() * 2.0);
|
|
button(gfx, r, label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, *b));
|
|
}
|
|
yy += CH() * 2.4 * ((shown + 2) / 3) as f32;
|
|
|
|
if !ui.rooms.is_empty() && !ui.started {
|
|
gfx.text(&tr("net.rooms"), ix, yy, S(), GOLD);
|
|
yy += CH() * 1.4;
|
|
for (k, room) in ui.rooms.iter().take(4).enumerate() {
|
|
let label = format!("{} {}{}{}", room.code, room.players, if room.started { " *" } else { "" }, if room.paused { " p" } else { "" });
|
|
let r = Rect::new(ix, yy, 24.0 * CW(), CH() * 2.0);
|
|
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, Btn::MatchJoinRoom(k)));
|
|
yy += CH() * 2.2;
|
|
}
|
|
} else if ui.rooms.is_empty() && !ui.started {
|
|
gfx.text(&tr("net.no_rooms"), ix, yy, SMALL(), DIM);
|
|
yy += CH() * 2.0;
|
|
}
|
|
|
|
if ui.room_open() {
|
|
gfx.text(&tr("net.seats"), ix, yy, S(), GOLD);
|
|
yy += CH() * 1.4;
|
|
let seat_cols = (cols - 6) as usize;
|
|
for (k, s) in ui.seats.iter().take(4).enumerate() {
|
|
let is_local = Some(s.side) == ui.local_side;
|
|
let name = if is_local {
|
|
format!("{} ({})", s.name, tr("net.you"))
|
|
} else {
|
|
s.name.clone()
|
|
};
|
|
let side_label = match s.side {
|
|
0 => tr("net.side0"),
|
|
2 => tr("net.side2"),
|
|
3 => tr("net.side3"),
|
|
4 => tr("net.side4"),
|
|
_ => s.side.to_string(),
|
|
};
|
|
let status = if s.connected { "●" } else if s.ai { "AI" } else { "○" };
|
|
let ready = if s.ready || s.ai { "✓" } else { "—" };
|
|
let line = format!("{} {} {} {} {}", side_label, name, status, ready, if s.side == 0 { tr("net.host") } else { String::new() });
|
|
gfx.text(&line, ix, yy, SMALL(), if s.connected || s.ai { TXT } else { DIM });
|
|
if !ui.started && ui.local_side.is_some() && !s.connected {
|
|
if ui.is_host && s.side != 0 {
|
|
if s.ai {
|
|
let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
|
|
let label = tr("net.ai_off");
|
|
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, Btn::MatchAi(k)));
|
|
} else {
|
|
let join_label = if is_local { tr("net.you") } else { tr("net.join") };
|
|
let r = Rect::new(ix + 22.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
|
|
button(gfx, r, &join_label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, Btn::MatchTake(k)));
|
|
let ai_label = tr("net.ai");
|
|
let r = Rect::new(ix + 36.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
|
|
button(gfx, r, &ai_label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, Btn::MatchAi(k)));
|
|
}
|
|
} else if !s.ai {
|
|
let r = Rect::new(ix + 34.0 * CW(), yy, 12.0 * CW(), CH() * 2.0);
|
|
let label = if is_local { tr("net.you") } else { tr("net.join") };
|
|
button(gfx, r, &label, r.contains(vec2(mx, my)), FRAME);
|
|
self.buttons.push((r, Btn::MatchTake(k)));
|
|
}
|
|
}
|
|
yy += CH() * 2.0;
|
|
}
|
|
let _ = seat_cols;
|
|
}
|
|
if !ui.chat.is_empty() {
|
|
yy = (y + (rows - 5) as f32 * CH()).max(yy);
|
|
gfx.text(&tr("net.chat"), ix, yy, SMALL(), DIM);
|
|
for (side, name, text) in ui.chat.iter().rev().take(3) {
|
|
gfx.text(&format!("{}: {}", name, text), ix, yy + SMALL() * GH * 1.2, SMALL(), side_color(*side));
|
|
yy += SMALL() * GH * 1.2;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// description of a mission goal (without progress)
|
|
pub fn goal_text(g: Goal) -> String {
|
|
match g {
|
|
Goal::Shards => tr("goal.shards"),
|
|
Goal::Heart => tr("goal.heart"),
|
|
Goal::KillAll => tr("goal.killall"),
|
|
Goal::Loot(n) => trf("goal.loot", &[n.to_string()]),
|
|
Goal::Towers => tr("goal.towers"),
|
|
Goal::Survive(n) => trf("goal.survive", &[n.to_string()]),
|
|
Goal::Rivals => tr("goal.rivals"),
|
|
Goal::Springs(n) => trf("goal.springs", &[n.to_string()]),
|
|
Goal::Freed(n) => trf("goal.freed", &[n.to_string()]),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{Menu, Page};
|
|
|
|
#[test]
|
|
fn the_multiplayer_page_remembers_where_it_came_from() {
|
|
let mut from_pause = Menu::new();
|
|
from_pause.page = Some(Page::Pause);
|
|
from_pause.open_match(false);
|
|
from_pause.back();
|
|
assert_eq!(from_pause.page, Some(Page::Pause), "back from a match menu goes back to the pause menu");
|
|
|
|
let mut from_title = Menu::new();
|
|
from_title.open_match(true);
|
|
from_title.back();
|
|
assert_eq!(from_title.page, Some(Page::Title), "back from the title multiplayer page goes to the title");
|
|
}
|
|
|
|
#[test]
|
|
fn the_version_follows_the_scheme() {
|
|
let rest = super::version().strip_prefix('v').expect("the version starts with v");
|
|
let (month, number) = rest.split_once('-').expect("the version is vYYYYMM-NNNNN");
|
|
assert_eq!(month.len(), 6);
|
|
assert!(month.chars().all(|c| c.is_ascii_digit()));
|
|
assert_eq!(number.len(), 5);
|
|
assert!(number.parse::<u32>().expect("a number") >= 1);
|
|
}
|
|
}
|