Skip to main content

dotzuki_renderer/
title.rs

1//! Generic title-screen menu driver — the reusable, render-agnostic logic behind
2//! a game's title screen.
3//!
4//! It owns the vertical menu (labels + which entries are enabled), the selection
5//! cursor, and the "press start" blink timer. Games pair it with a `.gui` title
6//! layout: each frame drive it with [`TitleMenu::update`], write its state into a
7//! [`DataContext`] with [`TitleMenu::write_context`] (`items` / `cursor` /
8//! `show_blink`), then render the layout.
9//!
10//! It deliberately knows nothing about *what* the entries mean (New Game /
11//! Continue / Quit) — the game maps the returned [`TitleEvent::Selected`] index to
12//! its own action, and decides which entries are enabled (e.g. disable "Continue"
13//! when there is no save file). This mirrors how [`crate::menu`]-style screens are
14//! driven elsewhere in the engine, so a title screen is authored the same way as
15//! any other `.gui` screen — no bespoke per-game title machinery.
16
17use crate::input::{GbButton, InputState};
18use crate::layout_engine::types::{DataContext, DataValue};
19
20/// One title-menu entry.
21#[derive(Debug, Clone)]
22pub struct TitleItem {
23    pub label: String,
24    /// Disabled entries are skipped by the cursor and cannot be selected (e.g.
25    /// "Continue" with no save). The layout may still render them dimmed.
26    pub enabled: bool,
27}
28
29impl TitleItem {
30    /// An enabled entry.
31    pub fn new(label: impl Into<String>) -> Self {
32        Self { label: label.into(), enabled: true }
33    }
34
35    /// An entry whose enabled state is decided at build time (e.g. `has_save`).
36    pub fn with_enabled(label: impl Into<String>, enabled: bool) -> Self {
37        Self { label: label.into(), enabled }
38    }
39}
40
41/// What [`TitleMenu::update`] reports for a frame.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum TitleEvent {
44    /// Nothing happened this frame.
45    None,
46    /// The cursor moved to a new entry.
47    Moved,
48    /// The player confirmed the entry at this index (always an enabled entry).
49    Selected(usize),
50}
51
52/// Default blink period in frames: the prompt is shown for N frames, hidden for N.
53pub const DEFAULT_BLINK_PERIOD: u32 = 30;
54
55/// A vertical title-screen menu with a blinking "press start" prompt.
56#[derive(Debug, Clone)]
57pub struct TitleMenu {
58    items: Vec<TitleItem>,
59    cursor: usize,
60    blink_period: u32,
61    blink_frame: u32,
62}
63
64impl TitleMenu {
65    /// Build a menu from entries, landing the cursor on the first enabled one.
66    pub fn new(items: Vec<TitleItem>) -> Self {
67        let mut menu = Self {
68            items,
69            cursor: 0,
70            blink_period: DEFAULT_BLINK_PERIOD,
71            blink_frame: 0,
72        };
73        // If the first entry is disabled, seek forward to the first enabled one.
74        if menu
75            .items
76            .first()
77            .map(|i| !i.enabled)
78            .unwrap_or(false)
79        {
80            if let Some(i) = menu.next_enabled(0, 1) {
81                menu.cursor = i;
82            }
83        }
84        menu
85    }
86
87    /// Override the blink period (frames on = frames off). Clamped to ≥1.
88    pub fn with_blink_period(mut self, frames: u32) -> Self {
89        self.blink_period = frames.max(1);
90        self
91    }
92
93    /// Advance one frame: Up/Down move the cursor to the nearest enabled entry,
94    /// A/Start confirm the current entry (if enabled), and the blink timer ticks.
95    pub fn update(&mut self, input: &InputState) -> TitleEvent {
96        // Blink timer ticks regardless of input.
97        let cycle = (self.blink_period * 2).max(1);
98        self.blink_frame = (self.blink_frame + 1) % cycle;
99
100        if self.items.is_empty() {
101            return TitleEvent::None;
102        }
103
104        let up = input.is_just_pressed(GbButton::Up);
105        let down = input.is_just_pressed(GbButton::Down);
106        if up || down {
107            let dir = if up { -1 } else { 1 };
108            if let Some(next) = self.next_enabled(self.cursor, dir) {
109                if next != self.cursor {
110                    self.cursor = next;
111                    return TitleEvent::Moved;
112                }
113            }
114        }
115
116        if input.is_just_pressed(GbButton::A) || input.is_just_pressed(GbButton::Start) {
117            if self.items[self.cursor].enabled {
118                return TitleEvent::Selected(self.cursor);
119            }
120        }
121        TitleEvent::None
122    }
123
124    /// The next enabled index from `from` stepping by `dir` (±1), wrapping around.
125    /// `None` if no entry is enabled.
126    fn next_enabled(&self, from: usize, dir: i32) -> Option<usize> {
127        let n = self.items.len();
128        if n == 0 {
129            return None;
130        }
131        let mut i = from as i32;
132        for _ in 0..n {
133            i = (i + dir).rem_euclid(n as i32);
134            if self.items[i as usize].enabled {
135                return Some(i as usize);
136            }
137        }
138        None
139    }
140
141    /// The selected entry index.
142    pub fn cursor(&self) -> usize {
143        self.cursor
144    }
145
146    /// The menu entries.
147    pub fn items(&self) -> &[TitleItem] {
148        &self.items
149    }
150
151    /// Whether the blink prompt is currently visible (first half of the period).
152    pub fn show_blink(&self) -> bool {
153        self.blink_frame < self.blink_period
154    }
155
156    /// Write this menu's state into `ctx` for the `.gui` title layout:
157    /// - `items`: the list of entry labels (bind a `list` to `source = "{items}"`),
158    /// - `cursor`: the selected index (`selected = "{cursor}"`),
159    /// - `show_blink`: whether a "press start" prompt is visible this frame
160    ///   (`visible = "{show_blink}"`).
161    pub fn write_context(&self, ctx: &mut DataContext) {
162        let labels: Vec<DataValue> = self
163            .items
164            .iter()
165            .map(|it| DataValue::from(it.label.as_str()))
166            .collect();
167        ctx.set("items", DataValue::List(labels));
168        ctx.set("cursor", self.cursor as i64);
169        ctx.set("show_blink", self.show_blink());
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    /// A single just-press of `button` on a fresh frame.
178    fn tap(menu: &mut TitleMenu, button: GbButton) -> TitleEvent {
179        let mut input = InputState::new();
180        input.begin_frame();
181        input.press(button);
182        menu.update(&input)
183    }
184
185    fn menu3() -> TitleMenu {
186        TitleMenu::new(vec![
187            TitleItem::new("新的征程"),
188            TitleItem::new("继续江湖"),
189            TitleItem::new("退出"),
190        ])
191    }
192
193    #[test]
194    fn cursor_moves_and_wraps() {
195        let mut m = menu3();
196        assert_eq!(m.cursor(), 0);
197        assert_eq!(tap(&mut m, GbButton::Down), TitleEvent::Moved);
198        assert_eq!(m.cursor(), 1);
199        tap(&mut m, GbButton::Down);
200        assert_eq!(m.cursor(), 2);
201        // Down from the last entry wraps to the first.
202        assert_eq!(tap(&mut m, GbButton::Down), TitleEvent::Moved);
203        assert_eq!(m.cursor(), 0);
204        // Up from the first wraps to the last.
205        tap(&mut m, GbButton::Up);
206        assert_eq!(m.cursor(), 2);
207    }
208
209    #[test]
210    fn confirm_reports_selected_index() {
211        let mut m = menu3();
212        tap(&mut m, GbButton::Down); // → 1
213        assert_eq!(tap(&mut m, GbButton::A), TitleEvent::Selected(1));
214        // Start also confirms.
215        assert_eq!(tap(&mut m, GbButton::Start), TitleEvent::Selected(1));
216    }
217
218    #[test]
219    fn disabled_entries_are_skipped_and_unselectable() {
220        // "Continue" disabled (no save): cursor starts on the first enabled entry
221        // and Down skips over the disabled middle entry.
222        let mut m = TitleMenu::new(vec![
223            TitleItem::new("新的征程"),
224            TitleItem::with_enabled("继续江湖", false),
225            TitleItem::new("退出"),
226        ]);
227        assert_eq!(m.cursor(), 0, "starts on the first enabled entry");
228        assert_eq!(tap(&mut m, GbButton::Down), TitleEvent::Moved);
229        assert_eq!(m.cursor(), 2, "Down skips the disabled entry");
230        // A on the disabled entry can never fire, because the cursor can't land
231        // there; confirm on an enabled entry works.
232        assert_eq!(tap(&mut m, GbButton::A), TitleEvent::Selected(2));
233    }
234
235    #[test]
236    fn first_entry_disabled_seeks_forward() {
237        let m = TitleMenu::new(vec![
238            TitleItem::with_enabled("继续江湖", false),
239            TitleItem::new("新的征程"),
240        ]);
241        assert_eq!(m.cursor(), 1, "cursor skips a disabled first entry");
242    }
243
244    #[test]
245    fn blink_toggles_over_the_period() {
246        let mut m = TitleMenu::new(vec![TitleItem::new("开始")]).with_blink_period(2);
247        // Fresh: frame 0 → visible.
248        assert!(m.show_blink());
249        let idle = InputState::new();
250        m.update(&idle); // frame 1 → still visible (< period 2)
251        assert!(m.show_blink());
252        m.update(&idle); // frame 2 → hidden (>= period)
253        assert!(!m.show_blink());
254        m.update(&idle); // frame 3 → hidden
255        assert!(!m.show_blink());
256        m.update(&idle); // frame 4 → wraps to 0 → visible
257        assert!(m.show_blink());
258    }
259}