Skip to main content

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}