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
// Screen-space hit-region schema.
use crate::{AssetId, SpriteFit, de_opt_asset_ref};
use alloc::string::String;
/// A responsive invisible rectangular region in screen space.
///
/// When clicked, fires an `action`. When hovered, it optionally restyles a
/// referenced [TextLabel](#textlabel) (colour and/or scale).
///
/// The cursor must be free (not captured for camera control) for events to fire.
///
/// ```rust
/// # use concinnity_asset::HitRegion;
/// HitRegion {
/// x: 430.0,
/// y: 330.0,
/// width: 220.0,
/// height: 40.0,
/// hover_color: Some([1.0, 0.85, 0.3]),
/// hover_scale: Some(1.08),
/// ..Default::default()
/// };
/// ```
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(default)]
pub struct HitRegion {
/// Left edge of the region in window pixels.
pub x: f32,
/// Top edge of the region in window pixels.
pub y: f32,
/// Width of the region in window pixels.
pub width: f32,
/// Height of the region in window pixels.
pub height: f32,
/// A [TextLabel](#textlabel) to style on hover. `None` = no label effect.
#[serde(deserialize_with = "de_opt_asset_ref")]
pub label: Option<AssetId>,
/// RGB colour applied to the label while hovered. `None` = no change.
pub hover_color: Option<[f32; 3]>,
/// Scale applied to the label while hovered. None = no change.
pub hover_scale: Option<f32>,
/// Action to fire on click. Recognised forms:
/// `"scene:<name>"`, `"quit"`, `"screen:show:<name>"`, `"screen:hide"`,
/// `"screen:toggle:<name>"`.
pub action: String,
/// The [Sprite](#sprite) a [Slider](#slider) drag region moves along its
/// track. `None` for ordinary regions. Set automatically when a `Slider`
/// expands; you don't set this directly.
#[serde(default, deserialize_with = "de_opt_asset_ref")]
pub drag_handle: Option<AssetId>,
/// [Screen](#screen) this region belongs to. Resolved automatically from the
/// naming convention (a region named `<screen>_*` belongs to screen
/// `<screen>`); you don't set this directly. While a screen is active,
/// only the top capturing screen's regions fire; with no screen active,
/// only screen-less regions fire.
#[serde(default, deserialize_with = "de_opt_asset_ref")]
pub screen: Option<AssetId>,
/// Whether this region is inert. A disabled region never hovers or fires.
/// Set by the engine at runtime (e.g. a settings row whose feature the GPU
/// cannot provide is disabled and grayed out); you don't set this directly.
#[serde(default)]
pub disabled: bool,
/// When set, this region tracks its referenced [`label`](#hitregion): it
/// follows the label's vertical position (so a menu the engine lays out at
/// runtime keeps its buttons clickable) and is inert while the label's text
/// is empty (so a hidden menu entry does not catch clicks). Requires
/// `label`.
#[serde(default)]
pub follow_label: bool,
/// How a screen-owned region maps from the reference canvas to the window
/// when their aspect ratios differ (matches [Sprite](#sprite)'s `fit`).
/// `Bottom` keeps a region aligned with bottom-anchored furniture it
/// covers. A region spanning the whole reference canvas always covers the
/// full window regardless of `fit`.
#[serde(default)]
pub fit: SpriteFit,
}
impl Default for HitRegion {
fn default() -> Self {
Self {
x: 0.0,
y: 0.0,
width: 100.0,
height: 40.0,
label: None,
hover_color: None,
hover_scale: None,
action: String::new(),
drag_handle: None,
screen: None,
disabled: false,
follow_label: false,
fit: SpriteFit::Fit,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_blank_region_is_an_enabled_button_sized_rectangle() {
let h = HitRegion::default();
assert_eq!((h.x, h.y), (0.0, 0.0));
assert_eq!((h.width, h.height), (100.0, 40.0));
assert!(!h.disabled);
assert!(!h.follow_label);
assert_eq!(h.fit, SpriteFit::Fit);
assert!(h.action.is_empty());
// Hover styling is opt-in: unset means "do not restyle on hover".
assert_eq!(h.hover_color, None);
assert_eq!(h.hover_scale, None);
assert!(h.label.is_none());
assert!(h.drag_handle.is_none());
assert!(h.screen.is_none());
}
#[test]
fn an_authored_region_parses_and_round_trips_through_postcard() {
crate::test_support::install_resolvers();
let h: HitRegion = serde_json::from_str(
r#"{"x":10,"y":20,"width":200,"height":48,"label":"play_label","action":"start",
"hover_color":[1,0.85,0.3],"hover_scale":1.1,"drag_handle":"grip",
"screen":"menu","disabled":true,"follow_label":true,"fit":"cover"}"#,
)
.unwrap();
assert_eq!(h.label, Some(AssetId(10)));
assert_eq!(h.drag_handle, Some(AssetId(4)));
assert_eq!(h.screen, Some(AssetId(4)));
assert_eq!(h.action, "start");
assert_eq!(h.hover_scale, Some(1.1));
assert_eq!(h.fit, SpriteFit::Cover);
assert!(h.disabled);
assert!(h.follow_label);
let bytes = postcard::to_allocvec(&h).unwrap();
let back: HitRegion = postcard::from_bytes(&bytes).unwrap();
assert_eq!(back.hover_color, Some([1.0, 0.85, 0.3]));
assert_eq!((back.width, back.height), (200.0, 48.0));
assert_eq!(back.label, Some(AssetId(10)));
assert_eq!(back.fit, SpriteFit::Cover);
}
}