scene.rsannotatedscene.rssource183 lines · 6.4 KB · raw

A mood as a whole picture: the shadow, the cat breathing, and what is round it. The cat itself is a theme's (grey.rs here); everything else is the same for every theme.

4use whiskers_pet::Mood;
6use crate::decor::{self, INK};
7use crate::motion::Motion;
8use crate::node::{Node, moving, ra};

The drawing area, in grid units: x, y, width and height. It runs from (-3, -4) so that bubbles, stars and sound waves fit round the body.

12pub const VIEW: (f32, f32, f32, f32) = (-3.0, -4.0, 21.0, 20.0);

The cat's middle and half-width in grid units, which touch is measured in.

15pub const CENTER: (f32, f32) = (7.5, 9.5);
16pub const HALF_WIDTH: f32 = 5.5;

How far the pupils travel for a gaze of 1 on each axis, and the step they snap to.

19pub const GAZE_REACH: (f32, f32) = (1.5, 1.0);
20pub const GAZE_STEP: f32 = 0.5;

Every mood the core has, in the order the core lists them.

23pub const MOODS: [Mood; 11] = [
24    Mood::Idle,
25    Mood::Listening,
26    Mood::Thinking,
27    Mood::Working,
28    Mood::Speaking,
29    Mood::Tangled,
30    Mood::Busy,
31    Mood::Offline,
32    Mood::Dizzy,
33    Mood::Tickled,
34    Mood::Sleeping,
35];

Where the pupils go for a gaze (each axis -1..1), snapped as the app snaps them.

38pub fn gaze_offset(gaze: (f32, f32)) -> (f32, f32) {
39    let snap = |v: f32| (v / GAZE_STEP).round() * GAZE_STEP;
40    (snap(gaze.0 * GAZE_REACH.0), snap(gaze.1 * GAZE_REACH.1))
41}

The shape of the mouth while speaking, as the voice's clock gives it. Without one the mouth flaps on a beat.

45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub enum Mouth {
47    Closed,
48    Small,
49    Wide,
50    Round,
51}
53impl Mouth {
54    pub const ALL: [Mouth; 4] = [Mouth::Closed, Mouth::Small, Mouth::Wide, Mouth::Round];
55}

A cat's body (the standing one for every mood but sleeping, the flat one for sleeping) as a whole picture: shadow, breathing, swaying or shivering, and what is round it.

59pub fn assemble(mood: Mood, body: Vec<Node>) -> Vec<Node> {
60    if mood == Mood::Sleeping {
61        let mut nodes = vec![ra(-1.0, 15.0, 17.0, 1.0, INK, 0.5), moving(Motion::BreatheAsleep, (7.5, 15.0), body)];
62        nodes.extend(decor::zs());
63        return nodes;
64    }
65    let mut body = moving(Motion::Breathe, (7.5, 13.0), body);
66    match mood {
67        Mood::Dizzy => body = moving(Motion::Sway, (0.0, 0.0), vec![moving(Motion::Tilt, (7.5, 13.0), vec![body])]),
68        Mood::Tickled => body = moving(Motion::Shiver, (0.0, 0.0), vec![body]),
69        _ => {}
70    }
71    let mut nodes = vec![ra(3.0, 15.0, 9.0, 1.0, INK, 0.5), body];
72    nodes.extend(decor::around(mood));
73    nodes
74}

The grey cat in mood. mouth matters only while speaking.

77pub fn scene(mood: Mood, mouth: Option<Mouth>) -> Vec<Node> {
78    let body = if mood == Mood::Sleeping { crate::grey::sleeping() } else { crate::grey::standing(mood, mouth) };
79    assemble(mood, body)
80}
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::node::{Group, Node};
86
87    fn walk(nodes: &[Node], f: &mut impl FnMut(&Node)) {
88        for node in nodes {
89            f(node);
90            if let Node::Group(Group { children, .. }) = node {
91                walk(children, f);
92            }
93        }
94    }
95
96    #[test]
97    fn every_mood_has_a_scene_that_stays_inside_the_drawing_area() {
98        let (x0, y0, w, h) = VIEW;
99        for mood in MOODS {
100            let nodes = scene(mood, None);
101            assert!(!nodes.is_empty());
102            walk(&nodes, &mut |node| {
103                if let Node::Rect(r) = node {
104                    // Orbiting stars, rising z's, balls and a wagging tail move; allow their travel.
105                    let slack = 6.0;
106                    assert!(r.x >= x0 - slack && r.x + r.w <= x0 + w + slack, "{mood:?} {r:?}");
107                    assert!(r.y >= y0 - slack && r.y + r.h <= y0 + h + slack, "{mood:?} {r:?}");
108                }
109            });
110        }
111    }
112
113    #[test]
114    fn the_moods_listed_are_the_cores_moods() {
115        // An exhaustive match: a mood added to the core stops this compiling until it is drawn.
116        for mood in MOODS {
117            match mood {
118                Mood::Idle | Mood::Listening | Mood::Thinking | Mood::Working | Mood::Speaking | Mood::Tangled | Mood::Busy | Mood::Offline | Mood::Dizzy | Mood::Tickled | Mood::Sleeping => {}
119            }
120        }
121        let mut seen = MOODS.to_vec();
122        seen.dedup();
123        assert_eq!(seen.len(), 11);
124    }
125
126    fn tops(mood: Mood) -> f32 {
127        let mut top = f32::MAX;
128        walk(&scene(mood, None), &mut |node| {
129            if let Node::Rect(r) = node {
130                if r.color != INK && r.alpha == 1.0 && r.w * r.h > 4.0 {
131                    top = top.min(r.y);
132                }
133            }
134        });
135        top
136    }
137
138    #[test]
139    fn a_sleeping_cat_lies_flat_and_the_others_stand() {
140        assert!(tops(Mood::Sleeping) > tops(Mood::Idle));
141    }
142
143    fn uses(mood: Mood, motion: Motion) -> bool {
144        let mut found = false;
145        walk(&scene(mood, None), &mut |node| found |= matches!(node, Node::Group(g) if g.motion == motion));
146        found
147    }
148
149    #[test]
150    fn only_the_dizzy_cat_sways_and_only_the_tickled_one_shivers() {
151        for mood in MOODS {
152            assert_eq!(uses(mood, Motion::Sway), mood == Mood::Dizzy, "{mood:?}");
153            assert_eq!(uses(mood, Motion::Shiver), mood == Mood::Tickled, "{mood:?}");
154        }
155    }
156
157    #[test]
158    fn the_gaze_snaps_to_half_units_as_the_app_does() {
159        assert_eq!(gaze_offset((0.0, 0.0)), (0.0, 0.0));
160        assert_eq!(gaze_offset((1.0, 1.0)), (1.5, 1.0));
161        assert_eq!(gaze_offset((-1.0, -1.0)), (-1.5, -1.0));
162        // Thinking looks up and to the right by (1.0, -1.0) (0.8 * 1.5 snaps to 1.0).
163        assert_eq!(gaze_offset((0.8, -0.8)), (1.0, -1.0));
164    }
165
166    #[test]
167    fn a_speaking_mouth_is_flapping_only_without_a_shape() {
168        assert!(uses(Mood::Speaking, Motion::MouthFlap));
169        for mouth in Mouth::ALL {
170            let mut found = false;
171            walk(&scene(Mood::Speaking, Some(mouth)), &mut |node| found |= matches!(node, Node::Group(g) if g.motion == Motion::MouthFlap));
172            assert!(!found, "{mouth:?}");
173        }
174    }
175
176    #[test]
177    fn only_a_speaking_cat_cares_about_its_mouth() {
178        for mood in MOODS.into_iter().filter(|m| *m != Mood::Speaking) {
179            assert_eq!(scene(mood, None), scene(mood, Some(Mouth::Wide)), "{mood:?}");
180        }
181        assert_ne!(scene(Mood::Speaking, Some(Mouth::Closed)), scene(Mood::Speaking, Some(Mouth::Wide)));
182    }
183}