Web: bake any game into the wasm; never block first paint on audio

- MRPGI_WEB_GAME env (default games/lost-fuse via .cargo/config.toml)
  selects the game include_dir! embeds; web/build.sh <game-dir> stages
  just game.json+rooms+sprites (a repo's raw art was ballooning the wasm
  to 41MB; now 1.9MB) and ships the game's web/index.html and music/.
- sound.rs: file overrides load through macroquad's loader (fs on native,
  HTTP fetch on web), and AudioOut now loads inside a coroutine behind
  LazyAudio — browsers gate audio decoding behind a user gesture, and
  blocking boot on it was a black screen. First frame draws immediately;
  music starts when ready, remembering the requested mood/mute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-05 15:53:22 +10:00
parent 755168a969
commit 23865f1483
4 changed files with 123 additions and 14 deletions

View File

@ -1,3 +1,8 @@
# Which game folder gets baked into wasm builds (embedded_world in main.rs).
# Override per build: MRPGI_WEB_GAME=/abs/path ./web/build.sh <dir> does it.
[env]
MRPGI_WEB_GAME = { value = "games/lost-fuse", relative = true }
# Web builds: miniquad's JS imports (console_log, init_webgl, ...) are
# resolved at runtime by web/mq_js_bundle.js — tell the linker to allow them.
# The mrpgi_* exports are the page's save/load bridge into the engine.

View File

@ -187,10 +187,11 @@ mod web_bridge {
}
/// Browser boot: the game folder is compiled into the binary (there is no
/// filesystem on the web) and handed to the core fully parsed.
/// filesystem on the web) and handed to the core fully parsed. Which game is
/// the MRPGI_WEB_GAME env var at compile time (default set in .cargo/config.toml).
#[cfg(target_arch = "wasm32")]
fn embedded_world() -> World {
static GAME: include_dir::Dir = include_dir::include_dir!("$CARGO_MANIFEST_DIR/../games/lost-fuse");
static GAME: include_dir::Dir = include_dir::include_dir!("$MRPGI_WEB_GAME");
let manifest = GAME
.get_file("game.json")
.and_then(|f| serde_json::from_slice(f.contents()).ok())
@ -266,9 +267,10 @@ async fn main() {
let mut crt = false;
let mut command = String::new();
let mut menu_open: Option<usize> = None;
let mut audio = sound::AudioOut::load(&game_dir).await;
let mut audio = sound::LazyAudio::start(&game_dir);
loop {
audio.tick();
let mut want_edit = false;
// --- web save/load bridge: one poll per frame ---------------------
@ -296,7 +298,7 @@ async fn main() {
ed.stop_editing();
} else if mode == Mode::Play && gs.dlg.is_some() {
for e in gs.apply(Command::EndDialogue) {
handle(&e, &audio);
handle(&e, &mut audio);
}
} else {
break;
@ -310,7 +312,7 @@ async fn main() {
let room = gs.world.current;
command.clear();
for e in gs.apply(Command::NewGame { room: Some(room) }) {
handle(&e, &audio);
handle(&e, &mut audio);
}
Mode::Play
}
@ -420,7 +422,7 @@ async fn main() {
// --- game cycles, transitions, AI results --------------------
events.extend(gs.tick(get_frame_time()));
for e in &events {
handle(e, &audio);
handle(e, &mut audio);
}
// --- render ---------------------------------------------------
@ -480,7 +482,7 @@ async fn main() {
/// The GUI's whole event handler: play the sounds. (Transcript, inventory and
/// win state are drawn straight from GameState each frame.)
fn handle(e: &Event, audio: &sound::AudioOut) {
fn handle(e: &Event, audio: &mut sound::LazyAudio) {
if let Event::Audio { cue } = e {
audio.play(*cue);
}

View File

@ -7,9 +7,70 @@
//! If the audio backend can't start, everything degrades to silence.
use macroquad::audio::{load_sound_from_bytes, play_sound, stop_sound, PlaySoundParams, Sound};
use macroquad::experimental::coroutines::{start_coroutine, Coroutine};
use mrpgi_core::audio::{music_wav, AudioCue, MOODS};
use std::path::Path;
/// The loop-facing audio front. Loading (file fetches + decoding) runs in a
/// coroutine so the first frame never waits on it — browsers gate audio
/// decoding behind a user gesture, and blocking boot on that meant a black
/// screen. Until the real [`AudioOut`] arrives this remembers what the game
/// asked for (mood, mute) and applies it on arrival.
pub struct LazyAudio {
inner: Option<AudioOut>,
loading: Coroutine<AudioOut>,
pub muted: bool,
mood: u8,
}
impl LazyAudio {
pub fn start(game_dir: &str) -> Self {
let dir = game_dir.to_string();
LazyAudio {
inner: None,
loading: start_coroutine(async move { AudioOut::load(&dir).await }),
muted: false,
mood: 0,
}
}
fn poll(&mut self) -> Option<&mut AudioOut> {
if self.inner.is_none() {
if let Some(mut a) = self.loading.retrieve() {
a.muted = self.muted;
a.set_music(self.mood);
self.inner = Some(a);
}
}
self.inner.as_mut()
}
/// Call once per frame: starts the music as soon as loading finishes.
pub fn tick(&mut self) {
self.poll();
}
pub fn play(&mut self, cue: AudioCue) {
if let Some(a) = self.poll() {
a.play(cue);
}
}
pub fn set_music(&mut self, mood: u8) {
self.mood = mood;
if let Some(a) = self.poll() {
a.set_music(mood);
}
}
pub fn set_muted(&mut self, m: bool) {
self.muted = m;
if let Some(a) = self.poll() {
a.set_muted(m);
}
}
}
pub struct AudioOut {
sfx: Vec<(AudioCue, Option<Sound>)>,
music: Vec<Option<Sound>>, // by mood index; [0] is silence
@ -18,9 +79,13 @@ pub struct AudioOut {
}
/// Read an override file for a base name, trying each supported extension.
fn file_bytes(dir: &Path, stem: &str) -> Option<Vec<u8>> {
/// Goes through macroquad's loader so it works everywhere: filesystem on
/// native, an HTTP fetch relative to the page on the web (a 404 just means
/// "no override" and the built-in synth plays instead).
async fn file_bytes(dir: &Path, stem: &str) -> Option<Vec<u8>> {
for ext in ["wav", "ogg", "mp3"] {
if let Ok(b) = std::fs::read(dir.join(format!("{stem}.{ext}"))) {
let path = dir.join(format!("{stem}.{ext}"));
if let Ok(b) = macroquad::file::load_file(&path.to_string_lossy()).await {
return Some(b);
}
}
@ -36,14 +101,19 @@ impl AudioOut {
let base = Path::new(game_dir);
let mut sfx = Vec::new();
for cue in AudioCue::ALL {
let bytes = file_bytes(&base.join("sfx"), cue.name()).unwrap_or_else(|| cue.wav());
let bytes = match file_bytes(&base.join("sfx"), cue.name()).await {
Some(b) => b,
None => cue.wav(),
};
sfx.push((cue, snd(&bytes).await));
}
let mut music = vec![None]; // mood 0 = silence
for mood in 1u8..=5 {
let bytes = file_bytes(&base.join("music"), MOODS[mood as usize])
.or_else(|| file_bytes(&base.join("music"), &mood.to_string()))
.or_else(|| music_wav(mood));
let mut bytes = file_bytes(&base.join("music"), MOODS[mood as usize]).await;
if bytes.is_none() {
bytes = file_bytes(&base.join("music"), &mood.to_string()).await;
}
let bytes = bytes.or_else(|| music_wav(mood));
music.push(match bytes {
Some(b) => snd(&b).await,
None => None,

View File

@ -2,7 +2,11 @@
# Build the browser version of MRPGI into web/dist/ (self-contained, static).
# Requires the wasm target: rustup target add wasm32-unknown-unknown
#
# Deploy (live at https://monsterrobot.games/engine/):
# ./web/build.sh # the default game (games/lost-fuse)
# ./web/build.sh /path/to/game # bake any game folder into the wasm;
# # its music/ and web/index.html ride along
#
# Deploy (engine live at https://monsterrobot.games/engine/):
# scp web/dist/* root@100.94.195.115:/opt/dealgod/images/engine/
# Served by the dealgod-nginx container via a `location /engine/` alias in
# /opt/dealgod/nginx/dealgod-servers.conf. NOTE: edit that file IN PLACE
@ -10,8 +14,36 @@
# and an inode swap desyncs the container until a `docker restart`.
set -e
cd "$(dirname "$0")/.."
GAME="${1:-}"
if [ -n "$GAME" ]; then
SRC="$(cd "$GAME" && pwd)"
# Stage only what the engine reads — include_dir! embeds the whole tree,
# and a game repo also carries raw art, tools, .git, etc.
STAGE="$(mktemp -d)/game"
mkdir -p "$STAGE"
cp "$SRC/game.json" "$STAGE/" 2>/dev/null || true
cp -R "$SRC/rooms" "$SRC/sprites" "$STAGE/"
MRPGI_WEB_GAME="$STAGE"
export MRPGI_WEB_GAME
echo "baking game: $SRC (staged at $STAGE)"
fi
# include_dir! reads the env var at compile time; make sure the crate recompiles.
touch mrpgi/src/main.rs
cargo build -p mrpgi --release --target wasm32-unknown-unknown
rm -rf web/dist
mkdir -p web/dist
cp target/wasm32-unknown-unknown/release/mrpgi.wasm web/dist/
cp web/index.html web/manual.html web/mq_js_bundle.js web/dist/
if [ -n "$GAME" ]; then
# A game can ship its own page shell and music; both are optional.
[ -f "$SRC/web/index.html" ] && cp "$SRC/web/index.html" web/dist/
if [ -d "$SRC/music" ]; then
mkdir -p web/dist/music
cp "$SRC"/music/*.wav web/dist/music/ 2>/dev/null || true
cp "$SRC"/music/*.ogg "$SRC"/music/*.mp3 web/dist/music/ 2>/dev/null || true
fi
fi
echo "web build ready: $(du -sh web/dist | cut -f1) in web/dist/"