Skip to main content

game_toolkit_core/
context.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use anyhow::Result;
5use game_toolkit_audio::SoundId;
6use game_toolkit_gfx::TextureId;
7use winit::event_loop::ActiveEventLoop;
8use winit::window::Window;
9
10use crate::Rng;
11use crate::app::AppConfig;
12use crate::time::Time;
13
14#[non_exhaustive]
15pub enum GameEvent {
16    Resized {
17        width: u32,
18        height: u32,
19    },
20    FocusChanged(bool),
21    CloseRequested,
22    /// Web only: the host page took the game's canvas out of the document (`false`: an SPA
23    /// route change, a component unmounting) or put it back (`true`). The browser sends no
24    /// frames to a detached canvas, so both arrive together, on the first frame after it
25    /// returns; the audio output is already suspended in between (see
26    /// [`Audio::suspend`](game_toolkit_audio::Audio::suspend) to keep it paused, e.g. behind a
27    /// pause menu). Also see [`Context::canvas_attached`].
28    CanvasAttached(bool),
29}
30
31pub struct Context {
32    pub gfx: game_toolkit_gfx::Graphics,
33    pub audio: Option<game_toolkit_audio::Audio>,
34    pub input: game_toolkit_input::Input,
35    pub assets: game_toolkit_assets::Assets,
36    pub time: Time,
37    pub rng: Rng,
38    pub window: Arc<Window>,
39    pub(crate) quit_requested: bool,
40    pub(crate) canvas_attached: bool,
41    /// Waiting for the browser to say whether audio may start without a gesture.
42    #[cfg(target_arch = "wasm32")]
43    pub(crate) autoplay: Option<crate::web::AutoplayProbe>,
44}
45
46/// The asynchronous half of context setup: GPU init and (on the web) asset preloading.
47/// Produced by [`init_parts`], turned into a [`Context`] by [`Context::from_parts`].
48pub(crate) struct Parts {
49    gfx: game_toolkit_gfx::Graphics,
50    assets: game_toolkit_assets::Assets,
51}
52
53/// Create the game window. On the web it renders into the `<canvas>` named by
54/// [`AppConfig::canvas_id`], or into a new canvas appended to `<body>`.
55pub(crate) fn create_window(
56    event_loop: &ActiveEventLoop,
57    config: &AppConfig,
58) -> Result<Arc<Window>> {
59    let attrs = Window::default_attributes()
60        .with_title(&config.title)
61        .with_inner_size(winit::dpi::LogicalSize::new(config.width, config.height));
62    #[cfg(target_arch = "wasm32")]
63    let attrs = crate::web::canvas_attributes(attrs, config.canvas_id.as_deref())?;
64    Ok(Arc::new(event_loop.create_window(attrs)?))
65}
66
67/// Initialize the GPU and the asset store. Awaited with `pollster` on native; on the web it
68/// runs as a browser task because adapter/device requests resolve as JS promises.
69pub(crate) async fn init_parts(window: Arc<Window>, config: AppConfig) -> Result<Parts> {
70    let gfx = game_toolkit_gfx::Graphics::new(
71        window,
72        config.vsync,
73        config.depth_format,
74        config.msaa_samples,
75    )
76    .await?;
77    let assets = open_assets(&config).await?;
78    Ok(Parts { gfx, assets })
79}
80
81#[cfg(not(target_arch = "wasm32"))]
82#[allow(clippy::unused_async)]
83async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
84    use game_toolkit_assets::Assets;
85    let mut assets = Assets::new(&config.asset_root).or_else(|e| {
86        log::warn!(
87            "asset root {} unusable ({e}); falling back to cwd",
88            config.asset_root.display()
89        );
90        Assets::new(".")
91    })?;
92    // Debug builds warn about assets the web build wouldn't preload.
93    if let Some(paths) = &config.web_preload {
94        assets.set_web_preload(paths);
95    }
96    Ok(assets)
97}
98
99#[cfg(target_arch = "wasm32")]
100async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
101    let mut assets = game_toolkit_assets::Assets::new(&config.asset_root)?;
102    let paths = match &config.web_preload {
103        Some(paths) => paths.clone(),
104        None => assets.fetch_manifest().await?,
105    };
106    log::info!("preloading {} asset(s)", paths.len());
107    assets.preload(paths).await?;
108    Ok(assets)
109}
110
111impl Context {
112    pub(crate) fn from_parts(window: Arc<Window>, parts: Parts, config: &AppConfig) -> Self {
113        let audio = match game_toolkit_audio::Audio::new() {
114            Ok(a) => Some(a),
115            Err(e) => {
116                log::warn!("audio init failed, continuing muted: {e}");
117                None
118            }
119        };
120        let mut input = game_toolkit_input::Input::new();
121        input.set_touch_emulates_mouse(config.touch_emulates_mouse);
122        Self {
123            gfx: parts.gfx,
124            audio,
125            input,
126            assets: parts.assets,
127            time: Time::new(),
128            rng: Rng::new(config.random_seed),
129            window,
130            quit_requested: false,
131            canvas_attached: true,
132            #[cfg(target_arch = "wasm32")]
133            autoplay: None,
134        }
135    }
136
137    pub fn quit(&mut self) {
138        self.quit_requested = true;
139    }
140
141    /// Whether the game's canvas is in the page, as of the last [`GameEvent::CanvasAttached`].
142    /// Always `true` natively.
143    pub fn canvas_attached(&self) -> bool {
144        self.canvas_attached
145    }
146
147    /// Load a PNG from the asset root. Works on every platform: natively it reads the file and
148    /// registers it for hot-reload ([`game_toolkit_gfx::Graphics::refresh_textures`]); on the
149    /// web it decodes the preloaded bytes.
150    pub fn load_texture(&mut self, rel: impl AsRef<Path>) -> Result<TextureId> {
151        let rel = rel.as_ref();
152        #[cfg(not(target_arch = "wasm32"))]
153        if !self.assets.is_loaded(rel) {
154            let id = self.gfx.load_texture(self.assets.resolve(rel))?;
155            self.assets.check_web_listed(rel);
156            return Ok(id);
157        }
158        let bytes = self.assets.read(rel)?;
159        self.gfx
160            .load_texture_bytes(&bytes, Some(&rel.to_string_lossy()))
161    }
162
163    /// Load a sound (wav/ogg/flac/mp3) from the asset root. `Ok(None)` when audio is muted
164    /// because the backend failed to initialize.
165    pub fn load_sound(&mut self, rel: impl AsRef<Path>) -> Result<Option<SoundId>> {
166        let Some(audio) = self.audio.as_mut() else {
167            return Ok(None);
168        };
169        let bytes = self.assets.read(rel)?;
170        audio.load_sound_bytes(bytes).map(Some)
171    }
172
173    /// Open the audio device on the first user gesture, or at startup on the web if the page
174    /// already had one. Browsers keep audio suspended until then; natively the device is
175    /// already open and this is a no-op.
176    pub(crate) fn start_audio(&mut self) {
177        if let Some(audio) = self.audio.as_mut()
178            && !audio.is_started()
179            && let Err(e) = audio.start()
180        {
181            log::warn!("audio start failed, continuing muted: {e}");
182            self.audio = None;
183        }
184    }
185
186    /// Start audio as soon as the browser allows it without another user gesture, e.g. when
187    /// the host page tells the game the player is back after interacting with the page. The
188    /// toolkit does this by itself at startup; call it again when a gesture may have happened
189    /// on the page since (outside the canvas, so the toolkit didn't see it).
190    ///
191    /// On the web it asks the browser and, if the answer is yes, starts the audio right away
192    /// or within about 1.5 s (Firefox and WebKit take a moment to say so). Otherwise audio
193    /// keeps waiting for the first key press, click or touch on the canvas. Natively the
194    /// device is open from the start and this does nothing.
195    pub fn start_audio_if_allowed(&mut self) {
196        #[cfg(target_arch = "wasm32")]
197        if self.audio.as_ref().is_some_and(|a| !a.is_started()) && self.autoplay.is_none() {
198            self.autoplay = crate::web::AutoplayProbe::start();
199            self.poll_autoplay();
200        }
201    }
202
203    /// Act on the autoplay probe's answer, if there is one yet.
204    #[cfg(target_arch = "wasm32")]
205    pub(crate) fn poll_autoplay(&mut self) {
206        use crate::web::Autoplay;
207        let Some(probe) = &self.autoplay else {
208            return;
209        };
210        // A gesture on the canvas may have started the audio in the meantime.
211        let started = self
212            .audio
213            .as_ref()
214            .is_none_or(game_toolkit_audio::Audio::is_started);
215        let answer = if started {
216            Autoplay::Denied
217        } else {
218            probe.poll()
219        };
220        match answer {
221            Autoplay::Pending => {}
222            Autoplay::Denied => self.autoplay = None,
223            Autoplay::Allowed => {
224                self.autoplay = None;
225                log::info!("audio allowed without a gesture, starting it now");
226                self.start_audio();
227            }
228        }
229    }
230}