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}