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:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 09:31:36 +01:00
1 parent c5b7896609
commit 7f4324abca
17 files changed
+1390 -30

No files matched your search

+72 -22
View File
@@ -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);
}