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    let mut assets = 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    // Debug builds warn about assets the web build wouldn't preload.
78    if let Some(paths) = &config.web_preload {
79        assets.set_web_preload(paths);
80    }
81    Ok(assets)
82}
83
84#[cfg(target_arch = "wasm32")]
85async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
86    let mut assets = game_toolkit_assets::Assets::new(&config.asset_root)?;
87    let paths = match &config.web_preload {
88        Some(paths) => paths.clone(),
89        None => assets.fetch_manifest().await?,
90    };
91    log::info!("preloading {} asset(s)", paths.len());
92    assets.preload(paths).await?;
93    Ok(assets)
94}
95
96impl Context {
97    pub(crate) fn from_parts(window: Arc<Window>, parts: Parts, config: &AppConfig) -> Self {
98        let audio = match game_toolkit_audio::Audio::new() {
99            Ok(a) => Some(a),
100            Err(e) => {
101                log::warn!("audio init failed, continuing muted: {e}");
102                None
103            }
104        };
105        let mut input = game_toolkit_input::Input::new();
106        input.set_touch_emulates_mouse(config.touch_emulates_mouse);
107        Self {
108            gfx: parts.gfx,
109            audio,
110            input,
111            assets: parts.assets,
112            time: Time::new(),
113            rng: Rng::new(config.random_seed),
114            window,
115            quit_requested: false,
116        }
117    }
118
119    pub fn quit(&mut self) {
120        self.quit_requested = true;
121    }
122
123    /// Load a PNG from the asset root. Works on every platform: natively it reads the file and
124    /// registers it for hot-reload ([`game_toolkit_gfx::Graphics::refresh_textures`]); on the
125    /// web it decodes the preloaded bytes.
126    pub fn load_texture(&mut self, rel: impl AsRef<Path>) -> Result<TextureId> {
127        let rel = rel.as_ref();
128        #[cfg(not(target_arch = "wasm32"))]
129        if !self.assets.is_loaded(rel) {
130            let id = self.gfx.load_texture(self.assets.resolve(rel))?;
131            self.assets.check_web_listed(rel);
132            return Ok(id);
133        }
134        let bytes = self.assets.read(rel)?;
135        self.gfx
136            .load_texture_bytes(&bytes, Some(&rel.to_string_lossy()))
137    }
138
139    /// Load a sound (wav/ogg/flac/mp3) from the asset root. `Ok(None)` when audio is muted
140    /// because the backend failed to initialize.
141    pub fn load_sound(&mut self, rel: impl AsRef<Path>) -> Result<Option<SoundId>> {
142        let Some(audio) = self.audio.as_mut() else {
143            return Ok(None);
144        };
145        let bytes = self.assets.read(rel)?;
146        audio.load_sound_bytes(bytes).map(Some)
147    }
148
149    /// Open the audio device on the first user gesture. Browsers keep audio suspended until
150    /// then; natively the device is already open and this is a no-op.
151    pub(crate) fn start_audio(&mut self) {
152        if let Some(audio) = self.audio.as_mut()
153            && !audio.is_started()
154            && let Err(e) = audio.start()
155        {
156            log::warn!("audio start failed, continuing muted: {e}");
157            self.audio = None;
158        }
159    }
160}