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    /// Device limits the game needs beyond the toolkit's own, for compute or render passes
67    /// it records itself through `ctx.gfx.device()`. `None` (default) requests only what the
68    /// toolkit needs (`Limits::downlevel_defaults()`, or `Limits::default()` with the `vector`
69    /// feature); with `Some`, each limit is the better of the two, e.g.
70    /// `Some(wgpu::Limits { max_storage_buffers_per_shader_stage: 8, ..wgpu::Limits::downlevel_defaults() })`.
71    /// Init fails with a [`DeviceRequirementsError`](game_toolkit_gfx::DeviceRequirementsError)
72    /// naming any limit the adapter can't provide. WebGL2 has no compute at all; to fall back
73    /// gracefully instead, use [`Self::preferred_limits`] and check
74    /// [`Graphics::supports_compute`](game_toolkit_gfx::Graphics::supports_compute) in
75    /// `Game::init`, or set [`Self::continue_without_requirements`].
76    pub required_limits: Option<wgpu::Limits>,
77    /// Optional device features the game needs (`SHADER_F16`, `TIMESTAMP_QUERY`, ...).
78    /// Default empty. Missing ones fail init like missing limits.
79    pub required_features: wgpu::Features,
80    /// Device limits the game would like but can do without: each is requested up to what the
81    /// adapter offers, so init never fails on them. Read what you got from
82    /// `ctx.gfx.device().limits()`. E.g. `Some(wgpu::Limits { max_storage_buffers_per_shader_stage:
83    /// 8, ..wgpu::Limits::downlevel_defaults() })` gets 8 on WebGPU and native and stays at
84    /// WebGL2's 0 there. See [`GraphicsConfig::preferred_limits`](game_toolkit_gfx::GraphicsConfig::preferred_limits).
85    pub preferred_limits: Option<wgpu::Limits>,
86    /// Device features the game would like but can do without; enabled where the adapter has
87    /// them. Check `ctx.gfx.device().features()`.
88    pub preferred_features: wgpu::Features,
89    /// `true`: when the adapter can't meet `required_limits` / `required_features`, start
90    /// anyway without them and report it through
91    /// [`Graphics::unmet_requirements`](game_toolkit_gfx::Graphics::unmet_requirements), so
92    /// `Game::init` can draw a "WebGPU required" screen. `false` (default): init fails.
93    pub continue_without_requirements: bool,
94    /// Last look at the device request, with the adapter at hand, for decisions the fields
95    /// above can't express; see [`DeviceHook`](game_toolkit_gfx::DeviceHook).
96    pub device_hook: Option<game_toolkit_gfx::DeviceHook>,
97    /// Draw into a fixed-size canvas of `(width, height)` pixels (e.g. `(320, 180)`) and scale
98    /// it onto the window, for crisp pixel art. `None` (default) draws straight into the
99    /// window. With a canvas, `ctx.gfx.size()`, the cameras and everything the painter draws
100    /// use canvas pixels, and so do `Input::mouse_pos` and touch positions; see
101    /// [`GraphicsConfig::virtual_resolution`](game_toolkit_gfx::GraphicsConfig::virtual_resolution).
102    pub virtual_resolution: Option<(u32, u32)>,
103    /// How the canvas is scaled onto the window: [`ScaleMode::Integer`] (default; whole
104    /// multiples, the rest is border), `Fit` (fractional) or `Stretch`.
105    ///
106    /// [`ScaleMode::Integer`]: game_toolkit_gfx::ScaleMode::Integer
107    pub scale_mode: game_toolkit_gfx::ScaleMode,
108    /// Nearest (default) or linear filtering when scaling the canvas.
109    pub scale_filter: game_toolkit_gfx::ScaleFilter,
110    /// Colour of the border around the canvas (default black).
111    pub border_color: game_toolkit_gfx::Color,
112}
113
114impl AppConfig {
115    /// The [`GraphicsConfig`](game_toolkit_gfx::GraphicsConfig) this config creates the
116    /// renderer with.
117    pub fn graphics_config(&self) -> game_toolkit_gfx::GraphicsConfig {
118        game_toolkit_gfx::GraphicsConfig {
119            vsync: self.vsync,
120            depth_format: self.depth_format,
121            msaa_samples: self.msaa_samples,
122            required_limits: self.required_limits.clone(),
123            required_features: self.required_features,
124            preferred_limits: self.preferred_limits.clone(),
125            preferred_features: self.preferred_features,
126            continue_without_requirements: self.continue_without_requirements,
127            device_hook: self.device_hook.clone(),
128            virtual_resolution: self.virtual_resolution,
129            scale_mode: self.scale_mode,
130            scale_filter: self.scale_filter,
131            border_color: self.border_color,
132        }
133    }
134}
135
136impl Default for AppConfig {
137    fn default() -> Self {
138        Self {
139            title: "Game".into(),
140            width: 1280,
141            height: 720,
142            vsync: true,
143            fixed_timestep: None,
144            max_frame_time: Duration::from_millis(250),
145            asset_root: std::path::PathBuf::from("assets"),
146            web_preload: None,
147            canvas_id: None,
148            random_seed: crate::Rng::DEFAULT_SEED,
149            depth_format: None,
150            msaa_samples: 1,
151            touch_emulates_mouse: false,
152            required_limits: None,
153            required_features: wgpu::Features::empty(),
154            preferred_limits: None,
155            preferred_features: wgpu::Features::empty(),
156            continue_without_requirements: false,
157            device_hook: None,
158            virtual_resolution: None,
159            scale_mode: game_toolkit_gfx::ScaleMode::Integer,
160            scale_filter: game_toolkit_gfx::ScaleFilter::Nearest,
161            border_color: game_toolkit_gfx::Color::BLACK,
162        }
163    }
164}
165
166/// Sent by the web init task once the GPU is up and the assets are preloaded. Unused natively,
167/// where init completes synchronously inside `resumed`.
168type InitDone = Box<(Arc<Window>, Result<Parts>)>;
169
170/// Open the window and run `G` until it quits.
171///
172/// Natively this blocks until the window closes. On the web it installs a panic hook and a
173/// console logger (unless a logger is already set), hands the loop to the browser and returns
174/// immediately; `G::init` runs once the GPU is ready and the assets are preloaded.
175pub fn run<G: Game>(config: AppConfig) -> Result<()> {
176    #[cfg(target_arch = "wasm32")]
177    crate::web::install_hooks();
178    let event_loop = EventLoop::<InitDone>::with_user_event().build()?;
179    event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
180    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
181    let mut runner = AppRunner::<G> {
182        config,
183        state: None,
184        accumulator: Duration::ZERO,
185        #[cfg(target_arch = "wasm32")]
186        proxy: event_loop.create_proxy(),
187        #[cfg(target_arch = "wasm32")]
188        initializing: false,
189    };
190    #[cfg(not(target_arch = "wasm32"))]
191    event_loop.run_app(&mut runner)?;
192    #[cfg(target_arch = "wasm32")]
193    {
194        use winit::platform::web::EventLoopExtWebSys;
195        event_loop.spawn_app(runner);
196    }
197    Ok(())
198}
199
200struct AppRunner<G: Game> {
201    config: AppConfig,
202    state: Option<RunState<G>>,
203    accumulator: Duration,
204    #[cfg(target_arch = "wasm32")]
205    proxy: winit::event_loop::EventLoopProxy<InitDone>,
206    /// Set while the web init task is in flight, so another `resumed` doesn't start a second.
207    #[cfg(target_arch = "wasm32")]
208    initializing: bool,
209}
210
211struct RunState<G: Game> {
212    ctx: Context,
213    game: G,
214    /// Suspends audio while the canvas is out of the page; `None` if it couldn't be set up.
215    #[cfg(target_arch = "wasm32")]
216    canvas_watch: Option<crate::web::CanvasWatch>,
217}
218
219impl<G: Game> ApplicationHandler<InitDone> for AppRunner<G> {
220    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
221        if self.state.is_some() {
222            return;
223        }
224        #[cfg(target_arch = "wasm32")]
225        if self.initializing {
226            return;
227        }
228        let window = match context::create_window(event_loop, &self.config) {
229            Ok(w) => w,
230            Err(e) => {
231                log::error!("window creation failed: {e:?}");
232                event_loop.exit();
233                return;
234            }
235        };
236        // Adapter/device requests are async. Natively we can block on them; the browser main
237        // thread can't block, so there init runs as a task that reports back via `user_event`.
238        #[cfg(not(target_arch = "wasm32"))]
239        {
240            let parts =
241                pollster::block_on(context::init_parts(window.clone(), self.config.clone()));
242            self.finish_init(event_loop, window, parts);
243        }
244        #[cfg(target_arch = "wasm32")]
245        {
246            self.initializing = true;
247            let proxy = self.proxy.clone();
248            let config = self.config.clone();
249            wasm_bindgen_futures::spawn_local(async move {
250                let parts = context::init_parts(window.clone(), config).await;
251                if proxy.send_event(Box::new((window, parts))).is_err() {
252                    log::error!("event loop closed before init finished");
253                }
254            });
255        }
256    }
257
258    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: InitDone) {
259        let (window, parts) = *event;
260        #[cfg(target_arch = "wasm32")]
261        {
262            self.initializing = false;
263        }
264        self.finish_init(event_loop, window, parts);
265    }
266
267    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
268        let Some(state) = self.state.as_mut() else {
269            return;
270        };
271        // The first key, click or touch is the user gesture browsers require before audio may
272        // play. Natively the device is already open and this is a no-op.
273        if is_user_gesture(&event) {
274            state.ctx.start_audio();
275        }
276        // Give the game's raw-event hook first crack (egui etc. needs this).
277        let consumed = state.game.raw_window_event(&mut state.ctx, &event);
278        match event {
279            WindowEvent::CloseRequested => {
280                state.game.event(&mut state.ctx, &GameEvent::CloseRequested);
281                event_loop.exit();
282            }
283            WindowEvent::Resized(size) => {
284                state.ctx.gfx.resize(size.width, size.height);
285                state.ctx.sync_pointer_mapping();
286                state.game.event(
287                    &mut state.ctx,
288                    &GameEvent::Resized {
289                        width: size.width,
290                        height: size.height,
291                    },
292                );
293            }
294            WindowEvent::Focused(focused) => {
295                if focused {
296                    // Don't replay time accumulated while the window was in the background.
297                    self.accumulator = Duration::ZERO;
298                }
299                state
300                    .ctx
301                    .input
302                    .handle_window_event(&WindowEvent::Focused(focused));
303                state
304                    .game
305                    .event(&mut state.ctx, &GameEvent::FocusChanged(focused));
306            }
307            WindowEvent::RedrawRequested => {
308                self.frame(event_loop);
309            }
310            ref other if !consumed => {
311                state.ctx.input.handle_window_event(other);
312            }
313            _ => {}
314        }
315    }
316
317    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
318        if let Some(state) = self.state.as_ref() {
319            state.ctx.window.request_redraw();
320        }
321    }
322
323    fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
324        if let Some(state) = self.state.as_mut() {
325            state.game.shutdown(&mut state.ctx);
326        }
327    }
328}
329
330/// How far `accumulator` is into the next fixed `step`, in `0.0..1.0`.
331fn alpha(accumulator: Duration, step: Duration) -> f32 {
332    if step.is_zero() {
333        return 0.0;
334    }
335    (accumulator.as_secs_f64() / step.as_secs_f64()).clamp(0.0, 1.0) as f32
336}
337
338fn is_user_gesture(event: &WindowEvent) -> bool {
339    match event {
340        WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
341        WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
342        WindowEvent::Touch(_) => true,
343        _ => false,
344    }
345}
346
347impl<G: Game> AppRunner<G> {
348    /// Second half of startup, once the window, GPU and assets exist: build the [`Context`]
349    /// and call `G::init`.
350    fn finish_init(
351        &mut self,
352        event_loop: &ActiveEventLoop,
353        window: Arc<Window>,
354        parts: Result<Parts>,
355    ) {
356        let parts = match parts {
357            Ok(p) => p,
358            Err(e) => {
359                log::error!("context init failed: {e:?}");
360                event_loop.exit();
361                return;
362            }
363        };
364        let mut ctx = Context::from_parts(window, parts, &self.config);
365        // On the web, init is async and the canvas's real size arrives as a `Resized` event
366        // while it runs, which `window_event` drops (no state yet). Catch up before `G::init`
367        // so it sees the true surface size.
368        let size = ctx.window.inner_size();
369        ctx.gfx.resize(size.width, size.height);
370        ctx.sync_pointer_mapping();
371        // If the page already had a user gesture, audio may start now rather than on the first
372        // click on the canvas: `G::init` then sees `is_started()` and music plays at once
373        // (Chromium). Where the browser takes a moment to tell (Firefox, WebKit), the probe
374        // keeps going and `frame` starts the audio once it knows.
375        ctx.start_audio_if_allowed();
376        let game = match G::init(&mut ctx) {
377            Ok(g) => g,
378            Err(e) => {
379                log::error!("game init failed: {e:?}");
380                event_loop.exit();
381                return;
382            }
383        };
384        ctx.window.request_redraw();
385        #[cfg(target_arch = "wasm32")]
386        let canvas_watch = {
387            let audio = ctx
388                .audio
389                .as_ref()
390                .map(game_toolkit_audio::Audio::detach_handle);
391            crate::web::CanvasWatch::new(&ctx.window, audio)
392                .inspect_err(|e| log::warn!("not watching the canvas for detach: {e:?}"))
393                .ok()
394        };
395        self.state = Some(RunState {
396            ctx,
397            game,
398            #[cfg(target_arch = "wasm32")]
399            canvas_watch,
400        });
401    }
402
403    fn frame(&mut self, event_loop: &ActiveEventLoop) {
404        let Some(state) = self.state.as_mut() else {
405            return;
406        };
407        state.ctx.time.tick();
408        state.ctx.input.poll_gamepads();
409        // The game may have changed the virtual resolution or scale mode last frame.
410        state.ctx.sync_pointer_mapping();
411        #[cfg(target_arch = "wasm32")]
412        state.ctx.poll_autoplay();
413        #[cfg(target_arch = "wasm32")]
414        if let Some(watch) = &state.canvas_watch {
415            for attached in watch.take_changes() {
416                if attached {
417                    // Don't replay the time the canvas spent out of the page.
418                    self.accumulator = Duration::ZERO;
419                }
420                state.ctx.canvas_attached = attached;
421                state
422                    .game
423                    .event(&mut state.ctx, &GameEvent::CanvasAttached(attached));
424            }
425        }
426
427        let delta = match self.config.max_frame_time {
428            Duration::ZERO => state.ctx.time.delta,
429            cap => state.ctx.time.delta.min(cap),
430        };
431        match self.config.fixed_timestep {
432            Some(step) => {
433                self.accumulator += delta;
434                let mut updated = false;
435                while self.accumulator >= step {
436                    state.game.update(&mut state.ctx, step.as_secs_f32());
437                    // `update` may have changed the virtual resolution or scale mode: the
438                    // next step and `render` read the pointer in the new canvas pixels.
439                    state.ctx.sync_pointer_mapping();
440                    self.accumulator -= step;
441                    // Edges belong to the first step; later steps this frame shouldn't see
442                    // the same press again. With no step at all they wait for the next frame.
443                    if !updated {
444                        state.ctx.input.end_frame();
445                        updated = true;
446                    }
447                }
448                state.ctx.time.alpha = alpha(self.accumulator, step);
449            }
450            None => {
451                state.game.update(&mut state.ctx, delta.as_secs_f32());
452                state.ctx.sync_pointer_mapping();
453                state.ctx.input.end_frame();
454            }
455        }
456
457        if state.ctx.quit_requested {
458            event_loop.exit();
459            return;
460        }
461
462        match state.ctx.gfx.begin_frame() {
463            Ok(mut frame) => {
464                state.game.render(&mut state.ctx, &mut frame);
465                state.ctx.gfx.present(frame);
466            }
467            Err(e) => {
468                log::warn!("begin_frame failed: {e:?}");
469                let (w, h) = state.ctx.gfx.size();
470                state.ctx.gfx.resize(w, h);
471            }
472        }
473    }
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn alpha_is_fraction_of_step() {
482        let step = Duration::from_millis(10);
483        assert_eq!(alpha(Duration::ZERO, step), 0.0);
484        assert!((alpha(Duration::from_millis(5), step) - 0.5).abs() < 1e-6);
485        assert_eq!(alpha(Duration::from_millis(5), Duration::ZERO), 0.0);
486    }
487}