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}
61
62impl Default for AppConfig {
63    fn default() -> Self {
64        Self {
65            title: "Game".into(),
66            width: 1280,
67            height: 720,
68            vsync: true,
69            fixed_timestep: None,
70            max_frame_time: Duration::from_millis(250),
71            asset_root: std::path::PathBuf::from("assets"),
72            web_preload: None,
73            canvas_id: None,
74            random_seed: crate::Rng::DEFAULT_SEED,
75            depth_format: None,
76            msaa_samples: 1,
77        }
78    }
79}
80
81/// Sent by the web init task once the GPU is up and the assets are preloaded. Unused natively,
82/// where init completes synchronously inside `resumed`.
83type InitDone = Box<(Arc<Window>, Result<Parts>)>;
84
85/// Open the window and run `G` until it quits.
86///
87/// Natively this blocks until the window closes. On the web it installs a panic hook and a
88/// console logger (unless a logger is already set), hands the loop to the browser and returns
89/// immediately; `G::init` runs once the GPU is ready and the assets are preloaded.
90pub fn run<G: Game>(config: AppConfig) -> Result<()> {
91    #[cfg(target_arch = "wasm32")]
92    crate::web::install_hooks();
93    let event_loop = EventLoop::<InitDone>::with_user_event().build()?;
94    event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
95    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
96    let mut runner = AppRunner::<G> {
97        config,
98        state: None,
99        accumulator: Duration::ZERO,
100        #[cfg(target_arch = "wasm32")]
101        proxy: event_loop.create_proxy(),
102        #[cfg(target_arch = "wasm32")]
103        initializing: false,
104    };
105    #[cfg(not(target_arch = "wasm32"))]
106    event_loop.run_app(&mut runner)?;
107    #[cfg(target_arch = "wasm32")]
108    {
109        use winit::platform::web::EventLoopExtWebSys;
110        event_loop.spawn_app(runner);
111    }
112    Ok(())
113}
114
115struct AppRunner<G: Game> {
116    config: AppConfig,
117    state: Option<RunState<G>>,
118    accumulator: Duration,
119    #[cfg(target_arch = "wasm32")]
120    proxy: winit::event_loop::EventLoopProxy<InitDone>,
121    /// Set while the web init task is in flight, so another `resumed` doesn't start a second.
122    #[cfg(target_arch = "wasm32")]
123    initializing: bool,
124}
125
126struct RunState<G: Game> {
127    ctx: Context,
128    game: G,
129}
130
131impl<G: Game> ApplicationHandler<InitDone> for AppRunner<G> {
132    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
133        if self.state.is_some() {
134            return;
135        }
136        #[cfg(target_arch = "wasm32")]
137        if self.initializing {
138            return;
139        }
140        let window = match context::create_window(event_loop, &self.config) {
141            Ok(w) => w,
142            Err(e) => {
143                log::error!("window creation failed: {e:?}");
144                event_loop.exit();
145                return;
146            }
147        };
148        // Adapter/device requests are async. Natively we can block on them; the browser main
149        // thread can't block, so there init runs as a task that reports back via `user_event`.
150        #[cfg(not(target_arch = "wasm32"))]
151        {
152            let parts =
153                pollster::block_on(context::init_parts(window.clone(), self.config.clone()));
154            self.finish_init(event_loop, window, parts);
155        }
156        #[cfg(target_arch = "wasm32")]
157        {
158            self.initializing = true;
159            let proxy = self.proxy.clone();
160            let config = self.config.clone();
161            wasm_bindgen_futures::spawn_local(async move {
162                let parts = context::init_parts(window.clone(), config).await;
163                if proxy.send_event(Box::new((window, parts))).is_err() {
164                    log::error!("event loop closed before init finished");
165                }
166            });
167        }
168    }
169
170    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: InitDone) {
171        let (window, parts) = *event;
172        #[cfg(target_arch = "wasm32")]
173        {
174            self.initializing = false;
175        }
176        self.finish_init(event_loop, window, parts);
177    }
178
179    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
180        let Some(state) = self.state.as_mut() else {
181            return;
182        };
183        // The first key, click or touch is the user gesture browsers require before audio may
184        // play. Natively the device is already open and this is a no-op.
185        if is_user_gesture(&event) {
186            state.ctx.start_audio();
187        }
188        // Give the game's raw-event hook first crack (egui etc. needs this).
189        let consumed = state.game.raw_window_event(&mut state.ctx, &event);
190        match event {
191            WindowEvent::CloseRequested => {
192                state.game.event(&mut state.ctx, &GameEvent::CloseRequested);
193                event_loop.exit();
194            }
195            WindowEvent::Resized(size) => {
196                state.ctx.gfx.resize(size.width, size.height);
197                state.game.event(
198                    &mut state.ctx,
199                    &GameEvent::Resized {
200                        width: size.width,
201                        height: size.height,
202                    },
203                );
204            }
205            WindowEvent::Focused(focused) => {
206                if focused {
207                    // Don't replay time accumulated while the window was in the background.
208                    self.accumulator = Duration::ZERO;
209                }
210                state
211                    .ctx
212                    .input
213                    .handle_window_event(&WindowEvent::Focused(focused));
214                state
215                    .game
216                    .event(&mut state.ctx, &GameEvent::FocusChanged(focused));
217            }
218            WindowEvent::RedrawRequested => {
219                self.frame(event_loop);
220            }
221            ref other if !consumed => {
222                state.ctx.input.handle_window_event(other);
223            }
224            _ => {}
225        }
226    }
227
228    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
229        if let Some(state) = self.state.as_ref() {
230            state.ctx.window.request_redraw();
231        }
232    }
233
234    fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
235        if let Some(state) = self.state.as_mut() {
236            state.game.shutdown(&mut state.ctx);
237        }
238    }
239}
240
241/// How far `accumulator` is into the next fixed `step`, in `0.0..1.0`.
242fn alpha(accumulator: Duration, step: Duration) -> f32 {
243    if step.is_zero() {
244        return 0.0;
245    }
246    (accumulator.as_secs_f64() / step.as_secs_f64()).clamp(0.0, 1.0) as f32
247}
248
249fn is_user_gesture(event: &WindowEvent) -> bool {
250    match event {
251        WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
252        WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
253        WindowEvent::Touch(_) => true,
254        _ => false,
255    }
256}
257
258impl<G: Game> AppRunner<G> {
259    /// Second half of startup, once the window, GPU and assets exist: build the [`Context`]
260    /// and call `G::init`.
261    fn finish_init(
262        &mut self,
263        event_loop: &ActiveEventLoop,
264        window: Arc<Window>,
265        parts: Result<Parts>,
266    ) {
267        let parts = match parts {
268            Ok(p) => p,
269            Err(e) => {
270                log::error!("context init failed: {e:?}");
271                event_loop.exit();
272                return;
273            }
274        };
275        let mut ctx = Context::from_parts(window, parts, &self.config);
276        // On the web, init is async and the canvas's real size arrives as a `Resized` event
277        // while it runs, which `window_event` drops (no state yet). Catch up before `G::init`
278        // so it sees the true surface size.
279        let size = ctx.window.inner_size();
280        ctx.gfx.resize(size.width, size.height);
281        let game = match G::init(&mut ctx) {
282            Ok(g) => g,
283            Err(e) => {
284                log::error!("game init failed: {e:?}");
285                event_loop.exit();
286                return;
287            }
288        };
289        ctx.window.request_redraw();
290        self.state = Some(RunState { ctx, game });
291    }
292
293    fn frame(&mut self, event_loop: &ActiveEventLoop) {
294        let Some(state) = self.state.as_mut() else {
295            return;
296        };
297        state.ctx.time.tick();
298        state.ctx.input.poll_gamepads();
299
300        let delta = match self.config.max_frame_time {
301            Duration::ZERO => state.ctx.time.delta,
302            cap => state.ctx.time.delta.min(cap),
303        };
304        match self.config.fixed_timestep {
305            Some(step) => {
306                self.accumulator += delta;
307                let mut updated = false;
308                while self.accumulator >= step {
309                    state.game.update(&mut state.ctx, step.as_secs_f32());
310                    self.accumulator -= step;
311                    // Edges belong to the first step; later steps this frame shouldn't see
312                    // the same press again. With no step at all they wait for the next frame.
313                    if !updated {
314                        state.ctx.input.end_frame();
315                        updated = true;
316                    }
317                }
318                state.ctx.time.alpha = alpha(self.accumulator, step);
319            }
320            None => {
321                state.game.update(&mut state.ctx, delta.as_secs_f32());
322                state.ctx.input.end_frame();
323            }
324        }
325
326        if state.ctx.quit_requested {
327            event_loop.exit();
328            return;
329        }
330
331        match state.ctx.gfx.begin_frame() {
332            Ok(mut frame) => {
333                state.game.render(&mut state.ctx, &mut frame);
334                state.ctx.gfx.present(frame);
335            }
336            Err(e) => {
337                log::warn!("begin_frame failed: {e:?}");
338                let (w, h) = state.ctx.gfx.size();
339                state.ctx.gfx.resize(w, h);
340            }
341        }
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn alpha_is_fraction_of_step() {
351        let step = Duration::from_millis(10);
352        assert_eq!(alpha(Duration::ZERO, step), 0.0);
353        assert!((alpha(Duration::from_millis(5), step) - 0.5).abs() < 1e-6);
354        assert_eq!(alpha(Duration::from_millis(5), Duration::ZERO), 0.0);
355    }
356}