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(window, &config.graphics_config()).await?;
71    let assets = open_assets(&config).await?;
72    Ok(Parts { gfx, assets })
73}
74
75#[cfg(not(target_arch = "wasm32"))]
76#[allow(clippy::unused_async)]
77async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
78    use game_toolkit_assets::Assets;
79    let mut assets = Assets::new(&config.asset_root).or_else(|e| {
80        log::warn!(
81            "asset root {} unusable ({e}); falling back to cwd",
82            config.asset_root.display()
83        );
84        Assets::new(".")
85    })?;
86    // Debug builds warn about assets the web build wouldn't preload.
87    if let Some(paths) = &config.web_preload {
88        assets.set_web_preload(paths);
89    }
90    Ok(assets)
91}
92
93#[cfg(target_arch = "wasm32")]
94async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
95    let mut assets = game_toolkit_assets::Assets::new(&config.asset_root)?;
96    let paths = match &config.web_preload {
97        Some(paths) => paths.clone(),
98        None => assets.fetch_manifest().await?,
99    };
100    log::info!("preloading {} asset(s)", paths.len());
101    assets.preload(paths).await?;
102    Ok(assets)
103}
104
105impl Context {
106    pub(crate) fn from_parts(window: Arc<Window>, parts: Parts, config: &AppConfig) -> Self {
107        let audio = match game_toolkit_audio::Audio::new() {
108            Ok(a) => Some(a),
109            Err(e) => {
110                log::warn!("audio init failed, continuing muted: {e}");
111                None
112            }
113        };
114        let mut input = game_toolkit_input::Input::new();
115        input.set_touch_emulates_mouse(config.touch_emulates_mouse);
116        Self {
117            gfx: parts.gfx,
118            audio,
119            input,
120            assets: parts.assets,
121            time: Time::new(),
122            rng: Rng::new(config.random_seed),
123            window,
124            quit_requested: false,
125            canvas_attached: true,
126            #[cfg(target_arch = "wasm32")]
127            autoplay: None,
128        }
129    }
130
131    /// Point the input's mouse and touch mapping at where the canvas lands in the window.
132    pub(crate) fn sync_pointer_mapping(&mut self) {
133        let mapping = pointer_mapping(self.gfx.canvas_rect(), self.gfx.size());
134        self.input.set_pointer_mapping(mapping);
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 pixel font from a glyph sheet PNG in the asset root, laid out as `layout`
164    /// describes (e.g. `GridLayout::ascii(6, 8)`).
165    pub fn load_bitmap_font(
166        &mut self,
167        rel: impl AsRef<Path>,
168        layout: &game_toolkit_gfx::GridLayout,
169    ) -> Result<game_toolkit_gfx::BitmapFont> {
170        let bytes = self.assets.read(rel)?;
171        game_toolkit_gfx::BitmapFont::from_grid(&mut self.gfx, &bytes, layout)
172    }
173
174    /// Load an AngelCode BMFont (text `.fnt`, one page) from the asset root; its page image is
175    /// read from the file the `.fnt` names, relative to the `.fnt`. On the web, list both in
176    /// the preload manifest.
177    pub fn load_bmfont(&mut self, rel: impl AsRef<Path>) -> Result<game_toolkit_gfx::BitmapFont> {
178        let rel = rel.as_ref();
179        let fnt = String::from_utf8(self.assets.read(rel)?)
180            .map_err(|e| anyhow::anyhow!("{}: not UTF-8 ({e})", rel.display()))?;
181        let page = game_toolkit_gfx::BitmapFont::bmfont_page_file(&fnt)
182            .ok_or_else(|| anyhow::anyhow!("{}: no page file", rel.display()))?;
183        // The page must sit next to the .fnt or below it: a crafted or careless `file=` with
184        // `..` or an absolute path must not read outside the asset root.
185        let page_path = Path::new(&page);
186        if page_path.is_absolute()
187            || page_path.components().any(|c| {
188                !matches!(
189                    c,
190                    std::path::Component::Normal(_) | std::path::Component::CurDir
191                )
192            })
193        {
194            anyhow::bail!(
195                "{}: page file {page:?} must be a relative path without `..`",
196                rel.display()
197            );
198        }
199        let page = rel.parent().unwrap_or(Path::new("")).join(page_path);
200        let png = self.assets.read(&page)?;
201        game_toolkit_gfx::BitmapFont::from_bmfont(&mut self.gfx, &fnt, &png)
202    }
203
204    /// Load an indexed-colour PNG from the asset root as a texture of palette indices, for an
205    /// indexed canvas (feature `indexed`); also returns the PNG's palette. See
206    /// [`Graphics::enable_indexed`](game_toolkit_gfx::Graphics::enable_indexed).
207    #[cfg(feature = "indexed")]
208    pub fn load_indexed_texture(
209        &mut self,
210        rel: impl AsRef<Path>,
211    ) -> Result<(TextureId, game_toolkit_gfx::Palette)> {
212        let rel = rel.as_ref();
213        let bytes = self.assets.read(rel)?;
214        self.gfx
215            .load_indexed_texture(&bytes, Some(&rel.to_string_lossy()))
216    }
217
218    /// Load a sound (wav/ogg/flac/mp3) from the asset root. `Ok(None)` when audio is muted
219    /// because the backend failed to initialize.
220    pub fn load_sound(&mut self, rel: impl AsRef<Path>) -> Result<Option<SoundId>> {
221        let Some(audio) = self.audio.as_mut() else {
222            return Ok(None);
223        };
224        let bytes = self.assets.read(rel)?;
225        audio.load_sound_bytes(bytes).map(Some)
226    }
227
228    /// Open the audio device on the first user gesture, or at startup on the web if the page
229    /// already had one. Browsers keep audio suspended until then; natively the device is
230    /// already open and this is a no-op.
231    pub(crate) fn start_audio(&mut self) {
232        if let Some(audio) = self.audio.as_mut()
233            && !audio.is_started()
234            && let Err(e) = audio.start()
235        {
236            log::warn!("audio start failed, continuing muted: {e}");
237            self.audio = None;
238        }
239    }
240
241    /// Start audio as soon as the browser allows it without another user gesture, e.g. when
242    /// the host page tells the game the player is back after interacting with the page. The
243    /// toolkit does this by itself at startup; call it again when a gesture may have happened
244    /// on the page since (outside the canvas, so the toolkit didn't see it).
245    ///
246    /// On the web it asks the browser and, if the answer is yes, starts the audio right away
247    /// or within about 1.5 s (Firefox and WebKit take a moment to say so). Otherwise audio
248    /// keeps waiting for the first key press, click or touch on the canvas. Natively the
249    /// device is open from the start and this does nothing.
250    pub fn start_audio_if_allowed(&mut self) {
251        #[cfg(target_arch = "wasm32")]
252        if self.audio.as_ref().is_some_and(|a| !a.is_started()) && self.autoplay.is_none() {
253            self.autoplay = crate::web::AutoplayProbe::start();
254            self.poll_autoplay();
255        }
256    }
257
258    /// Act on the autoplay probe's answer, if there is one yet.
259    #[cfg(target_arch = "wasm32")]
260    pub(crate) fn poll_autoplay(&mut self) {
261        use crate::web::Autoplay;
262        let Some(probe) = &self.autoplay else {
263            return;
264        };
265        // A gesture on the canvas may have started the audio in the meantime.
266        let started = self
267            .audio
268            .as_ref()
269            .is_none_or(game_toolkit_audio::Audio::is_started);
270        let answer = if started {
271            Autoplay::Denied
272        } else {
273            probe.poll()
274        };
275        match answer {
276            Autoplay::Pending => {}
277            Autoplay::Denied => self.autoplay = None,
278            Autoplay::Allowed => {
279                self.autoplay = None;
280                log::info!("audio allowed without a gesture, starting it now");
281                self.start_audio();
282            }
283        }
284    }
285}
286
287/// The input mapping from window to canvas pixels for a canvas of `size` placed at `rect`
288/// (`None`: no virtual resolution, positions stay in window pixels).
289fn pointer_mapping(
290    rect: Option<game_toolkit_gfx::CanvasRect>,
291    size: (u32, u32),
292) -> Option<game_toolkit_input::PointerMapping> {
293    rect.map(|rect| {
294        let scale = rect.canvas_per_window([size.0, size.1]);
295        game_toolkit_input::PointerMapping {
296            origin: (rect.origin[0], rect.origin[1]),
297            scale: (scale[0], scale[1]),
298        }
299    })
300}
301
302#[cfg(test)]
303mod tests {
304    use game_toolkit_gfx::CanvasRect;
305    use game_toolkit_input::Input;
306
307    use super::pointer_mapping;
308
309    fn close(a: (f32, f32), b: (f32, f32)) -> bool {
310        (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
311    }
312
313    /// After a runtime switch from 640 x 360 (integer x3) to 800 x 600 (fractional fit) on a
314    /// 1080p window, the pointer maps to the new canvas as soon as the mapping is synced:
315    /// `Input` applies the mapping when read, not when the cursor moved.
316    #[test]
317    fn pointer_follows_a_runtime_resolution_change() {
318        let mut input = Input::new();
319        input.handle_window_event(&winit::event::WindowEvent::CursorMoved {
320            device_id: winit::event::DeviceId::dummy(),
321            position: winit::dpi::PhysicalPosition::new(960.0, 540.0),
322        });
323
324        // 640 x 360 scaled by 3 fills the window.
325        let rect = CanvasRect {
326            origin: [0.0, 0.0],
327            size: [1920.0, 1080.0],
328        };
329        input.set_pointer_mapping(pointer_mapping(Some(rect), (640, 360)));
330        assert!(close(input.mouse_pos(), (320.0, 180.0)));
331
332        // 800 x 600 fit at 1.8x: 1440 x 1080, centred with 240-pixel bars.
333        let rect = CanvasRect {
334            origin: [240.0, 0.0],
335            size: [1440.0, 1080.0],
336        };
337        input.set_pointer_mapping(pointer_mapping(Some(rect), (800, 600)));
338        assert!(
339            close(input.mouse_pos(), (400.0, 300.0)),
340            "{:?}",
341            input.mouse_pos()
342        );
343
344        // Back to drawing straight into the window.
345        input.set_pointer_mapping(pointer_mapping(None, (1920, 1080)));
346        assert_eq!(input.mouse_pos(), (960.0, 540.0));
347    }
348}