Skip to main content

concinnity_world/schema/
main_menu.rs

1//! One-line main-menu schema.
2
3use std::vec;
4
5/// A ready-made menu declared in a single line.
6///
7/// `MainMenu` is a build-time shorthand. It expands into the assets a menu is
8/// built from: a [Screen](#screen) layer, a dim backdrop [Sprite](#sprite), a
9/// [TextLabel](#textlabel) and [HitRegion](#hitregion) for each item, an
10/// optional [KeyBinding](#keybinding) that toggles the menu, and an optional
11/// in-engine mouse cursor [Sprite](#sprite). So `world.jsonl` stays small.
12///
13/// The bare form gives a centered Return / Settings / Quit menu that starts
14/// closed, with Escape opening it, so the scene itself shows first. Set
15/// `"initial": true` to show the menu as soon as the world loads:
16///
17/// Declaring a `MainMenu` also injects the [StatHud](#stathud) (and its chip
18/// labels) at build time when the world declares none, so the menu's
19/// performance-stats toggles have chips to drive.
20///
21/// **Items.** Each item has a `label` (the text) and an `action` fired on
22/// click. `action` takes the same vocabulary as [HitRegion](#hitregion)
23/// (`"scene:<name>"`, `"quit"`, `"screen:show:<name>"`, `"screen:hide"`,
24/// `"screen:toggle:<name>"`) plus two conveniences resolved against this menu:
25/// - `"return"`: hide this menu (the same as `"screen:hide"`).
26/// - `"settings"`: open a generated settings sub-menu that has a Back button.
27///
28/// **Generated names** are prefixed with the menu's `name` (`<name>_btn_0`,
29/// `<name>_label_0`, `<name>_cursor`, ...), so they never clash with
30/// hand-authored assets and you never reference them by hand.
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
32#[serde(default)]
33pub struct MainMenu {
34    /// Menu entries, top to bottom. Each one is a clickable button.
35    pub items: Vec<MainMenuItem>,
36    /// Optional heading drawn above the items. Empty draws no heading.
37    pub title: String,
38    /// Show the menu as soon as the world loads. Off by default: the scene
39    /// shows first and the toggle key opens the menu.
40    pub initial: bool,
41    /// InputKey that toggles the menu while the cursor is free. Empty binds no key.
42    /// Only `"Escape"` is currently recognised by the runtime.
43    pub toggle_key: String,
44    /// RGBA fill drawn across the whole window behind the items. Defaults to
45    /// opaque black: a fully opaque alpha (1.0) hides the scene completely, which
46    /// lets the renderer skip the entire world render while the menu is open, so
47    /// the frame costs only the menu overlay. Lower the alpha to keep the world
48    /// visible behind a translucent fade (the world then keeps rendering); an
49    /// alpha of 0 draws no backdrop at all.
50    pub dim: [f32; 4],
51    /// Horizontally center the menu and align it to the top of the window.
52    /// When false, `x` is the column's center and `y` is the top of the first
53    /// item.
54    ///
55    /// The menu is a screen overlay laid out against a fixed reference
56    /// resolution and uniformly scaled to fill the window, so it keeps the same
57    /// proportions at any window size. All pixel fields below are in that
58    /// reference space, not raw window pixels.
59    pub centered: bool,
60    /// Column center x in reference-space pixels, used when `centered` is false.
61    pub x: f32,
62    /// Top of the first item in reference-space pixels, used when `centered` is
63    /// false.
64    pub y: f32,
65    /// Width of each item's clickable region in pixels.
66    pub button_width: f32,
67    /// Height of each item's clickable region in pixels.
68    pub button_height: f32,
69    /// Pixels between adjacent items.
70    pub row_gap: f32,
71    /// [Font](#font) for the item text. Empty uses the built-in font.
72    pub font: String,
73    /// Pixel size of the item text when this menu emits its own built-in font
74    /// (that is, when `font` is empty). Ignored when `font` names a
75    /// [Font](#font), which carries its own size. In reference-space pixels.
76    pub font_px: f32,
77    /// Linear-space RGB color of the item text.
78    pub text_color: [f32; 3],
79    /// Scale applied to the item text.
80    pub text_scale: f32,
81    /// RGB color of an item's text while it is hovered.
82    pub hover_color: [f32; 3],
83    /// Multiplier applied to an item's text size while it is hovered. The
84    /// default `1.0` keeps the size and position fixed, so only the color
85    /// changes on hover; a value like `1.1` grows the hovered text by 10%.
86    pub hover_scale: f32,
87    /// Draw an in-engine arrow cursor while the menu is shown (the system
88    /// cursor is hidden). When false the system cursor is used.
89    pub cursor: bool,
90    /// RGBA fill color of the arrow cursor. A contrasting outline is added
91    /// automatically so it stays legible over any scene.
92    pub cursor_color: [f32; 4],
93    /// Arrow cursor height in pixels (its width follows the arrow's shape).
94    pub cursor_size: f32,
95    /// Which settings screen the `"settings"` item generates. `full` is the
96    /// complete Video / Audio / Controls set a 3D world configures; `minimal`
97    /// is the trimmed Video (window mode, resolution, vsync, frame rate) and
98    /// Audio (volume) set that fits a world with nothing to render into (a
99    /// visual-novel story, say), dropping the Controls tab and every
100    /// scene-render group.
101    pub settings_profile: SettingsProfile,
102    /// Action fired by the settings screen's Back button, overriding the
103    /// default (which returns to this menu). Setting it also generates the
104    /// settings screen even when no item uses the `"settings"` convenience, so
105    /// a caller that opens settings by its own action (a story, say) still gets
106    /// the screen. Empty keeps the default Back-to-menu behavior.
107    pub settings_back_action: String,
108}
109
110/// Which settings screen a [MainMenu](#mainmenu)'s `"settings"` item builds.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
112#[serde(rename_all = "lowercase")]
113pub enum SettingsProfile {
114    /// The complete Video / Audio / Controls settings, with the graphics
115    /// quality preset and the Quality / Advanced render-feature groups.
116    #[default]
117    Full,
118    /// A trimmed Video tab (window mode, resolution, vsync, frame rate) and an
119    /// Audio tab (volume) only: no Controls tab, no graphics quality preset,
120    /// and no scene-render groups. Suits a world that renders no 3D scene.
121    Minimal,
122}
123
124/// One entry in a [MainMenu](#mainmenu).
125#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
126#[serde(default)]
127pub struct MainMenuItem {
128    /// Button text.
129    pub label: String,
130    /// Action fired on click. See [MainMenu](#mainmenu) for the vocabulary.
131    pub action: String,
132}
133
134impl Default for MainMenu {
135    fn default() -> Self {
136        Self {
137            items: vec![
138                MainMenuItem {
139                    label: "Return".to_string(),
140                    action: "return".to_string(),
141                },
142                MainMenuItem {
143                    label: "Settings".to_string(),
144                    action: "settings".to_string(),
145                },
146                MainMenuItem {
147                    label: "Quit".to_string(),
148                    action: "quit".to_string(),
149                },
150            ],
151            title: String::new(),
152            initial: false,
153            toggle_key: "Escape".to_string(),
154            dim: [0.0, 0.0, 0.0, 1.0],
155            centered: true,
156            x: 640.0,
157            y: 300.0,
158            button_width: 360.0,
159            button_height: 60.0,
160            row_gap: 14.0,
161            font: String::new(),
162            font_px: 48.0,
163            text_color: [0.85, 0.85, 0.85],
164            text_scale: 1.1,
165            hover_color: [1.0, 0.85, 0.3],
166            hover_scale: 1.0,
167            cursor: true,
168            cursor_color: [1.0, 1.0, 1.0, 1.0],
169            cursor_size: 22.0,
170            settings_profile: SettingsProfile::Full,
171            settings_back_action: String::new(),
172        }
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn the_default_menu_can_resume_configure_and_quit() {
182        // The engine never injects a MainMenu, so a world that declares one with
183        // no items still gets a usable pause menu out of the three defaults.
184        let m = MainMenu::default();
185        let actions: Vec<&str> = m.items.iter().map(|i| i.action.as_str()).collect();
186        assert_eq!(actions, ["return", "settings", "quit"]);
187        assert_eq!(m.items[0].label, "Return");
188        assert_eq!(m.toggle_key, "Escape");
189        assert_eq!(m.settings_profile, SettingsProfile::Full);
190        // A pause menu is opened by its key, not shown at startup.
191        assert!(!m.initial);
192        assert!(m.centered);
193        assert!(m.cursor);
194    }
195
196    #[test]
197    fn an_authored_item_list_replaces_the_defaults_wholesale() {
198        let m: MainMenu = serde_json::from_str(
199            r#"{"title":"Ash","initial":true,"items":[{"label":"Play","action":"start"}],
200                "settings_profile":"minimal","toggle_key":"Tab"}"#,
201        )
202        .unwrap();
203        assert_eq!(m.items.len(), 1);
204        assert_eq!(m.items[0].label, "Play");
205        assert_eq!(m.items[0].action, "start");
206        assert_eq!(m.title, "Ash");
207        assert!(m.initial);
208        assert_eq!(m.settings_profile, SettingsProfile::Minimal);
209        assert_eq!(m.toggle_key, "Tab");
210        // Layout the args did not mention keeps the schema defaults.
211        assert_eq!((m.x, m.y), (640.0, 300.0));
212        assert_eq!(m.button_width, 360.0);
213    }
214
215    #[test]
216    fn a_blank_item_carries_neither_label_nor_action() {
217        let item = MainMenuItem::default();
218        assert!(item.label.is_empty());
219        assert!(item.action.is_empty());
220    }
221
222    #[test]
223    fn settings_profile_names_parse_in_lowercase() {
224        assert_eq!(SettingsProfile::default(), SettingsProfile::Full);
225        assert_eq!(
226            serde_json::from_str::<SettingsProfile>(r#""full""#).unwrap(),
227            SettingsProfile::Full
228        );
229        assert_eq!(
230            serde_json::to_string(&SettingsProfile::Minimal).unwrap(),
231            r#""minimal""#
232        );
233    }
234
235    #[test]
236    fn an_authored_menu_round_trips_through_postcard() {
237        let m: MainMenu = serde_json::from_str(
238            r#"{"items":[{"label":"Play","action":"start"},{"label":"Quit","action":"quit"}],
239                "dim":[0,0,0,0.7],"font":"body","font_px":32,"hover_scale":1.2,
240                "settings_back_action":"pause"}"#,
241        )
242        .unwrap();
243        let bytes = postcard::to_allocvec(&m).unwrap();
244        let back: MainMenu = postcard::from_bytes(&bytes).unwrap();
245        assert_eq!(back.items.len(), 2);
246        assert_eq!(back.items[1].action, "quit");
247        assert_eq!(back.dim, [0.0, 0.0, 0.0, 0.7]);
248        assert_eq!(back.font, "body");
249        assert_eq!(back.font_px, 32.0);
250        assert_eq!(back.hover_scale, 1.2);
251        assert_eq!(back.settings_back_action, "pause");
252    }
253}