From 5e86bbb040189b766f88bbd5056310d2449d6bba Mon Sep 17 00:00:00 2001 From: Xevion Date: Mon, 29 Dec 2025 02:06:15 -0600 Subject: [PATCH] feat(web): implement game lifecycle management for SPA navigation Add stop_game and restart_game FFI functions to properly pause/resume the game loop during page transitions, preventing resource leaks and audio issues when navigating between pages --- pacman/src/main.rs | 45 ++++++++++++++++++++ pacman/src/platform/emscripten.rs | 4 ++ web/lib/pacman.ts | 16 ++++++++ web/pages/+onPageTransitionEnd.ts | 31 +++++++++++++- web/pages/+onPageTransitionStart.ts | 8 ++++ web/pages/index/+Page.tsx | 64 ++++++++++++++++++----------- web/vite.config.ts | 6 +++ 7 files changed, 148 insertions(+), 26 deletions(-) create mode 100644 web/lib/pacman.ts diff --git a/pacman/src/main.rs b/pacman/src/main.rs index 07da6b6..c0cd0ee 100644 --- a/pacman/src/main.rs +++ b/pacman/src/main.rs @@ -46,6 +46,51 @@ pub extern "C" fn start_game() { } } +/// Called from JavaScript when navigating away from the game page. +/// Stops the Emscripten main loop and halts all audio. +#[cfg(target_os = "emscripten")] +#[no_mangle] +pub extern "C" fn stop_game() { + tracing::info!("Stopping game loop and halting audio"); + unsafe { + platform::emscripten_cancel_main_loop(); + sdl2::mixer::Channel::all().halt(); + } +} + +/// Called from JavaScript to restart the game after navigating back. +/// Creates a fresh App instance with the new canvas and starts the main loop. +#[cfg(target_os = "emscripten")] +#[no_mangle] +pub extern "C" fn restart_game() { + use std::ptr; + + tracing::info!("Restarting game with fresh App instance"); + + unsafe { + // Drop old App to clean up resources + APP = None; + + // Reinitialize audio subsystem for fresh state + sdl2::mixer::close_audio(); + + // Create fresh App with new canvas + match App::new() { + Ok(app) => { + APP = Some(app); + tracing::info!("Game restarted successfully"); + + // Signal ready and start the main loop + platform::run_script("if (window.pacmanReady) window.pacmanReady()"); + platform::emscripten_set_main_loop_arg(main_loop_callback, ptr::null_mut(), 0, 1); + } + Err(e) => { + tracing::error!("Failed to restart game: {}", e); + } + } + } +} + /// 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) { diff --git a/pacman/src/platform/emscripten.rs b/pacman/src/platform/emscripten.rs index ae201ce..b74ef56 100644 --- a/pacman/src/platform/emscripten.rs +++ b/pacman/src/platform/emscripten.rs @@ -25,6 +25,10 @@ extern "C" { /// - `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); + /// Cancel the currently running main loop. + /// After calling this, the loop callback will no longer be invoked. + pub fn emscripten_cancel_main_loop(); + /// Execute JavaScript code from Rust fn emscripten_run_script(script: *const i8); } diff --git a/web/lib/pacman.ts b/web/lib/pacman.ts new file mode 100644 index 0000000..a00b886 --- /dev/null +++ b/web/lib/pacman.ts @@ -0,0 +1,16 @@ +export interface PacmanModule { + canvas: HTMLCanvasElement; + _start_game?: () => void; + _stop_game?: () => void; + _restart_game?: () => void; + locateFile: (path: string) => string; + preRun: unknown[]; +} + +export interface PacmanWindow extends Window { + Module?: PacmanModule; + pacmanReady?: () => void; + SDL_CANVAS_ID?: string; +} + +export const getPacmanWindow = (): PacmanWindow => window as unknown as PacmanWindow; diff --git a/web/pages/+onPageTransitionEnd.ts b/web/pages/+onPageTransitionEnd.ts index 75af2e0..b46c1f0 100644 --- a/web/pages/+onPageTransitionEnd.ts +++ b/web/pages/+onPageTransitionEnd.ts @@ -1,6 +1,35 @@ import type { OnPageTransitionEndAsync } from "vike/types"; +import { getPacmanWindow } from "@/lib/pacman"; -export const onPageTransitionEnd: OnPageTransitionEndAsync = async () => { +export const onPageTransitionEnd: OnPageTransitionEndAsync = async ( + pageContext +) => { console.log("Page transition end"); document.querySelector("body")?.classList.remove("page-is-transitioning"); + + // Restart the game loop when returning to the game page + if (pageContext.urlPathname === "/") { + const win = getPacmanWindow(); + const module = win.Module; + + if (module?._restart_game) { + const canvas = document.getElementById("canvas") as HTMLCanvasElement | null; + if (!canvas) { + console.error("Canvas element not found during game restart"); + return; + } + + // Update canvas reference BEFORE restart - App::new() will read from Module.canvas + module.canvas = canvas; + // SDL2's Emscripten backend reads this for canvas lookup + win.SDL_CANVAS_ID = "#canvas"; + + try { + console.log("Restarting game with fresh App instance"); + module._restart_game(); + } catch (error) { + console.error("Failed to restart game:", error); + } + } + } }; diff --git a/web/pages/+onPageTransitionStart.ts b/web/pages/+onPageTransitionStart.ts index 12c344b..fb9a189 100644 --- a/web/pages/+onPageTransitionStart.ts +++ b/web/pages/+onPageTransitionStart.ts @@ -1,6 +1,14 @@ import type { OnPageTransitionStartAsync } from "vike/types"; +import { getPacmanWindow } from "@/lib/pacman"; export const onPageTransitionStart: OnPageTransitionStartAsync = async () => { console.log("Page transition start"); document.querySelector("body")?.classList.add("page-is-transitioning"); + + // Stop the game loop when navigating away from the game page + const win = getPacmanWindow(); + if (win.Module?._stop_game) { + console.log("Stopping game loop for page transition"); + win.Module._stop_game(); + } }; diff --git a/web/pages/index/+Page.tsx b/web/pages/index/+Page.tsx index c78d578..c726f03 100644 --- a/web/pages/index/+Page.tsx +++ b/web/pages/index/+Page.tsx @@ -1,44 +1,58 @@ import { useCallback, useEffect, useState } from "react"; +import { getPacmanWindow } from "@/lib/pacman"; 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 = () => { + const win = getPacmanWindow(); + + // Always set up the ready callback (restart_game will call it too) + win.pacmanReady = () => { setGameReady(true); }; - if (!(window as any).Module) { - const canvas = document.getElementById("canvas"); + const module = win.Module; - (window as any).Module = { - canvas: canvas, - locateFile: (path: string) => { - return path.startsWith("/") ? path : `/${path}`; - }, - preRun: [], - }; - - const script = document.createElement("script"); - script.src = "/pacman.js"; - script.async = false; - document.body.appendChild(script); - - return () => { - script.remove(); - delete (window as any).pacmanReady; - }; + // If Module already exists (returning after navigation), + // the onPageTransitionEnd hook handles calling restart_game + if (module?._restart_game) { + setGameStarted(false); + // Don't delete pacmanReady here - restart_game needs it + return; } + + // First time initialization + const canvas = document.getElementById("canvas") as HTMLCanvasElement | null; + if (!canvas) { + console.error("Canvas element not found"); + return; + } + + win.Module = { + canvas, + locateFile: (path: string) => { + return path.startsWith("/") ? path : `/${path}`; + }, + preRun: [], + }; + + const script = document.createElement("script"); + script.src = "/pacman.js"; + script.async = false; + document.body.appendChild(script); + + return () => { + delete win.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(); + const win = getPacmanWindow(); + if (win.Module?._start_game) { + win.Module._start_game(); setGameStarted(true); } } diff --git a/web/vite.config.ts b/web/vite.config.ts index 0a2dd52..5bb0c1a 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,9 +2,15 @@ import tailwindcss from "@tailwindcss/vite"; import react from "@vitejs/plugin-react"; import vike from "vike/plugin"; import { defineConfig } from "vite"; +import path from "path"; export default defineConfig({ plugins: [vike(), react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "."), + }, + }, build: { target: "es2022", },