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:
parent
755168a969
commit
23865f1483
@ -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
|
# 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.
|
# 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.
|
# The mrpgi_* exports are the page's save/load bridge into the engine.
|
||||||
|
|||||||
@ -187,10 +187,11 @@ mod web_bridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Browser boot: the game folder is compiled into the binary (there is no
|
/// 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")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
fn embedded_world() -> World {
|
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
|
let manifest = GAME
|
||||||
.get_file("game.json")
|
.get_file("game.json")
|
||||||
.and_then(|f| serde_json::from_slice(f.contents()).ok())
|
.and_then(|f| serde_json::from_slice(f.contents()).ok())
|
||||||
@ -266,9 +267,10 @@ async fn main() {
|
|||||||
let mut crt = false;
|
let mut crt = false;
|
||||||
let mut command = String::new();
|
let mut command = String::new();
|
||||||
let mut menu_open: Option<usize> = None;
|
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 {
|
loop {
|
||||||
|
audio.tick();
|
||||||
let mut want_edit = false;
|
let mut want_edit = false;
|
||||||
|
|
||||||
// --- web save/load bridge: one poll per frame ---------------------
|
// --- web save/load bridge: one poll per frame ---------------------
|
||||||
@ -296,7 +298,7 @@ async fn main() {
|
|||||||
ed.stop_editing();
|
ed.stop_editing();
|
||||||
} else if mode == Mode::Play && gs.dlg.is_some() {
|
} else if mode == Mode::Play && gs.dlg.is_some() {
|
||||||
for e in gs.apply(Command::EndDialogue) {
|
for e in gs.apply(Command::EndDialogue) {
|
||||||
handle(&e, &audio);
|
handle(&e, &mut audio);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
break;
|
break;
|
||||||
@ -310,7 +312,7 @@ async fn main() {
|
|||||||
let room = gs.world.current;
|
let room = gs.world.current;
|
||||||
command.clear();
|
command.clear();
|
||||||
for e in gs.apply(Command::NewGame { room: Some(room) }) {
|
for e in gs.apply(Command::NewGame { room: Some(room) }) {
|
||||||
handle(&e, &audio);
|
handle(&e, &mut audio);
|
||||||
}
|
}
|
||||||
Mode::Play
|
Mode::Play
|
||||||
}
|
}
|
||||||
@ -420,7 +422,7 @@ async fn main() {
|
|||||||
// --- game cycles, transitions, AI results --------------------
|
// --- game cycles, transitions, AI results --------------------
|
||||||
events.extend(gs.tick(get_frame_time()));
|
events.extend(gs.tick(get_frame_time()));
|
||||||
for e in &events {
|
for e in &events {
|
||||||
handle(e, &audio);
|
handle(e, &mut audio);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- render ---------------------------------------------------
|
// --- render ---------------------------------------------------
|
||||||
@ -480,7 +482,7 @@ async fn main() {
|
|||||||
|
|
||||||
/// The GUI's whole event handler: play the sounds. (Transcript, inventory and
|
/// The GUI's whole event handler: play the sounds. (Transcript, inventory and
|
||||||
/// win state are drawn straight from GameState each frame.)
|
/// 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 {
|
if let Event::Audio { cue } = e {
|
||||||
audio.play(*cue);
|
audio.play(*cue);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,70 @@
|
|||||||
//! If the audio backend can't start, everything degrades to silence.
|
//! 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::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 mrpgi_core::audio::{music_wav, AudioCue, MOODS};
|
||||||
use std::path::Path;
|
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 {
|
pub struct AudioOut {
|
||||||
sfx: Vec<(AudioCue, Option<Sound>)>,
|
sfx: Vec<(AudioCue, Option<Sound>)>,
|
||||||
music: Vec<Option<Sound>>, // by mood index; [0] is silence
|
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.
|
/// 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"] {
|
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);
|
return Some(b);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -36,14 +101,19 @@ impl AudioOut {
|
|||||||
let base = Path::new(game_dir);
|
let base = Path::new(game_dir);
|
||||||
let mut sfx = Vec::new();
|
let mut sfx = Vec::new();
|
||||||
for cue in AudioCue::ALL {
|
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));
|
sfx.push((cue, snd(&bytes).await));
|
||||||
}
|
}
|
||||||
let mut music = vec![None]; // mood 0 = silence
|
let mut music = vec![None]; // mood 0 = silence
|
||||||
for mood in 1u8..=5 {
|
for mood in 1u8..=5 {
|
||||||
let bytes = file_bytes(&base.join("music"), MOODS[mood as usize])
|
let mut bytes = file_bytes(&base.join("music"), MOODS[mood as usize]).await;
|
||||||
.or_else(|| file_bytes(&base.join("music"), &mood.to_string()))
|
if bytes.is_none() {
|
||||||
.or_else(|| music_wav(mood));
|
bytes = file_bytes(&base.join("music"), &mood.to_string()).await;
|
||||||
|
}
|
||||||
|
let bytes = bytes.or_else(|| music_wav(mood));
|
||||||
music.push(match bytes {
|
music.push(match bytes {
|
||||||
Some(b) => snd(&b).await,
|
Some(b) => snd(&b).await,
|
||||||
None => None,
|
None => None,
|
||||||
|
|||||||
34
web/build.sh
34
web/build.sh
@ -2,7 +2,11 @@
|
|||||||
# Build the browser version of MRPGI into web/dist/ (self-contained, static).
|
# Build the browser version of MRPGI into web/dist/ (self-contained, static).
|
||||||
# Requires the wasm target: rustup target add wasm32-unknown-unknown
|
# 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/
|
# scp web/dist/* root@100.94.195.115:/opt/dealgod/images/engine/
|
||||||
# Served by the dealgod-nginx container via a `location /engine/` alias in
|
# Served by the dealgod-nginx container via a `location /engine/` alias in
|
||||||
# /opt/dealgod/nginx/dealgod-servers.conf. NOTE: edit that file IN PLACE
|
# /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`.
|
# and an inode swap desyncs the container until a `docker restart`.
|
||||||
set -e
|
set -e
|
||||||
cd "$(dirname "$0")/.."
|
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
|
cargo build -p mrpgi --release --target wasm32-unknown-unknown
|
||||||
|
rm -rf web/dist
|
||||||
mkdir -p web/dist
|
mkdir -p web/dist
|
||||||
cp target/wasm32-unknown-unknown/release/mrpgi.wasm 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/
|
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/"
|
echo "web build ready: $(du -sh web/dist | cut -f1) in web/dist/"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user