fix(web): implement browser autoplay policy compliance with click-to-start

Add WaitingForInteraction game stage and Suspended audio state for Emscripten builds.
Audio unlocks and game starts after user clicks or presses any key, satisfying browser
autoplay restrictions while maintaining immediate playback on desktop.
This commit is contained in:
2025-12-29 00:41:31 -06:00
parent 191fe49c64
commit fc349c45c5
7 changed files with 207 additions and 12 deletions
+39 -4
View File
@@ -58,6 +58,9 @@ pub struct Audio {
enum AudioState { enum AudioState {
Enabled { volume: u8 }, Enabled { volume: u8 },
Muted { previous_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, Disabled,
} }
@@ -133,11 +136,19 @@ impl Audio {
return Err(anyhow!("No sounds loaded successfully")); 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 { Ok(Audio {
_mixer_context: Some(mixer_context), _mixer_context: Some(mixer_context),
sounds, sounds,
next_waka_index: 0u8, 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. /// Halts all currently playing audio channels.
pub fn stop_all(&mut self) { pub fn stop_all(&mut self) {
if self.state != AudioState::Disabled { if matches!(self.state, AudioState::Enabled { .. } | AudioState::Muted { .. }) {
mixer::Channel::all().halt(); mixer::Channel::all().halt();
} }
} }
/// Pauses all currently playing audio channels. /// Pauses all currently playing audio channels.
pub fn pause_all(&mut self) { pub fn pause_all(&mut self) {
if self.state != AudioState::Disabled { if matches!(self.state, AudioState::Enabled { .. } | AudioState::Muted { .. }) {
mixer::Channel::all().pause(); mixer::Channel::all().pause();
} }
} }
/// Resumes all currently playing audio channels. /// Resumes all currently playing audio channels.
pub fn resume_all(&mut self) { pub fn resume_all(&mut self) {
if self.state != AudioState::Disabled { if matches!(self.state, AudioState::Enabled { .. } | AudioState::Muted { .. }) {
mixer::Channel::all().resume(); mixer::Channel::all().resume();
} }
} }
@@ -248,4 +259,28 @@ impl Audio {
pub fn is_disabled(&self) -> bool { pub fn is_disabled(&self) -> bool {
matches!(self.state, AudioState::Disabled) 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 { .. })
}
} }
+30
View File
@@ -443,6 +443,12 @@ impl Game {
world.insert_resource(IntroPlayed::default()); world.insert_resource(IntroPlayed::default());
world.insert_resource(CursorPosition::default()); world.insert_resource(CursorPosition::default());
world.insert_resource(TouchState::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 { world.insert_resource(GameStage::Starting(StartupSequence::TextOnly {
remaining_ticks: constants::startup::STARTUP_FRAMES, remaining_ticks: constants::startup::STARTUP_FRAMES,
})); }));
@@ -720,6 +726,30 @@ impl Game {
Ok(GhostAnimations::new(animations, eyes, frightened, frightened_flashing)) 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::<AudioResource>() {
audio.0.unlock();
}
// Transition to Starting state if we're waiting
if let Some(mut stage) = self.world.get_resource_mut::<GameStage>() {
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. /// 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: /// Updates the world's delta time resource and runs the complete system pipeline:
+53 -4
View File
@@ -29,6 +29,31 @@ mod map;
mod systems; mod systems;
mod texture; 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<App> = 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. /// The main entry point of the application.
/// ///
/// This function initializes SDL, the window, the game state, and then enters /// 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 // On Emscripten, this connects the subscriber to the browser console
platform::init_console(force_console).expect("Could not initialize 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"); info!(loop_time = ?LOOP_TIME, "Starting game loop");
loop { #[cfg(target_os = "emscripten")]
if !app.run() { {
break; 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;
}
} }
} }
} }
+31
View File
@@ -7,10 +7,41 @@ use std::ffi::CString;
use std::io::{self, Write}; use std::io::{self, Write};
use std::time::Duration; 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 // Emscripten FFI functions
extern "C" { extern "C" {
fn emscripten_sleep(ms: u32); fn emscripten_sleep(ms: u32);
fn printf(format: *const u8, ...) -> i32; 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) { pub fn sleep(duration: Duration, _focused: bool) {
+9 -2
View File
@@ -41,6 +41,9 @@ pub struct IntroPlayed(pub bool);
/// A resource to track the overall stage of the game from a high-level perspective. /// A resource to track the overall stage of the game from a high-level perspective.
#[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)] #[derive(Resource, Debug, PartialEq, Eq, Clone, Copy)]
pub enum GameStage { 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), Starting(StartupSequence),
/// The main gameplay loop is active. /// The main gameplay loop is active.
Playing, Playing,
@@ -183,7 +186,7 @@ impl TooSimilar for GameStage {
fn too_similar(&self, other: &Self) -> bool { fn too_similar(&self, other: &Self) -> bool {
discriminant(self) == discriminant(other) && { discriminant(self) == discriminant(other) && {
// These states are very simple, so they're 'too similar' automatically // 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; return true;
} }
@@ -207,7 +210,7 @@ impl TooSimilar for GameStage {
}, },
) => ghost_entity == other_ghost_entity && ghost_type == other_ghost_type && node == other_node, ) => ghost_entity == other_ghost_entity && ghost_type == other_ghost_type && node == other_node,
// Already handled, but kept to properly exhaust the match // Already handled, but kept to properly exhaust the match
(GameStage::Playing, _) | (GameStage::GameOver, _) => unreachable!(), (GameStage::Playing, _) | (GameStage::GameOver, _) | (GameStage::WaitingForInteraction, _) => unreachable!(),
_ => unreachable!(), _ => unreachable!(),
} }
} }
@@ -312,6 +315,10 @@ pub fn stage_system(
} }
let new_state: GameStage = new_state_opt.unwrap_or_else(|| match *game_state { 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 => { GameStage::Playing => {
// This is the default state, do nothing // This is the default state, do nothing
*game_state *game_state
+1
View File
@@ -1,5 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "pacman-web", "name": "pacman-web",
+44 -2
View File
@@ -1,7 +1,15 @@
import { useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
export default function Page() { export default function Page() {
const [gameReady, setGameReady] = useState(false);
const [gameStarted, setGameStarted] = useState(false);
useEffect(() => { useEffect(() => {
// Set up callback for when WASM signals it's ready
(window as any).pacmanReady = () => {
setGameReady(true);
};
if (!(window as any).Module) { if (!(window as any).Module) {
const canvas = document.getElementById("canvas"); const canvas = document.getElementById("canvas");
@@ -20,20 +28,54 @@ export default function Page() {
return () => { return () => {
script.remove(); 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 ( return (
<div className="mt-4 flex justify-center h-[calc(100vh-120px)]"> <div className="mt-4 flex justify-center h-[calc(100vh-120px)]">
<div <div
className="block border-1 border-yellow-400/50 aspect-[5/6] h-[min(calc(100vh-120px),_calc(95vw_*_6/5))] w-auto" className="relative block border-1 border-yellow-400/50 aspect-[5/6] h-[min(calc(100vh-120px),_calc(95vw_*_6/5))] w-auto"
style={{ style={{
boxShadow: boxShadow:
"0 0 12px rgba(250, 204, 21, 0.35), 0 0 2px rgba(255, 255, 255, 0.25)", "0 0 12px rgba(250, 204, 21, 0.35), 0 0 2px rgba(255, 255, 255, 0.25)",
}} }}
onClick={handleInteraction}
> >
<canvas id="canvas" className="w-full h-full" /> <canvas id="canvas" className="w-full h-full" />
{/* Click to Start overlay */}
{gameReady && !gameStarted && (
<div className="absolute inset-0 flex items-center justify-center bg-black/60 cursor-pointer">
<span className="text-yellow-400 text-5xl font-bold">
Click to Start
</span>
</div>
)}
</div> </div>
</div> </div>
); );