The ginger cat: an upright cat on the 15 by 16 grid with a block torso, four legs, arms at its sides, ears that perk and droop, whiskers, and a pink nose. Every pose the app has is drawn here, so ginger is a complete character like the grey cat and the bunny, and the website draws the very same code.

Layout, back to front: legs, tail, ears, arms, torso, nose and whiskers, eyes, mouth, and whatever the pose adds (a laptop under typing paws). The eyes are Role::Eyes groups so the gaze moves them; each blinks about its own middle. The mouth is pixel-mouth's, drawn only while speaking.

9use pixel_mouth::{MouthSpec, Palette, Style};
10use whiskers_pet::Mood;
12use crate::decor::{BLUSH, INK, WHITE, spiral};
13use crate::motion::Motion;
14use crate::mouth;
15use crate::node::{Color, Look, Node, eyes, moving, r, ra};
16use crate::scene::Mouth;
17
18const FUR: Color = Color::hex(0xDE886D);
19const INNER_EAR: Color = Color::hex(0xF2A7B0);
20const WHISKER: Color = Color::hex(0xF5EDE6);
21const LAPTOP_BODY: Color = Color::hex(0xB9B9C4);
22const LAPTOP_LID: Color = Color::hex(0x3A3A44);

Where ginger's mouth sits and how it is drawn: a plain line when shut, drawn only while speaking.

25pub const MOUTH: MouthSpec = MouthSpec { anchor: (7.5, 11.0), size: 2.4, style: Style::Flat, palette: Palette { ink: 0x000000, beak: 0x000000 } };

The ginger cat in mood (any but sleeping). mouth matters only while speaking.

28pub fn standing(mood: Mood, mouth: Option<Mouth>) -> Vec<Node> {
29    debug_assert!(mood != Mood::Sleeping, "a sleeping cat is lying down: use `sleeping`");
30    let mut out = Vec::new();
31    // Legs.
32    for x in [3.0, 5.0, 9.0, 11.0] {
33        out.push(r(x, 13.0, 1.0, 2.0, FUR));
34    }
35    // Tail: the elbow stays, the upright wags (whipping when tickled, hanging when unhappy).
36    out.push(r(13.0, 12.0, 2.0, 1.0, FUR));
37    match mood {
38        Mood::Tangled => out.push(r(14.0, 11.3, 1.0, 0.7, FUR)),
39        Mood::Tickled => out.push(moving(Motion::TailFlick, (0.0, 0.0), vec![r(14.0, 10.5, 1.0, 1.5, FUR)])),
40        _ => out.push(moving(Motion::TailWag, (0.0, 0.0), vec![r(14.0, 10.5, 1.0, 1.5, FUR)])),
41    }
42    // Ears: bottoms fixed on the head, tops perk up when listening and droop when unhappy.
43    let lift = match mood {
44        Mood::Listening => -1.0,
45        Mood::Tangled | Mood::Offline => 0.7,
46        _ => 0.0,
47    };
48    for (x, top) in [(2.0, 4.0), (3.0, 3.0), (11.0, 3.0), (12.0, 4.0)] {
49        out.push(r(x, top + lift, 1.0, 6.0 - (top + lift), FUR));
50    }
51    out.push(r(3.0, 4.0 + lift, 1.0, 2.0 - lift, INNER_EAR));
52    out.push(r(11.0, 4.0 + lift, 1.0, 2.0 - lift, INNER_EAR));
53    // Arms (before the torso so the laptop and torso sit over them).
54    match mood {
55        Mood::Working => {
56            out.push(moving(Motion::Type(0), (0.0, 0.0), vec![r(1.5, 11.0, 2.0, 1.5, FUR)]));
57            out.push(moving(Motion::Type(1), (0.0, 0.0), vec![r(11.5, 11.0, 2.0, 1.5, FUR)]));
58        }
59        Mood::Busy => {
60            // Hands up, catching and throwing.
61            out.push(moving(Motion::Bob(0), (0.0, 0.0), vec![r(0.0, 6.5, 2.0, 2.0, FUR)]));
62            out.push(moving(Motion::Bob(1), (0.0, 0.0), vec![r(13.0, 6.5, 2.0, 2.0, FUR)]));
63        }
64        _ => {
65            out.push(r(0.0, 9.0, 2.0, 2.0, FUR));
66            out.push(r(13.0, 9.0, 2.0, 2.0, FUR));
67        }
68    }
69    out.push(r(2.0, 6.0, 11.0, 7.0, FUR)); // torso
70    face(mood, mouth, &mut out);
71    if mood == Mood::Working {
72        out.extend([r(4.5, 10.9, 6.0, 2.2, LAPTOP_LID), ra(7.0, 11.6, 1.0, 0.8, WHITE, 0.8), r(3.5, 13.0, 8.0, 0.9, LAPTOP_BODY)]);
73    }
74    out
75}
77fn face(mood: Mood, mouth: Option<Mouth>, out: &mut Vec<Node>) {
78    // Nose and whiskers.
79    out.push(r(7.0, 9.5, 1.0, 1.0, INNER_EAR));
80    for y in [8.0, 11.3] {
81        out.push(r(-1.0, y, 4.0, 0.35, WHISKER));
82        out.push(r(12.0, y, 4.0, 0.35, WHISKER));
83    }
84    match mood {
85        Mood::Dizzy => {
86            out.extend(spiral(4.5, 9.0, INK));
87            out.extend(spiral(10.5, 9.0, INK));
88        }
89        Mood::Tickled => {
90            for x in [4.0, 10.0] {
91                out.extend([r(x - 1.0, 9.0, 1.0, 1.0, INK), r(x, 8.0, 1.0, 1.0, INK), r(x + 1.0, 9.0, 1.0, 1.0, INK)]);
92            }
93            out.push(ra(3.0, 10.4, 1.6, 1.0, BLUSH, 0.7));
94            out.push(ra(10.4, 10.4, 1.6, 1.0, BLUSH, 0.7));
95            out.push(r(6.5, 10.8, 2.0, 1.0, INK));
96            out.push(r(7.0, 11.3, 1.0, 0.5, INNER_EAR));
97        }
98        Mood::Tangled => {
99            for x in [4.0, 10.0] {
100                out.extend([r(x - 1.0, 8.0, 1.0, 1.0, INK), r(x, 9.0, 1.0, 1.0, INK), r(x + 1.0, 8.0, 1.0, 1.0, INK)]);
101            }
102            out.extend([r(6.5, 10.8, 0.7, 0.4, INK), r(7.2, 11.1, 0.7, 0.4, INK), r(7.9, 10.8, 0.7, 0.4, INK)]);
103        }
104        Mood::Offline => {
105            // Sad, heavy-lidded eyes and a small frown.
106            for x in [4.0, 10.0] {
107                out.push(r(x - 0.5, 9.0, 2.0, 0.5, INK));
108                out.push(r(x, 9.5, 1.0, 1.0, INK));
109            }
110            out.extend([r(6.5, 11.3, 2.0, 0.4, INK), r(6.0, 11.7, 0.6, 0.4, INK), r(8.4, 11.7, 0.6, 0.4, INK)]);
111        }
112        Mood::Busy => {
113            // The eyes follow the balls round.
114            out.push(moving(Motion::Scan, (0.0, 0.0), vec![eyes(Look::Fixed(0.0, 0.0), vec![pupil(4.0, 8.0, 2.0), pupil(10.0, 8.0, 2.0)])]));
115        }
116        _ => {
117            let (height, top, look) = match mood {
118                Mood::Listening => (3.0, 7.5, Look::Follow),
119                Mood::Thinking => (2.0, 8.0, Look::Fixed(0.8, -0.8)),
120                Mood::Working => (2.0, 8.0, Look::Fixed(0.0, 0.9)),
121                _ => (2.0, 8.0, Look::Follow),
122            };
123            out.push(eyes(look, vec![pupil(4.0, top, height), pupil(10.0, top, height)]));
124            if mood == Mood::Speaking {
125                out.extend(mouth::speaking(&MOUTH, mouth));
126            }
127        }
128    }
129}

One eye, shutting about its middle in a blink.

132fn pupil(x: f32, top: f32, height: f32) -> Node {
133    moving(Motion::Blink, (x, top + height / 2.0), vec![r(x, top, 1.0, height, INK)])
134}

The flat cat: a loaf with its ears folded, eyes shut and its tail out behind.

137pub fn sleeping() -> Vec<Node> {
138    let mut out = Vec::new();
139    for x in [3.0, 5.0, 9.0, 11.0] {
140        out.push(r(x, 9.0, 1.0, 1.0, FUR));
141    }
142    out.push(r(14.0, 12.5, 2.0, 1.0, FUR)); // tail
143    out.extend([r(2.0, 9.0, 1.0, 1.0, FUR), r(3.0, 8.0, 1.0, 2.0, FUR), r(11.0, 8.0, 1.0, 2.0, FUR), r(12.0, 9.0, 1.0, 1.0, FUR)]);
144    out.extend([r(3.0, 9.0, 1.0, 1.0, INNER_EAR), r(11.0, 9.0, 1.0, 1.0, INNER_EAR)]);
145    out.push(r(1.0, 10.0, 13.0, 5.0, FUR));
146    out.push(r(-1.0, 13.0, 2.0, 2.0, FUR));
147    out.push(r(14.0, 13.0, 2.0, 2.0, FUR));
148    out.push(r(7.0, 12.6, 1.0, 1.0, INNER_EAR));
149    out.push(r(3.5, 12.0, 2.0, 0.4, INK));
150    out.push(r(9.5, 12.0, 2.0, 0.4, INK));
151    for y in [11.5, 14.0] {
152        out.push(r(-1.0, y, 3.0, 0.35, WHISKER));
153        out.push(r(13.0, y, 4.0, 0.35, WHISKER));
154    }
155    out
156}
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use crate::scene::MOODS;
162
163    #[test]
164    fn every_standing_mood_draws_a_cat_and_no_two_moods_draw_alike() {
165        let mut seen: Vec<Vec<Node>> = Vec::new();
166        for mood in MOODS {
167            let nodes = if mood == Mood::Sleeping { sleeping() } else { standing(mood, None) };
168            assert!(nodes.len() > 8, "{mood:?}");
169            assert!(!seen.contains(&nodes), "{mood:?} repeats another mood");
170            seen.push(nodes);
171        }
172    }
173
174    #[test]
175    fn only_the_speaking_cat_has_a_mouth_and_its_shape_changes_it() {
176        assert_eq!(standing(Mood::Idle, None), standing(Mood::Idle, Some(Mouth::Wide)));
177        assert_ne!(standing(Mood::Speaking, Some(Mouth::Closed)), standing(Mood::Speaking, Some(Mouth::Wide)));
178        assert_ne!(standing(Mood::Speaking, None), standing(Mood::Idle, None));
179    }
180
181    #[test]
182    fn the_eyes_that_look_are_the_ones_the_gaze_moves() {
183        let has_eyes = |mood| standing(mood, None).iter().any(|n| matches!(n, Node::Group(g) if matches!(g.role, crate::node::Role::Eyes(_))));
184        for mood in [Mood::Idle, Mood::Listening, Mood::Thinking, Mood::Working, Mood::Speaking] {
185            assert!(has_eyes(mood), "{mood:?}");
186        }
187        for mood in [Mood::Tickled, Mood::Tangled, Mood::Offline, Mood::Dizzy] {
188            assert!(!has_eyes(mood), "{mood:?}");
189        }
190    }
191}