concinnity_core/components/screen.rs
1// Overlay-screen schema.
2
3use crate::ecs::asset_id::AssetId;
4use crate::ecs::asset_id::de_opt_asset_ref;
5use alloc::string::String;
6
7/// How a [Screen](#screen) treats input while it is active.
8#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
9#[serde(rename_all = "lowercase")]
10pub enum ScreenInput {
11 /// The screen owns input while it is the topmost capturing screen:
12 /// gameplay input is suppressed and lower screens' [HitRegion](#hitregion)s
13 /// stop firing.
14 #[default]
15 Capture,
16 /// The screen only draws; input passes through to whatever is beneath it.
17 Passthrough,
18}
19
20/// A named full-screen layer of UI drawn over the world: a pause menu, a
21/// settings page, a console, a score overlay.
22///
23/// UI elements ([Sprite](#sprite), [TextLabel](#textlabel),
24/// [TextInput](#textinput), [HitRegion](#hitregion)) belong to a screen by
25/// name prefix `<screen_name>_*`, mirroring the [Scene](#scene) →
26/// [Prop](#prop) convention. Active screens form a stack; each is shown /
27/// hidden via [HitRegion](#hitregion) or [KeyBinding](#keybinding) actions:
28/// - `screen:show:<name>` replaces the top of the stack (menu navigation)
29/// - `screen:push:<name>` opens on top of what is already showing
30/// - `screen:hide` closes the top screen, revealing what was beneath
31/// - `screen:toggle:<name>` closes the screen if it is on top, opens it otherwise
32///
33/// Screens draw in stack order (later on top); `layer` orders a screen
34/// against the always-on HUD and other screens independent of stack position.
35/// While any active screen has `pauses_world` set, the world freezes exactly
36/// as today's pause menu does. A `toggle_key` opens and closes the screen from
37/// anywhere. `focus` names a [TextInput](#textinput) that receives keyboard
38/// focus whenever the screen reaches the top of the stack. Worlds that need no
39/// menus simply declare no screens.
40///
41/// ```rust
42/// # use concinnity_core::components::Screen;
43/// Screen {
44/// toggle_key: "Escape".into(),
45/// ..Default::default()
46/// };
47/// ```
48#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
49#[serde(default)]
50pub struct Screen {
51 /// Assigned by the loader; not authored.
52 #[serde(skip)]
53 pub asset_id: AssetId,
54 /// When true, this screen is shown as soon as the world loads.
55 pub initial: bool,
56 /// Seconds to fade the screen in when it's shown. 0 shows it instantly.
57 pub fade_in_secs: f32,
58 /// InputKey that toggles this screen open / closed from anywhere, by the same
59 /// canonical key names a [KeyBinding](#keybinding) uses (e.g. "Escape",
60 /// "Backtick"). Empty leaves the screen action-driven only.
61 pub toggle_key: String,
62 /// Input policy while the screen is active.
63 pub input: ScreenInput,
64 /// When true (the default), the world pauses beneath this screen while it
65 /// is active: gameplay input, physics, and animation freeze.
66 pub pauses_world: bool,
67 /// [TextInput](#textinput) that receives keyboard focus whenever this
68 /// screen reaches the top of the stack.
69 #[serde(deserialize_with = "de_opt_asset_ref")]
70 pub focus: Option<AssetId>,
71 /// Draw-order bias against the always-on HUD and other screens. Screens
72 /// default above the HUD in stack order; a negative layer draws beneath
73 /// the HUD, a higher layer stays above later-pushed screens.
74 pub layer: i32,
75}
76
77impl Default for Screen {
78 fn default() -> Self {
79 Self {
80 asset_id: AssetId::default(),
81 initial: false,
82 fade_in_secs: 0.0,
83 toggle_key: String::new(),
84 input: ScreenInput::Capture,
85 pauses_world: true,
86 focus: None,
87 layer: 0,
88 }
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95
96 #[test]
97 fn a_blank_screen_captures_input_and_pauses_the_world() {
98 // An overlay is modal by default: the world underneath neither ticks nor
99 // sees the input the screen is consuming.
100 let s = Screen::default();
101 assert_eq!(s.input, ScreenInput::Capture);
102 assert!(s.pauses_world);
103 assert!(!s.initial);
104 assert_eq!(s.fade_in_secs, 0.0);
105 assert_eq!(s.layer, 0);
106 assert!(s.toggle_key.is_empty());
107 assert!(s.focus.is_none());
108 assert_eq!(ScreenInput::default(), ScreenInput::Capture);
109 }
110
111 #[test]
112 fn a_passthrough_hud_parses_and_round_trips_through_postcard() {
113 crate::test_support::install_resolvers();
114 let s: Screen = serde_json::from_str(
115 r#"{"initial":true,"fade_in_secs":0.5,"toggle_key":"Tab","input":"passthrough",
116 "pauses_world":false,"focus":"first_button","layer":-1}"#,
117 )
118 .unwrap();
119 assert_eq!(s.input, ScreenInput::Passthrough);
120 assert!(!s.pauses_world);
121 assert!(s.initial);
122 assert_eq!(s.focus, Some(AssetId(12)));
123 assert_eq!(
124 serde_json::to_string(&ScreenInput::Passthrough).unwrap(),
125 r#""passthrough""#
126 );
127
128 let bytes = postcard::to_allocvec(&s).unwrap();
129 let back: Screen = postcard::from_bytes(&bytes).unwrap();
130 assert_eq!(back.fade_in_secs, 0.5);
131 assert_eq!(back.toggle_key, "Tab");
132 assert_eq!(back.input, ScreenInput::Passthrough);
133 // A negative layer sits below the default overlays.
134 assert_eq!(back.layer, -1);
135 assert_eq!(back.asset_id, AssetId::default());
136 }
137}