diff --git a/.cargo/config.toml b/.cargo/config.toml index c27d4c3..a3637d1 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -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 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. diff --git a/mrpgi/src/main.rs b/mrpgi/src/main.rs index cb0fdc9..752a18f 100644 --- a/mrpgi/src/main.rs +++ b/mrpgi/src/main.rs @@ -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 = 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); } diff --git a/mrpgi/src/sound.rs b/mrpgi/src/sound.rs index 2a940b3..944349f 100644 --- a/mrpgi/src/sound.rs +++ b/mrpgi/src/sound.rs @@ -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, + loading: Coroutine, + 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)>, music: Vec>, // 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> { +/// 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> { 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, diff --git a/web/build.sh b/web/build.sh index 1c3e3b7..4840c28 100755 --- a/web/build.sh +++ b/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/"