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