diff --git a/.dockerignore b/.dockerignore index c7f83c2..0c6cec4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,3 @@ target .git +server diff --git a/DESIGN.md b/DESIGN.md index 8e0ab86..e2fd194 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -37,6 +37,7 @@ An original dungeon management game (genre: dungeon building & defence) that run ## Menus, savegames, settings - **Title screen** (Continue, New Game, Load, Settings, Help) and **pause menu** on Esc (Resume, Save, Load, Settings, Help, Main Menu) - Savegames as **JSON** (`*.dcsave`, format tag + version, ~300 KB): **save = download**, **load = file picker or drag & drop** onto the game window +- **Server savegames** (savegame service in `server/`, shared list without accounts): save under a name / overwrite / load / delete in a list page; the game checks `api/health` at start and **falls back to savegame files** when the service cannot be reached (e.g. local HTML) - **Autosave** in the browser (localStorage) every 2 minutes and when the tab is hidden/closed; "Continue" on the title screen - Settings: language + sound, camera (rotation/zoom speed, edge scrolling, invert mouse), graphics (tile outlines, wall detail, light sources, interface size), game (speed 1×/2×, hints), **key bindings** (conflicts swap keys; Esc and arrow keys are fixed) - Settings are stored in the browser **and** in every savegame; loading a savegame **always applies** its settings diff --git a/README.md b/README.md index 87b5413..33d877e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ See [DESIGN.md](DESIGN.md) for all design decisions and [CREDITS.md](CREDITS.md) ## Running -The easiest way is Docker; the image builds the WASM file and serves it with nginx: +The easiest way is Docker; the image builds the WASM file and serves it with nginx. A second container runs the savegame service (see below): ```sh docker compose up -d --build @@ -66,7 +66,26 @@ All keys except Esc and the arrow keys can be changed in Settings → Keys. ## Savegames -Use *Save Game* in the pause menu to download a `.dcsave` file (JSON). Load it again via *Load Game* or by dropping the file onto the game window. The game also autosaves into the browser every two minutes and when the tab is closed; *Continue* on the title screen resumes from there. +When the game is served together with the savegame service (`server/`, started by `docker compose`), *Save Game* and *Load Game* open a list of savegames stored on the server: save under a new name (type it into the name field), overwrite, load or delete (overwrite and delete ask for a second click). The list is shared by everybody who can reach the game - protect a public installation, for example with a password on the reverse proxy. + +If the service cannot be reached (for example when `www/` is opened as local files or served by a plain web server), the game falls back to savegame files: *Save Game* downloads a `.dcsave` file (JSON), *Load Game* opens the file picker; dropping the file onto the game window works too. Savegame files are also available from the server list (*Download as file* / *Load from file*). + +The game also autosaves into the browser every two minutes and when the tab is closed; *Continue* on the title screen resumes from there. + +### Savegame service + +A small Rust service (axum) in `server/` stores savegames as files in the Docker volume `savegames`. nginx forwards `/api/` to it; the service itself is not published. + +| Request | Meaning | +|---|---| +| `GET /api/health` | `ok` when the service runs | +| `GET /api/saves` | list (id, name, date, map size, wave, play time), newest first | +| `POST /api/saves?name=...` | store a new savegame (body: savegame JSON) | +| `PUT /api/saves/{id}?name=...` | overwrite a savegame | +| `GET /api/saves/{id}` | savegame JSON | +| `DELETE /api/saves/{id}` | delete a savegame | + +Limits: 1 MB per savegame, 500 MB in total, 30 write requests per minute. Settings: `SAVE_DIR` (default `/data`), `LISTEN` (default `0.0.0.0:8080`). ## Adding a language diff --git a/docker-compose.yml b/docker-compose.yml index dff2b2e..4b95a4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,3 +6,14 @@ services: ports: - "18093:80" restart: unless-stopped + # savegame service, only reachable through the dungeoncraft container (/api/) + saves: + build: ./server + image: dungeoncraft-saves:latest + container_name: dungeoncraft-saves + volumes: + - savegames:/data + restart: unless-stopped + +volumes: + savegames: diff --git a/lang/de.lang b/lang/de.lang index 09ac531..42bc9fb 100644 --- a/lang/de.lang +++ b/lang/de.lang @@ -343,3 +343,25 @@ hint.punch_where = Die Hand kann Helden nur auf eigenem Gebiet schlagen. unit.chainbreaker = Kettenbrecher hint.chainbreaker_restless = Der Kettenbrecher wird unruhig: Er will kämpfen! + +# savegames on the server +saves.name = Name: +saves.new = Neu speichern +saves.default_name = Welle {0} - {1} +saves.overwrite = Überschreiben +saves.load = Laden +saves.delete = Löschen +saves.sure = Sicher? +saves.meta = {3} UTC, Welle {0}, Karte {1}, gespielt {2} +saves.empty = Noch keine Spielstände auf dem Server. +saves.loading = Liste wird geladen ... +saves.wait = Bitte warten ... +saves.file_save = Als Datei herunterladen +saves.file_load = Aus Datei laden +cloud.deleted = Spielstand gelöscht. +cloud.err_net = Der Spielstand-Server ist nicht erreichbar. +cloud.err_large = Der Spielstand ist zu groß für den Server. +cloud.err_rate = Zu viele Anfragen - bitte einen Moment warten. +cloud.err_full = Der Spielstand-Server ist voll. +cloud.err_missing = Diesen Spielstand gibt es nicht mehr. +cloud.err = Der Spielstand-Server meldet einen Fehler. diff --git a/lang/en.lang b/lang/en.lang index cf510e7..aa0191d 100644 --- a/lang/en.lang +++ b/lang/en.lang @@ -344,3 +344,25 @@ hint.punch_where = The hand can only punch heroes on your own territory. unit.chainbreaker = Chainbreaker hint.chainbreaker_restless = The Chainbreaker is restless: it wants something to fight! + +# savegames on the server +saves.name = Name: +saves.new = Save as new +saves.default_name = Wave {0} - {1} +saves.overwrite = Overwrite +saves.load = Load +saves.delete = Delete +saves.sure = Sure? +saves.meta = {3} UTC, wave {0}, map {1}, played {2} +saves.empty = No savegames on the server yet. +saves.loading = Loading the list ... +saves.wait = Please wait ... +saves.file_save = Download as file +saves.file_load = Load from file +cloud.deleted = Savegame deleted. +cloud.err_net = The savegame server cannot be reached. +cloud.err_large = The savegame is too large for the server. +cloud.err_rate = Too many requests - please wait a moment. +cloud.err_full = The savegame server is full. +cloud.err_missing = This savegame no longer exists. +cloud.err = The savegame server reported an error. diff --git a/nginx.conf b/nginx.conf index 296e1fb..9ca393c 100644 --- a/nginx.conf +++ b/nginx.conf @@ -7,4 +7,13 @@ server { location / { try_files $uri $uri/ =404; } + # savegame service (server/). Resolved at request time, so the game still starts without it; + # the game then falls back to savegame files. + location /api/ { + resolver 127.0.0.11 valid=10s ipv6=off; + set $saves http://saves:8080; + proxy_pass $saves; + client_max_body_size 2m; + proxy_read_timeout 15s; + } } diff --git a/server/.dockerignore b/server/.dockerignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/server/.dockerignore @@ -0,0 +1 @@ +target/ diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..f0eb1ed --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,489 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "dungeoncraft-saves" +version = "0.1.0" +dependencies = [ + "axum", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "log" +version = "0.4.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..d52b67c --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "dungeoncraft-saves" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "Savegame storage service for DungeonCraft" + +[dependencies] +axum = "0.8" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "fs", "sync", "time"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +[profile.release] +opt-level = 3 +lto = true diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..aaec3ff --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,13 @@ +FROM rust:1-slim AS build +WORKDIR /src +COPY Cargo.toml Cargo.lock* ./ +RUN mkdir src && echo "fn main() {}" > src/main.rs && cargo build --release && rm -rf src +COPY src src +RUN touch src/main.rs && cargo build --release + +FROM debian:bookworm-slim +COPY --from=build /src/target/release/dungeoncraft-saves /usr/local/bin/dungeoncraft-saves +ENV SAVE_DIR=/data LISTEN=0.0.0.0:8080 +VOLUME /data +EXPOSE 8080 +CMD ["dungeoncraft-saves"] diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..a980771 --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,212 @@ +// DungeonCraft savegame service: a small HTTP API that stores savegames as files. +// +// GET /api/health -> "ok" +// GET /api/saves -> list of savegames (newest first) +// GET /api/saves/{id} -> savegame JSON +// POST /api/saves?name=.. -> store a new savegame (body = savegame JSON), returns its info +// PUT /api/saves/{id}?name -> overwrite a savegame +// DELETE /api/saves/{id} -> delete a savegame +// +// Storage is shared by everybody (no accounts). Limits: 1 MB per savegame, 500 MB in total, +// at most 30 write requests per minute. + +use axum::{ + body::Bytes, + extract::{DefaultBodyLimit, Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use std::{ + path::PathBuf, + sync::Arc, + time::{SystemTime, UNIX_EPOCH}, +}; +use tokio::sync::Mutex; + +const MAX_SAVE: usize = 1024 * 1024; +const MAX_TOTAL: u64 = 500 * 1024 * 1024; +const WRITES_PER_MINUTE: usize = 30; + +#[derive(Clone)] +struct AppState { + dir: PathBuf, + writes: Arc>>, // timestamps of recent writes +} + +#[derive(Serialize, Deserialize, Clone)] +struct SaveInfo { + id: String, + name: String, + saved_at: u64, // unix seconds + size: usize, + map_w: u64, + map_h: u64, + wave: u64, + time: f64, // play time in seconds +} + +#[derive(Deserialize)] +struct NameQuery { + name: Option, +} + +fn now() -> u64 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 40 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') +} + +fn clean_name(n: Option) -> String { + let n: String = n.unwrap_or_default().chars().filter(|c| !c.is_control()).take(60).collect(); + if n.trim().is_empty() { + "Savegame".into() + } else { + n.trim().to_string() + } +} + +fn err(code: StatusCode, msg: &str) -> Response { + (code, msg.to_string()).into_response() +} + +async fn rate_ok(st: &AppState) -> bool { + let mut w = st.writes.lock().await; + let t = now(); + w.retain(|&x| t - x < 60); + if w.len() >= WRITES_PER_MINUTE { + return false; + } + w.push(t); + true +} + +async fn total_size(dir: &PathBuf) -> u64 { + let mut total = 0; + if let Ok(mut rd) = tokio::fs::read_dir(dir).await { + while let Ok(Some(e)) = rd.next_entry().await { + if let Ok(m) = e.metadata().await { + total += m.len(); + } + } + } + total +} + +async fn health() -> &'static str { + "ok" +} + +async fn list(State(st): State) -> Response { + let mut out: Vec = Vec::new(); + if let Ok(mut rd) = tokio::fs::read_dir(&st.dir).await { + while let Ok(Some(e)) = rd.next_entry().await { + let p = e.path(); + if p.extension().and_then(|x| x.to_str()) == Some("meta") { + if let Ok(b) = tokio::fs::read(&p).await { + if let Ok(info) = serde_json::from_slice::(&b) { + out.push(info); + } + } + } + } + } + out.sort_by(|a, b| b.saved_at.cmp(&a.saved_at)); + Json(out).into_response() +} + +async fn get_save(State(st): State, Path(id): Path) -> Response { + if !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + match tokio::fs::read(st.dir.join(format!("{}.json", id))).await { + Ok(b) => ([("content-type", "application/json")], b).into_response(), + Err(_) => err(StatusCode::NOT_FOUND, "not found"), + } +} + +async fn store(st: &AppState, id: String, name: Option, body: Bytes) -> Response { + if body.len() > MAX_SAVE { + return err(StatusCode::PAYLOAD_TOO_LARGE, "savegame too large"); + } + if !rate_ok(st).await { + return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + if total_size(&st.dir).await + body.len() as u64 > MAX_TOTAL { + return err(StatusCode::INSUFFICIENT_STORAGE, "storage full"); + } + // must be a DungeonCraft savegame; a few values are kept for the list + let v: serde_json::Value = match serde_json::from_slice(&body) { + Ok(v) => v, + Err(_) => return err(StatusCode::BAD_REQUEST, "not JSON"), + }; + if v.get("format").and_then(|f| f.as_str()) != Some("dungeoncraft-save") { + return err(StatusCode::BAD_REQUEST, "not a DungeonCraft savegame"); + } + let game = &v["game"]; + let info = SaveInfo { + id: id.clone(), + name: clean_name(name), + saved_at: now(), + size: body.len(), + map_w: game["map"]["w"].as_u64().unwrap_or(64), + map_h: game["map"]["h"].as_u64().unwrap_or(64), + wave: game["wave"].as_u64().unwrap_or(0), + time: game["time"].as_f64().unwrap_or(0.0), + }; + let save_path = st.dir.join(format!("{}.json", id)); + let meta_path = st.dir.join(format!("{}.meta", id)); + if tokio::fs::write(&save_path, &body).await.is_err() || tokio::fs::write(&meta_path, serde_json::to_vec(&info).unwrap_or_default()).await.is_err() { + return err(StatusCode::INTERNAL_SERVER_ERROR, "write failed"); + } + Json(info).into_response() +} + +async fn create(State(st): State, Query(q): Query, body: Bytes) -> Response { + let id = format!("{}-{:04x}", now(), (now_nanos() % 0xffff) as u16); + store(&st, id, q.name, body).await +} + +async fn overwrite(State(st): State, Path(id): Path, Query(q): Query, body: Bytes) -> Response { + if !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + store(&st, id, q.name, body).await +} + +async fn delete(State(st): State, Path(id): Path) -> Response { + if !valid_id(&id) { + return err(StatusCode::BAD_REQUEST, "bad id"); + } + if !rate_ok(&st).await { + return err(StatusCode::TOO_MANY_REQUESTS, "too many requests"); + } + let _ = tokio::fs::remove_file(st.dir.join(format!("{}.json", id))).await; + let _ = tokio::fs::remove_file(st.dir.join(format!("{}.meta", id))).await; + StatusCode::NO_CONTENT.into_response() +} + +fn now_nanos() -> u128 { + SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0) +} + +#[tokio::main] +async fn main() { + let dir = PathBuf::from(std::env::var("SAVE_DIR").unwrap_or_else(|_| "/data".into())); + tokio::fs::create_dir_all(&dir).await.expect("save directory"); + let state = AppState { dir, writes: Arc::new(Mutex::new(Vec::new())) }; + let app = Router::new() + .route("/api/health", get(health)) + .route("/api/saves", get(list).post(create)) + .route("/api/saves/{id}", get(get_save).put(overwrite).delete(delete)) + .layer(DefaultBodyLimit::max(MAX_SAVE + 1024)) + .with_state(state); + let addr = std::env::var("LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let listener = tokio::net::TcpListener::bind(&addr).await.expect("bind"); + println!("DungeonCraft savegame service on {}", addr); + axum::serve(listener, app).await.expect("server"); +} diff --git a/src/cloud.rs b/src/cloud.rs new file mode 100644 index 0000000..4983e0b --- /dev/null +++ b/src/cloud.rs @@ -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), // savegame data + Deleted, + Error(&'static str), // language key +} + +pub struct Cloud { + pub online: Online, + pub list: Vec, + 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::>(&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"); + } +} diff --git a/src/main.rs b/src/main.rs index 0eef2be..5d3daba 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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); } diff --git a/src/menu.rs b/src/menu.rs index ce9c197..2f4c218 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -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), // (id to overwrite, name) + CloudLoad(String), + CloudDelete(String), } pub struct Menu { pub page: Option, pub has_autosave: bool, + pub save_name: String, // name for a new savegame (typed on the savegame page) + confirm: Option, // 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 { 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 = 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 })); + } } diff --git a/src/platform.rs b/src/platform.rs index 65c1f28..5c0f9b4 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -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, +} #[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 { + 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 { @@ -81,6 +109,13 @@ mod imp { pub fn take_file() -> Option> { 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 { + Some(super::HttpResult { status: 0, body: Vec::new() }) + } } pub use imp::*; diff --git a/www/dc_plugin.js b/www/dc_plugin.js index bedf831..11c01ed 100644 --- a/www/dc_plugin.js +++ b/www/dc_plugin.js @@ -1,9 +1,12 @@ -// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave, audio output. +// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave, +// HTTP requests (savegame service), audio output. (function () { "use strict"; var pending = null; // value handed over by dc_storage_get var file = null; // bytes of a file chosen via the file picker var cache = {}; // key -> string, persisted to localStorage when the page is hidden/closed + var http = {}; // request id -> { done, status, body } + var httpNext = 1; function bytes(ptr, len) { return new Uint8Array(wasm_memory.buffer, ptr, len); @@ -34,8 +37,59 @@ }); document.addEventListener("DOMContentLoaded", function () { document.body.appendChild(input); }); + // Asynchronous HTTP request; the result is polled by the game. + // status 0 = network error (e.g. no service, or the page was opened as a local file) + function httpStart(method, url, body) { + var id = httpNext++; + var req = { done: false, status: 0, body: new Uint8Array(0) }; + http[id] = req; + var ctrl = typeof AbortController !== "undefined" ? new AbortController() : null; + var timer = setTimeout(function () { if (ctrl) ctrl.abort(); }, 15000); + try { + var opts = { method: method, cache: "no-store", headers: {} }; + if (ctrl) opts.signal = ctrl.signal; + if (body !== null) { opts.body = body; opts.headers["Content-Type"] = "application/json"; } + fetch(url, opts).then(function (r) { + req.status = r.status; + return r.arrayBuffer(); + }).then(function (buf) { + req.body = new Uint8Array(buf); + req.done = true; + clearTimeout(timer); + }).catch(function () { + req.status = 0; + req.done = true; + clearTimeout(timer); + }); + } catch (e) { + req.done = true; + } + return id; + } + function register(importObject) { var env = importObject.env; + env.dc_http = function (mp, ml, up, ul, bp, bl) { + var body = bl > 0 ? bytes(bp, bl).slice() : null; + return httpStart(str(mp, ml), str(up, ul), body); + }; + // -2 = still running, otherwise the HTTP status (0 = network error) + env.dc_http_poll = function (id) { + var r = http[id]; + if (!r) return 0; + return r.done ? r.status : -2; + }; + env.dc_http_len = function (id) { + var r = http[id]; + return r ? r.body.length : 0; + }; + // copies the response body and forgets the request + env.dc_http_take = function (id, ptr) { + var r = http[id]; + if (!r) return; + bytes(ptr, r.body.length).set(r.body); + delete http[id]; + }; env.dc_storage_get = function (kp, kl) { var v = null; try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { }