concinnity_asset/sprite.rs
1// Screen-space sprite overlay schema.
2
3use crate::{AssetId, TextureHandle, de_opt_asset_ref, de_opt_texture_handle};
4
5/// Screen-space 2D rectangle drawn as a UI overlay each frame.
6///
7/// Sprites are pixel-anchored quads with an RGBA tint. They draw alongside
8/// [TextLabel](#textlabel)s, ordered behind labels so text sits on top.
9///
10/// A sprite with a `texture` draws that image, multiplied by the tint (use a
11/// white tint to show the image unchanged; the tint's alpha fades it).
12/// Without one, the tint is drawn as a solid-coloured rectangle.
13///
14/// ```rust
15/// # use concinnity_asset::Sprite;
16/// Sprite {
17/// x: 0.0,
18/// y: 0.0,
19/// width: 1280.0,
20/// height: 720.0,
21/// tint: [0.04, 0.06, 0.1, 1.0],
22/// ..Default::default()
23/// };
24/// ```
25#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
26#[serde(default)]
27pub struct Sprite {
28 /// Assigned by the loader; not authored.
29 #[serde(skip)]
30 pub asset_id: AssetId,
31 /// Left edge in screen pixels from the window's top-left.
32 pub x: f32,
33 /// Top edge in screen pixels from the window's top-left.
34 pub y: f32,
35 /// Width in screen pixels.
36 pub width: f32,
37 /// Height in screen pixels.
38 pub height: f32,
39 /// [Texture](#texture) to draw, sampled over the sprite's rect and
40 /// multiplied by `tint`. Omitted, the sprite is a solid `tint` fill.
41 #[serde(deserialize_with = "de_opt_texture_handle")]
42 pub texture: Option<TextureHandle>,
43 /// RGBA colour the rectangle is filled with, each channel in [0, 1].
44 pub tint: [f32; 4],
45 /// When true, the sprite acts as an in-engine cursor: it is drawn on top of
46 /// the other overlays as an arrow pointer tracking the mouse, with the
47 /// pointer at the arrow's tip. `tint` is the arrow fill (a contrasting
48 /// outline is added automatically) and `height` its size; `width` is
49 /// ignored so the arrow keeps its shape. The system cursor is hidden while
50 /// a visible `follow_cursor` sprite exists.
51 pub follow_cursor: bool,
52 /// When false the sprite is skipped each frame.
53 pub visible: bool,
54 /// [Screen](#screen) this sprite belongs to. Resolved automatically from
55 /// the naming convention (`<screen>_*`); you don't set this directly.
56 /// `None` means the sprite is always visible (e.g. a scene background).
57 #[serde(default, deserialize_with = "de_opt_asset_ref")]
58 pub screen: Option<AssetId>,
59 /// How a screen-owned sprite maps from the reference canvas to the window
60 /// when their aspect ratios differ.
61 pub fit: SpriteFit,
62 /// Corner rounding radius in the sprite's own pixel space. `0` keeps
63 /// sharp corners; larger values round each corner with a quarter-circle
64 /// arc (clamped to half the sprite's shorter side). The rounded edge is
65 /// softly anti-aliased.
66 pub corner_radius: f32,
67 /// Border stroke width in the sprite's own pixel space, drawn just inside
68 /// the sprite's outline and following its rounded corners. `0` draws no
69 /// border; larger values inset the tinted fill by this width and paint the
70 /// ring in `border_color` (clamped to half the sprite's shorter side).
71 pub border_width: f32,
72 /// RGBA colour of the border stroke, each channel in [0, 1]. Ignored when
73 /// `border_width` is `0`.
74 pub border_color: [f32; 4],
75}
76
77/// How a screen-owned overlay element (a [Sprite](#sprite), [TextLabel](#textlabel),
78/// or [HitRegion](#hitregion)) maps from the 1280x720 reference canvas to the
79/// live window when their aspect ratios differ.
80///
81/// Screen-owned UI is authored against a fixed reference canvas and uniformly
82/// scaled to the window at runtime.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
84#[serde(rename_all = "lowercase")]
85pub enum SpriteFit {
86 /// The canvas fits inside the window, centered, leaving margins on the
87 /// shorter axis. UI elements keep their proportions and stay fully
88 /// visible.
89 #[default]
90 Fit,
91 /// The canvas fills the window, centered, cropping the overflowing axis
92 /// equally on both sides. Full-bleed stage imagery (scene backdrops,
93 /// character portraits) reaches the window edges without distorting, and
94 /// content anchored to a canvas edge stays flush with the window edge.
95 Cover,
96 /// The canvas keeps the `fit` scale (no cropping), but the whole overlay is
97 /// shifted so the reference bottom edge lands on the window bottom edge.
98 /// Bottom-anchored furniture (a visual-novel dialog box and its controls)
99 /// hugs the window bottom at any aspect ratio instead of floating above a
100 /// letterbox margin.
101 Bottom,
102}
103
104impl Default for Sprite {
105 fn default() -> Self {
106 Self {
107 asset_id: AssetId::default(),
108 x: 0.0,
109 y: 0.0,
110 width: 100.0,
111 height: 100.0,
112 texture: None,
113 tint: [1.0, 1.0, 1.0, 1.0],
114 follow_cursor: false,
115 visible: true,
116 screen: None,
117 fit: SpriteFit::Fit,
118 corner_radius: 0.0,
119 border_width: 0.0,
120 border_color: [0.0, 0.0, 0.0, 1.0],
121 }
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn a_blank_sprite_is_a_visible_untinted_square_with_no_border() {
131 let s = Sprite::default();
132 assert_eq!((s.width, s.height), (100.0, 100.0));
133 assert_eq!(s.tint, [1.0, 1.0, 1.0, 1.0]);
134 assert!(s.visible);
135 assert!(!s.follow_cursor);
136 assert_eq!(s.fit, SpriteFit::Fit);
137 assert_eq!(s.corner_radius, 0.0);
138 // Zero width is what suppresses the border, so its colour is irrelevant.
139 assert_eq!(s.border_width, 0.0);
140 assert!(s.texture.is_none());
141 assert!(s.screen.is_none());
142 assert_eq!(SpriteFit::default(), SpriteFit::Fit);
143 }
144
145 #[test]
146 fn fit_names_parse_in_lowercase() {
147 let f = |s: &str| serde_json::from_str::<SpriteFit>(s).unwrap();
148 assert_eq!(f(r#""fit""#), SpriteFit::Fit);
149 assert_eq!(f(r#""cover""#), SpriteFit::Cover);
150 assert_eq!(f(r#""bottom""#), SpriteFit::Bottom);
151 assert_eq!(
152 serde_json::to_string(&SpriteFit::Bottom).unwrap(),
153 r#""bottom""#
154 );
155 }
156
157 #[test]
158 fn a_cursor_sprite_parses_and_round_trips_through_postcard() {
159 crate::test_support::install_resolvers();
160 let s: Sprite = serde_json::from_str(
161 r#"{"x":10,"y":20,"width":32,"height":32,"texture":"tex_cursor",
162 "tint":[1,1,1,0.8],"follow_cursor":true,"visible":false,"screen":"menu",
163 "fit":"cover","corner_radius":4,"border_width":2,"border_color":[1,0,0,1]}"#,
164 )
165 .unwrap();
166 assert_eq!(s.texture, Some(TextureHandle(10)));
167 assert_eq!(s.screen, Some(AssetId(4)));
168 assert!(s.follow_cursor);
169 assert!(!s.visible);
170
171 let bytes = postcard::to_allocvec(&s).unwrap();
172 let back: Sprite = postcard::from_bytes(&bytes).unwrap();
173 assert_eq!((back.x, back.y), (10.0, 20.0));
174 assert_eq!(back.tint, [1.0, 1.0, 1.0, 0.8]);
175 assert_eq!(back.fit, SpriteFit::Cover);
176 assert_eq!(back.corner_radius, 4.0);
177 assert_eq!(back.border_width, 2.0);
178 assert_eq!(back.border_color, [1.0, 0.0, 0.0, 1.0]);
179 assert_eq!(back.asset_id, AssetId::default());
180 }
181}