1use std::path::Path;
2use std::sync::Arc;
3
4use anyhow::Result;
5use game_toolkit_audio::SoundId;
6use game_toolkit_gfx::TextureId;
7use winit::event_loop::ActiveEventLoop;
8use winit::window::Window;
9
10use crate::Rng;
11use crate::app::AppConfig;
12use crate::time::Time;
13
14#[non_exhaustive]
15pub enum GameEvent {
16 Resized {
17 width: u32,
18 height: u32,
19 },
20 FocusChanged(bool),
21 CloseRequested,
22 CanvasAttached(bool),
29}
30
31pub struct Context {
32 pub gfx: game_toolkit_gfx::Graphics,
33 pub audio: Option<game_toolkit_audio::Audio>,
34 pub input: game_toolkit_input::Input,
35 pub assets: game_toolkit_assets::Assets,
36 pub time: Time,
37 pub rng: Rng,
38 pub window: Arc<Window>,
39 pub(crate) quit_requested: bool,
40 pub(crate) canvas_attached: bool,
41 #[cfg(target_arch = "wasm32")]
43 pub(crate) autoplay: Option<crate::web::AutoplayProbe>,
44}
45
46pub(crate) struct Parts {
49 gfx: game_toolkit_gfx::Graphics,
50 assets: game_toolkit_assets::Assets,
51}
52
53pub(crate) fn create_window(
56 event_loop: &ActiveEventLoop,
57 config: &AppConfig,
58) -> Result<Arc<Window>> {
59 let attrs = Window::default_attributes()
60 .with_title(&config.title)
61 .with_inner_size(winit::dpi::LogicalSize::new(config.width, config.height));
62 #[cfg(target_arch = "wasm32")]
63 let attrs = crate::web::canvas_attributes(attrs, config.canvas_id.as_deref())?;
64 Ok(Arc::new(event_loop.create_window(attrs)?))
65}
66
67pub(crate) async fn init_parts(window: Arc<Window>, config: AppConfig) -> Result<Parts> {
70 let gfx = game_toolkit_gfx::Graphics::new(window, &config.graphics_config()).await?;
71 let assets = open_assets(&config).await?;
72 Ok(Parts { gfx, assets })
73}
74
75#[cfg(not(target_arch = "wasm32"))]
76#[allow(clippy::unused_async)]
77async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
78 use game_toolkit_assets::Assets;
79 let mut assets = Assets::new(&config.asset_root).or_else(|e| {
80 log::warn!(
81 "asset root {} unusable ({e}); falling back to cwd",
82 config.asset_root.display()
83 );
84 Assets::new(".")
85 })?;
86 if let Some(paths) = &config.web_preload {
88 assets.set_web_preload(paths);
89 }
90 Ok(assets)
91}
92
93#[cfg(target_arch = "wasm32")]
94async fn open_assets(config: &AppConfig) -> Result<game_toolkit_assets::Assets> {
95 let mut assets = game_toolkit_assets::Assets::new(&config.asset_root)?;
96 let paths = match &config.web_preload {
97 Some(paths) => paths.clone(),
98 None => assets.fetch_manifest().await?,
99 };
100 log::info!("preloading {} asset(s)", paths.len());
101 assets.preload(paths).await?;
102 Ok(assets)
103}
104
105impl Context {
106 pub(crate) fn from_parts(window: Arc<Window>, parts: Parts, config: &AppConfig) -> Self {
107 let audio = match game_toolkit_audio::Audio::new() {
108 Ok(a) => Some(a),
109 Err(e) => {
110 log::warn!("audio init failed, continuing muted: {e}");
111 None
112 }
113 };
114 let mut input = game_toolkit_input::Input::new();
115 input.set_touch_emulates_mouse(config.touch_emulates_mouse);
116 Self {
117 gfx: parts.gfx,
118 audio,
119 input,
120 assets: parts.assets,
121 time: Time::new(),
122 rng: Rng::new(config.random_seed),
123 window,
124 quit_requested: false,
125 canvas_attached: true,
126 #[cfg(target_arch = "wasm32")]
127 autoplay: None,
128 }
129 }
130
131 pub(crate) fn sync_pointer_mapping(&mut self) {
133 let mapping = pointer_mapping(self.gfx.canvas_rect(), self.gfx.size());
134 self.input.set_pointer_mapping(mapping);
135 }
136
137 pub fn quit(&mut self) {
138 self.quit_requested = true;
139 }
140
141 pub fn canvas_attached(&self) -> bool {
144 self.canvas_attached
145 }
146
147 pub fn load_texture(&mut self, rel: impl AsRef<Path>) -> Result<TextureId> {
151 let rel = rel.as_ref();
152 #[cfg(not(target_arch = "wasm32"))]
153 if !self.assets.is_loaded(rel) {
154 let id = self.gfx.load_texture(self.assets.resolve(rel))?;
155 self.assets.check_web_listed(rel);
156 return Ok(id);
157 }
158 let bytes = self.assets.read(rel)?;
159 self.gfx
160 .load_texture_bytes(&bytes, Some(&rel.to_string_lossy()))
161 }
162
163 pub fn load_bitmap_font(
166 &mut self,
167 rel: impl AsRef<Path>,
168 layout: &game_toolkit_gfx::GridLayout,
169 ) -> Result<game_toolkit_gfx::BitmapFont> {
170 let bytes = self.assets.read(rel)?;
171 game_toolkit_gfx::BitmapFont::from_grid(&mut self.gfx, &bytes, layout)
172 }
173
174 pub fn load_bmfont(&mut self, rel: impl AsRef<Path>) -> Result<game_toolkit_gfx::BitmapFont> {
178 let rel = rel.as_ref();
179 let fnt = String::from_utf8(self.assets.read(rel)?)
180 .map_err(|e| anyhow::anyhow!("{}: not UTF-8 ({e})", rel.display()))?;
181 let page = game_toolkit_gfx::BitmapFont::bmfont_page_file(&fnt)
182 .ok_or_else(|| anyhow::anyhow!("{}: no page file", rel.display()))?;
183 let page_path = Path::new(&page);
186 if page_path.is_absolute()
187 || page_path.components().any(|c| {
188 !matches!(
189 c,
190 std::path::Component::Normal(_) | std::path::Component::CurDir
191 )
192 })
193 {
194 anyhow::bail!(
195 "{}: page file {page:?} must be a relative path without `..`",
196 rel.display()
197 );
198 }
199 let page = rel.parent().unwrap_or(Path::new("")).join(page_path);
200 let png = self.assets.read(&page)?;
201 game_toolkit_gfx::BitmapFont::from_bmfont(&mut self.gfx, &fnt, &png)
202 }
203
204 #[cfg(feature = "indexed")]
208 pub fn load_indexed_texture(
209 &mut self,
210 rel: impl AsRef<Path>,
211 ) -> Result<(TextureId, game_toolkit_gfx::Palette)> {
212 let rel = rel.as_ref();
213 let bytes = self.assets.read(rel)?;
214 self.gfx
215 .load_indexed_texture(&bytes, Some(&rel.to_string_lossy()))
216 }
217
218 pub fn load_sound(&mut self, rel: impl AsRef<Path>) -> Result<Option<SoundId>> {
221 let Some(audio) = self.audio.as_mut() else {
222 return Ok(None);
223 };
224 let bytes = self.assets.read(rel)?;
225 audio.load_sound_bytes(bytes).map(Some)
226 }
227
228 pub(crate) fn start_audio(&mut self) {
232 if let Some(audio) = self.audio.as_mut()
233 && !audio.is_started()
234 && let Err(e) = audio.start()
235 {
236 log::warn!("audio start failed, continuing muted: {e}");
237 self.audio = None;
238 }
239 }
240
241 pub fn start_audio_if_allowed(&mut self) {
251 #[cfg(target_arch = "wasm32")]
252 if self.audio.as_ref().is_some_and(|a| !a.is_started()) && self.autoplay.is_none() {
253 self.autoplay = crate::web::AutoplayProbe::start();
254 self.poll_autoplay();
255 }
256 }
257
258 #[cfg(target_arch = "wasm32")]
260 pub(crate) fn poll_autoplay(&mut self) {
261 use crate::web::Autoplay;
262 let Some(probe) = &self.autoplay else {
263 return;
264 };
265 let started = self
267 .audio
268 .as_ref()
269 .is_none_or(game_toolkit_audio::Audio::is_started);
270 let answer = if started {
271 Autoplay::Denied
272 } else {
273 probe.poll()
274 };
275 match answer {
276 Autoplay::Pending => {}
277 Autoplay::Denied => self.autoplay = None,
278 Autoplay::Allowed => {
279 self.autoplay = None;
280 log::info!("audio allowed without a gesture, starting it now");
281 self.start_audio();
282 }
283 }
284 }
285}
286
287fn pointer_mapping(
290 rect: Option<game_toolkit_gfx::CanvasRect>,
291 size: (u32, u32),
292) -> Option<game_toolkit_input::PointerMapping> {
293 rect.map(|rect| {
294 let scale = rect.canvas_per_window([size.0, size.1]);
295 game_toolkit_input::PointerMapping {
296 origin: (rect.origin[0], rect.origin[1]),
297 scale: (scale[0], scale[1]),
298 }
299 })
300}
301
302#[cfg(test)]
303mod tests {
304 use game_toolkit_gfx::CanvasRect;
305 use game_toolkit_input::Input;
306
307 use super::pointer_mapping;
308
309 fn close(a: (f32, f32), b: (f32, f32)) -> bool {
310 (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3
311 }
312
313 #[test]
317 fn pointer_follows_a_runtime_resolution_change() {
318 let mut input = Input::new();
319 input.handle_window_event(&winit::event::WindowEvent::CursorMoved {
320 device_id: winit::event::DeviceId::dummy(),
321 position: winit::dpi::PhysicalPosition::new(960.0, 540.0),
322 });
323
324 let rect = CanvasRect {
326 origin: [0.0, 0.0],
327 size: [1920.0, 1080.0],
328 };
329 input.set_pointer_mapping(pointer_mapping(Some(rect), (640, 360)));
330 assert!(close(input.mouse_pos(), (320.0, 180.0)));
331
332 let rect = CanvasRect {
334 origin: [240.0, 0.0],
335 size: [1440.0, 1080.0],
336 };
337 input.set_pointer_mapping(pointer_mapping(Some(rect), (800, 600)));
338 assert!(
339 close(input.mouse_pos(), (400.0, 300.0)),
340 "{:?}",
341 input.mouse_pos()
342 );
343
344 input.set_pointer_mapping(pointer_mapping(None, (1920, 1080)));
346 assert_eq!(input.mouse_pos(), (960.0, 540.0));
347 }
348}