retroglyph_core/app.rs
1//! The `App`-driven game loop.
2//!
3//! `App` is the update-side dual of [`Backend`](crate::Backend): where a
4//! backend is the output contract, an [`App`] is the per-frame update contract.
5//! A game implements [`App`] once and runs on every backend unchanged.
6//!
7//! The loop decomposes into three pieces:
8//!
9//! - the contract ([`App`], [`Flow`], [`Frame`]), here in the core;
10//! - the generic blocking driver ([`run_blocking`], `std` only), which covers
11//! `Crossterm` (in `retroglyph-crossterm`) and [`Headless`](crate::backend::Headless);
12//! - the inverted driver in the windowing layer (the software backend's
13//! `run_app`), which cannot be generic because winit owns the loop instead of
14//! handing control back to a shared driver function.
15//!
16//! Both drivers share [`step`] as the per-frame body. The low-level
17//! [`poll`](crate::Terminal::poll) / [`present`](crate::Terminal::present) API
18//! remains available for turn-based games and headless tests.
19
20use crate::backend::Backend;
21use crate::terminal::Terminal;
22use core::time::Duration;
23
24/// Whether the game loop should continue or stop after a frame.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
26pub enum Flow {
27 /// Run another frame.
28 Continue,
29 /// Stop the loop. The driver returns and the terminal unwinds normally, so
30 /// backend `Drop` logic (for example crossterm's terminal restore) runs.
31 Exit,
32}
33
34/// Per-frame context handed to [`App::update`].
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct Frame {
37 /// Wall-clock time elapsed since the previous frame, supplied by the driver.
38 pub delta: Duration,
39 /// Monotonic frame counter, starting at 0.
40 pub frame: u64,
41}
42
43/// The per-frame update contract for a game.
44///
45/// Implement this once, generically over the backend, to run everywhere:
46///
47/// ```
48/// use retroglyph_core::{App, Backend, Flow, Frame, Terminal};
49///
50/// struct MyGame;
51/// impl<B: Backend> App<B> for MyGame {
52/// fn update(&mut self, term: &mut Terminal<B>, _frame: &Frame) -> Flow {
53/// term.put(0, 0, '@');
54/// term.present().ok();
55/// Flow::Exit
56/// }
57/// }
58/// ```
59pub trait App<B: Backend> {
60 /// Advance and render one frame.
61 ///
62 /// Draw into `term`, read input via `term`, and call
63 /// [`term.present()`](Terminal::present) to render the frame. Return
64 /// [`Flow::Exit`] to stop the loop.
65 fn update(&mut self, term: &mut Terminal<B>, frame: &Frame) -> Flow;
66}
67
68/// Run one frame: the per-frame body shared by every driver.
69///
70/// Calls [`App::update`]. Both [`run_blocking`] and the windowing layer's
71/// inverted driver call this function instead of `update` directly, so the
72/// two drivers cannot drift apart as the per-frame body grows.
73#[must_use]
74pub fn step<B: Backend, A: App<B>>(term: &mut Terminal<B>, app: &mut A, frame: &Frame) -> Flow {
75 app.update(term, frame)
76}
77
78/// Drive an [`App`] with a blocking loop until it returns [`Flow::Exit`].
79///
80/// Generic over the backend, so it powers every non-inverted backend
81/// (`Crossterm` in `retroglyph-crossterm`, [`Headless`](crate::backend::Headless))
82/// with no per-backend loop code.
83/// Inverted backends (software/winit) provide their own driver.
84///
85/// The terminal is owned and dropped when the loop exits, so backend teardown
86/// (for example crossterm's terminal restore) runs on the way out.
87#[cfg(feature = "std")]
88pub fn run_blocking<B, A>(mut term: Terminal<B>, mut app: A)
89where
90 B: Backend,
91 A: App<B>,
92{
93 let mut frame_count = 0u64;
94 let mut last = std::time::Instant::now();
95 loop {
96 let now = std::time::Instant::now();
97 let delta = now.duration_since(last);
98 last = now;
99 let frame = Frame {
100 delta,
101 frame: frame_count,
102 };
103 frame_count = frame_count.wrapping_add(1);
104 match step(&mut term, &mut app, &frame) {
105 Flow::Continue => {}
106 Flow::Exit => return,
107 }
108 }
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::backend::Headless;
115 use crate::event::{Event, KeyCode, KeyEvent, KeyModifiers};
116
117 struct Counter {
118 frames: u64,
119 }
120
121 impl App<Headless> for Counter {
122 fn update(&mut self, term: &mut Terminal<Headless>, frame: &Frame) -> Flow {
123 self.frames += 1;
124 term.put(0, 0, '#');
125 term.present().expect("present");
126 // Quit when a key is pending, or after a safety cap.
127 if term.has_input() || frame.frame >= 100 {
128 Flow::Exit
129 } else {
130 Flow::Continue
131 }
132 }
133 }
134
135 #[test]
136 fn run_blocking_exits_on_flow_exit() {
137 let mut backend = Headless::new(4, 1);
138 backend.push_event(Event::Key(KeyEvent::new(
139 KeyCode::Char('q'),
140 KeyModifiers::NONE,
141 )));
142 let term = Terminal::new(backend);
143 let app = Counter { frames: 0 };
144 // Runs until the queued key is observed. Reaching the next line proves
145 // the loop terminated on Flow::Exit rather than spinning forever.
146 run_blocking(term, app);
147 }
148
149 #[test]
150 fn step_forwards_to_update() {
151 let mut term = Terminal::new(Headless::new(2, 1));
152 let mut app = Counter { frames: 0 };
153 let frame = Frame {
154 delta: Duration::ZERO,
155 frame: 200,
156 };
157 let flow = step(&mut term, &mut app, &frame);
158 assert_eq!(flow, Flow::Exit); // frame >= 100
159 assert_eq!(app.frames, 1);
160 }
161}