Server-side savegames: Rust service with a shared savegame list in the menu, fallback to files
- server/: small axum service storing savegames as files (1 MB each, 500 MB total, rate limit) - docker compose: second container with a volume; nginx forwards /api/ - JS plugin: asynchronous HTTP requests polled by the game - Save/Load open a list page (name field, new, overwrite, load, delete with confirmation) - Without a reachable service the game keeps using savegame files and the browser autosave Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c5b7896609
commit
7f4324abca
17 files changed
+1390
-30
No files matched your search
+219
@@ -0,0 +1,219 @@
|
||||
// Client for the savegame service (server/): a shared list of savegames on the web server.
|
||||
// All requests are asynchronous; poll() is called once per frame and reports finished requests.
|
||||
// When the service cannot be reached (no server, or the game was opened as a local file),
|
||||
// `online` becomes false and the game falls back to savegame files and the browser autosave.
|
||||
|
||||
use crate::platform;
|
||||
use serde::Deserialize;
|
||||
|
||||
const API: &str = "api"; // relative, so the game also works below a sub path
|
||||
|
||||
#[derive(Clone, Deserialize, Debug)]
|
||||
pub struct SaveInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub saved_at: u64,
|
||||
#[serde(default)]
|
||||
pub map_w: u64,
|
||||
#[serde(default)]
|
||||
pub map_h: u64,
|
||||
#[serde(default)]
|
||||
pub wave: u64,
|
||||
#[serde(default)]
|
||||
pub time: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
pub enum Online {
|
||||
Unknown,
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Debug)]
|
||||
enum Req {
|
||||
Health,
|
||||
List,
|
||||
Save,
|
||||
Load,
|
||||
Delete,
|
||||
}
|
||||
|
||||
pub enum CloudEvent {
|
||||
None,
|
||||
Saved(String), // name of the stored savegame
|
||||
Loaded(Vec<u8>), // savegame data
|
||||
Deleted,
|
||||
Error(&'static str), // language key
|
||||
}
|
||||
|
||||
pub struct Cloud {
|
||||
pub online: Online,
|
||||
pub list: Vec<SaveInfo>,
|
||||
pub listed: bool, // the list has been fetched at least once
|
||||
pending: Option<(u32, Req)>,
|
||||
save_name: String,
|
||||
}
|
||||
|
||||
// percent-encoding for a query parameter
|
||||
fn encode(s: &str) -> String {
|
||||
let mut out = String::new();
|
||||
for b in s.bytes() {
|
||||
if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) {
|
||||
out.push(b as char);
|
||||
} else {
|
||||
out.push_str(&format!("%{:02X}", b));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn error_key(status: i32) -> &'static str {
|
||||
match status {
|
||||
0 => "cloud.err_net",
|
||||
413 => "cloud.err_large",
|
||||
429 => "cloud.err_rate",
|
||||
507 => "cloud.err_full",
|
||||
404 => "cloud.err_missing",
|
||||
_ => "cloud.err",
|
||||
}
|
||||
}
|
||||
|
||||
impl Cloud {
|
||||
// starts the availability check
|
||||
pub fn new() -> Cloud {
|
||||
let id = platform::http("GET", &format!("{}/health", API), &[]);
|
||||
Cloud { online: Online::Unknown, list: Vec::new(), listed: false, pending: Some((id, Req::Health)), save_name: String::new() }
|
||||
}
|
||||
|
||||
pub fn available(&self) -> bool {
|
||||
self.online == Online::Yes
|
||||
}
|
||||
|
||||
pub fn busy(&self) -> bool {
|
||||
self.pending.is_some()
|
||||
}
|
||||
|
||||
fn start(&mut self, method: &str, path: &str, body: &[u8], req: Req) -> bool {
|
||||
if self.pending.is_some() {
|
||||
return false;
|
||||
}
|
||||
let id = platform::http(method, &format!("{}/{}", API, path), body);
|
||||
self.pending = Some((id, req));
|
||||
true
|
||||
}
|
||||
|
||||
pub fn refresh(&mut self) {
|
||||
self.start("GET", "saves", &[], Req::List);
|
||||
}
|
||||
|
||||
// id = None stores a new savegame, Some(id) overwrites an existing one
|
||||
pub fn save(&mut self, id: Option<&str>, name: &str, json: &str) -> bool {
|
||||
self.save_name = name.to_string();
|
||||
match id {
|
||||
None => self.start("POST", &format!("saves?name={}", encode(name)), json.as_bytes(), Req::Save),
|
||||
Some(id) => self.start("PUT", &format!("saves/{}?name={}", id, encode(name)), json.as_bytes(), Req::Save),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self, id: &str) -> bool {
|
||||
self.start("GET", &format!("saves/{}", id), &[], Req::Load)
|
||||
}
|
||||
|
||||
pub fn delete(&mut self, id: &str) -> bool {
|
||||
self.start("DELETE", &format!("saves/{}", id), &[], Req::Delete)
|
||||
}
|
||||
|
||||
pub fn poll(&mut self) -> CloudEvent {
|
||||
let Some((id, req)) = self.pending else { return CloudEvent::None };
|
||||
let Some(res) = platform::http_poll(id) else { return CloudEvent::None };
|
||||
self.pending = None;
|
||||
let ok = (200..300).contains(&res.status);
|
||||
match req {
|
||||
Req::Health => {
|
||||
self.online = if ok && res.body.starts_with(b"ok") { Online::Yes } else { Online::No };
|
||||
CloudEvent::None
|
||||
}
|
||||
Req::List => {
|
||||
if !ok {
|
||||
return CloudEvent::Error(error_key(res.status));
|
||||
}
|
||||
match serde_json::from_slice::<Vec<SaveInfo>>(&res.body) {
|
||||
Ok(l) => {
|
||||
self.list = l;
|
||||
self.listed = true;
|
||||
CloudEvent::None
|
||||
}
|
||||
Err(_) => CloudEvent::Error("cloud.err"),
|
||||
}
|
||||
}
|
||||
Req::Save => {
|
||||
if !ok {
|
||||
return CloudEvent::Error(error_key(res.status));
|
||||
}
|
||||
self.refresh();
|
||||
CloudEvent::Saved(std::mem::take(&mut self.save_name))
|
||||
}
|
||||
Req::Load => {
|
||||
if ok {
|
||||
CloudEvent::Loaded(res.body)
|
||||
} else {
|
||||
CloudEvent::Error(error_key(res.status))
|
||||
}
|
||||
}
|
||||
Req::Delete => {
|
||||
if !ok {
|
||||
return CloudEvent::Error(error_key(res.status));
|
||||
}
|
||||
self.refresh();
|
||||
CloudEvent::Deleted
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (year, month, day, hour, minute) in UTC from unix seconds (Howard Hinnant's civil date algorithm)
|
||||
pub fn civil(secs: i64) -> (i64, i64, i64, i64, i64) {
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
|
||||
(y, m, d, rem / 3600, rem % 3600 / 60)
|
||||
}
|
||||
|
||||
// "2026-11-03 14:20" (UTC)
|
||||
pub fn date_string(secs: i64) -> String {
|
||||
let (y, m, d, h, mi) = civil(secs);
|
||||
format!("{:04}-{:02}-{:02} {:02}:{:02}", y, m, d, h, mi)
|
||||
}
|
||||
|
||||
// play time as "1:02:03" or "12:34"
|
||||
pub fn duration_string(secs: f64) -> String {
|
||||
let s = secs.max(0.0) as u64;
|
||||
if s >= 3600 {
|
||||
format!("{}:{:02}:{:02}", s / 3600, s / 60 % 60, s % 60)
|
||||
} else {
|
||||
format!("{}:{:02}", s / 60, s % 60)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn helpers() {
|
||||
assert_eq!(date_string(0), "1970-01-01 00:00");
|
||||
assert_eq!(date_string(1793693897), "2026-11-03 08:18");
|
||||
assert_eq!(duration_string(754.0), "12:34");
|
||||
assert_eq!(duration_string(3723.0), "1:02:03");
|
||||
assert_eq!(encode("Wave 3 – ä"), "Wave%203%20%E2%80%93%20%C3%A4");
|
||||
}
|
||||
}
|
||||
+72
-22
@@ -1,6 +1,7 @@
|
||||
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||||
|
||||
mod audio;
|
||||
mod cloud;
|
||||
mod game;
|
||||
mod gfx;
|
||||
mod i18n;
|
||||
@@ -16,6 +17,7 @@ mod sound;
|
||||
mod ui;
|
||||
mod xm;
|
||||
|
||||
use cloud::{Cloud, CloudEvent};
|
||||
use game::{Game, Msg};
|
||||
use gfx::Gfx;
|
||||
use macroquad::prelude::*;
|
||||
@@ -147,20 +149,13 @@ fn store_settings(s: &Settings) {
|
||||
|
||||
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
||||
fn save_file_name() -> String {
|
||||
let secs = miniquad::date::now() as i64;
|
||||
let days = secs.div_euclid(86400);
|
||||
let rem = secs.rem_euclid(86400);
|
||||
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm)
|
||||
let z = days + 719468;
|
||||
let era = z.div_euclid(146097);
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + if m <= 2 { 1 } else { 0 };
|
||||
format!("dungeoncraft_{:04}-{:02}-{:02}_{:02}{:02}.dcsave", y, m, d, rem / 3600, rem % 3600 / 60)
|
||||
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
||||
format!("dungeoncraft_{:04}-{:02}-{:02}_{:02}{:02}.dcsave", y, m, d, h, mi)
|
||||
}
|
||||
|
||||
// suggested name for a new savegame on the server: "Wave 3 - 2026-11-03 14:20"
|
||||
fn default_save_name(g: &Game) -> String {
|
||||
i18n::trf("saves.default_name", &[(g.wave + 1).to_string(), cloud::date_string(miniquad::date::now() as i64)])
|
||||
}
|
||||
|
||||
struct App {
|
||||
@@ -215,6 +210,7 @@ async fn main() {
|
||||
let mut music = MusicDirector::new();
|
||||
let mut ui = Ui::new();
|
||||
let mut menu = Menu::new();
|
||||
let mut cloud = Cloud::new(); // savegame service; files + browser autosave when unreachable
|
||||
|
||||
// settings: stored in the browser; on the very first start the player picks a language
|
||||
let stored = platform::storage_get(SETTINGS_KEY).and_then(|s| Settings::from_json(&s));
|
||||
@@ -255,6 +251,37 @@ async fn main() {
|
||||
app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now);
|
||||
}
|
||||
|
||||
// ---------------- savegame service: finished requests
|
||||
match cloud.poll() {
|
||||
CloudEvent::None => {}
|
||||
CloudEvent::Saved(name) => app.set_status(i18n::trf("save.saved", &[name]), now),
|
||||
CloudEvent::Loaded(bytes) => app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now),
|
||||
CloudEvent::Deleted => app.set_status(i18n::tr("cloud.deleted"), now),
|
||||
CloudEvent::Error(key) => app.set_status(i18n::tr(key), now),
|
||||
}
|
||||
|
||||
// ---------------- name field of the savegame page
|
||||
if menu.typing() {
|
||||
while let Some(c) = get_char_pressed() {
|
||||
menu.type_char(c);
|
||||
}
|
||||
if is_key_pressed(KeyCode::Backspace) {
|
||||
menu.save_name.pop();
|
||||
}
|
||||
if is_key_pressed(KeyCode::Enter) || is_key_pressed(KeyCode::KpEnter) {
|
||||
let name = menu.save_name.trim().to_string();
|
||||
if cloud.save(None, &name, &app.autosave_json()) {
|
||||
app.set_status(i18n::tr("saves.wait"), now);
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(menu.page, Some(Page::Saves { .. })) {
|
||||
let (_, wheel) = mouse_wheel();
|
||||
if wheel != 0.0 {
|
||||
menu.scroll_by(if wheel > 0.0 { -1 } else { 1 }, cloud.list.len());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- key capture for the key binding dialog
|
||||
if let Some(action) = menu.capturing() {
|
||||
if is_key_pressed(KeyCode::Escape) {
|
||||
@@ -267,7 +294,7 @@ async fn main() {
|
||||
menu.finish_capture();
|
||||
}
|
||||
}
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &inp, now);
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, now);
|
||||
next_frame().await;
|
||||
continue;
|
||||
}
|
||||
@@ -321,7 +348,7 @@ async fn main() {
|
||||
app.game.hints = app.settings.hints;
|
||||
}
|
||||
}
|
||||
if !dialog {
|
||||
if !dialog && !menu.typing() {
|
||||
if is_key_pressed(key(Action::Mute)) {
|
||||
app.settings.muted = !app.settings.muted;
|
||||
settings_changed = true;
|
||||
@@ -449,7 +476,8 @@ async fn main() {
|
||||
_ => {}
|
||||
}
|
||||
} else if in_menu {
|
||||
match menu.click(ux, uy, &mut app.settings) {
|
||||
let from_title = menu.page == Some(Page::Title);
|
||||
match menu.click(ux, uy, &mut app.settings, &cloud) {
|
||||
MenuAction::SettingsChanged => settings_changed = true,
|
||||
MenuAction::Resume => {}
|
||||
MenuAction::NewGame => {
|
||||
@@ -466,15 +494,37 @@ async fn main() {
|
||||
app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, now);
|
||||
}
|
||||
}
|
||||
MenuAction::Load => {
|
||||
// with the savegame service: list on the server, otherwise savegame files
|
||||
MenuAction::Load if cloud.available() => {
|
||||
menu.open_saves(false, from_title, String::new());
|
||||
cloud.refresh();
|
||||
}
|
||||
MenuAction::Save if cloud.available() => {
|
||||
menu.open_saves(true, false, default_save_name(&app.game));
|
||||
cloud.refresh();
|
||||
}
|
||||
MenuAction::Load | MenuAction::FileLoad => {
|
||||
platform::open_file();
|
||||
app.set_status(i18n::tr("save.choose"), now);
|
||||
}
|
||||
MenuAction::Save => {
|
||||
MenuAction::Save | MenuAction::FileSave => {
|
||||
let name = save_file_name();
|
||||
platform::download(&name, app.autosave_json().as_bytes());
|
||||
app.set_status(i18n::trf("save.saved", &[name]), now);
|
||||
}
|
||||
MenuAction::CloudSave(id, name) => {
|
||||
if cloud.save(id.as_deref(), &name, &app.autosave_json()) {
|
||||
app.set_status(i18n::tr("saves.wait"), now);
|
||||
}
|
||||
}
|
||||
MenuAction::CloudLoad(id) => {
|
||||
if cloud.load(&id) {
|
||||
app.set_status(i18n::tr("saves.wait"), now);
|
||||
}
|
||||
}
|
||||
MenuAction::CloudDelete(id) => {
|
||||
cloud.delete(&id);
|
||||
}
|
||||
MenuAction::Help => ui.show_help = true,
|
||||
MenuAction::MainMenu => {
|
||||
if app.game.state == 0 {
|
||||
@@ -593,13 +643,13 @@ async fn main() {
|
||||
ui.log.push((m, now));
|
||||
}
|
||||
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &inp, now);
|
||||
draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &cloud, &inp, now);
|
||||
next_frame().await
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, inp: &Input, now: f64) {
|
||||
fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, cloud: &Cloud, inp: &Input, now: f64) {
|
||||
let (mx, my) = mouse_position();
|
||||
let (ux, uy) = ui::mouse();
|
||||
let blocked = menu.open() || ui.show_help || ui.show_langsel;
|
||||
@@ -644,7 +694,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M
|
||||
if app.status.as_ref().map_or(false, |(_, t)| now - t > 4.0) {
|
||||
app.status = None;
|
||||
}
|
||||
menu.draw(gfx, &app.settings, app.status.as_ref().map(|(s, _)| s.as_str()));
|
||||
menu.draw(gfx, &app.settings, app.status.as_ref().map(|(s, _)| s.as_str()), cloud);
|
||||
if !menu.open() || ui.show_help || ui.show_langsel {
|
||||
ui.draw_dialogs(&app.game, gfx);
|
||||
}
|
||||
|
||||
+190
-4
@@ -1,6 +1,8 @@
|
||||
// Menus: title screen, pause menu and the settings dialog (tabs, values, key bindings).
|
||||
// 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::cloud::{date_string, duration_string, Cloud};
|
||||
use crate::gfx::*;
|
||||
use crate::i18n::{languages, tr, trf};
|
||||
use crate::map::{MAX_SIZE, MIN_SIZE};
|
||||
@@ -15,6 +17,7 @@ pub enum Page {
|
||||
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)
|
||||
}
|
||||
|
||||
pub const TABS: [&str; 5] = ["set.tab.sound", "set.tab.camera", "set.tab.graphics", "set.tab.game", "set.tab.keys"];
|
||||
@@ -167,6 +170,12 @@ enum Btn {
|
||||
Start,
|
||||
Key(usize),
|
||||
ResetKeys,
|
||||
SaveNew,
|
||||
Slot(usize), // load / overwrite
|
||||
Delete(usize),
|
||||
Scroll(i32),
|
||||
FileSave,
|
||||
FileLoad,
|
||||
}
|
||||
|
||||
pub enum MenuAction {
|
||||
@@ -180,11 +189,20 @@ pub enum MenuAction {
|
||||
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),
|
||||
}
|
||||
|
||||
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)
|
||||
confirm: Option<Btn>, // overwrite / delete waiting for a second click
|
||||
scroll: usize,
|
||||
rows_visible: usize,
|
||||
buttons: Vec<(Rect, Btn)>,
|
||||
}
|
||||
|
||||
@@ -197,7 +215,7 @@ fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) {
|
||||
|
||||
impl Menu {
|
||||
pub fn new() -> Menu {
|
||||
Menu { page: Some(Page::Title), has_autosave: false, buttons: Vec::new() }
|
||||
Menu { page: Some(Page::Title), has_autosave: false, save_name: String::new(), confirm: None, scroll: 0, rows_visible: 1, buttons: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn open(&self) -> bool {
|
||||
@@ -211,12 +229,38 @@ impl Menu {
|
||||
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::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;
|
||||
}
|
||||
|
||||
// the name field takes keyboard input
|
||||
pub fn typing(&self) -> bool {
|
||||
matches!(self.page, Some(Page::Saves { save: true, .. }))
|
||||
}
|
||||
|
||||
pub fn type_char(&mut self, c: char) {
|
||||
if !c.is_control() && self.save_name.chars().count() < 40 {
|
||||
self.save_name.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_by(&mut self, d: i32, total: usize) {
|
||||
let max = total.saturating_sub(self.rows_visible);
|
||||
self.scroll = (self.scroll as i32 + d).clamp(0, max as i32) as usize;
|
||||
}
|
||||
|
||||
pub fn capturing(&self) -> Option<Action> {
|
||||
match self.page {
|
||||
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
|
||||
@@ -230,11 +274,14 @@ impl Menu {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn click(&mut self, x: f32, y: f32, settings: &mut Settings) -> MenuAction {
|
||||
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,
|
||||
@@ -289,10 +336,40 @@ impl Menu {
|
||||
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)) => {
|
||||
self.scroll_by(d * self.rows_visible.max(1) as i32, cloud.list.len());
|
||||
MenuAction::Consumed
|
||||
}
|
||||
Some(Btn::FileSave) => MenuAction::FileSave,
|
||||
Some(Btn::FileLoad) => MenuAction::FileLoad,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw(&mut self, gfx: &Gfx, settings: &Settings, status: Option<&str>) {
|
||||
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();
|
||||
@@ -303,6 +380,7 @@ impl Menu {
|
||||
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::KeyCapture { action, .. } => {
|
||||
self.draw_settings(gfx, settings, 4);
|
||||
self.buttons.clear();
|
||||
@@ -495,4 +573,112 @@ impl Menu {
|
||||
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);
|
||||
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 }));
|
||||
}
|
||||
}
|
||||
+36
-1
@@ -1,6 +1,13 @@
|
||||
// Browser integration via our own miniquad JS plugin (www/dc_plugin.js):
|
||||
// persistent key/value storage (localStorage), a cache that is written to storage when the
|
||||
// page is hidden or closed, file download and file upload. Native builds use simple fallbacks.
|
||||
// page is hidden or closed, file download, file upload and HTTP requests.
|
||||
// Native builds use simple fallbacks.
|
||||
|
||||
// Result of an HTTP request: status 0 means the service could not be reached
|
||||
pub struct HttpResult {
|
||||
pub status: i32,
|
||||
pub body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod imp {
|
||||
@@ -13,6 +20,27 @@ mod imp {
|
||||
fn dc_open_file();
|
||||
fn dc_file_poll() -> i32;
|
||||
fn dc_file_take(dst: *mut u8);
|
||||
fn dc_http(m: *const u8, ml: u32, u: *const u8, ul: u32, b: *const u8, bl: u32) -> u32;
|
||||
fn dc_http_poll(id: u32) -> i32;
|
||||
fn dc_http_len(id: u32) -> u32;
|
||||
fn dc_http_take(id: u32, dst: *mut u8);
|
||||
}
|
||||
|
||||
// starts a request; poll the returned id with http_poll()
|
||||
pub fn http(method: &str, url: &str, body: &[u8]) -> u32 {
|
||||
unsafe { dc_http(method.as_ptr(), method.len() as u32, url.as_ptr(), url.len() as u32, body.as_ptr(), body.len() as u32) }
|
||||
}
|
||||
|
||||
pub fn http_poll(id: u32) -> Option<super::HttpResult> {
|
||||
unsafe {
|
||||
let status = dc_http_poll(id);
|
||||
if status == -2 {
|
||||
return None;
|
||||
}
|
||||
let mut body = vec![0u8; dc_http_len(id) as usize];
|
||||
dc_http_take(id, body.as_mut_ptr());
|
||||
Some(super::HttpResult { status, body })
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage_get(key: &str) -> Option<String> {
|
||||
@@ -81,6 +109,13 @@ mod imp {
|
||||
pub fn take_file() -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
// no savegame service in native builds: every request fails like an unreachable server
|
||||
pub fn http(_method: &str, _url: &str, _body: &[u8]) -> u32 {
|
||||
0
|
||||
}
|
||||
pub fn http_poll(_id: u32) -> Option<super::HttpResult> {
|
||||
Some(super::HttpResult { status: 0, body: Vec::new() })
|
||||
}
|
||||
}
|
||||
|
||||
pub use imp::*;
|
||||
|
||||
Reference in new issue
Block a user