Files
tdkpin/tdkpin-rs/src/assets.rs
T
ddidderr b079cfa196 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
2026-08-29 14:32:41 +02:00

332 lines
12 KiB
Rust

#[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},
};
pub struct Assets {
pub active_table: Texture2D,
pub inactive_table: Texture2D,
pub loading: Texture2D,
pub loading_progress: Texture2D,
pub highscore_background: Texture2D,
pub help: [Texture2D; 5],
pub player_effects: [Texture2D; 8],
pub media: [Texture2D; 4],
pub diamond: [Texture2D; 9],
pub wheel: Texture2D,
pub panel_pair_narrow_a: Texture2D,
pub panel_pair_narrow_b: Texture2D,
pub panel_pair_wide_a: Texture2D,
pub panel_pair_wide_b: Texture2D,
pub panel_target: Texture2D,
pub robot: Texture2D,
pub plunger: Texture2D,
pub ball: Texture2D,
pub digits: Texture2D,
sounds: Vec<(u16, Sound)>,
#[cfg(target_arch = "wasm32")]
sound_loader: Option<Coroutine<Vec<(u16, Sound)>>>,
}
impl Assets {
#[allow(clippy::too_many_lines)]
pub async fn load() -> Self {
let active_table = texture(include_bytes!("../assets/original/images/dat_00997.png"));
let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png"));
let loading = texture(include_bytes!("../assets/original/images/dat_00995.png"));
let loading_progress = texture(include_bytes!("../assets/original/images/dat_00994.png"));
let highscore_background =
texture(include_bytes!("../assets/original/images/dat_00993.png"));
let help = [
texture(include_bytes!("../assets/original/images/dat_01001.png")),
texture(include_bytes!("../assets/original/images/dat_01002.png")),
texture(include_bytes!("../assets/original/images/dat_01003.png")),
texture(include_bytes!("../assets/original/images/dat_01004.png")),
texture(include_bytes!("../assets/original/images/dat_01005.png")),
];
let player_effects = [
texture(include_bytes!("../assets/original/images/dat_00400.png")),
texture(include_bytes!("../assets/original/images/dat_00401.png")),
texture(include_bytes!("../assets/original/images/dat_00402.png")),
texture(include_bytes!("../assets/original/images/dat_00403.png")),
texture(include_bytes!("../assets/original/images/dat_00404.png")),
texture(include_bytes!("../assets/original/images/dat_00405.png")),
texture(include_bytes!("../assets/original/images/dat_00406.png")),
texture(include_bytes!("../assets/original/images/dat_00407.png")),
];
let media = [
texture(include_bytes!("../assets/original/images/dat_00701.png")),
texture(include_bytes!("../assets/original/images/dat_00702.png")),
texture(include_bytes!("../assets/original/images/dat_00703.png")),
texture(include_bytes!("../assets/original/images/dat_00704.png")),
];
let diamond = [
texture(include_bytes!("../assets/original/images/dat_00801.png")),
texture(include_bytes!("../assets/original/images/dat_00802.png")),
texture(include_bytes!("../assets/original/images/dat_00803.png")),
texture(include_bytes!("../assets/original/images/dat_00804.png")),
texture(include_bytes!("../assets/original/images/dat_00805.png")),
texture(include_bytes!("../assets/original/images/dat_00806.png")),
texture(include_bytes!("../assets/original/images/dat_00807.png")),
texture(include_bytes!("../assets/original/images/dat_00808.png")),
texture(include_bytes!("../assets/original/images/dat_00809.png")),
];
let wheel_bytes = include_bytes!("../assets/original/images/dat_00600.png");
let wheel = texture(wheel_bytes);
let panel_pair_narrow_a = masked_atlas_pair(wheel_bytes, 0, 90, 45, 90, 45, 90);
let panel_pair_narrow_b = masked_atlas_pair(wheel_bytes, 90, 90, 135, 90, 45, 90);
let panel_pair_wide_a = masked_atlas_pair(wheel_bytes, 271, 90, 362, 90, 91, 90);
let panel_pair_wide_b = masked_atlas_pair(wheel_bytes, 453, 90, 362, 90, 91, 90);
let panel_target = masked_atlas_pair(wheel_bytes, 455, 0, 455, 17, 17, 17);
let robot = texture(include_bytes!("../assets/original/images/dat_00900.png"));
let plunger = texture(include_bytes!("../assets/original/images/dat_00901.png"));
let ball = masked_texture(
include_bytes!("../assets/original/images/bitmap_00101.png"),
include_bytes!("../assets/original/images/bitmap_00102.png"),
);
let digits =
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
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,
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);
texture
}
fn masked_texture(color_bytes: &[u8], mask_bytes: &[u8]) -> Texture2D {
let mut color =
Image::from_file_with_format(color_bytes, None).expect("embedded color bitmap must decode");
let mask =
Image::from_file_with_format(mask_bytes, None).expect("embedded mask bitmap must decode");
assert_eq!((color.width, color.height), (mask.width, mask.height));
let (color_pixels, color_remainder) = color.bytes.as_chunks_mut::<4>();
let (mask_pixels, mask_remainder) = mask.bytes.as_chunks::<4>();
assert!(color_remainder.is_empty() && mask_remainder.is_empty());
for (pixel, mask_pixel) in color_pixels.iter_mut().zip(mask_pixels) {
pixel[3] = u8::MAX - mask_pixel[0];
}
let texture = Texture2D::from_image(&color);
texture.set_filter(FilterMode::Nearest);
texture
}
fn monochrome_texture(bytes: &[u8]) -> Texture2D {
let mut image =
Image::from_file_with_format(bytes, None).expect("embedded monochrome bitmap must decode");
let (pixels, remainder) = image.bytes.as_chunks_mut::<4>();
assert!(remainder.is_empty());
for pixel in pixels {
pixel[3] = u8::MAX - pixel[0];
pixel[..3].fill(u8::MAX);
}
let texture = Texture2D::from_image(&image);
texture.set_filter(FilterMode::Nearest);
texture
}
fn masked_atlas_pair(
bytes: &[u8],
image_x: usize,
image_y: usize,
mask_x: usize,
mask_y: usize,
width: usize,
height: usize,
) -> Texture2D {
let atlas = Image::from_file_with_format(bytes, None).expect("embedded DAT600 must decode");
let atlas_width = usize::from(atlas.width);
let mut output = Image {
bytes: vec![0; width * height * 4],
width: u16::try_from(width).unwrap_or(0),
height: u16::try_from(height).unwrap_or(0),
};
for y in 0..height {
for x in 0..width {
let output_index = (y * width + x) * 4;
let image_index = ((y + image_y) * atlas_width + image_x + x) * 4;
let mask_index = ((y + mask_y) * atlas_width + mask_x + x) * 4;
output.bytes[output_index..output_index + 3]
.copy_from_slice(&atlas.bytes[image_index..image_index + 3]);
output.bytes[output_index + 3] = u8::MAX - atlas.bytes[mask_index];
}
}
let texture = Texture2D::from_image(&output);
texture.set_filter(FilterMode::Nearest);
texture
}