game-toolkit-core 0.11.2

Game loop, the Game trait, Context and time for the game-toolkit game-jam kit.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
use std::sync::Arc;
use std::time::Duration;

use anyhow::Result;
use winit::application::ApplicationHandler;
use winit::event::{ElementState, WindowEvent};
use winit::event_loop::{ActiveEventLoop, EventLoop};
use winit::window::{Window, WindowId};

use crate::Game;
use crate::context::{self, Context, GameEvent, Parts};

/// Runtime configuration passed to [`run`].
///
/// Construct it with `..Default::default()` so new fields added in future minor releases don't
/// break your call site: `AppConfig { title: "game".into(), ..Default::default() }`.
#[derive(Clone)]
pub struct AppConfig {
    pub title: String,
    pub width: u32,
    pub height: u32,
    pub vsync: bool,
    /// `Some(step)` calls `update` with a fixed `dt` of `step`: zero, one or several times per
    /// frame, as many as the real time elapsed allows. `None` (default) calls it once per
    /// frame with the frame's `dt`. Either way `render` runs once per frame; with a fixed
    /// step, [`Time::alpha`](crate::Time::alpha) says how far the frame is into the next step,
    /// for interpolating motion between steps.
    ///
    /// Leave this `None` if the game steps a simulation itself, e.g. a sillyecs world whose
    /// `apply_system_phases` runs its own fixed phases from real time; otherwise those fixed
    /// phases nest inside the toolkit's steps.
    ///
    /// Input edges (`key_pressed`, `mouse_delta`, ...) are seen by the first `update` of a
    /// frame and carried over to the next frame when no step ran, so presses aren't dropped.
    pub fixed_timestep: Option<Duration>,
    /// Upper bound on the time one frame advances the game (default 250 ms). After a long
    /// frame - the window was dragged, the tab was in the background, a breakpoint hit - the
    /// game slows down for that frame instead of fast-forwarding through the gap: `dt` is
    /// capped at this, and with a fixed timestep at most `max_frame_time / step` updates run.
    /// [`Time::delta`](crate::Time::delta) still reports the real frame time.
    /// `Duration::ZERO` disables the cap.
    pub max_frame_time: Duration,
    /// Asset root directory. Defaults to `./assets`. On the web it is a URL path relative to
    /// the page, and the files listed in its `manifest.txt` are fetched before `Game::init`.
    pub asset_root: std::path::PathBuf,
    /// Web only: asset paths to preload instead of reading `manifest.txt`. Ignored natively.
    pub web_preload: Option<Vec<String>>,
    /// Web only: `id` of an existing `<canvas>` to render into. `None` (default) appends a new
    /// canvas to `<body>`. Ignored natively.
    pub canvas_id: Option<String>,
    /// Seed for deterministic runtime RNG (`ctx.rng`).
    pub random_seed: u64,
    /// Depth attachment format. `None` (default) keeps the 2D path depth-less; `Some(fmt)`
    /// (e.g. `wgpu::TextureFormat::Depth32Float`) allocates a depth buffer for depth-tested
    /// rendering. The built-in 2D pipelines never write depth, so enabling it is harmless.
    pub depth_format: Option<wgpu::TextureFormat>,
    /// MSAA sample count for the surface. `1` (default) disables MSAA; `2`/`4`/`8` enable it
    /// (the value must be supported by the adapter for the surface format).
    pub msaa_samples: u32,
    /// Let the first finger on a touchscreen act as the mouse: it moves the cursor and
    /// presses, holds and releases [`MouseButton::Left`](game_toolkit_input::MouseButton),
    /// so a game written for the mouse also works on a phone. Off by default; toggle later
    /// with [`Input::set_touch_emulates_mouse`](game_toolkit_input::Input::set_touch_emulates_mouse),
    /// which has the details. No effect on Windows, where the OS already does this.
    pub touch_emulates_mouse: bool,
    /// Device limits the game needs beyond the toolkit's own, for compute or render passes
    /// it records itself through `ctx.gfx.device()`. `None` (default) requests only what the
    /// toolkit needs (`Limits::downlevel_defaults()`, or `Limits::default()` with the `vector`
    /// feature); with `Some`, each limit is the better of the two, e.g.
    /// `Some(wgpu::Limits { max_storage_buffers_per_shader_stage: 8, ..wgpu::Limits::downlevel_defaults() })`.
    /// Init fails with a [`DeviceRequirementsError`](game_toolkit_gfx::DeviceRequirementsError)
    /// naming any limit the adapter can't provide. WebGL2 has no compute at all; to fall back
    /// gracefully instead, use [`Self::preferred_limits`] and check
    /// [`Graphics::supports_compute`](game_toolkit_gfx::Graphics::supports_compute) in
    /// `Game::init`, or set [`Self::continue_without_requirements`].
    pub required_limits: Option<wgpu::Limits>,
    /// Optional device features the game needs (`SHADER_F16`, `TIMESTAMP_QUERY`, ...).
    /// Default empty. Missing ones fail init like missing limits.
    pub required_features: wgpu::Features,
    /// Device limits the game would like but can do without: each is requested up to what the
    /// adapter offers, so init never fails on them. Read what you got from
    /// `ctx.gfx.device().limits()`. E.g. `Some(wgpu::Limits { max_storage_buffers_per_shader_stage:
    /// 8, ..wgpu::Limits::downlevel_defaults() })` gets 8 on WebGPU and native and stays at
    /// WebGL2's 0 there. See [`GraphicsConfig::preferred_limits`](game_toolkit_gfx::GraphicsConfig::preferred_limits).
    pub preferred_limits: Option<wgpu::Limits>,
    /// Device features the game would like but can do without; enabled where the adapter has
    /// them. Check `ctx.gfx.device().features()`.
    pub preferred_features: wgpu::Features,
    /// `true`: when the adapter can't meet `required_limits` / `required_features`, start
    /// anyway without them and report it through
    /// [`Graphics::unmet_requirements`](game_toolkit_gfx::Graphics::unmet_requirements), so
    /// `Game::init` can draw a "WebGPU required" screen. `false` (default): init fails.
    pub continue_without_requirements: bool,
    /// Last look at the device request, with the adapter at hand, for decisions the fields
    /// above can't express; see [`DeviceHook`](game_toolkit_gfx::DeviceHook).
    pub device_hook: Option<game_toolkit_gfx::DeviceHook>,
    /// Draw into a fixed-size canvas of `(width, height)` pixels (e.g. `(320, 180)`) and scale
    /// it onto the window, for crisp pixel art. `None` (default) draws straight into the
    /// window. With a canvas, `ctx.gfx.size()`, the cameras and everything the painter draws
    /// use canvas pixels, and so do `Input::mouse_pos` and touch positions; see
    /// [`GraphicsConfig::virtual_resolution`](game_toolkit_gfx::GraphicsConfig::virtual_resolution).
    pub virtual_resolution: Option<(u32, u32)>,
    /// How the canvas is scaled onto the window: [`ScaleMode::Integer`] (default; whole
    /// multiples, the rest is border), `Fit` (fractional) or `Stretch`.
    ///
    /// [`ScaleMode::Integer`]: game_toolkit_gfx::ScaleMode::Integer
    pub scale_mode: game_toolkit_gfx::ScaleMode,
    /// Nearest (default) or linear filtering when scaling the canvas.
    pub scale_filter: game_toolkit_gfx::ScaleFilter,
    /// Colour of the border around the canvas (default black).
    pub border_color: game_toolkit_gfx::Color,
}

impl AppConfig {
    /// The [`GraphicsConfig`](game_toolkit_gfx::GraphicsConfig) this config creates the
    /// renderer with.
    pub fn graphics_config(&self) -> game_toolkit_gfx::GraphicsConfig {
        game_toolkit_gfx::GraphicsConfig {
            vsync: self.vsync,
            depth_format: self.depth_format,
            msaa_samples: self.msaa_samples,
            required_limits: self.required_limits.clone(),
            required_features: self.required_features,
            preferred_limits: self.preferred_limits.clone(),
            preferred_features: self.preferred_features,
            continue_without_requirements: self.continue_without_requirements,
            device_hook: self.device_hook.clone(),
            virtual_resolution: self.virtual_resolution,
            scale_mode: self.scale_mode,
            scale_filter: self.scale_filter,
            border_color: self.border_color,
        }
    }
}

impl Default for AppConfig {
    fn default() -> Self {
        Self {
            title: "Game".into(),
            width: 1280,
            height: 720,
            vsync: true,
            fixed_timestep: None,
            max_frame_time: Duration::from_millis(250),
            asset_root: std::path::PathBuf::from("assets"),
            web_preload: None,
            canvas_id: None,
            random_seed: crate::Rng::DEFAULT_SEED,
            depth_format: None,
            msaa_samples: 1,
            touch_emulates_mouse: false,
            required_limits: None,
            required_features: wgpu::Features::empty(),
            preferred_limits: None,
            preferred_features: wgpu::Features::empty(),
            continue_without_requirements: false,
            device_hook: None,
            virtual_resolution: None,
            scale_mode: game_toolkit_gfx::ScaleMode::Integer,
            scale_filter: game_toolkit_gfx::ScaleFilter::Nearest,
            border_color: game_toolkit_gfx::Color::BLACK,
        }
    }
}

/// Sent by the web init task once the GPU is up and the assets are preloaded. Unused natively,
/// where init completes synchronously inside `resumed`.
type InitDone = Box<(Arc<Window>, Result<Parts>)>;

/// Open the window and run `G` until it quits.
///
/// Natively this blocks until the window closes. On the web it installs a panic hook and a
/// console logger (unless a logger is already set), hands the loop to the browser and returns
/// immediately; `G::init` runs once the GPU is ready and the assets are preloaded.
pub fn run<G: Game>(config: AppConfig) -> Result<()> {
    #[cfg(target_arch = "wasm32")]
    crate::web::install_hooks();
    let event_loop = EventLoop::<InitDone>::with_user_event().build()?;
    event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
    let mut runner = AppRunner::<G> {
        config,
        state: None,
        accumulator: Duration::ZERO,
        #[cfg(target_arch = "wasm32")]
        proxy: event_loop.create_proxy(),
        #[cfg(target_arch = "wasm32")]
        initializing: false,
    };
    #[cfg(not(target_arch = "wasm32"))]
    event_loop.run_app(&mut runner)?;
    #[cfg(target_arch = "wasm32")]
    {
        use winit::platform::web::EventLoopExtWebSys;
        event_loop.spawn_app(runner);
    }
    Ok(())
}

struct AppRunner<G: Game> {
    config: AppConfig,
    state: Option<RunState<G>>,
    accumulator: Duration,
    #[cfg(target_arch = "wasm32")]
    proxy: winit::event_loop::EventLoopProxy<InitDone>,
    /// Set while the web init task is in flight, so another `resumed` doesn't start a second.
    #[cfg(target_arch = "wasm32")]
    initializing: bool,
}

struct RunState<G: Game> {
    ctx: Context,
    game: G,
    /// Suspends audio while the canvas is out of the page; `None` if it couldn't be set up.
    #[cfg(target_arch = "wasm32")]
    canvas_watch: Option<crate::web::CanvasWatch>,
}

impl<G: Game> ApplicationHandler<InitDone> for AppRunner<G> {
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        if self.state.is_some() {
            return;
        }
        #[cfg(target_arch = "wasm32")]
        if self.initializing {
            return;
        }
        let window = match context::create_window(event_loop, &self.config) {
            Ok(w) => w,
            Err(e) => {
                log::error!("window creation failed: {e:?}");
                event_loop.exit();
                return;
            }
        };
        // Adapter/device requests are async. Natively we can block on them; the browser main
        // thread can't block, so there init runs as a task that reports back via `user_event`.
        #[cfg(not(target_arch = "wasm32"))]
        {
            let parts =
                pollster::block_on(context::init_parts(window.clone(), self.config.clone()));
            self.finish_init(event_loop, window, parts);
        }
        #[cfg(target_arch = "wasm32")]
        {
            self.initializing = true;
            let proxy = self.proxy.clone();
            let config = self.config.clone();
            wasm_bindgen_futures::spawn_local(async move {
                let parts = context::init_parts(window.clone(), config).await;
                if proxy.send_event(Box::new((window, parts))).is_err() {
                    log::error!("event loop closed before init finished");
                }
            });
        }
    }

    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: InitDone) {
        let (window, parts) = *event;
        #[cfg(target_arch = "wasm32")]
        {
            self.initializing = false;
        }
        self.finish_init(event_loop, window, parts);
    }

    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
        let Some(state) = self.state.as_mut() else {
            return;
        };
        // The first key, click or touch is the user gesture browsers require before audio may
        // play. Natively the device is already open and this is a no-op.
        if is_user_gesture(&event) {
            state.ctx.start_audio();
        }
        // Give the game's raw-event hook first crack (egui etc. needs this).
        let consumed = state.game.raw_window_event(&mut state.ctx, &event);
        match event {
            WindowEvent::CloseRequested => {
                state.game.event(&mut state.ctx, &GameEvent::CloseRequested);
                event_loop.exit();
            }
            WindowEvent::Resized(size) => {
                state.ctx.gfx.resize(size.width, size.height);
                state.ctx.sync_pointer_mapping();
                state.game.event(
                    &mut state.ctx,
                    &GameEvent::Resized {
                        width: size.width,
                        height: size.height,
                    },
                );
            }
            WindowEvent::Focused(focused) => {
                if focused {
                    // Don't replay time accumulated while the window was in the background.
                    self.accumulator = Duration::ZERO;
                }
                state
                    .ctx
                    .input
                    .handle_window_event(&WindowEvent::Focused(focused));
                state
                    .game
                    .event(&mut state.ctx, &GameEvent::FocusChanged(focused));
            }
            WindowEvent::RedrawRequested => {
                self.frame(event_loop);
            }
            ref other if !consumed => {
                state.ctx.input.handle_window_event(other);
            }
            _ => {}
        }
    }

    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
        if let Some(state) = self.state.as_ref() {
            state.ctx.window.request_redraw();
        }
    }

    fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
        if let Some(state) = self.state.as_mut() {
            state.game.shutdown(&mut state.ctx);
        }
    }
}

/// How far `accumulator` is into the next fixed `step`, in `0.0..1.0`.
fn alpha(accumulator: Duration, step: Duration) -> f32 {
    if step.is_zero() {
        return 0.0;
    }
    (accumulator.as_secs_f64() / step.as_secs_f64()).clamp(0.0, 1.0) as f32
}

fn is_user_gesture(event: &WindowEvent) -> bool {
    match event {
        WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
        WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
        WindowEvent::Touch(_) => true,
        _ => false,
    }
}

impl<G: Game> AppRunner<G> {
    /// Second half of startup, once the window, GPU and assets exist: build the [`Context`]
    /// and call `G::init`.
    fn finish_init(
        &mut self,
        event_loop: &ActiveEventLoop,
        window: Arc<Window>,
        parts: Result<Parts>,
    ) {
        let parts = match parts {
            Ok(p) => p,
            Err(e) => {
                log::error!("context init failed: {e:?}");
                event_loop.exit();
                return;
            }
        };
        let mut ctx = Context::from_parts(window, parts, &self.config);
        // On the web, init is async and the canvas's real size arrives as a `Resized` event
        // while it runs, which `window_event` drops (no state yet). Catch up before `G::init`
        // so it sees the true surface size.
        let size = ctx.window.inner_size();
        ctx.gfx.resize(size.width, size.height);
        ctx.sync_pointer_mapping();
        // If the page already had a user gesture, audio may start now rather than on the first
        // click on the canvas: `G::init` then sees `is_started()` and music plays at once
        // (Chromium). Where the browser takes a moment to tell (Firefox, WebKit), the probe
        // keeps going and `frame` starts the audio once it knows.
        ctx.start_audio_if_allowed();
        let game = match G::init(&mut ctx) {
            Ok(g) => g,
            Err(e) => {
                log::error!("game init failed: {e:?}");
                event_loop.exit();
                return;
            }
        };
        ctx.window.request_redraw();
        #[cfg(target_arch = "wasm32")]
        let canvas_watch = {
            let audio = ctx
                .audio
                .as_ref()
                .map(game_toolkit_audio::Audio::detach_handle);
            crate::web::CanvasWatch::new(&ctx.window, audio)
                .inspect_err(|e| log::warn!("not watching the canvas for detach: {e:?}"))
                .ok()
        };
        self.state = Some(RunState {
            ctx,
            game,
            #[cfg(target_arch = "wasm32")]
            canvas_watch,
        });
    }

    fn frame(&mut self, event_loop: &ActiveEventLoop) {
        let Some(state) = self.state.as_mut() else {
            return;
        };
        state.ctx.time.tick();
        state.ctx.input.poll_gamepads();
        // The game may have changed the virtual resolution or scale mode last frame.
        state.ctx.sync_pointer_mapping();
        #[cfg(target_arch = "wasm32")]
        state.ctx.poll_autoplay();
        #[cfg(target_arch = "wasm32")]
        if let Some(watch) = &state.canvas_watch {
            for attached in watch.take_changes() {
                if attached {
                    // Don't replay the time the canvas spent out of the page.
                    self.accumulator = Duration::ZERO;
                }
                state.ctx.canvas_attached = attached;
                state
                    .game
                    .event(&mut state.ctx, &GameEvent::CanvasAttached(attached));
            }
        }

        let delta = match self.config.max_frame_time {
            Duration::ZERO => state.ctx.time.delta,
            cap => state.ctx.time.delta.min(cap),
        };
        match self.config.fixed_timestep {
            Some(step) => {
                self.accumulator += delta;
                let mut updated = false;
                while self.accumulator >= step {
                    state.game.update(&mut state.ctx, step.as_secs_f32());
                    // `update` may have changed the virtual resolution or scale mode: the
                    // next step and `render` read the pointer in the new canvas pixels.
                    state.ctx.sync_pointer_mapping();
                    self.accumulator -= step;
                    // Edges belong to the first step; later steps this frame shouldn't see
                    // the same press again. With no step at all they wait for the next frame.
                    if !updated {
                        state.ctx.input.end_frame();
                        updated = true;
                    }
                }
                state.ctx.time.alpha = alpha(self.accumulator, step);
            }
            None => {
                state.game.update(&mut state.ctx, delta.as_secs_f32());
                state.ctx.sync_pointer_mapping();
                state.ctx.input.end_frame();
            }
        }

        if state.ctx.quit_requested {
            event_loop.exit();
            return;
        }

        match state.ctx.gfx.begin_frame() {
            Ok(mut frame) => {
                state.game.render(&mut state.ctx, &mut frame);
                state.ctx.gfx.present(frame);
            }
            Err(e) => {
                log::warn!("begin_frame failed: {e:?}");
                let (w, h) = state.ctx.gfx.size();
                state.ctx.gfx.resize(w, h);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn alpha_is_fraction_of_step() {
        let step = Duration::from_millis(10);
        assert_eq!(alpha(Duration::ZERO, step), 0.0);
        assert!((alpha(Duration::from_millis(5), step) - 0.5).abs() < 1e-6);
        assert_eq!(alpha(Duration::from_millis(5), Duration::ZERO), 0.0);
    }
}