Skip to main content

shuyi_assets/
lighting.rs

1//! Lights: named recipes for shading pictures, read from a RON file of
2//! `name: (grey: 0.35, tint: (0.62, 0.56, 0.46))` entries. Look data only:
3//! a game's rules never see it. As a format (ADR-0007): [`LightingRon`].
4use crate::prelude::*;
5
6use alloc::collections::BTreeMap;
7
8use serde::{Deserialize, Serialize};
9
10use crate::error::ContentError;
11use crate::format::{AssetKind, AssetPath, DecodeCx, Format, TEXT_MAX_BYTES, text};
12use crate::grid_map::DEFAULT_LIGHT;
13use crate::ron::parse_ron;
14
15impl AssetKind for Lighting {
16    const NAME: &'static str = "lights";
17}
18
19/// A lights' file (`lights`, `.ron`), checked as [`from_source`] says,
20/// with the light a map that names none is in.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct LightingRon {
23    default: String,
24}
25
26impl LightingRon {
27    /// The lights of a game whose maps are in `default` unless they say.
28    pub fn new(default: impl Into<String>) -> Self {
29        Self {
30            default: default.into(),
31        }
32    }
33}
34
35impl Default for LightingRon {
36    /// Maps are in [`DEFAULT_LIGHT`] unless they say.
37    fn default() -> Self {
38        Self::new(DEFAULT_LIGHT)
39    }
40}
41
42impl Format for LightingRon {
43    type Asset = Lighting;
44
45    fn id(&self) -> &'static str {
46        "lights"
47    }
48
49    fn extensions(&self) -> &'static [&'static str] {
50        &["ron"]
51    }
52
53    fn max_bytes(&self) -> usize {
54        TEXT_MAX_BYTES
55    }
56
57    fn decode(
58        &self,
59        file: &AssetPath,
60        bytes: &[u8],
61        _cx: &DecodeCx<'_>,
62    ) -> Result<Lighting, Vec<ContentError>> {
63        from_source(file.shown(), text(file, bytes)?, &self.default)
64    }
65}
66
67/// How many parts a [`Light`]'s numbers are counted in: the file's `0.35`
68/// is kept as 350.
69const PARTS: u16 = 1000;
70
71/// One light: how a picture's colours change in it. Each pixel goes
72/// `grey` of the way to its own grey, then its red, green and blue are
73/// multiplied by `tint`. The numbers are thousandths (0 to 1000), so a
74/// light is a plain value a frame item can carry and compare.
75#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76pub struct Light {
77    /// How far a colour goes to its own grey: 0 = not at all, 1000 = grey.
78    pub grey: u16,
79    /// What red, green and blue are multiplied by: 1000 = left as they
80    /// are.
81    pub tint: [u16; 3],
82}
83
84impl Light {
85    /// The light that changes nothing.
86    pub const PLAIN: Self = Self {
87        grey: 0,
88        tint: [PARTS; 3],
89    };
90
91    /// The light of `grey` and `tint` as the file writes them (0 to 1),
92    /// to the nearest thousandth; what is outside that range is cut to it.
93    pub fn new(grey: f32, tint: [f32; 3]) -> Self {
94        Self {
95            grey: parts(grey),
96            tint: tint.map(parts),
97        }
98    }
99
100    /// Whether it changes no colour.
101    pub fn is_plain(self) -> bool {
102        self == Self::PLAIN
103    }
104
105    /// The colour `rgb` (red, green, blue) in this light. The one place
106    /// that knows the sum: every renderer shades with it.
107    pub fn apply(self, rgb: [u8; 3]) -> [u8; 3] {
108        let [r, g, b] = rgb.map(f32::from);
109        let own_grey = 0.299 * r + 0.587 * g + 0.114 * b;
110        let grey = fraction(self.grey);
111        let shade = |c: f32, tint: u16| to_channel((c + (own_grey - c) * grey) * fraction(tint));
112        [
113            shade(r, self.tint[0]),
114            shade(g, self.tint[1]),
115            shade(b, self.tint[2]),
116        ]
117    }
118}
119
120impl Default for Light {
121    fn default() -> Self {
122        Self::PLAIN
123    }
124}
125
126/// `v` (0 to 1) in thousandths. NaN counts as 0.
127#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped first
128fn parts(v: f32) -> u16 {
129    libm::roundf(v * f32::from(PARTS)).clamp(0.0, f32::from(PARTS)) as u16
130}
131
132/// Thousandths as a fraction.
133fn fraction(parts: u16) -> f32 {
134    f32::from(parts) / f32::from(PARTS)
135}
136
137/// Rounds and saturates a channel value to `0..=255`.
138#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // clamped first
139fn to_channel(v: f32) -> u8 {
140    libm::roundf(v).clamp(0.0, 255.0) as u8
141}
142
143/// Every light, by name.
144#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
145pub struct Lighting {
146    /// The lights by name.
147    pub lights: BTreeMap<String, Light>,
148}
149
150impl Lighting {
151    /// The light called `name`.
152    pub fn get(&self, name: &str) -> Option<Light> {
153        self.lights.get(name).copied()
154    }
155
156    /// Every light's name in double quotes, in order, for an error
157    /// message.
158    pub fn names(&self) -> String {
159        let quoted: Vec<String> = self.lights.keys().map(|l| format!("\"{l}\"")).collect();
160        quoted.join(", ")
161    }
162}
163
164#[derive(Deserialize)]
165#[serde(deny_unknown_fields)]
166struct RawLight {
167    grey: f32,
168    tint: (f32, f32, f32),
169}
170
171/// Parses lights' file `source`, attributing errors to `file`. Every
172/// number must be from 0 to 1, and there must be a light called `default`:
173/// the light of a map that names none. Reports every problem found.
174pub fn from_source(file: &str, source: &str, default: &str) -> Result<Lighting, Vec<ContentError>> {
175    let raw: BTreeMap<String, RawLight> = parse_ron(file, source).map_err(|e| vec![e])?;
176    let mut errors = Vec::new();
177    let mut lights = BTreeMap::new();
178    for (name, light) in raw {
179        let (r, g, b) = light.tint;
180        let numbers = [
181            ("grey", light.grey),
182            ("tint red", r),
183            ("tint green", g),
184            ("tint blue", b),
185        ];
186        for (what, v) in numbers {
187            if !(0.0..=1.0).contains(&v) {
188                let message = format!("light \"{name}\": {what} is {v}; it must be from 0 to 1");
189                errors.push(ContentError::new(file, message));
190            }
191        }
192        lights.insert(name, Light::new(light.grey, [r, g, b]));
193    }
194    if !lights.contains_key(default) {
195        let message = format!("no light \"{default}\": it is the light of a map that names none");
196        errors.push(ContentError::new(file, message));
197    }
198    if errors.is_empty() {
199        Ok(Lighting { lights })
200    } else {
201        Err(errors)
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    /// A dusk light: 35% to grey, then × 0.62, 0.56, 0.46.
210    const C3: Light = Light {
211        grey: 350,
212        tint: [620, 560, 460],
213    };
214
215    #[test]
216    fn c3s_light_on_three_colours() {
217        // A grass green: its grey is 135.5; red 96 → 109.8 → 68.1, green
218        // 168 → 156.6 → 87.7, blue 72 → 94.2 → 43.3.
219        assert_eq!(C3.apply([96, 168, 72]), [68, 88, 43]);
220        // A water blue: its grey is 127.4.
221        assert_eq!(C3.apply([64, 144, 208]), [53, 77, 83]);
222        // A sand: its grey is 195.3.
223        assert_eq!(C3.apply([224, 200, 96]), [133, 111, 60]);
224        // White keeps the tint; black stays black.
225        assert_eq!(C3.apply([255, 255, 255]), [158, 143, 117]);
226        assert_eq!(C3.apply([0, 0, 0]), [0, 0, 0]);
227    }
228
229    #[test]
230    fn grey_and_tint_each_do_their_part() {
231        let grey = Light {
232            grey: 1000,
233            ..Light::PLAIN
234        };
235        // All the way to its own grey: 0.299 × 255.
236        assert_eq!(grey.apply([255, 0, 0]), [76, 76, 76]);
237        let half = Light {
238            grey: 500,
239            ..Light::PLAIN
240        };
241        assert_eq!(half.apply([255, 0, 0]), [166, 38, 38]);
242        let tint = Light {
243            grey: 0,
244            tint: [1000, 500, 0],
245        };
246        assert_eq!(tint.apply([200, 200, 200]), [200, 100, 0]);
247    }
248
249    #[test]
250    fn the_plain_light_changes_nothing() {
251        assert!(Light::PLAIN.is_plain());
252        assert!(Light::default().is_plain());
253        assert!(!C3.is_plain());
254        for rgb in [[0, 0, 0], [255, 255, 255], [1, 2, 3], [96, 168, 72]] {
255            assert_eq!(Light::PLAIN.apply(rgb), rgb);
256        }
257    }
258
259    #[test]
260    fn a_light_keeps_thousandths() {
261        assert_eq!(Light::new(0.35, [0.62, 0.56, 0.46]), C3);
262        assert_eq!(Light::new(0.0, [1.0; 3]), Light::PLAIN);
263        assert_eq!(Light::new(0.0004, [0.9996, 0.5, 0.5]).grey, 0);
264        assert_eq!(Light::new(0.0006, [0.9996, 0.5, 0.5]).tint[0], 1000);
265        // Outside 0 to 1 is cut; NaN is 0.
266        let cut = Light::new(-1.0, [2.0, f32::NAN, 0.25]);
267        assert_eq!((cut.grey, cut.tint), (0, [1000, 0, 250]));
268    }
269
270    const VALID: &str = r#"{
271        "noon": (grey: 0.0, tint: (1.0, 1.0, 1.0)),
272        "dusk": (grey: 0.35, tint: (0.62, 0.56, 0.46)),
273    }"#;
274
275    #[test]
276    fn a_valid_file_loads() {
277        let lighting = from_source("l.ron", VALID, "noon").unwrap();
278        assert_eq!(lighting.lights.len(), 2);
279        assert_eq!(lighting.get("noon"), Some(Light::PLAIN));
280        assert_eq!(lighting.get("dusk"), Some(C3));
281        assert_eq!(lighting.get("night"), None);
282        assert_eq!(lighting.names(), "\"dusk\", \"noon\"");
283    }
284
285    fn messages(source: &str) -> Vec<String> {
286        let errors = from_source("l.ron", source, "noon")
287            .err()
288            .unwrap_or_default();
289        errors
290            .into_iter()
291            .map(|e| {
292                assert_eq!(e.file, "l.ron");
293                e.message
294            })
295            .collect()
296    }
297
298    #[test]
299    fn every_number_must_be_from_0_to_1() {
300        let source = r#"{
301            "noon": (grey: 0.0, tint: (1.0, 1.0, 1.0)),
302            "odd": (grey: 1.5, tint: (-0.1, 1.001, 2.0)),
303        }"#;
304        assert_eq!(
305            messages(source),
306            [
307                "light \"odd\": grey is 1.5; it must be from 0 to 1",
308                "light \"odd\": tint red is -0.1; it must be from 0 to 1",
309                "light \"odd\": tint green is 1.001; it must be from 0 to 1",
310                "light \"odd\": tint blue is 2; it must be from 0 to 1",
311            ]
312        );
313        // The ends are in.
314        let ends = r#"{"noon": (grey: 1.0, tint: (0.0, 0.0, 1.0))}"#;
315        assert_eq!(messages(ends), [""; 0]);
316    }
317
318    #[test]
319    fn the_default_light_must_exist() {
320        let source = r#"{"dusk": (grey: 0.35, tint: (0.62, 0.56, 0.46))}"#;
321        assert_eq!(
322            messages(source),
323            ["no light \"noon\": it is the light of a map that names none"]
324        );
325        assert_eq!(messages("{}").len(), 1);
326        // The default is whichever the game names.
327        assert!(from_source("l.ron", source, "dusk").is_ok());
328    }
329
330    #[test]
331    fn a_broken_file_is_reported_where_it_breaks() {
332        let errors = from_source("l.ron", "{\n \"noon\": (grey: 0.0),\n}", "noon")
333            .err()
334            .unwrap_or_default();
335        assert_eq!(errors.len(), 1);
336        assert_eq!(errors[0].line, Some(2));
337        // A field a light doesn't have.
338        let extra = VALID.replace("grey: 0.0,", "grey: 0.0, glow: 1.0,");
339        assert!(from_source("l.ron", &extra, "noon").is_err());
340    }
341}