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
|
||||
# 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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
34
web/build.sh
34
web/build.sh
@ -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/"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user