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
14pub enum GameEvent {
15    Resized { width: u32, height: u32 },
16    FocusChanged(bool),
17    CloseRequested,
18}
19
20pub struct Context {
21    pub gfx: game_toolkit_gfx::Graphics,
22    pub audio: Option<game_toolkit_audio::Audio>,
23    pub input: game_toolkit_input::Input,
24    pub assets: game_toolkit_assets::Assets,
25    pub time: Time,
26    pub rng: Rng,
27    pub window: Arc<Window>,
28    pub(crate) quit_requested: bool,
29}
30
31/// The asynchronous half of context setup: GPU init and (on the web) asset preloading.
32/// Produced by [`init_parts`], turned into a [`Context`] by [`Context::from_parts`].
33pub(crate) struct Parts {
34    gfx: game_toolkit_gfx::Graphics,
35    assets: game_toolkit_assets::Assets,
36}
37
38/// Create the game window. On the web it renders into the `<canvas>` named by
39/// [`AppConfig::canvas_id`], or into a new canvas appended to `<body>`.
40pub(crate) fn create_window(
41    event_loop: &ActiveEventLoop,
42    config: &AppConfig,
43) -> Result<Arc<Window>> {
44    let attrs = Window::default_attributes()
45        .with_title(&config.title)
46        .with_inner_size(winit::dpi::LogicalSize::new(config.width, config.height));
47    #[cfg(target_arch = "wasm32")]
48    let attrs = crate::web::canvas_attributes(attrs, config.canvas_id.as_deref())?;
49    Ok(Arc::new(event_loop.create_window(attrs)?))
50}
51
52/// Initialize the GPU and the asset store. Awaited with `pollster` on native; on the web it
53/// runs as a browser task because adapter/device requests resolve as JS promises.
54pub(crate) async fn init_parts(window: Arc<Window>, config: AppConfig) -> Result<Parts> {
55    let gfx = game_toolkit_gfx::Graphics::new(
56        window,
57        config.vsync,
58        config.depth_format,
59        config.msaa_samples,
60    )
61    .await?;
62    let assets = open_assets(&config).await?;
63    Ok(Parts { gfx, assets })
64}
65
66#[cfg(not(target_arch = "wasm32"))]
67#[allow(clippy::unused_async)]
68async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
69    use game_toolkit_assets::Assets;
70    Assets::new(&config.asset_root).or_else(|e| {
71        log::warn!(
72            "asset root {} unusable ({e}); falling back to cwd",
73            config.asset_root.display()
74        );
75        Assets::new(".")
76    })
77}
78
79#[cfg(target_arch = "wasm32")]
80async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
81    let mut assets = game_toolkit_assets::Assets::new(&config.asset_root)?;
82    let paths = match &config.web_preload {
83        Some(paths) => paths.clone(),
84        None => assets.fetch_manifest().await?,
85    };
86    log::info!("preloading {} asset(s)", paths.len());
87    assets.preload(paths).await?;
88    Ok(assets)
89}
90
91impl Context {
92    pub(crate) fn from_parts(window: Arc<Window>, parts: Parts, config: &AppConfig) -> Self {
93        let audio = match game_toolkit_audio::Audio::new() {
94            Ok(a) => Some(a),
95            Err(e) => {
96                log::warn!("audio init failed, continuing muted: {e}");
97                None
98            }
99        };
100        Self {
101            gfx: parts.gfx,
102            audio,
103            input: game_toolkit_input::Input::new(),
104            assets: parts.assets,
105            time: Time::new(),
106            rng: Rng::new(config.random_seed),
107            window,
108            quit_requested: false,
109        }
110    }
111
112    pub fn quit(&mut self) {
113        self.quit_requested = true;
114    }
115
116    /// Load a PNG from the asset root. Works on every platform: natively it reads the file and
117    /// registers it for hot-reload ([`game_toolkit_gfx::Graphics::refresh_textures`]); on the
118    /// web it decodes the preloaded bytes.
119    pub fn load_texture(&mut self, rel: impl AsRef<Path>) -> Result<TextureId> {
120        let rel = rel.as_ref();
121        #[cfg(not(target_arch = "wasm32"))]
122        if !self.assets.is_loaded(rel) {
123            return self.gfx.load_texture(self.assets.resolve(rel));
124        }
125        let bytes = self.assets.read(rel)?;
126        self.gfx
127            .load_texture_bytes(&bytes, Some(&rel.to_string_lossy()))
128    }
129
130    /// Load a sound (wav/ogg/flac/mp3) from the asset root. `Ok(None)` when audio is muted
131    /// because the backend failed to initialize.
132    pub fn load_sound(&mut self, rel: impl AsRef<Path>) -> Result<Option<SoundId>> {
133        let Some(audio) = self.audio.as_mut() else {
134            return Ok(None);
135        };
136        let bytes = self.assets.read(rel)?;
137        audio.load_sound_bytes(bytes).map(Some)
138    }
139
140    /// Open the audio device on the first user gesture. Browsers keep audio suspended until
141    /// then; natively the device is already open and this is a no-op.
142    pub(crate) fn start_audio(&mut self) {
143        if let Some(audio) = self.audio.as_mut()
144            && !audio.is_started()
145            && let Err(e) = audio.start()
146        {
147            log::warn!("audio start failed, continuing muted: {e}");
148            self.audio = None;
149        }
150    }
151}