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