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(d)` for deterministic fixed-step `update`; `None` for variable `dt`.
24    pub fixed_timestep: Option<Duration>,
25    /// Asset root directory. Defaults to `./assets`. On the web it is a URL path relative to
26    /// the page, and the files listed in its `manifest.txt` are fetched before `Game::init`.
27    pub asset_root: std::path::PathBuf,
28    /// Web only: asset paths to preload instead of reading `manifest.txt`. Ignored natively.
29    pub web_preload: Option<Vec<String>>,
30    /// Web only: `id` of an existing `<canvas>` to render into. `None` (default) appends a new
31    /// canvas to `<body>`. Ignored natively.
32    pub canvas_id: Option<String>,
33    /// Seed for deterministic runtime RNG (`ctx.rng`).
34    pub random_seed: u64,
35    /// Depth attachment format. `None` (default) keeps the 2D path depth-less; `Some(fmt)`
36    /// (e.g. `wgpu::TextureFormat::Depth32Float`) allocates a depth buffer for depth-tested
37    /// rendering. The built-in 2D pipelines never write depth, so enabling it is harmless.
38    pub depth_format: Option<wgpu::TextureFormat>,
39    /// MSAA sample count for the surface. `1` (default) disables MSAA; `2`/`4`/`8` enable it
40    /// (the value must be supported by the adapter for the surface format).
41    pub msaa_samples: u32,
42}
43
44impl Default for AppConfig {
45    fn default() -> Self {
46        Self {
47            title: "Game".into(),
48            width: 1280,
49            height: 720,
50            vsync: true,
51            fixed_timestep: None,
52            asset_root: std::path::PathBuf::from("assets"),
53            web_preload: None,
54            canvas_id: None,
55            random_seed: crate::Rng::DEFAULT_SEED,
56            depth_format: None,
57            msaa_samples: 1,
58        }
59    }
60}
61
62/// Sent by the web init task once the GPU is up and the assets are preloaded. Unused natively,
63/// where init completes synchronously inside `resumed`.
64type InitDone = Box<(Arc<Window>, Result<Parts>)>;
65
66/// Open the window and run `G` until it quits.
67///
68/// Natively this blocks until the window closes. On the web it installs a panic hook and a
69/// console logger (unless a logger is already set), hands the loop to the browser and returns
70/// immediately; `G::init` runs once the GPU is ready and the assets are preloaded.
71pub fn run<G: Game>(config: AppConfig) -> Result<()> {
72    #[cfg(target_arch = "wasm32")]
73    crate::web::install_hooks();
74    let event_loop = EventLoop::<InitDone>::with_user_event().build()?;
75    event_loop.set_control_flow(winit::event_loop::ControlFlow::Poll);
76    #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
77    let mut runner = AppRunner::<G> {
78        config,
79        state: None,
80        accumulator: Duration::ZERO,
81        #[cfg(target_arch = "wasm32")]
82        proxy: event_loop.create_proxy(),
83        #[cfg(target_arch = "wasm32")]
84        initializing: false,
85    };
86    #[cfg(not(target_arch = "wasm32"))]
87    event_loop.run_app(&mut runner)?;
88    #[cfg(target_arch = "wasm32")]
89    {
90        use winit::platform::web::EventLoopExtWebSys;
91        event_loop.spawn_app(runner);
92    }
93    Ok(())
94}
95
96struct AppRunner<G: Game> {
97    config: AppConfig,
98    state: Option<RunState<G>>,
99    accumulator: Duration,
100    #[cfg(target_arch = "wasm32")]
101    proxy: winit::event_loop::EventLoopProxy<InitDone>,
102    /// Set while the web init task is in flight, so another `resumed` doesn't start a second.
103    #[cfg(target_arch = "wasm32")]
104    initializing: bool,
105}
106
107struct RunState<G: Game> {
108    ctx: Context,
109    game: G,
110}
111
112impl<G: Game> ApplicationHandler<InitDone> for AppRunner<G> {
113    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
114        if self.state.is_some() {
115            return;
116        }
117        #[cfg(target_arch = "wasm32")]
118        if self.initializing {
119            return;
120        }
121        let window = match context::create_window(event_loop, &self.config) {
122            Ok(w) => w,
123            Err(e) => {
124                log::error!("window creation failed: {e:?}");
125                event_loop.exit();
126                return;
127            }
128        };
129        // Adapter/device requests are async. Natively we can block on them; the browser main
130        // thread can't block, so there init runs as a task that reports back via `user_event`.
131        #[cfg(not(target_arch = "wasm32"))]
132        {
133            let parts =
134                pollster::block_on(context::init_parts(window.clone(), self.config.clone()));
135            self.finish_init(event_loop, window, parts);
136        }
137        #[cfg(target_arch = "wasm32")]
138        {
139            self.initializing = true;
140            let proxy = self.proxy.clone();
141            let config = self.config.clone();
142            wasm_bindgen_futures::spawn_local(async move {
143                let parts = context::init_parts(window.clone(), config).await;
144                if proxy.send_event(Box::new((window, parts))).is_err() {
145                    log::error!("event loop closed before init finished");
146                }
147            });
148        }
149    }
150
151    fn user_event(&mut self, event_loop: &ActiveEventLoop, event: InitDone) {
152        let (window, parts) = *event;
153        #[cfg(target_arch = "wasm32")]
154        {
155            self.initializing = false;
156        }
157        self.finish_init(event_loop, window, parts);
158    }
159
160    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
161        let Some(state) = self.state.as_mut() else {
162            return;
163        };
164        // The first key, click or touch is the user gesture browsers require before audio may
165        // play. Natively the device is already open and this is a no-op.
166        if is_user_gesture(&event) {
167            state.ctx.start_audio();
168        }
169        // Give the game's raw-event hook first crack (egui etc. needs this).
170        let consumed = state.game.raw_window_event(&mut state.ctx, &event);
171        match event {
172            WindowEvent::CloseRequested => {
173                state.game.event(&mut state.ctx, &GameEvent::CloseRequested);
174                event_loop.exit();
175            }
176            WindowEvent::Resized(size) => {
177                state.ctx.gfx.resize(size.width, size.height);
178                state.game.event(
179                    &mut state.ctx,
180                    &GameEvent::Resized {
181                        width: size.width,
182                        height: size.height,
183                    },
184                );
185            }
186            WindowEvent::Focused(focused) => {
187                state
188                    .ctx
189                    .input
190                    .handle_window_event(&WindowEvent::Focused(focused));
191                state
192                    .game
193                    .event(&mut state.ctx, &GameEvent::FocusChanged(focused));
194            }
195            WindowEvent::RedrawRequested => {
196                self.frame(event_loop);
197            }
198            ref other if !consumed => {
199                state.ctx.input.handle_window_event(other);
200            }
201            _ => {}
202        }
203    }
204
205    fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) {
206        if let Some(state) = self.state.as_ref() {
207            state.ctx.window.request_redraw();
208        }
209    }
210
211    fn exiting(&mut self, _event_loop: &ActiveEventLoop) {
212        if let Some(state) = self.state.as_mut() {
213            state.game.shutdown(&mut state.ctx);
214        }
215    }
216}
217
218fn is_user_gesture(event: &WindowEvent) -> bool {
219    match event {
220        WindowEvent::KeyboardInput { event, .. } => event.state == ElementState::Pressed,
221        WindowEvent::MouseInput { state, .. } => *state == ElementState::Pressed,
222        WindowEvent::Touch(_) => true,
223        _ => false,
224    }
225}
226
227impl<G: Game> AppRunner<G> {
228    /// Second half of startup, once the window, GPU and assets exist: build the [`Context`]
229    /// and call `G::init`.
230    fn finish_init(
231        &mut self,
232        event_loop: &ActiveEventLoop,
233        window: Arc<Window>,
234        parts: Result<Parts>,
235    ) {
236        let parts = match parts {
237            Ok(p) => p,
238            Err(e) => {
239                log::error!("context init failed: {e:?}");
240                event_loop.exit();
241                return;
242            }
243        };
244        let mut ctx = Context::from_parts(window, parts, &self.config);
245        // On the web, init is async and the canvas's real size arrives as a `Resized` event
246        // while it runs, which `window_event` drops (no state yet). Catch up before `G::init`
247        // so it sees the true surface size.
248        let size = ctx.window.inner_size();
249        ctx.gfx.resize(size.width, size.height);
250        let game = match G::init(&mut ctx) {
251            Ok(g) => g,
252            Err(e) => {
253                log::error!("game init failed: {e:?}");
254                event_loop.exit();
255                return;
256            }
257        };
258        ctx.window.request_redraw();
259        self.state = Some(RunState { ctx, game });
260    }
261
262    fn frame(&mut self, event_loop: &ActiveEventLoop) {
263        let Some(state) = self.state.as_mut() else {
264            return;
265        };
266        state.ctx.time.tick();
267        state.ctx.input.poll_gamepads();
268
269        match self.config.fixed_timestep {
270            Some(step) => {
271                self.accumulator += state.ctx.time.delta;
272                while self.accumulator >= step {
273                    state.game.update(&mut state.ctx, step.as_secs_f32());
274                    self.accumulator -= step;
275                }
276            }
277            None => {
278                let dt = state.ctx.time.delta.as_secs_f32();
279                state.game.update(&mut state.ctx, dt);
280            }
281        }
282
283        if state.ctx.quit_requested {
284            event_loop.exit();
285            return;
286        }
287
288        state.ctx.input.end_frame();
289
290        match state.ctx.gfx.begin_frame() {
291            Ok(mut frame) => {
292                state.game.render(&mut state.ctx, &mut frame);
293                state.ctx.gfx.present(frame);
294            }
295            Err(e) => {
296                log::warn!("begin_frame failed: {e:?}");
297                let (w, h) = state.ctx.gfx.size();
298                state.ctx.gfx.resize(w, h);
299            }
300        }
301    }
302}