Skip to main content

game_toolkit_core/
app.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use anyhow::Result;
5use winit::application::ApplicationHandler;
6use winit::event::{ElementState, WindowEvent};
7use winit::event_loop::{ActiveEventLoop, EventLoop};
8use winit::window::{Window, WindowId};
9
10use crate::Game;
11use crate::context::{self, Context, GameEvent, Parts};
12
13/// Runtime configuration passed to [`run`].
14///
15/// Construct it with `..Default::default()` so new fields added in future minor releases don't
16/// break your call site: `AppConfig { title: "game".into(), ..Default::default() }`.
17#[derive(Clone)]
18pub struct AppConfig {
19    pub title: String,
20    pub width: u32,
21    pub height: u32,
22    pub vsync: bool,
23    /// `Some(step)` calls `update` with a fixed `dt` of `step`: zero, one or several times per
24    /// frame, as many as the real time elapsed allows. `None` (default) calls it once per
25    /// frame with the frame's `dt`. Either way `render` runs once per frame; with a fixed
26    /// step, [`Time::alpha`](crate::Time::alpha) says how far the frame is into the next step,
27    /// for interpolating motion between steps.
28    ///
29    /// Leave this `None` if the game steps a simulation itself, e.g. a sillyecs world whose
30    /// `apply_system_phases` runs its own fixed phases from real time; otherwise those fixed
31    /// phases nest inside the toolkit's steps.
32    ///
33    /// Input edges (`key_pressed`, `mouse_delta`, ...) are seen by the first `update` of a
34    /// frame and carried over to the next frame when no step ran, so presses aren't dropped.
35    pub fixed_timestep: Option<Duration>,
36    /// Upper bound on the time one frame advances the game (default 250 ms). After a long
37    /// frame - the window was dragged, the tab was in the background, a breakpoint hit - the
38    /// game slows down for that frame instead of fast-forwarding through the gap: `dt` is
39    /// capped at this, and with a fixed timestep at most `max_frame_time / step` updates run.
40    /// [`Time::delta`](crate::Time::delta) still reports the real frame time.
41    /// `Duration::ZERO` disables the cap.
42    pub max_frame_time: Duration,
43    /// Asset root directory. Defaults to `./assets`. On the web it is a URL path relative to
44    /// the page, and the files listed in its `manifest.txt` are fetched before `Game::init`.
45    pub asset_root: std::path::PathBuf,
46    /// Web only: asset paths to preload instead of reading `manifest.txt`. Ignored natively.
47    pub web_preload: Option<Vec<String>>,
48    /// Web only: `id` of an existing `<canvas>` to render into. `None` (default) appends a new
49    /// canvas to `<body>`. Ignored natively.
50    pub canvas_id: Option<String>,
51    /// Seed for deterministic runtime RNG (`ctx.rng`).
52    pub random_seed: u64,
53    /// Depth attachment format. `None` (default) keeps the 2D path depth-less; `Some(fmt)`
54    /// (e.g. `wgpu::TextureFormat::Depth32Float`) allocates a depth buffer for depth-tested
55    /// rendering. The built-in 2D pipelines never write depth, so enabling it is harmless.
56    pub depth_format: Option<wgpu::TextureFormat>,
57    /// MSAA sample count for the surface. `1` (default) disables MSAA; `2`/`4`/`8` enable it
58    /// (the value must be supported by the adapter for the surface format).
59    pub msaa_samples: u32,
60    /// Let the first finger on a touchscreen act as the mouse: it moves the cursor and
61    /// presses, holds and releases [`MouseButton::Left`](game_toolkit_input::MouseButton),
62    /// so a game written for the mouse also works on a phone. Off by default; toggle later
63    /// with [`Input::set_touch_emulates_mouse`](game_toolkit_input::Input::set_touch_emulates_mouse),
64    /// which has the details. No effect on Windows, where the OS already does this.
65    pub touch_emulates_mouse: bool,
66}
67
68impl Default for AppConfig {
69    fn default() -> Self {
70        Self {
71            title: "Game".into(),
72            width: 1280,
73            height: 720,
74            vsync: true,
75            fixed_timestep: None,
76            max_frame_time: Duration::from_millis(250),
77            asset_root: std::path::PathBuf::from("assets"),
78            web_preload: None,
79            canvas_id: None,
80            random_seed: crate::Rng::DEFAULT_SEED,
81            depth_format: None,
82            msaa_samples: 1,
83            touch_emulates_mouse: false,
84        }
85    }
86}
87
88/// Sent by the web init task once the GPU is up and the assets are preloaded. Unused natively,
89/// where init completes synchronously inside `resumed`.
90type InitDone = Box<(Arc<Window>, Result<Parts>)>;
91
92/// Open the window and run `G` until it quits.
93///
94/// Natively this blocks until the window closes. On the web it installs a panic hook and a
95/// console logger (unless a logger is already set), hands the loop to the browser and returns
96/// immediately; `G::init` runs once the GPU is ready and the assets are preloaded.
97pub fn run<G: Game>(config: AppConfig) -> Result<()> {
98    #[cfg(target_arch = "wasm32")]
99    crate::web::install_hooks();
100    let event_loop = EventLoop::<InitDone>::with_user_event().build()?;
101    event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
102    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
103    let mut runner = AppRunner::<G> {
104        config,
105        state: None,
106        accumulator: Duration::ZERO,
107        #[cfg(target_arch = "wasm32")]
108        proxy: event_loop.create_proxy(),
109        #[cfg(target_arch = "wasm32")]
110        initializing: false,
111    };
112    #[cfg(not(target_arch = "wasm32"))]
113    event_loop.run_app(&mut runner)?;
114    #[cfg(target_arch = "wasm32")]
115    {
116        use winit::platform::web::EventLoopExtWebSys;
117        event_loop.spawn_app(runner);
118    }
119    Ok(())
120}
121
122struct AppRunner<G: Game> {
123    config: AppConfig,
124    state: Option<RunState<G>>,
125    accumulator: Duration,
126    #[cfg(target_arch = "wasm32")]
127    proxy: winit::event_loop::EventLoopProxy<InitDone>,
128    /// Set while the web init task is in flight, so another `resumed` doesn't start a second.
129    #[cfg(target_arch = "wasm32")]
130    initializing: bool,
131}
132
133struct RunState<G: Game> {
134    ctx: Context,
135    game: G,
136    /// Suspends audio while the canvas is out of the page; `None` if it couldn't be set up.
137    #[cfg(target_arch = "wasm32")]
138    canvas_watch: Option<crate::web::CanvasWatch>,
139}
140
141impl<G: Game> ApplicationHandler<InitDone> for AppRunner<G> {
142    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
143        if self.state.is_some() {
144            return;
145        }
146        #[cfg(target_arch = "wasm32")]
147        if self.initializing {
148            return;
149        }
150        let window = match context::create_window(event_loop, &self.config) {
151            Ok(w) => w,
152            Err(e) => {
153                log::error!("window creation failed: {e:?}");
154                event_loop.exit();
155                return;
156            }
157        };
158        // Adapter/device requests are async. Natively we can block on them; the browser main
159        // thread can't block, so there init runs as a task that reports back via `user_event`.
160        #[cfg(not(target_arch = "wasm32"))]
161        {
162            let parts =
163                pollster::block_on(context::init_parts(window.clone(), self.config.clone()));
164            self.finish_init(event_loop, window, parts);
165        }
166        #[cfg(target_arch = "wasm32")]
167        {
168            self.initializing = true;
169            let proxy = self.proxy.clone();
170            let config = self.config.clone();
171            wasm_bindgen_futures::spawn_local(async move {
172                let parts = context::init_parts(window.clone(), config).await;
173                if proxy.send_event(Box::new((window, parts))).is_err() {
174                    log::error!("event loop closed before init finished");
175                }
176            });
177        }
178    }
179
180    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: InitDone) {
181        let (window, parts) = *event;
182        #[cfg(target_arch = "wasm32")]
183        {
184            self.initializing = false;
185        }
186        self.finish_init(event_loop, window, parts);
187    }
188
189    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
190        let Some(state) = self.state.as_mut() else {
191            return;
192        };
193        // The first key, click or touch is the user gesture browsers require before audio may
194        // play. Natively the device is already open and this is a no-op.
195        if is_user_gesture(&event) {
196            state.ctx.start_audio();
197        }
198        // Give the game's raw-event hook first crack (egui etc. needs this).
199        let consumed = state.game.raw_window_event(&mut state.ctx, &event);
200        match event {
201            WindowEvent::CloseRequested => {
202                state.game.event(&mut state.ctx, &GameEvent::CloseRequested);
203                event_loop.exit();
204            }
205            WindowEvent::Resized(size) => {
206                state.ctx.gfx.resize(size.width, size.height);
207                state.game.event(
208                    &mut state.ctx,
209                    &GameEvent::Resized {
210                        width: size.width,
211                        height: size.height,
212                    },
213                );
214            }
215            WindowEvent::Focused(focused) => {
216                if focused {
217                    // Don't replay time accumulated while the window was in the background.
218                    self.accumulator = Duration::ZERO;
219                }
220                state
221                    .ctx
222                    .input
223                    .handle_window_event(&WindowEvent::Focused(focused));
224                state
225                    .game
226                    .event(&mut state.ctx, &GameEvent::FocusChanged(focused));
227            }
228            WindowEvent::RedrawRequested => {
229                self.frame(event_loop);
230            }
231            ref other if !consumed => {
232                state.ctx.input.handle_window_event(other);
233            }
234            _ => {}
235        }
236    }
237
238    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
239        if let Some(state) = self.state.as_ref() {
240            state.ctx.window.request_redraw();
241        }
242    }
243
244    fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
245        if let Some(state) = self.state.as_mut() {
246            state.game.shutdown(&mut state.ctx);
247        }
248    }
249}
250
251/// How far `accumulator` is into the next fixed `step`, in `0.0..1.0`.
252fn alpha(accumulator: Duration, step: Duration) -> f32 {
253    if step.is_zero() {
254        return 0.0;
255    }
256    (accumulator.as_secs_f64() / step.as_secs_f64()).clamp(0.0, 1.0) as f32
257}
258
259fn is_user_gesture(event: &WindowEvent) -> bool {
260    match event {
261        WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
262        WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
263        WindowEvent::Touch(_) => true,
264        _ => false,
265    }
266}
267
268impl<G: Game> AppRunner<G> {
269    /// Second half of startup, once the window, GPU and assets exist: build the [`Context`]
270    /// and call `G::init`.
271    fn finish_init(
272        &mut self,
273        event_loop: &ActiveEventLoop,
274        window: Arc<Window>,
275        parts: Result<Parts>,
276    ) {
277        let parts = match parts {
278            Ok(p) => p,
279            Err(e) => {
280                log::error!("context init failed: {e:?}");
281                event_loop.exit();
282                return;
283            }
284        };
285        let mut ctx = Context::from_parts(window, parts, &self.config);
286        // On the web, init is async and the canvas's real size arrives as a `Resized` event
287        // while it runs, which `window_event` drops (no state yet). Catch up before `G::init`
288        // so it sees the true surface size.
289        let size = ctx.window.inner_size();
290        ctx.gfx.resize(size.width, size.height);
291        // If the page already had a user gesture, audio may start now rather than on the first
292        // click on the canvas: `G::init` then sees `is_started()` and music plays at once
293        // (Chromium). Where the browser takes a moment to tell (Firefox, WebKit), the probe
294        // keeps going and `frame` starts the audio once it knows.
295        ctx.start_audio_if_allowed();
296        let game = match G::init(&mut ctx) {
297            Ok(g) => g,
298            Err(e) => {
299                log::error!("game init failed: {e:?}");
300                event_loop.exit();
301                return;
302            }
303        };
304        ctx.window.request_redraw();
305        #[cfg(target_arch = "wasm32")]
306        let canvas_watch = {
307            let audio = ctx
308                .audio
309                .as_ref()
310                .map(game_toolkit_audio::Audio::detach_handle);
311            crate::web::CanvasWatch::new(&ctx.window, audio)
312                .inspect_err(|e| log::warn!("not watching the canvas for detach: {e:?}"))
313                .ok()
314        };
315        self.state = Some(RunState {
316            ctx,
317            game,
318            #[cfg(target_arch = "wasm32")]
319            canvas_watch,
320        });
321    }
322
323    fn frame(&mut self, event_loop: &ActiveEventLoop) {
324        let Some(state) = self.state.as_mut() else {
325            return;
326        };
327        state.ctx.time.tick();
328        state.ctx.input.poll_gamepads();
329        #[cfg(target_arch = "wasm32")]
330        state.ctx.poll_autoplay();
331        #[cfg(target_arch = "wasm32")]
332        if let Some(watch) = &state.canvas_watch {
333            for attached in watch.take_changes() {
334                if attached {
335                    // Don't replay the time the canvas spent out of the page.
336                    self.accumulator = Duration::ZERO;
337                }
338                state.ctx.canvas_attached = attached;
339                state
340                    .game
341                    .event(&mut state.ctx, &GameEvent::CanvasAttached(attached));
342            }
343        }
344
345        let delta = match self.config.max_frame_time {
346            Duration::ZERO => state.ctx.time.delta,
347            cap => state.ctx.time.delta.min(cap),
348        };
349        match self.config.fixed_timestep {
350            Some(step) => {
351                self.accumulator += delta;
352                let mut updated = false;
353                while self.accumulator >= step {
354                    state.game.update(&mut state.ctx, step.as_secs_f32());
355                    self.accumulator -= step;
356                    // Edges belong to the first step; later steps this frame shouldn't see
357                    // the same press again. With no step at all they wait for the next frame.
358                    if !updated {
359                        state.ctx.input.end_frame();
360                        updated = true;
361                    }
362                }
363                state.ctx.time.alpha = alpha(self.accumulator, step);
364            }
365            None => {
366                state.game.update(&mut state.ctx, delta.as_secs_f32());
367                state.ctx.input.end_frame();
368            }
369        }
370
371        if state.ctx.quit_requested {
372            event_loop.exit();
373            return;
374        }
375
376        match state.ctx.gfx.begin_frame() {
377            Ok(mut frame) => {
378                state.game.render(&mut state.ctx, &mut frame);
379                state.ctx.gfx.present(frame);
380            }
381            Err(e) => {
382                log::warn!("begin_frame failed: {e:?}");
383                let (w, h) = state.ctx.gfx.size();
384                state.ctx.gfx.resize(w, h);
385            }
386        }
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn alpha_is_fraction_of_step() {
396        let step = Duration::from_millis(10);
397        assert_eq!(alpha(Duration::ZERO, step), 0.0);
398        assert!((alpha(Duration::from_millis(5), step) - 0.5).abs() < 1e-6);
399        assert_eq!(alpha(Duration::from_millis(5), Duration::ZERO), 0.0);
400    }
401}