feat(web): add browser build for TDK Pinball

Expose the existing Macroquad game as a static WASM website while preserving
native desktop behavior. Native-only simulation/file-export code and the
per-user filesystem save path are now separated from the browser build.

The browser version uses a small WASM-only storage support crate and a
Macroquad-compatible JavaScript plugin to persist the same JSON settings and
high scores in localStorage. Browser audio decoding starts in an owned
background coroutine so the game can render its original loading/attract
screens while the embedded sounds finish loading. The checked-in web bundle
contains the optimized WASM, centered black HTML shell, and build/serve
instructions.

Test Plan:
- `just test` -- passed, 135 tests
- `just clippy` -- passed
- `cargo clippy --target wasm32-unknown-unknown -- -D warnings` -- passed
- `cargo +nightly fmt --check` and web-storage format check -- passed
- `just web-build` -- passed; packaged WASM matches the production artifact
- Browser smoke test at `http://127.0.0.1:8000/` -- rendered the centered
  game, started gameplay, opened settings, and restored a changed language
  from browser storage in a fresh page with no runtime errors
This commit is contained in:
2026-08-29 14:32:41 +02:00
parent c2f1443436
commit b079cfa196
16 changed files with 523 additions and 89 deletions
+21
View File
@@ -0,0 +1,21 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"link-arg=--import-undefined",
"-C",
"link-arg=--export=tdkpin_storage_crate_version",
"-C",
"link-arg=--export=tdkpin_browser_storage_clear",
"-C",
"link-arg=--export=tdkpin_browser_storage_push",
"-C",
"link-arg=--export=tdkpin_browser_storage_finish",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_revision",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_length",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_byte",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_ack",
]
+54
View File
@@ -164,6 +164,42 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[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-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -362,6 +398,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
@@ -481,6 +523,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "0.6.14" version = "0.6.14"
@@ -506,11 +554,17 @@ name = "tdkpin-rs"
version = "1.0.0" version = "1.0.0"
dependencies = [ dependencies = [
"directories", "directories",
"futures-util",
"macroquad", "macroquad",
"serde", "serde",
"serde_json", "serde_json",
"tdkpin-web-storage",
] ]
[[package]]
name = "tdkpin-web-storage"
version = "1.0.0"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
+7 -1
View File
@@ -4,11 +4,17 @@ version = "1.0.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
directories = "6"
macroquad = { version = "0.4", features = ["audio"] } macroquad = { version = "0.4", features = ["audio"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
directories = "6"
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-util = "0.3"
tdkpin-web-storage = { path = "web_storage" }
[lints.clippy] [lints.clippy]
pedantic = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 }
todo = "warn" todo = "warn"
+13
View File
@@ -31,6 +31,19 @@ development packages are also required (X11, OpenGL, and ALSA). Windows needs
no extra runtime installation; macOS builds with the normal Apple developer no extra runtime installation; macOS builds with the normal Apple developer
command-line tools. command-line tools.
## Browser version
Build and serve the WASM website locally with:
```sh
just web-serve
```
Then open <http://127.0.0.1:8000/>. The browser build keeps the original
640x460 presentation centered on a black page and stores settings and high
scores in browser storage. See [web/README.md](web/README.md) for the static
bundle details.
## Deterministic mechanics validation ## Deterministic mechanics validation
Named scenarios can be advanced without waiting in real time. The simulator Named scenarios can be advanced without waiting in real time. The simulator
+7
View File
@@ -12,6 +12,13 @@ build-release:
build-production: build-production:
cargo build --profile production cargo build --profile production
web-build:
cargo build --profile production --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/production/tdkpin-rs.wasm web/tdkpin-rs.wasm
web-serve: web-build
python3 -m http.server 8000 --directory web
fmt: fmt:
cargo +nightly fmt cargo +nightly fmt
tombi format tombi format
+11
View File
@@ -1,3 +1,4 @@
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path; use std::path::Path;
use macroquad::prelude::*; use macroquad::prelude::*;
@@ -247,6 +248,8 @@ impl App {
} }
pub fn frame(&mut self) { pub fn frame(&mut self) {
#[cfg(target_arch = "wasm32")]
self.assets.update();
self.handle_global_input(); self.handle_global_input();
match self.screen { match self.screen {
Screen::Loading => { Screen::Loading => {
@@ -266,11 +269,13 @@ impl App {
self.present(); self.present();
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_game(&mut self, game: Game) { pub fn set_simulation_game(&mut self, game: Game) {
self.game = Some(game); self.game = Some(game);
self.screen = Screen::Playing; self.screen = Screen::Playing;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_attract(&mut self, steps: u64) { pub fn set_simulation_attract(&mut self, steps: u64) {
self.game = None; self.game = None;
self.screen = Screen::Attract; self.screen = Screen::Attract;
@@ -280,6 +285,7 @@ impl App {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_loading(&mut self, steps: u64) { pub fn set_simulation_loading(&mut self, steps: u64) {
self.game = None; self.game = None;
self.screen = Screen::Loading; self.screen = Screen::Loading;
@@ -288,12 +294,14 @@ impl App {
self.loading_until = get_time() + 60.0; self.loading_until = get_time() + 60.0;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_highscores(&mut self) { pub fn set_simulation_highscores(&mut self) {
self.game = None; self.game = None;
self.return_screen = Screen::Attract; self.return_screen = Screen::Attract;
self.screen = Screen::HighScores; self.screen = Screen::HighScores;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_name_entry(&mut self) { pub fn set_simulation_name_entry(&mut self) {
self.game = Some(Game::new(1)); self.game = Some(Game::new(1));
self.return_screen = Screen::Playing; self.return_screen = Screen::Playing;
@@ -302,10 +310,12 @@ impl App {
self.screen = Screen::NameEntry; self.screen = Screen::NameEntry;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn render_simulation(&self) { pub fn render_simulation(&self) {
self.draw_logical(); self.draw_logical();
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> { pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
let path = path let path = path
.to_str() .to_str()
@@ -451,6 +461,7 @@ impl App {
let now = get_time(); let now = get_time();
if now - self.last_help_click <= 0.40 { if now - self.last_help_click <= 0.40 {
self.save(); self.save();
#[cfg(not(target_arch = "wasm32"))]
macroquad::miniquad::window::request_quit(); macroquad::miniquad::window::request_quit();
} }
self.last_help_click = now; self.last_help_click = now;
+104 -48
View File
@@ -1,3 +1,7 @@
#[cfg(target_arch = "wasm32")]
use futures_util::future::join_all;
#[cfg(target_arch = "wasm32")]
use macroquad::experimental::coroutines::{Coroutine, start_coroutine};
use macroquad::{ use macroquad::{
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound}, audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
prelude::{FilterMode, Image, Texture2D}, prelude::{FilterMode, Image, Texture2D},
@@ -24,6 +28,8 @@ pub struct Assets {
pub ball: Texture2D, pub ball: Texture2D,
pub digits: Texture2D, pub digits: Texture2D,
sounds: Vec<(u16, Sound)>, sounds: Vec<(u16, Sound)>,
#[cfg(target_arch = "wasm32")]
sound_loader: Option<Coroutine<Vec<(u16, Sound)>>>,
} }
impl Assets { impl Assets {
@@ -85,7 +91,82 @@ impl Assets {
let digits = let digits =
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png")); monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
let sound_bytes: [(u16, &[u8]); 16] = [ let sound_bytes = sound_bytes();
#[cfg(target_arch = "wasm32")]
let (sounds, sound_loader) = (
Vec::new(),
Some(start_coroutine(
async move { load_sounds(&sound_bytes).await },
)),
);
#[cfg(not(target_arch = "wasm32"))]
let sounds = load_sounds(&sound_bytes).await;
#[cfg(target_arch = "wasm32")]
macroquad::window::next_frame().await;
Self {
active_table,
inactive_table,
loading,
loading_progress,
highscore_background,
help,
player_effects,
media,
diamond,
wheel,
panel_pair_narrow_a,
panel_pair_narrow_b,
panel_pair_wide_a,
panel_pair_wide_b,
panel_target,
robot,
plunger,
ball,
digits,
sounds,
#[cfg(target_arch = "wasm32")]
sound_loader,
}
}
#[cfg(target_arch = "wasm32")]
pub fn update(&mut self) {
let Some(loader) = self.sound_loader.as_ref() else {
return;
};
if !loader.is_done() {
return;
}
let loader = self.sound_loader.take().expect("sound loader must exist");
self.sounds = loader.retrieve().unwrap_or_default();
}
pub fn play(&self, id: u16, enabled: bool) {
if !enabled {
return;
}
if let Some((_, sound)) = self.sounds.iter().find(|(sound_id, _)| *sound_id == id) {
self.stop_all_sounds();
play_sound(
sound,
PlaySoundParams {
looped: false,
volume: 1.0,
},
);
}
}
pub fn stop_all_sounds(&self) {
for (_, sound) in &self.sounds {
stop_sound(sound);
}
}
}
fn sound_bytes() -> [(u16, &'static [u8]); 16] {
[
( (
2001, 2001,
include_bytes!("../assets/original/audio/wav_02001.wav"), include_bytes!("../assets/original/audio/wav_02001.wav"),
@@ -150,58 +231,33 @@ impl Assets {
2022, 2022,
include_bytes!("../assets/original/audio/wav_02022.wav"), include_bytes!("../assets/original/audio/wav_02022.wav"),
), ),
]; ]
}
async fn load_sounds(sound_bytes: &[(u16, &[u8])]) -> Vec<(u16, Sound)> {
#[cfg(target_arch = "wasm32")]
{
join_all(sound_bytes.iter().map(|(id, bytes)| async move {
load_sound_from_bytes(bytes)
.await
.ok()
.map(|sound| (*id, sound))
}))
.await
.into_iter()
.flatten()
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut sounds = Vec::with_capacity(sound_bytes.len()); let mut sounds = Vec::with_capacity(sound_bytes.len());
for (id, bytes) in sound_bytes { for &(id, bytes) in sound_bytes {
if let Ok(sound) = load_sound_from_bytes(bytes).await { if let Ok(sound) = load_sound_from_bytes(bytes).await {
sounds.push((id, sound)); sounds.push((id, sound));
} }
} }
sounds
Self {
active_table,
inactive_table,
loading,
loading_progress,
highscore_background,
help,
player_effects,
media,
diamond,
wheel,
panel_pair_narrow_a,
panel_pair_narrow_b,
panel_pair_wide_a,
panel_pair_wide_b,
panel_target,
robot,
plunger,
ball,
digits,
sounds,
}
}
pub fn play(&self, id: u16, enabled: bool) {
if !enabled {
return;
}
if let Some((_, sound)) = self.sounds.iter().find(|(sound_id, _)| *sound_id == id) {
self.stop_all_sounds();
play_sound(
sound,
PlaySoundParams {
looped: false,
volume: 1.0,
},
);
}
}
pub fn stop_all_sounds(&self) {
for (_, sound) in &self.sounds {
stop_sound(sound);
}
} }
} }
+5
View File
@@ -382,6 +382,7 @@ pub struct Game {
} }
impl Game { impl Game {
#[cfg(not(target_arch = "wasm32"))]
pub fn new(player_count: usize) -> Self { pub fn new(player_count: usize) -> Self {
Self::new_with_seed(player_count, macroquad::rand::rand()) Self::new_with_seed(player_count, macroquad::rand::rand())
} }
@@ -460,6 +461,7 @@ impl Game {
true true
} }
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> { pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
self.ball.in_launcher = false; self.ball.in_launcher = false;
self.ball.position = CLAW_TRIGGER_CENTER; self.ball.position = CLAW_TRIGGER_CENTER;
@@ -469,6 +471,7 @@ impl Game {
events events
} }
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) { pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
self.target_effect = effect.min(7); self.target_effect = effect.min(7);
self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0; self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0;
@@ -478,6 +481,7 @@ impl Game {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_target_scenario(&mut self) { pub(crate) fn begin_target_scenario(&mut self) {
self.ball.in_launcher = false; self.ball.in_launcher = false;
self.ball.position = vec2(170.0, 230.0); self.ball.position = vec2(170.0, 230.0);
@@ -2024,6 +2028,7 @@ impl Game {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) { fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame)); debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
if self.claw.active { if self.claw.active {
+6
View File
@@ -7,11 +7,13 @@ mod geometry;
mod original_physics; mod original_physics;
mod persistence; mod persistence;
mod real48; mod real48;
#[cfg(not(target_arch = "wasm32"))]
mod simulation; mod simulation;
mod table; mod table;
use app::App; use app::App;
use macroquad::prelude::*; use macroquad::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use simulation::{Request, Scenario, Simulation, usage}; use simulation::{Request, Scenario, Simulation, usage};
const WINDOW_WIDTH: i32 = 640; const WINDOW_WIDTH: i32 = 640;
@@ -30,6 +32,8 @@ fn window_conf() -> Conf {
#[macroquad::main(window_conf)] #[macroquad::main(window_conf)]
async fn main() { async fn main() {
#[cfg(not(target_arch = "wasm32"))]
{
let request = match Request::parse(std::env::args()) { let request = match Request::parse(std::env::args()) {
Ok(request) => request, Ok(request) => request,
Err(error) => { Err(error) => {
@@ -43,6 +47,7 @@ async fn main() {
} }
return; return;
} }
}
let mut app = App::load().await; let mut app = App::load().await;
loop { loop {
@@ -51,6 +56,7 @@ async fn main() {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
async fn run_simulation(request: Request) -> Result<(), String> { async fn run_simulation(request: Request) -> Result<(), String> {
let mut simulation = Simulation::new(request.scenario, request.seed); let mut simulation = Simulation::new(request.scenario, request.seed);
simulation.advance_to(request.target_step); simulation.advance_to(request.target_step);
+43 -6
View File
@@ -1,7 +1,12 @@
use std::{fs, io, path::PathBuf}; use std::io;
#[cfg(not(target_arch = "wasm32"))]
use std::{fs, path::PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[cfg(target_arch = "wasm32")]
use tdkpin_web_storage::{queue_save, take_loaded};
const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT"); const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT");
const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI"); const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI");
@@ -75,10 +80,15 @@ impl Default for SavedData {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
pub struct Persistence { pub struct Persistence {
path: PathBuf, path: PathBuf,
} }
#[cfg(target_arch = "wasm32")]
pub struct Persistence;
#[cfg(not(target_arch = "wasm32"))]
impl Persistence { impl Persistence {
pub fn new() -> Self { pub fn new() -> Self {
let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else( let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else(
@@ -92,11 +102,7 @@ impl Persistence {
fs::read_to_string(&self.path) fs::read_to_string(&self.path)
.ok() .ok()
.and_then(|text| serde_json::from_str(&text).ok()) .and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| SavedData { .unwrap_or_else(native_default_saved_data)
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
})
} }
pub fn save(&self, data: &SavedData) -> io::Result<()> { pub fn save(&self, data: &SavedData) -> io::Result<()> {
@@ -118,6 +124,37 @@ impl Persistence {
} }
} }
#[cfg(target_arch = "wasm32")]
impl Persistence {
pub fn new() -> Self {
Self
}
#[allow(clippy::unused_self)]
pub fn load(&self) -> SavedData {
take_loaded()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
#[allow(clippy::unused_self)]
pub fn save(&self, data: &SavedData) -> io::Result<()> {
let encoded = serde_json::to_vec(data).map_err(io::Error::other)?;
queue_save(encoded);
Ok(())
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_default_saved_data() -> SavedData {
SavedData {
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn load_adjacent_original_settings() -> Option<Settings> { fn load_adjacent_original_settings() -> Option<Settings> {
let executable = std::env::current_exe().ok()?; let executable = std::env::current_exe().ok()?;
let exact_name = executable.with_extension("INI"); let exact_name = executable.with_extension("INI");
+15
View File
@@ -0,0 +1,15 @@
# TDK Pinball Machine web build
Build and serve the browser version from this directory with:
```sh
just web-serve
```
The game is compiled for `wasm32-unknown-unknown` and loaded into the centered
black canvas by `index.html`. Browser settings and high scores are saved in
`localStorage`; native builds continue to use their normal per-user save file.
The page uses Macroquad's official browser loader from the miniquad samples
site. The web page must be served over HTTP rather than opened directly from a
`file:` URL.
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta
name="description"
content="Play the reconstructed 1995 TDK Pinball Machine in your browser."
/>
<title>TDK Pinball Machine 1.00</title>
<style>
:root {
color-scheme: dark;
background: #000;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #000;
}
body {
display: grid;
place-items: center;
}
#glcanvas {
display: block;
width: 100vw;
height: 100vh;
background: #000;
outline: none;
image-rendering: pixelated;
}
</style>
</head>
<body>
<canvas id="glcanvas" tabindex="1" aria-label="TDK Pinball Machine"></canvas>
<noscript>This game needs JavaScript enabled.</noscript>
<script src="https://not-fl3.github.io/miniquad-samples/mq_js_bundle.js"></script>
<script src="./storage.js"></script>
<script>
load("tdkpin-rs.wasm");
</script>
</body>
</html>
+69
View File
@@ -0,0 +1,69 @@
"use strict";
(function registerStoragePlugin() {
const storageKey = "tdkpin.save.v1";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let lastRevision = 0;
function browserStorage() {
try {
return window.localStorage;
} catch (_error) {
return null;
}
}
function sendSavedDataToRust() {
wasm_exports.tdkpin_browser_storage_clear();
const storage = browserStorage();
let saved = null;
try {
saved = storage?.getItem(storageKey);
} catch (_error) {
saved = null;
}
if (saved !== null && saved !== undefined) {
for (const byte of encoder.encode(saved)) {
wasm_exports.tdkpin_browser_storage_push(byte);
}
}
wasm_exports.tdkpin_browser_storage_finish();
}
function flushRustSave() {
const revision = wasm_exports.tdkpin_browser_storage_save_revision();
if (revision === lastRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_storage_save_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_storage_save_byte(index);
}
const storage = browserStorage();
try {
storage?.setItem(storageKey, decoder.decode(bytes));
} catch (_error) {
// Private browsing or a full quota should not stop the game loop.
}
wasm_exports.tdkpin_browser_storage_save_ack();
lastRevision = revision;
}
function onInit() {
sendSavedDataToRust();
lastRevision = wasm_exports.tdkpin_browser_storage_save_revision();
window.setInterval(flushRustSave, 50);
window.addEventListener("beforeunload", flushRustSave);
}
miniquad_add_plugin({
name: "tdkpin_storage",
on_init: onInit,
version: 1,
});
})();
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "tdkpin-web-storage"
version = "1.0.0"
edition = "2024"
publish = false
[lib]
path = "src/lib.rs"
+73
View File
@@ -0,0 +1,73 @@
#![allow(unsafe_code)]
use std::{
cell::{Cell, RefCell},
mem,
};
thread_local! {
static LOADED_BYTES: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static PENDING_SAVE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
static SAVE_REVISION: Cell<u32> = const { Cell::new(0) };
}
pub fn take_loaded() -> Option<Vec<u8>> {
let bytes = LOADED_BYTES.with(|loaded| mem::take(&mut *loaded.borrow_mut()));
(!bytes.is_empty()).then_some(bytes)
}
pub fn queue_save(bytes: Vec<u8>) {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = Some(bytes));
SAVE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_storage_crate_version() -> u32 {
1
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_clear() {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().clear());
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_push(byte: u32) {
if let Ok(byte) = u8::try_from(byte) {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().push(byte));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_finish() {}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_revision() -> u32 {
SAVE_REVISION.with(Cell::get)
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_length() -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_byte(index: u32) -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.and_then(|bytes| bytes.get(usize::try_from(index).ok()?))
.map_or(0, |byte| u32::from(*byte))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_ack() {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = None);
}