diff --git a/pacman/src/audio.rs b/pacman/src/audio.rs index 202decc..7441459 100644 --- a/pacman/src/audio.rs +++ b/pacman/src/audio.rs @@ -58,6 +58,9 @@ pub struct Audio { enum AudioState { Enabled { volume: u8 }, Muted { previous_volume: u8 }, + /// Audio is suspended until user interaction unlocks it (browser autoplay policy). + /// On Emscripten, audio starts in this state and transitions to Enabled when unlock() is called. + Suspended { volume: u8 }, Disabled, } @@ -133,11 +136,19 @@ impl Audio { return Err(anyhow!("No sounds loaded successfully")); } + // On Emscripten, start suspended due to browser autoplay policy. + // Audio will be unlocked when the user interacts with the page. + #[cfg(target_os = "emscripten")] + let initial_state = AudioState::Suspended { volume: DEFAULT_VOLUME }; + + #[cfg(not(target_os = "emscripten"))] + let initial_state = AudioState::Enabled { volume: DEFAULT_VOLUME }; + Ok(Audio { _mixer_context: Some(mixer_context), sounds, next_waka_index: 0u8, - state: AudioState::Enabled { volume: DEFAULT_VOLUME }, + state: initial_state, }) } @@ -188,21 +199,21 @@ impl Audio { /// Halts all currently playing audio channels. pub fn stop_all(&mut self) { - if self.state != AudioState::Disabled { + if matches!(self.state, AudioState::Enabled { .. } | AudioState::Muted { .. }) { mixer::Channel::all().halt(); } } /// Pauses all currently playing audio channels. pub fn pause_all(&mut self) { - if self.state != AudioState::Disabled { + if matches!(self.state, AudioState::Enabled { .. } | AudioState::Muted { .. }) { mixer::Channel::all().pause(); } } /// Resumes all currently playing audio channels. pub fn resume_all(&mut self) { - if self.state != AudioState::Disabled { + if matches!(self.state, AudioState::Enabled { .. } | AudioState::Muted { .. }) { mixer::Channel::all().resume(); } } @@ -248,4 +259,28 @@ impl Audio { pub fn is_disabled(&self) -> bool { matches!(self.state, AudioState::Disabled) } + + /// Unlocks audio after user interaction (Emscripten only). + /// + /// Transitions from Suspended to Enabled state, allowing audio to play. + /// Called when the user clicks or presses a key to satisfy browser autoplay policy. + pub fn unlock(&mut self) { + if let AudioState::Suspended { volume } = self.state { + tracing::info!("Audio unlocked after user interaction"); + self.state = AudioState::Enabled { volume }; + } + } + + /// Returns whether audio is ready to play. + /// + /// Returns `true` if audio is in the Enabled state, `false` if suspended, + /// muted, or disabled. + pub fn is_ready(&self) -> bool { + matches!(self.state, AudioState::Enabled { .. }) + } + + /// Returns whether audio is suspended waiting for user interaction. + pub fn is_suspended(&self) -> bool { + matches!(self.state, AudioState::Suspended { .. }) + } } diff --git a/pacman/src/game.rs b/pacman/src/game.rs index cfcf0ca..c348924 100644 --- a/pacman/src/game.rs +++ b/pacman/src/game.rs @@ -443,6 +443,12 @@ impl Game { world.insert_resource(IntroPlayed::default()); world.insert_resource(CursorPosition::default()); world.insert_resource(TouchState::default()); + // On Emscripten, start in WaitingForInteraction state due to browser autoplay policy. + // The game will transition to Starting when the user clicks or presses a key. + #[cfg(target_os = "emscripten")] + world.insert_resource(GameStage::WaitingForInteraction); + + #[cfg(not(target_os = "emscripten"))] world.insert_resource(GameStage::Starting(StartupSequence::TextOnly { remaining_ticks: constants::startup::STARTUP_FRAMES, })); @@ -720,6 +726,30 @@ impl Game { Ok(GhostAnimations::new(animations, eyes, frightened, frightened_flashing)) } + /// Starts the game after user interaction (Emscripten only). + /// + /// Transitions from WaitingForInteraction to Starting state and unlocks audio. + /// Called from JavaScript when the user clicks or presses a key. + #[cfg(target_os = "emscripten")] + pub fn start(&mut self) { + use crate::systems::state::{GameStage, StartupSequence}; + + // Unlock audio now that user has interacted + if let Some(mut audio) = self.world.get_non_send_resource_mut::() { + audio.0.unlock(); + } + + // Transition to Starting state if we're waiting + if let Some(mut stage) = self.world.get_resource_mut::() { + if matches!(*stage, GameStage::WaitingForInteraction) { + tracing::info!("User interaction detected, starting game"); + *stage = GameStage::Starting(StartupSequence::TextOnly { + remaining_ticks: constants::startup::STARTUP_FRAMES, + }); + } + } + } + /// Executes one frame of game logic by running all scheduled ECS systems. /// /// Updates the world's delta time resource and runs the complete system pipeline: diff --git a/pacman/src/main.rs b/pacman/src/main.rs index 1026595..07da6b6 100644 --- a/pacman/src/main.rs +++ b/pacman/src/main.rs @@ -29,6 +29,31 @@ mod map; mod systems; mod texture; +// Emscripten-specific: static storage for the App instance +// Required because emscripten_set_main_loop_arg needs a persistent pointer +#[cfg(target_os = "emscripten")] +static mut APP: Option = None; + +/// Called from JavaScript when the user interacts with the page. +/// Transitions the game from WaitingForInteraction to Starting state. +#[cfg(target_os = "emscripten")] +#[no_mangle] +pub extern "C" fn start_game() { + unsafe { + if let Some(ref mut app) = APP { + app.game.start(); + } + } +} + +/// Emscripten main loop callback - runs once per frame +#[cfg(target_os = "emscripten")] +unsafe extern "C" fn main_loop_callback(_arg: *mut std::ffi::c_void) { + if let Some(ref mut app) = APP { + let _ = app.run(); + } +} + /// The main entry point of the application. /// /// This function initializes SDL, the window, the game state, and then enters @@ -41,13 +66,37 @@ pub fn main() { // On Emscripten, this connects the subscriber to the browser console platform::init_console(force_console).expect("Could not initialize console"); - let mut app = App::new().expect("Could not create app"); + let app = App::new().expect("Could not create app"); info!(loop_time = ?LOOP_TIME, "Starting game loop"); - loop { - if !app.run() { - break; + #[cfg(target_os = "emscripten")] + { + use std::ptr; + + // Store app in static for callback access + unsafe { + APP = Some(app); + } + + // Signal to JavaScript that the game is ready for interaction + platform::run_script("if (window.pacmanReady) window.pacmanReady()"); + + // Use emscripten_set_main_loop_arg for browser-friendly loop + // fps=0 means use requestAnimationFrame for optimal performance + // simulate_infinite_loop=1 means this call won't return + unsafe { + platform::emscripten_set_main_loop_arg(main_loop_callback, ptr::null_mut(), 0, 1); + } + } + + #[cfg(not(target_os = "emscripten"))] + { + let mut app = app; + loop { + if !app.run() { + break; + } } } } diff --git a/pacman/src/platform/emscripten.rs b/pacman/src/platform/emscripten.rs index 1e27605..decf3af 100644 --- a/pacman/src/platform/emscripten.rs +++ b/pacman/src/platform/emscripten.rs @@ -7,10 +7,41 @@ use std::ffi::CString; use std::io::{self, Write}; use std::time::Duration; +use std::ffi::c_void; +use std::os::raw::c_int; + +/// Callback function type for emscripten main loop +pub type EmMainLoopCallback = unsafe extern "C" fn(*mut c_void); + // Emscripten FFI functions extern "C" { fn emscripten_sleep(ms: u32); fn printf(format: *const u8, ...) -> i32; + + /// Set up a browser-friendly main loop with argument passing. + /// - `func`: callback to run each frame + /// - `arg`: user data pointer passed to callback + /// - `fps`: target FPS (0 = use requestAnimationFrame) + /// - `simulate_infinite_loop`: if 1, never returns (standard for games) + pub fn emscripten_set_main_loop_arg( + func: EmMainLoopCallback, + arg: *mut c_void, + fps: c_int, + simulate_infinite_loop: c_int, + ); + + /// Execute JavaScript code from Rust + fn emscripten_run_script(script: *const i8); +} + +/// Execute a JavaScript snippet from Rust. +/// Useful for signaling events to the frontend. +pub fn run_script(script: &str) { + if let Ok(cstr) = CString::new(script) { + unsafe { + emscripten_run_script(cstr.as_ptr()); + } + } } pub fn sleep(duration: Duration, _focused: bool) { diff --git a/pacman/src/systems/state.rs b/pacman/src/systems/state.rs index e2829a3..ac9eac5 100644 --- a/pacman/src/systems/state.rs +++ b/pacman/src/systems/state.rs @@ -41,6 +41,9 @@ pub struct IntroPlayed(pub bool); /// A resource to track the overall stage of the game from a high-level perspective. #[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)] pub enum GameStage { + /// Waiting for user interaction before starting (Emscripten only). + /// Game is rendered but audio/gameplay are paused until the user clicks or presses a key. + WaitingForInteraction, Starting(StartupSequence), /// The main gameplay loop is active. Playing, @@ -183,7 +186,7 @@ impl TooSimilar for GameStage { fn too_similar(&self, other: &Self) -> bool { discriminant(self) == discriminant(other) && { // These states are very simple, so they're 'too similar' automatically - if matches!(self, GameStage::Playing | GameStage::GameOver) { + if matches!(self, GameStage::Playing | GameStage::GameOver | GameStage::WaitingForInteraction) { return true; } @@ -207,7 +210,7 @@ impl TooSimilar for GameStage { }, ) => ghost_entity == other_ghost_entity && ghost_type == other_ghost_type && node == other_node, // Already handled, but kept to properly exhaust the match - (GameStage::Playing, _) | (GameStage::GameOver, _) => unreachable!(), + (GameStage::Playing, _) | (GameStage::GameOver, _) | (GameStage::WaitingForInteraction, _) => unreachable!(), _ => unreachable!(), } } @@ -312,6 +315,10 @@ pub fn stage_system( } let new_state: GameStage = new_state_opt.unwrap_or_else(|| match *game_state { + GameStage::WaitingForInteraction => { + // Stay in this state until JS calls start_game() + *game_state + } GameStage::Playing => { // This is the default state, do nothing *game_state diff --git a/web/bun.lock b/web/bun.lock index 69a1e4a..e7cedf3 100644 --- a/web/bun.lock +++ b/web/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "pacman-web", diff --git a/web/pages/index/+Page.tsx b/web/pages/index/+Page.tsx index c262fd9..c78d578 100644 --- a/web/pages/index/+Page.tsx +++ b/web/pages/index/+Page.tsx @@ -1,7 +1,15 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; export default function Page() { + const [gameReady, setGameReady] = useState(false); + const [gameStarted, setGameStarted] = useState(false); + useEffect(() => { + // Set up callback for when WASM signals it's ready + (window as any).pacmanReady = () => { + setGameReady(true); + }; + if (!(window as any).Module) { const canvas = document.getElementById("canvas"); @@ -20,20 +28,54 @@ export default function Page() { return () => { script.remove(); + delete (window as any).pacmanReady; }; } }, []); + const handleInteraction = useCallback(() => { + if (gameReady && !gameStarted) { + // Call the exported Rust function to start the game + const module = (window as any).Module; + if (module && module._start_game) { + module._start_game(); + setGameStarted(true); + } + } + }, [gameReady, gameStarted]); + + // Handle keyboard interaction + useEffect(() => { + if (!gameReady || gameStarted) return; + + const handleKeyDown = (e: KeyboardEvent) => { + handleInteraction(); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [gameReady, gameStarted, handleInteraction]); + return (
+ + {/* Click to Start overlay */} + {gameReady && !gameStarted && ( +
+ + Click to Start + +
+ )}
);