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
@@ -1,2 +1,3 @@
|
|||||||
target
|
target
|
||||||
.git
|
.git
|
||||||
|
server
|
||||||
@@ -37,6 +37,7 @@ An original dungeon management game (genre: dungeon building & defence) that run
|
|||||||
## Menus, savegames, settings
|
## Menus, savegames, settings
|
||||||
- **Title screen** (Continue, New Game, Load, Settings, Help) and **pause menu** on Esc (Resume, Save, Load, Settings, Help, Main Menu)
|
- **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
|
- 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
|
- **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: 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
|
- Settings are stored in the browser **and** in every savegame; loading a savegame **always applies** its settings
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ See [DESIGN.md](DESIGN.md) for all design decisions and [CREDITS.md](CREDITS.md)
|
|||||||
|
|
||||||
## Running
|
## 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
|
```sh
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
@@ -66,7 +66,26 @@ All keys except Esc and the arrow keys can be changed in Settings → Keys.
|
|||||||
|
|
||||||
## Savegames
|
## 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
|
## Adding a language
|
||||||
|
|
||||||
|
|||||||
@@ -6,3 +6,14 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "18093:80"
|
- "18093:80"
|
||||||
restart: unless-stopped
|
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:
|
||||||
@@ -343,3 +343,25 @@ hint.punch_where = Die Hand kann Helden nur auf eigenem Gebiet schlagen.
|
|||||||
|
|
||||||
unit.chainbreaker = Kettenbrecher
|
unit.chainbreaker = Kettenbrecher
|
||||||
hint.chainbreaker_restless = Der Kettenbrecher wird unruhig: Er will kämpfen!
|
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.
|
||||||
@@ -344,3 +344,25 @@ hint.punch_where = The hand can only punch heroes on your own territory.
|
|||||||
|
|
||||||
unit.chainbreaker = Chainbreaker
|
unit.chainbreaker = Chainbreaker
|
||||||
hint.chainbreaker_restless = The Chainbreaker is restless: it wants something to fight!
|
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.
|
||||||
@@ -7,4 +7,13 @@ server {
|
|||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
Generated
+489
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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<Mutex<Vec<u64>>>, // 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>) -> 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<AppState>) -> Response {
|
||||||
|
let mut out: Vec<SaveInfo> = 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::<SaveInfo>(&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<AppState>, Path(id): Path<String>) -> 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<String>, 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<AppState>, Query(q): Query<NameQuery>, 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<AppState>, Path(id): Path<String>, Query(q): Query<NameQuery>, 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<AppState>, Path(id): Path<String>) -> 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");
|
||||||
|
}
|
||||||
+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).
|
// DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM).
|
||||||
|
|
||||||
mod audio;
|
mod audio;
|
||||||
|
mod cloud;
|
||||||
mod game;
|
mod game;
|
||||||
mod gfx;
|
mod gfx;
|
||||||
mod i18n;
|
mod i18n;
|
||||||
@@ -16,6 +17,7 @@ mod sound;
|
|||||||
mod ui;
|
mod ui;
|
||||||
mod xm;
|
mod xm;
|
||||||
|
|
||||||
|
use cloud::{Cloud, CloudEvent};
|
||||||
use game::{Game, Msg};
|
use game::{Game, Msg};
|
||||||
use gfx::Gfx;
|
use gfx::Gfx;
|
||||||
use macroquad::prelude::*;
|
use macroquad::prelude::*;
|
||||||
@@ -147,20 +149,13 @@ fn store_settings(s: &Settings) {
|
|||||||
|
|
||||||
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
// "dungeoncraft_2026-11-02_2145.dcsave" (UTC)
|
||||||
fn save_file_name() -> String {
|
fn save_file_name() -> String {
|
||||||
let secs = miniquad::date::now() as i64;
|
let (y, m, d, h, mi) = cloud::civil(miniquad::date::now() as i64);
|
||||||
let days = secs.div_euclid(86400);
|
format!("dungeoncraft_{:04}-{:02}-{:02}_{:02}{:02}.dcsave", y, m, d, h, mi)
|
||||||
let rem = secs.rem_euclid(86400);
|
}
|
||||||
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm)
|
|
||||||
let z = days + 719468;
|
// suggested name for a new savegame on the server: "Wave 3 - 2026-11-03 14:20"
|
||||||
let era = z.div_euclid(146097);
|
fn default_save_name(g: &Game) -> String {
|
||||||
let doe = z - era * 146097;
|
i18n::trf("saves.default_name", &[(g.wave + 1).to_string(), cloud::date_string(miniquad::date::now() as i64)])
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
@@ -215,6 +210,7 @@ async fn main() {
|
|||||||
let mut music = MusicDirector::new();
|
let mut music = MusicDirector::new();
|
||||||
let mut ui = Ui::new();
|
let mut ui = Ui::new();
|
||||||
let mut menu = Menu::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
|
// 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));
|
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);
|
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
|
// ---------------- key capture for the key binding dialog
|
||||||
if let Some(action) = menu.capturing() {
|
if let Some(action) = menu.capturing() {
|
||||||
if is_key_pressed(KeyCode::Escape) {
|
if is_key_pressed(KeyCode::Escape) {
|
||||||
@@ -267,7 +294,7 @@ async fn main() {
|
|||||||
menu.finish_capture();
|
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;
|
next_frame().await;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -321,7 +348,7 @@ async fn main() {
|
|||||||
app.game.hints = app.settings.hints;
|
app.game.hints = app.settings.hints;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !dialog {
|
if !dialog && !menu.typing() {
|
||||||
if is_key_pressed(key(Action::Mute)) {
|
if is_key_pressed(key(Action::Mute)) {
|
||||||
app.settings.muted = !app.settings.muted;
|
app.settings.muted = !app.settings.muted;
|
||||||
settings_changed = true;
|
settings_changed = true;
|
||||||
@@ -449,7 +476,8 @@ async fn main() {
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else if in_menu {
|
} 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::SettingsChanged => settings_changed = true,
|
||||||
MenuAction::Resume => {}
|
MenuAction::Resume => {}
|
||||||
MenuAction::NewGame => {
|
MenuAction::NewGame => {
|
||||||
@@ -466,15 +494,37 @@ async fn main() {
|
|||||||
app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, now);
|
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();
|
platform::open_file();
|
||||||
app.set_status(i18n::tr("save.choose"), now);
|
app.set_status(i18n::tr("save.choose"), now);
|
||||||
}
|
}
|
||||||
MenuAction::Save => {
|
MenuAction::Save | MenuAction::FileSave => {
|
||||||
let name = save_file_name();
|
let name = save_file_name();
|
||||||
platform::download(&name, app.autosave_json().as_bytes());
|
platform::download(&name, app.autosave_json().as_bytes());
|
||||||
app.set_status(i18n::trf("save.saved", &[name]), now);
|
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::Help => ui.show_help = true,
|
||||||
MenuAction::MainMenu => {
|
MenuAction::MainMenu => {
|
||||||
if app.game.state == 0 {
|
if app.game.state == 0 {
|
||||||
@@ -593,13 +643,13 @@ async fn main() {
|
|||||||
ui.log.push((m, now));
|
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
|
next_frame().await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[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 (mx, my) = mouse_position();
|
||||||
let (ux, uy) = ui::mouse();
|
let (ux, uy) = ui::mouse();
|
||||||
let blocked = menu.open() || ui.show_help || ui.show_langsel;
|
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) {
|
if app.status.as_ref().map_or(false, |(_, t)| now - t > 4.0) {
|
||||||
app.status = None;
|
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 {
|
if !menu.open() || ui.show_help || ui.show_langsel {
|
||||||
ui.draw_dialogs(&app.game, gfx);
|
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.
|
// Drawn as CP437 windows on top of the (paused) game world.
|
||||||
|
|
||||||
|
use crate::cloud::{date_string, duration_string, Cloud};
|
||||||
use crate::gfx::*;
|
use crate::gfx::*;
|
||||||
use crate::i18n::{languages, tr, trf};
|
use crate::i18n::{languages, tr, trf};
|
||||||
use crate::map::{MAX_SIZE, MIN_SIZE};
|
use crate::map::{MAX_SIZE, MIN_SIZE};
|
||||||
@@ -15,6 +17,7 @@ pub enum Page {
|
|||||||
Settings { tab: usize, from_title: bool },
|
Settings { tab: usize, from_title: bool },
|
||||||
NewGame,
|
NewGame,
|
||||||
KeyCapture { action: usize, from_title: bool },
|
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"];
|
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,
|
Start,
|
||||||
Key(usize),
|
Key(usize),
|
||||||
ResetKeys,
|
ResetKeys,
|
||||||
|
SaveNew,
|
||||||
|
Slot(usize), // load / overwrite
|
||||||
|
Delete(usize),
|
||||||
|
Scroll(i32),
|
||||||
|
FileSave,
|
||||||
|
FileLoad,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum MenuAction {
|
pub enum MenuAction {
|
||||||
@@ -180,11 +189,20 @@ pub enum MenuAction {
|
|||||||
Help,
|
Help,
|
||||||
MainMenu,
|
MainMenu,
|
||||||
SettingsChanged,
|
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 struct Menu {
|
||||||
pub page: Option<Page>,
|
pub page: Option<Page>,
|
||||||
pub has_autosave: bool,
|
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)>,
|
buttons: Vec<(Rect, Btn)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +215,7 @@ fn button(gfx: &Gfx, r: Rect, label: &str, hot: bool, accent: Color) {
|
|||||||
|
|
||||||
impl Menu {
|
impl Menu {
|
||||||
pub fn new() -> 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 {
|
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: true, .. }) => Some(Page::Title),
|
||||||
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
|
Some(Page::Settings { from_title: false, .. }) => Some(Page::Pause),
|
||||||
Some(Page::NewGame) => Some(Page::Title),
|
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::Pause) => None,
|
||||||
Some(Page::Title) => Some(Page::Title),
|
Some(Page::Title) => Some(Page::Title),
|
||||||
None => Some(Page::Pause),
|
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> {
|
pub fn capturing(&self) -> Option<Action> {
|
||||||
match self.page {
|
match self.page {
|
||||||
Some(Page::KeyCapture { action, .. }) => all_actions().get(action).copied(),
|
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() {
|
if self.page.is_none() {
|
||||||
return MenuAction::None;
|
return MenuAction::None;
|
||||||
}
|
}
|
||||||
let hit = self.buttons.iter().find(|(r, _)| r.contains(vec2(x, y))).map(|(_, b)| *b);
|
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, .. }));
|
let from_title = matches!(self.page, Some(Page::Title) | Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }));
|
||||||
match hit {
|
match hit {
|
||||||
None => MenuAction::Consumed,
|
None => MenuAction::Consumed,
|
||||||
@@ -289,10 +336,40 @@ impl Menu {
|
|||||||
settings.reset_keys();
|
settings.reset_keys();
|
||||||
MenuAction::SettingsChanged
|
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();
|
self.buttons.clear();
|
||||||
let Some(page) = self.page else { return };
|
let Some(page) = self.page else { return };
|
||||||
let sw = sw();
|
let sw = sw();
|
||||||
@@ -303,6 +380,7 @@ impl Menu {
|
|||||||
Page::Pause => self.draw_pause(gfx),
|
Page::Pause => self.draw_pause(gfx),
|
||||||
Page::NewGame => self.draw_new_game(gfx, settings),
|
Page::NewGame => self.draw_new_game(gfx, settings),
|
||||||
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
Page::Settings { tab, .. } => self.draw_settings(gfx, settings, tab),
|
||||||
|
Page::Saves { save, .. } => self.draw_saves(gfx, cloud, save),
|
||||||
Page::KeyCapture { action, .. } => {
|
Page::KeyCapture { action, .. } => {
|
||||||
self.draw_settings(gfx, settings, 4);
|
self.draw_settings(gfx, settings, 4);
|
||||||
self.buttons.clear();
|
self.buttons.clear();
|
||||||
@@ -495,4 +573,112 @@ impl Menu {
|
|||||||
button(gfx, br, &tr("menu.back"), br.contains(vec2(mx, my)), FRAME);
|
button(gfx, br, &tr("menu.back"), br.contains(vec2(mx, my)), FRAME);
|
||||||
self.buttons.push((br, Btn::Back));
|
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):
|
// 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
|
// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
mod imp {
|
mod imp {
|
||||||
@@ -13,6 +20,27 @@ mod imp {
|
|||||||
fn dc_open_file();
|
fn dc_open_file();
|
||||||
fn dc_file_poll() -> i32;
|
fn dc_file_poll() -> i32;
|
||||||
fn dc_file_take(dst: *mut u8);
|
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> {
|
pub fn storage_get(key: &str) -> Option<String> {
|
||||||
@@ -81,6 +109,13 @@ mod imp {
|
|||||||
pub fn take_file() -> Option<Vec<u8>> {
|
pub fn take_file() -> Option<Vec<u8>> {
|
||||||
None
|
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::*;
|
pub use imp::*;
|
||||||
|
|||||||
+55
-1
@@ -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 () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var pending = null; // value handed over by dc_storage_get
|
var pending = null; // value handed over by dc_storage_get
|
||||||
var file = null; // bytes of a file chosen via the file picker
|
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 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) {
|
function bytes(ptr, len) {
|
||||||
return new Uint8Array(wasm_memory.buffer, ptr, len);
|
return new Uint8Array(wasm_memory.buffer, ptr, len);
|
||||||
@@ -34,8 +37,59 @@
|
|||||||
});
|
});
|
||||||
document.addEventListener("DOMContentLoaded", function () { document.body.appendChild(input); });
|
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) {
|
function register(importObject) {
|
||||||
var env = importObject.env;
|
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) {
|
env.dc_storage_get = function (kp, kl) {
|
||||||
var v = null;
|
var v = null;
|
||||||
try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { }
|
try { v = window.localStorage.getItem(str(kp, kl)); } catch (e) { }
|
||||||
|
|||||||
Reference in new issue
Block a user