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:
@@ -1,3 +1,4 @@
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::path::Path;
|
||||
|
||||
use macroquad::prelude::*;
|
||||
@@ -247,6 +248,8 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn frame(&mut self) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.assets.update();
|
||||
self.handle_global_input();
|
||||
match self.screen {
|
||||
Screen::Loading => {
|
||||
@@ -266,11 +269,13 @@ impl App {
|
||||
self.present();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_simulation_game(&mut self, game: Game) {
|
||||
self.game = Some(game);
|
||||
self.screen = Screen::Playing;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_simulation_attract(&mut self, steps: u64) {
|
||||
self.game = None;
|
||||
self.screen = Screen::Attract;
|
||||
@@ -280,6 +285,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_simulation_loading(&mut self, steps: u64) {
|
||||
self.game = None;
|
||||
self.screen = Screen::Loading;
|
||||
@@ -288,12 +294,14 @@ impl App {
|
||||
self.loading_until = get_time() + 60.0;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_simulation_highscores(&mut self) {
|
||||
self.game = None;
|
||||
self.return_screen = Screen::Attract;
|
||||
self.screen = Screen::HighScores;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn set_simulation_name_entry(&mut self) {
|
||||
self.game = Some(Game::new(1));
|
||||
self.return_screen = Screen::Playing;
|
||||
@@ -302,10 +310,12 @@ impl App {
|
||||
self.screen = Screen::NameEntry;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn render_simulation(&self) {
|
||||
self.draw_logical();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
|
||||
let path = path
|
||||
.to_str()
|
||||
@@ -451,6 +461,7 @@ impl App {
|
||||
let now = get_time();
|
||||
if now - self.last_help_click <= 0.40 {
|
||||
self.save();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
macroquad::miniquad::window::request_quit();
|
||||
}
|
||||
self.last_help_click = now;
|
||||
|
||||
+128
-72
@@ -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::{
|
||||
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
|
||||
prelude::{FilterMode, Image, Texture2D},
|
||||
@@ -24,6 +28,8 @@ pub struct Assets {
|
||||
pub ball: Texture2D,
|
||||
pub digits: Texture2D,
|
||||
sounds: Vec<(u16, Sound)>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
sound_loader: Option<Coroutine<Vec<(u16, Sound)>>>,
|
||||
}
|
||||
|
||||
impl Assets {
|
||||
@@ -85,78 +91,18 @@ impl Assets {
|
||||
let digits =
|
||||
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
|
||||
|
||||
let sound_bytes: [(u16, &[u8]); 16] = [
|
||||
(
|
||||
2001,
|
||||
include_bytes!("../assets/original/audio/wav_02001.wav"),
|
||||
),
|
||||
(
|
||||
2002,
|
||||
include_bytes!("../assets/original/audio/wav_02002.wav"),
|
||||
),
|
||||
(
|
||||
2004,
|
||||
include_bytes!("../assets/original/audio/wav_02004.wav"),
|
||||
),
|
||||
(
|
||||
2006,
|
||||
include_bytes!("../assets/original/audio/wav_02006.wav"),
|
||||
),
|
||||
(
|
||||
2007,
|
||||
include_bytes!("../assets/original/audio/wav_02007.wav"),
|
||||
),
|
||||
(
|
||||
2008,
|
||||
include_bytes!("../assets/original/audio/wav_02008.wav"),
|
||||
),
|
||||
(
|
||||
2011,
|
||||
include_bytes!("../assets/original/audio/wav_02011.wav"),
|
||||
),
|
||||
(
|
||||
2012,
|
||||
include_bytes!("../assets/original/audio/wav_02012.wav"),
|
||||
),
|
||||
(
|
||||
2013,
|
||||
include_bytes!("../assets/original/audio/wav_02013.wav"),
|
||||
),
|
||||
(
|
||||
2015,
|
||||
include_bytes!("../assets/original/audio/wav_02015.wav"),
|
||||
),
|
||||
(
|
||||
2016,
|
||||
include_bytes!("../assets/original/audio/wav_02016.wav"),
|
||||
),
|
||||
(
|
||||
2017,
|
||||
include_bytes!("../assets/original/audio/wav_02017.wav"),
|
||||
),
|
||||
(
|
||||
2019,
|
||||
include_bytes!("../assets/original/audio/wav_02019.wav"),
|
||||
),
|
||||
(
|
||||
2020,
|
||||
include_bytes!("../assets/original/audio/wav_02020.wav"),
|
||||
),
|
||||
(
|
||||
2021,
|
||||
include_bytes!("../assets/original/audio/wav_02021.wav"),
|
||||
),
|
||||
(
|
||||
2022,
|
||||
include_bytes!("../assets/original/audio/wav_02022.wav"),
|
||||
),
|
||||
];
|
||||
let mut sounds = Vec::with_capacity(sound_bytes.len());
|
||||
for (id, bytes) in sound_bytes {
|
||||
if let Ok(sound) = load_sound_from_bytes(bytes).await {
|
||||
sounds.push((id, sound));
|
||||
}
|
||||
}
|
||||
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,
|
||||
@@ -179,9 +125,23 @@ impl Assets {
|
||||
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;
|
||||
@@ -205,6 +165,102 @@ impl Assets {
|
||||
}
|
||||
}
|
||||
|
||||
fn sound_bytes() -> [(u16, &'static [u8]); 16] {
|
||||
[
|
||||
(
|
||||
2001,
|
||||
include_bytes!("../assets/original/audio/wav_02001.wav"),
|
||||
),
|
||||
(
|
||||
2002,
|
||||
include_bytes!("../assets/original/audio/wav_02002.wav"),
|
||||
),
|
||||
(
|
||||
2004,
|
||||
include_bytes!("../assets/original/audio/wav_02004.wav"),
|
||||
),
|
||||
(
|
||||
2006,
|
||||
include_bytes!("../assets/original/audio/wav_02006.wav"),
|
||||
),
|
||||
(
|
||||
2007,
|
||||
include_bytes!("../assets/original/audio/wav_02007.wav"),
|
||||
),
|
||||
(
|
||||
2008,
|
||||
include_bytes!("../assets/original/audio/wav_02008.wav"),
|
||||
),
|
||||
(
|
||||
2011,
|
||||
include_bytes!("../assets/original/audio/wav_02011.wav"),
|
||||
),
|
||||
(
|
||||
2012,
|
||||
include_bytes!("../assets/original/audio/wav_02012.wav"),
|
||||
),
|
||||
(
|
||||
2013,
|
||||
include_bytes!("../assets/original/audio/wav_02013.wav"),
|
||||
),
|
||||
(
|
||||
2015,
|
||||
include_bytes!("../assets/original/audio/wav_02015.wav"),
|
||||
),
|
||||
(
|
||||
2016,
|
||||
include_bytes!("../assets/original/audio/wav_02016.wav"),
|
||||
),
|
||||
(
|
||||
2017,
|
||||
include_bytes!("../assets/original/audio/wav_02017.wav"),
|
||||
),
|
||||
(
|
||||
2019,
|
||||
include_bytes!("../assets/original/audio/wav_02019.wav"),
|
||||
),
|
||||
(
|
||||
2020,
|
||||
include_bytes!("../assets/original/audio/wav_02020.wav"),
|
||||
),
|
||||
(
|
||||
2021,
|
||||
include_bytes!("../assets/original/audio/wav_02021.wav"),
|
||||
),
|
||||
(
|
||||
2022,
|
||||
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());
|
||||
for &(id, bytes) in sound_bytes {
|
||||
if let Ok(sound) = load_sound_from_bytes(bytes).await {
|
||||
sounds.push((id, sound));
|
||||
}
|
||||
}
|
||||
sounds
|
||||
}
|
||||
}
|
||||
|
||||
fn texture(bytes: &[u8]) -> Texture2D {
|
||||
let texture = Texture2D::from_file_with_format(bytes, None);
|
||||
texture.set_filter(FilterMode::Nearest);
|
||||
|
||||
@@ -382,6 +382,7 @@ pub struct Game {
|
||||
}
|
||||
|
||||
impl Game {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn new(player_count: usize) -> Self {
|
||||
Self::new_with_seed(player_count, macroquad::rand::rand())
|
||||
}
|
||||
@@ -460,6 +461,7 @@ impl Game {
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
|
||||
self.ball.in_launcher = false;
|
||||
self.ball.position = CLAW_TRIGGER_CENTER;
|
||||
@@ -469,6 +471,7 @@ impl Game {
|
||||
events
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
|
||||
self.target_effect = effect.min(7);
|
||||
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) {
|
||||
self.ball.in_launcher = false;
|
||||
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>) {
|
||||
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
|
||||
if self.claw.active {
|
||||
|
||||
+16
-10
@@ -7,11 +7,13 @@ mod geometry;
|
||||
mod original_physics;
|
||||
mod persistence;
|
||||
mod real48;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod simulation;
|
||||
mod table;
|
||||
|
||||
use app::App;
|
||||
use macroquad::prelude::*;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use simulation::{Request, Scenario, Simulation, usage};
|
||||
|
||||
const WINDOW_WIDTH: i32 = 640;
|
||||
@@ -30,18 +32,21 @@ fn window_conf() -> Conf {
|
||||
|
||||
#[macroquad::main(window_conf)]
|
||||
async fn main() {
|
||||
let request = match Request::parse(std::env::args()) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let request = match Request::parse(std::env::args()) {
|
||||
Ok(request) => request,
|
||||
Err(error) => {
|
||||
eprintln!("{error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(request) = request {
|
||||
if let Err(error) = run_simulation(request).await {
|
||||
eprintln!("{error}\n\n{}", usage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(request) = request {
|
||||
if let Err(error) = run_simulation(request).await {
|
||||
eprintln!("{error}\n\n{}", usage());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut app = App::load().await;
|
||||
@@ -51,6 +56,7 @@ async fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
async fn run_simulation(request: Request) -> Result<(), String> {
|
||||
let mut simulation = Simulation::new(request.scenario, request.seed);
|
||||
simulation.advance_to(request.target_step);
|
||||
|
||||
@@ -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 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_INI: &str = include_str!("../../original/TDKPIN.INI");
|
||||
@@ -75,10 +80,15 @@ impl Default for SavedData {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub struct Persistence {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub struct Persistence;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl Persistence {
|
||||
pub fn new() -> Self {
|
||||
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)
|
||||
.ok()
|
||||
.and_then(|text| serde_json::from_str(&text).ok())
|
||||
.unwrap_or_else(|| SavedData {
|
||||
settings: load_adjacent_original_settings()
|
||||
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
|
||||
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
|
||||
})
|
||||
.unwrap_or_else(native_default_saved_data)
|
||||
}
|
||||
|
||||
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> {
|
||||
let executable = std::env::current_exe().ok()?;
|
||||
let exact_name = executable.with_extension("INI");
|
||||
|
||||
Reference in New Issue
Block a user