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
+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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user