shuyi_platform/platform.rs
1//! [`Platform`]: what a backend gives the engine (ADR-0008 §2, ADR-0024),
2//! and the plain data that crosses it: the time ([`Millis`]), the
3//! display ([`DisplayInfo`]), a [`Frame`] to show and the system's
4//! events ([`SystemEvent`]).
5
6use crate::alloc_prelude::*;
7use core::time::Duration;
8
9use shuyi_assets::font::FontAtlasDef;
10use shuyi_assets::image::ImageTable;
11pub use shuyi_assets::target::Color;
12use shuyi_ui::frame::Rgb;
13use shuyi_ui::frame::glyph_buffer::GlyphBuffer;
14use shuyi_ui::frame::onebit::{Font, Images, PatternMap};
15use shuyi_ui::frame_cap::FrameCap;
16use shuyi_ui::perf::{HeapSnapshot, MemoryReading, MemoryScopes, PerfClock};
17use shuyi_ui::storage::Storage;
18use shuyi_ui::{PlatformFacts, RawInputEvent};
19
20use crate::app::Card;
21use crate::audio::Audio;
22use crate::text_input::TextInput;
23
24/// A moment on the backend's clock, in whole milliseconds. Where the clock
25/// counts from (the program's start, the device's, 1970) doesn't matter:
26/// [`App`](crate::App) only takes differences. A clock that goes backwards
27/// counts as no time passing.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
29pub struct Millis(pub u64);
30
31impl Millis {
32 /// The time `secs` seconds into the clock: a backend whose clock gives
33 /// seconds as an `f64`, from a clock that should never go back. A
34 /// negative, NaN or endless time is 0.
35 pub fn from_secs_f64(secs: f64) -> Self {
36 let ms = Duration::try_from_secs_f64(secs).map_or(0, |d| d.as_millis());
37 Self(u64::try_from(ms).unwrap_or(u64::MAX))
38 }
39
40 /// The time since `earlier`, or none if `earlier` is later.
41 pub fn since(self, earlier: Millis) -> Duration {
42 Duration::from_millis(self.0.saturating_sub(earlier.0))
43 }
44
45 /// The time in seconds, for a sound player that keeps time in them.
46 pub fn as_secs_f64(self) -> f64 {
47 Duration::from_millis(self.0).as_secs_f64()
48 }
49
50 /// `self` moved on by `elapsed`, to the millisecond below.
51 #[must_use]
52 pub fn after(self, elapsed: Duration) -> Millis {
53 let ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
54 Self(self.0.saturating_add(ms))
55 }
56}
57
58/// What a backend's display is (ADR-0008 §2). One screen for now; a
59/// device with more (the DS) reports them here once ticket 3023 adds
60/// surfaces.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
62#[serde(deny_unknown_fields)]
63#[non_exhaustive]
64pub struct DisplayInfo {
65 /// Its width in device pixels.
66 pub width: u32,
67 /// Its height in device pixels.
68 pub height: u32,
69 /// How many colours it shows, in a target profile's terms
70 /// ([`Color`], ADR-0008 §1): on `OneBit` the backend draws a game's
71 /// frame with its look's [`one_bit`](Look::one_bit) part.
72 pub color: Color,
73}
74
75impl DisplayInfo {
76 /// A full-colour display of `width` × `height` device pixels.
77 pub const fn new(width: u32, height: u32) -> Self {
78 Self {
79 width,
80 height,
81 color: Color::Rgba8,
82 }
83 }
84
85 /// The same display in black and white.
86 #[must_use]
87 pub const fn one_bit(mut self) -> Self {
88 self.color = Color::OneBit;
89 self
90 }
91}
92
93/// What a game's frame is painted with: the font atlas and the pictures in
94/// the game's data. A backend makes its textures from them the first time
95/// it sees them. Build it with [`Look::new`] (ADR-0039); a game that runs on
96/// a one-bit display adds [`Look::with_one_bit`] (ADR-0062).
97#[derive(Debug, Clone, Copy)]
98#[non_exhaustive]
99pub struct Look<'a> {
100 /// The font atlas the glyph cells are drawn from.
101 pub font: &'a FontAtlasDef,
102 /// The pictures the sprite items name.
103 pub images: &'a ImageTable,
104 /// What a one-bit display draws the frame with; `None` for a game that
105 /// has no one-bit look, which a one-bit backend says instead of drawing.
106 pub one_bit: Option<OneBitLook<'a>>,
107}
108
109/// A game's look on a one-bit display (ADR-0062): what
110/// [`render_with`](shuyi_ui::frame::onebit::render_with) needs beside the
111/// frame. The game's load makes it from its pack, once.
112#[derive(Clone, Copy)]
113#[non_exhaustive]
114pub struct OneBitLook<'a> {
115 /// Each colour's patterns, by the skin's pattern map.
116 pub map: &'a PatternMap,
117 /// The font's one-bit rows.
118 pub font: &'a Font<'a>,
119 /// The sprites' one-bit pictures: the pack's bundle, for a game whose
120 /// pack holds them ([`Images`]).
121 pub images: &'a dyn Images,
122}
123
124impl<'a> OneBitLook<'a> {
125 /// The look of `map`'s patterns, `font`'s rows and `images`' pictures.
126 pub fn new(map: &'a PatternMap, font: &'a Font<'a>, images: &'a dyn Images) -> Self {
127 Self { map, font, images }
128 }
129}
130
131impl core::fmt::Debug for OneBitLook<'_> {
132 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
133 f.debug_struct("OneBitLook")
134 .field("map", self.map)
135 .field("font", self.font)
136 .finish_non_exhaustive()
137 }
138}
139
140impl<'a> Look<'a> {
141 /// The look of glyphs from `font` and pictures from `images`.
142 #[must_use]
143 pub const fn new(font: &'a FontAtlasDef, images: &'a ImageTable) -> Self {
144 Self {
145 font,
146 images,
147 one_bit: None,
148 }
149 }
150
151 /// The same look, with what a one-bit display draws it with.
152 #[must_use]
153 pub const fn with_one_bit(mut self, one_bit: OneBitLook<'a>) -> Self {
154 self.one_bit = Some(one_bit);
155 self
156 }
157}
158
159/// What a frame shows.
160#[derive(Debug, Clone, Copy)]
161#[non_exhaustive]
162pub enum Show<'a> {
163 /// The loading card, while the game's data loads: drawn with what the
164 /// backend has of its own (its font, its picture decoder), since the
165 /// game's look isn't loaded yet.
166 Card(&'a Card),
167 /// Why the game couldn't start (a broken asset, a wrong command line),
168 /// in the backend's own font. It stays until the player quits.
169 Errors(&'a str),
170 /// The game's console: its cells and pictures in full colour. A
171 /// one-bit backend draws it with its look's
172 /// [`one_bit`](Look::one_bit) part.
173 #[non_exhaustive]
174 Game {
175 /// The cells and the sprite items, as the screens drew them.
176 buffer: &'a GlyphBuffer,
177 /// The colour behind the console and around it: that of the cell
178 /// the frame is cleared to.
179 background: Rgb,
180 /// What it is painted with; `None` when the game didn't say (an
181 /// `App` a test made over a running game).
182 look: Option<Look<'a>>,
183 },
184}
185
186/// One frame for the backend to show ([`Platform::present`]).
187#[derive(Debug, Clone, Copy)]
188#[non_exhaustive]
189pub struct Frame<'a> {
190 /// What it shows.
191 pub show: Show<'a>,
192 /// Whether the game should fill the screen (the player's Fullscreen
193 /// setting): a windowed backend switches when it changes; a device
194 /// that is always full screen ignores it.
195 pub fullscreen: bool,
196 /// The most frames a second to show (the player's frame cap): a
197 /// backend that paces its frames paces the next ones to it, and reports
198 /// what it applied before the next frame
199 /// ([`App::set_pacing`](crate::App::set_pacing)).
200 pub frame_cap: FrameCap,
201 /// The serial of the cap's latest change: the backend reports it with
202 /// the cap it applied.
203 pub frame_cap_serial: u32,
204 /// The errors of the hot reloads that failed, each with its file and
205 /// line ([`Ctx::reload_errors`](shuyi_ui::Ctx::reload_errors)): a
206 /// backend with a debug overlay shows them at the top of the frame. In
207 /// a build with debug tools; empty otherwise.
208 pub reload_errors: &'a [String],
209}
210
211impl<'a> Frame<'a> {
212 /// A frame showing `show`, windowed, at the engine's default cap
213 /// ([`FrameCap::DEFAULT`]) with serial 0.
214 pub fn new(show: Show<'a>) -> Self {
215 Self {
216 show,
217 fullscreen: false,
218 frame_cap: FrameCap::DEFAULT,
219 frame_cap_serial: 0,
220 reload_errors: &[],
221 }
222 }
223}
224
225/// Something the system tells the game, between frames.
226#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
227#[non_exhaustive]
228pub enum SystemEvent {
229 /// The game is about to stop running for a while: the device sleeps or
230 /// locks, the system's home menu opens, the app goes to the
231 /// background. [`App`](crate::App) lets the game save and stops
232 /// stepping it until [`Resume`](Self::Resume).
233 Suspend,
234 /// The game runs again. [`App`](crate::App) forgets the time it was
235 /// away, so the sleep doesn't become a burst of catch-up steps.
236 Resume,
237 /// The game's window lost the player's attention (another window, an
238 /// overlay over it). The game keeps running; its hook may pause it.
239 FocusLost,
240 /// The game's window has the player's attention again.
241 FocusGained,
242 /// The player asked the system to close the game (the window's close
243 /// button). [`App`](crate::App) lets the game save, then quits.
244 QuitRequested,
245 /// The battery is nearly empty: the game may save in case the device
246 /// turns off.
247 LowBattery,
248 /// The window's own fullscreen shortcut was pressed (F11, Alt+Enter):
249 /// [`App`](crate::App) flips the Fullscreen setting, so the next frame
250 /// asks for the other window mode and an options screen shows it.
251 ToggleFullscreen,
252 /// The device's crank was folded away, or was already when the game
253 /// loaded: a screen whose required action is on the crank shows a hint
254 /// to unfold it ([`Ctx::crank_docked`](shuyi_ui::Ctx::crank_docked)).
255 CrankDocked,
256 /// The device's crank was unfolded.
257 CrankUndocked,
258}
259
260/// What a backend gives the engine: a display to [`present`] frames on,
261/// the player's input, the system's events, storage, a sound device, the
262/// facts of where the game runs and, if the device has one, its own
263/// keyboard for text and, in debug builds, a clock to measure frames with.
264///
265/// The backend owns the loop (macroquad's, the Playdate SDK's update
266/// callback, a console's main loop) and calls [`App::frame`] once per
267/// frame with its clock; `App` calls these methods from inside it, in the
268/// order `docs/platform-backends.md` gives. Only a backend touches a device
269/// (`AGENTS.md` rule 5): the engine's other crates see this trait.
270///
271/// [`present`]: Self::present
272/// [`App::frame`]: crate::App::frame
273pub trait Platform {
274 /// The display, as it is now (a window may be resized between frames).
275 fn display(&self) -> DisplayInfo;
276
277 /// Shows `frame`. Called once per frame the game isn't suspended,
278 /// after its input, audio and update; the backend shows it as soon
279 /// as it can and keeps it on the screen until the next.
280 fn present(&mut self, frame: &Frame<'_>);
281
282 /// Adds the keyboard and controller events since the last call to
283 /// `out`, in the order they happened: releases before presses within
284 /// one poll of a device, as [`RawInputEvent::from_pads`] gives them.
285 fn poll_input(&mut self, out: &mut Vec<RawInputEvent>);
286
287 /// Adds the system's events since the last call to `out`, in order.
288 fn system_events(&mut self, out: &mut Vec<SystemEvent>);
289
290 /// Hands over the game's storage: its saves, settings and key bindings,
291 /// by key. `App` calls it once, as the game loads, and the game's
292 /// context keeps it (screens save through it).
293 fn storage(&mut self) -> Box<dyn Storage>;
294
295 /// The sound device: it plays each frame's sounds, music and voices
296 /// and says what music is sounding.
297 fn audio(&mut self) -> &mut dyn Audio;
298
299 /// The system's own keyboard for text (a console's, Steam's on-screen
300 /// one), if the device has one; `None` where the player types on keys,
301 /// which come through [`poll_input`](Self::poll_input).
302 fn text_input(&mut self) -> Option<&mut dyn TextInput>;
303
304 /// Where the game runs ([`PlatformFacts`]): the target's id, whether
305 /// the game may quit, whether its window goes full screen and back,
306 /// and the keyboard, pointer and touch screen the device has. Read
307 /// from the target's profile, or from what the backend reads of the
308 /// system; constant for a run. `App` asks once, as the game loads, and
309 /// the game's context keeps them ([`Ctx::platform`]).
310 ///
311 /// It has no default: a backend always says which target it is, so a
312 /// console's can't inherit the desktop's facts by forgetting.
313 ///
314 /// [`Ctx::platform`]: shuyi_ui::Ctx::platform
315 fn facts(&self) -> PlatformFacts;
316
317 /// A clock in whole microseconds, to measure frames with in debug
318 /// builds (ADR-0046): `App` takes it once, on its first frame, and only
319 /// with [`DEBUG_TOOLS`](shuyi_ui::DEBUG_TOOLS) on. `None` (the
320 /// default) for a backend without one: nothing is measured.
321 fn clock(&self) -> Option<PerfClock> {
322 None
323 }
324
325 /// The device's memory now, in debug builds (ADR-0046): the heap's
326 /// live bytes, their peak and the allocations since the last call.
327 /// `App` asks once a frame, at the end of a frame it keeps a record of
328 /// (debug tools on, with a clock), and puts the heap's counts in the
329 /// record. `None` (the default) for a backend that doesn't count:
330 /// records have no heap counts.
331 fn memory(&mut self) -> Option<MemoryReading> {
332 None
333 }
334
335 /// The memory tags of the backend's heap counter, in debug builds
336 /// (ADR-0046 §6): plain functions over its tags, so what the game
337 /// allocates counts by the frame's phase and the game's spans. `App`
338 /// takes them with the clock ([`clock`](Self::clock)), once, with
339 /// [`DEBUG_TOOLS`](shuyi_ui::DEBUG_TOOLS) on, and moves them into the
340 /// game's context when it runs. `None` (the default) for a backend
341 /// whose counter has no tags. A backend with them also tags each
342 /// asset decode when it starts, before the game's load
343 /// (`shuyi_assets::format::watch_decodes`).
344 fn memory_scopes(&self) -> Option<MemoryScopes> {
345 None
346 }
347
348 /// What holds the heap now, by memory tag, in debug builds (ADR-0046
349 /// §6): its live bytes and each tag's counts, its `frame` left 0 for
350 /// [`App::heap_snapshot`](crate::App::heap_snapshot) to stamp. Taking
351 /// one allocates the rows, so it is asked for now and then (a tool, a
352 /// trace every 600 frames), never every frame. `None` (the default)
353 /// for a backend whose counter has no tags.
354 fn heap_snapshot(&mut self) -> Option<HeapSnapshot> {
355 None
356 }
357
358 /// Adds the paths in the bundle of the files that changed since the
359 /// last call to `out`, in a build with debug tools (ADR-0071): the
360 /// backend has already put their new bytes in their layers
361 /// ([`Layer::replace`](shuyi_assets::bundle::Layer::replace)). `App` asks
362 /// once a frame while the game runs, only with
363 /// [`Ctx::debug_tools`](shuyi_ui::Ctx::debug_tools) on. By default it
364 /// adds nothing: a device with no files to watch.
365 fn changed_files(&mut self, _out: &mut Vec<String>) {}
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 #[test]
373 fn a_clock_in_seconds_is_whole_milliseconds() {
374 assert_eq!(Millis::from_secs_f64(1.2344), Millis(1234));
375 assert_eq!(Millis::from_secs_f64(1.7e9), Millis(1_700_000_000_000));
376 for nonsense in [-1.0, f64::NAN, f64::INFINITY] {
377 assert_eq!(Millis::from_secs_f64(nonsense), Millis(0), "{nonsense}");
378 }
379 assert!((Millis(1500).as_secs_f64() - 1.5).abs() < f64::EPSILON);
380 }
381
382 #[test]
383 fn time_never_runs_backwards() {
384 assert_eq!(Millis(1500).since(Millis(1000)), Duration::from_millis(500));
385 assert_eq!(Millis(1000).since(Millis(1500)), Duration::ZERO);
386 assert_eq!(Millis(10).after(Duration::from_micros(2900)), Millis(12));
387 assert_eq!(
388 Millis(u64::MAX).after(Duration::from_secs(1)),
389 Millis(u64::MAX)
390 );
391 }
392
393 #[test]
394 fn a_display_is_full_colour_unless_said() {
395 let screen = DisplayInfo::new(400, 240);
396 assert_eq!((screen.width, screen.height), (400, 240));
397 assert_eq!(screen.color, Color::Rgba8);
398 assert_eq!(screen.one_bit().color, Color::OneBit);
399 let frame = Frame::new(Show::Errors("x"));
400 assert!(!frame.fullscreen);
401 assert_eq!(
402 (frame.frame_cap, frame.frame_cap_serial),
403 (FrameCap::Fps(60), 0)
404 );
405 }
406
407 #[test]
408 fn a_one_bit_look_is_part_of_a_look_and_says_what_it_holds() {
409 let font = FontAtlasDef {
410 cell_w: 8,
411 cell_h: 8,
412 columns: 16,
413 glyphs: alloc::collections::BTreeMap::new(),
414 wide: alloc::collections::BTreeMap::new(),
415 wide_sets: alloc::collections::BTreeMap::new(),
416 };
417 let images = ImageTable::default();
418 let map = PatternMap::default();
419 let rows = Font::new(&font, &[]).unwrap_or_else(|e| panic!("{e}"));
420 let one_bit = OneBitLook::new(&map, &rows, &());
421 let shown = alloc::format!("{one_bit:?}");
422 assert!(shown.starts_with("OneBitLook"), "{shown}");
423 assert!(Look::new(&font, &images).one_bit.is_none());
424 let look = Look::new(&font, &images).with_one_bit(one_bit);
425 assert!(look.one_bit.is_some());
426 }
427
428 #[test]
429 fn a_new_look_is_the_literal() {
430 let font = FontAtlasDef {
431 cell_w: 8,
432 cell_h: 8,
433 columns: 16,
434 glyphs: alloc::collections::BTreeMap::new(),
435 wide: alloc::collections::BTreeMap::new(),
436 wide_sets: alloc::collections::BTreeMap::new(),
437 };
438 let images = ImageTable::default();
439 let look = Look::new(&font, &images);
440 let literal = Look {
441 font: &font,
442 images: &images,
443 one_bit: None,
444 };
445 assert!(core::ptr::eq(look.font, literal.font));
446 assert!(core::ptr::eq(look.images, literal.images));
447 }
448}