body.rsannotatedbody.rssource294 lines · 12.3 KB · raw
1//! How a character's whole body moves, the same for all thirteen: the three drawn ones (the two cats
2//! and the bunny) and the ten pictures of the pack. This is the one definition. The site plays it as
3//! CSS keyframes sampled from [`Motion::at`], the app plays the Kotlin twin of it (`art/Motion.kt` and
4//! `CharacterMotion.kt`), and `fixtures/poses.txt` holds the two to the same numbers.
5//!
6//! A pose is worked out in **ninths**: one ninth is a pixel of a pack picture, and a drawn character
7//! stands in the same square (12 grid units, so a ninth is `NINTH` grid units). Offsets are in
8//! ninths, angles in degrees, and the pivot says where the scale and the turn are about, as a
9//! fraction of that square (0, 0 is its top left).
10//!
11//! What a drawn character adds (blinking, gaze, ears, the shape of its mouth) is layered on top of
12//! this by its own drawing; when an old motion of a drawing disagreed with the body, the body won
13//! and the old motion was removed.
14
15use std::f32::consts::{PI, TAU};
16
17use whiskers_pet::Mood;
18
19use crate::motion::{Motion, Xf};
20use crate::scene::Mouth;
21use pixel_mouth::flap_open;
22
23/// Where the square every character stands in is, in grid units: its left, its top and its side. The
24/// feet are on its bottom edge (y 15) and its middle is x 7.5.
25pub const BOX: (f32, f32, f32) = (1.5, 3.0, 12.0);
26
27/// Grid units in a ninth of the square: the size of one pixel of a pack picture.
28pub const NINTH: f32 = BOX.2 / 9.0;
29
30/// Seconds one bob of listening takes.
31pub const BOB_SECONDS: f32 = 0.7;
32/// Seconds one slow tilt of thinking takes.
33pub const TILT_SECONDS: f32 = 3.0;
34/// Seconds one full turn of dizzy takes.
35pub const SPIN_SECONDS: f32 = 1.1;
36/// Seconds one breath at rest takes.
37pub const BREATH_SECONDS: f32 = 3.0;
38/// Seconds one breath of sleep takes.
39pub const SLEEP_BREATH_SECONDS: f32 = 3.5;
40/// Seconds one sway of the sad droop takes.
41pub const DROOP_SECONDS: f32 = 4.0;
42/// How fast the shake of a tickled (poked) character goes, in radians a second.
43pub const SHAKE_RATE: f32 = 40.0;
44
45/// The ways a body moves, other than speaking (which follows the mouth: [`speaking_pose`]).
46#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
47pub enum Body {
48    /// A slow breath about the feet.
49    Resting,
50    /// A bob: up and back to the ground, never below it.
51    Listening,
52    /// A slow tilt to one side and the other, about the feet.
53    Thinking,
54    /// A droop: lower and wider than it should be, leaning, hardly swaying.
55    Sad,
56    /// A spin about the middle, round and round.
57    Dizzy,
58    /// Lying on its side, breathing: for a picture, which cannot draw itself lying down.
59    Asleep,
60    /// Lying, breathing: for a character whose drawing already lies down, so it is not turned again.
61    AsleepFlat,
62    /// A shake with laughter, for a poke.
63    Tickled,
64}
65
66impl Body {
67    pub const ALL: [Body; 8] = [Body::Resting, Body::Listening, Body::Thinking, Body::Sad, Body::Dizzy, Body::Asleep, Body::AsleepFlat, Body::Tickled];
68
69    pub fn name(self) -> &'static str {
70        match self {
71            Body::Resting => "resting",
72            Body::Listening => "listening",
73            Body::Thinking => "thinking",
74            Body::Sad => "sad",
75            Body::Dizzy => "dizzy",
76            Body::Asleep => "asleep",
77            Body::AsleepFlat => "asleep-flat",
78            Body::Tickled => "tickled",
79        }
80    }
81
82    /// Seconds until the motion repeats exactly.
83    pub fn period(self) -> f32 {
84        match self {
85            Body::Resting => BREATH_SECONDS,
86            Body::Listening => BOB_SECONDS,
87            Body::Thinking => TILT_SECONDS,
88            Body::Sad => DROOP_SECONDS,
89            Body::Dizzy => SPIN_SECONDS,
90            Body::Asleep | Body::AsleepFlat => SLEEP_BREATH_SECONDS,
91            Body::Tickled => TAU / SHAKE_RATE,
92        }
93    }
94}
95
96/// The body of a character in a mood, as the core's mood says it. Speaking is not a [`Body`]: it follows the mouth.
97#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
98pub enum Moves {
99    Body(Body),
100    /// Speaking, with the mouth in a shape, or not knowing the shape (it flaps on a beat).
101    Speaking(Option<Mouth>),
102}
103
104/// How a character in `mood` moves. `mouth` is the shape of the voice's mouth when known and matters only while speaking.
105/// `lies_flat` says whether the character's own drawing lies down when it sleeps (the drawn ones do; a pack picture is turned).
106pub fn moves(mood: Mood, mouth: Option<Mouth>, lies_flat: bool) -> Moves {
107    match mood {
108        Mood::Idle => Moves::Body(Body::Resting),
109        Mood::Listening => Moves::Body(Body::Listening),
110        // Working and juggling are a mind at work: the same slow tilt.
111        Mood::Thinking | Mood::Working | Mood::Busy => Moves::Body(Body::Thinking),
112        Mood::Speaking => Moves::Speaking(mouth),
113        Mood::Tangled | Mood::Offline => Moves::Body(Body::Sad),
114        Mood::Dizzy => Moves::Body(Body::Dizzy),
115        Mood::Tickled => Moves::Body(Body::Tickled),
116        Mood::Sleeping => Moves::Body(if lies_flat { Body::AsleepFlat } else { Body::Asleep }),
117    }
118}
119
120/// How the body is moved for one instant, in ninths and degrees.
121#[derive(Clone, Copy, Debug, PartialEq)]
122pub struct Pose {
123    pub dx: f32,
124    pub dy: f32,
125    pub degrees: f32,
126    pub scale_x: f32,
127    pub scale_y: f32,
128    pub pivot_x: f32,
129    pub pivot_y: f32,
130}
131
132impl Pose {
133    const REST: Pose = Pose { dx: 0.0, dy: 0.0, degrees: 0.0, scale_x: 1.0, scale_y: 1.0, pivot_x: 0.5, pivot_y: 1.0 };
134
135    /// The same as the motion's transform in grid units, about [`Pose::origin`].
136    pub fn xf(&self) -> Xf {
137        Xf { tx: self.dx * NINTH, ty: self.dy * NINTH, rotate: self.degrees, sx: self.scale_x, sy: self.scale_y, opacity: 1.0 }
138    }
139
140    /// The point the scale and the turn are about, in grid units.
141    pub fn origin(&self) -> (f32, f32) {
142        (BOX.0 + BOX.2 * self.pivot_x, BOX.1 + BOX.2 * self.pivot_y)
143    }
144}
145
146/// Squash and stretch: shut, a little low and wide; wide open, tall and narrow.
147pub fn speaking_pose(open: f32) -> Pose {
148    let open = open.clamp(0.0, 1.0);
149    Pose { scale_x: 1.04 - 0.08 * open, scale_y: 0.94 + 0.14 * open, ..Pose::REST }
150}
151
152/// The pose of a body at `t` seconds.
153pub fn pose_of(body: Body, t: f32) -> Pose {
154    match body {
155        Body::Resting => Pose { scale_y: 1.0 + 0.02 * (TAU * t / BREATH_SECONDS).sin(), ..Pose::REST },
156        Body::Listening => Pose { dy: -0.7 * (PI * t / BOB_SECONDS).sin().abs(), ..Pose::REST },
157        Body::Thinking => Pose { degrees: 9.0 * (TAU * t / TILT_SECONDS).sin(), ..Pose::REST },
158        Body::Sad => Pose { degrees: -5.0 + 1.5 * (TAU * t / DROOP_SECONDS).sin(), scale_x: 1.04, scale_y: 0.86, ..Pose::REST },
159        Body::Dizzy => Pose { degrees: 360.0 * (t / SPIN_SECONDS).rem_euclid(1.0), pivot_y: 0.5, ..Pose::REST },
160        Body::Asleep => Pose { degrees: 90.0, scale_x: 1.0 + 0.03 * (TAU * t / SLEEP_BREATH_SECONDS).sin(), pivot_y: 0.5, ..Pose::REST },
161        Body::AsleepFlat => Pose { scale_y: 1.0 + 0.03 * (TAU * t / SLEEP_BREATH_SECONDS).sin(), ..Pose::REST },
162        Body::Tickled => Pose { dx: 0.2 * (SHAKE_RATE * t).sin(), ..Pose::REST },
163    }
164}
165
166/// The pose of whatever moves, at `t` seconds.
167pub fn pose_at(moves: Moves, t: f32) -> Pose {
168    match moves {
169        Moves::Body(body) => pose_of(body, t),
170        Moves::Speaking(Some(mouth)) => speaking_pose(mouth.open()),
171        Moves::Speaking(None) => speaking_pose(flap_open(t)),
172    }
173}
174
175/// Where the speaking pose stands when nothing plays (reduced motion): the mouth half open, which is no squash at all.
176pub fn speaking_rest() -> Pose {
177    speaking_pose(0.5)
178}
179
180impl Moves {
181    pub fn motion(self) -> Motion {
182        match self {
183            Moves::Body(body) => Motion::Body(body),
184            Moves::Speaking(mouth) => Motion::Speak(mouth),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    const TIMES: [f32; 8] = [0.0, 0.013, 0.31, 0.7, 1.1, 1.7, 2.9, 7.3];
194
195    fn all_moves() -> Vec<Moves> {
196        let mut out: Vec<Moves> = Body::ALL.into_iter().map(Moves::Body).collect();
197        out.push(Moves::Speaking(None));
198        out.extend(Mouth::ALL.map(|m| Moves::Speaking(Some(m))));
199        out
200    }
201
202    #[test]
203    fn every_pose_is_finite_and_never_flips_or_vanishes() {
204        for m in all_moves() {
205            for t in TIMES {
206                let p = pose_at(m, t);
207                for v in [p.dx, p.dy, p.degrees, p.scale_x, p.scale_y] {
208                    assert!(v.is_finite(), "{m:?} at {t}");
209                }
210                assert!((0.7..=1.3).contains(&p.scale_x) && (0.7..=1.3).contains(&p.scale_y), "{m:?} at {t}: {p:?}");
211                assert!((0.0..=1.0).contains(&p.pivot_x) && (0.0..=1.0).contains(&p.pivot_y));
212            }
213        }
214    }
215
216    #[test]
217    fn every_mood_of_the_core_moves_and_the_ones_that_are_one_mood_of_the_body_say_so() {
218        for mood in crate::scene::MOODS {
219            let _ = moves(mood, None, true);
220        }
221        assert_eq!(moves(Mood::Offline, None, true), Moves::Body(Body::Sad));
222        assert_eq!(moves(Mood::Tangled, None, true), Moves::Body(Body::Sad));
223        assert_eq!(moves(Mood::Busy, None, true), Moves::Body(Body::Thinking));
224        assert_eq!(moves(Mood::Working, None, true), Moves::Body(Body::Thinking));
225        assert_eq!(moves(Mood::Tickled, None, true), Moves::Body(Body::Tickled));
226        assert_eq!(moves(Mood::Sleeping, None, false), Moves::Body(Body::Asleep));
227        assert_eq!(moves(Mood::Sleeping, None, true), Moves::Body(Body::AsleepFlat));
228        assert_eq!(moves(Mood::Speaking, Some(Mouth::Wide), true), Moves::Speaking(Some(Mouth::Wide)));
229    }
230
231    #[test]
232    fn listening_bobs_up_and_back_to_the_ground_never_below_it() {
233        for t in TIMES {
234            assert!(pose_of(Body::Listening, t).dy <= 0.0);
235        }
236        assert!(pose_of(Body::Listening, 0.0).dy.abs() < 1e-6);
237        assert!(pose_of(Body::Listening, BOB_SECONDS).dy.abs() < 1e-4);
238        assert!((pose_of(Body::Listening, BOB_SECONDS / 2.0).dy + 0.7).abs() < 1e-4);
239    }
240
241    #[test]
242    fn thinking_tilts_gently_about_the_feet_both_ways() {
243        let angles: Vec<f32> = (0..400).map(|i| pose_of(Body::Thinking, i as f32 * 0.037).degrees).collect();
244        assert!(angles.iter().cloned().fold(f32::MIN, f32::max) > 8.0 && angles.iter().cloned().fold(f32::MAX, f32::min) < -8.0);
245        assert!(angles.iter().all(|a| a.abs() <= 9.0 + 1e-4));
246        assert_eq!(pose_of(Body::Thinking, 1.0).pivot_y, 1.0);
247    }
248
249    #[test]
250    fn speaking_stretches_taller_and_narrower_as_the_mouth_opens() {
251        let (shut, half, wide) = (speaking_pose(0.0), speaking_pose(0.5), speaking_pose(1.0));
252        assert!(shut.scale_y < half.scale_y && half.scale_y < wide.scale_y);
253        assert!(shut.scale_x > half.scale_x && half.scale_x > wide.scale_x);
254        assert!(shut.scale_y < 1.0 && wide.scale_y > 1.0);
255        assert_eq!(speaking_pose(3.0), wide);
256        let shapes: Vec<f32> = Mouth::ALL.map(Mouth::open).to_vec();
257        assert_eq!(shapes, [0.0, 0.5, 1.0, 0.8]);
258        // The mouth that flaps drives the body the same way.
259        assert_eq!(pose_at(Moves::Speaking(None), 0.0), speaking_pose(0.0));
260    }
261
262    #[test]
263    fn sad_droops_lower_and_leans_and_stays_there() {
264        for t in TIMES {
265            let p = pose_of(Body::Sad, t);
266            assert!(p.scale_y < 0.9 && p.scale_x > 1.0 && p.degrees < 0.0);
267        }
268    }
269
270    #[test]
271    fn dizzy_turns_once_around_the_middle_every_round() {
272        assert!(pose_of(Body::Dizzy, 0.0).degrees.abs() < 1e-4);
273        assert!((pose_of(Body::Dizzy, SPIN_SECONDS / 2.0).degrees - 180.0).abs() < 0.1);
274        assert_eq!(pose_of(Body::Dizzy, 1.0).pivot_y, 0.5);
275    }
276
277    #[test]
278    fn asleep_is_turned_onto_its_side_and_the_flat_drawing_is_not() {
279        for t in TIMES {
280            assert_eq!(pose_of(Body::Asleep, t).degrees, 90.0);
281            assert_eq!(pose_of(Body::AsleepFlat, t).degrees, 0.0);
282            // Both breathe by the same amount, the picture along its length and the loaf up and down.
283            assert!((pose_of(Body::Asleep, t).scale_x - 1.0).abs() <= 0.03 + 1e-6 && (pose_of(Body::AsleepFlat, t).scale_y - 1.0).abs() <= 0.03 + 1e-6);
284        }
285    }
286
287    #[test]
288    fn the_bodys_xf_is_the_pose_in_grid_units_and_its_origin_is_the_pivot_in_the_square() {
289        let p = pose_of(Body::Listening, BOB_SECONDS / 2.0);
290        assert!((p.xf().ty - (-0.7 * NINTH)).abs() < 1e-4);
291        assert_eq!(pose_of(Body::Resting, 0.0).origin(), (7.5, 15.0));
292        assert_eq!(pose_of(Body::Dizzy, 0.0).origin(), (7.5, 9.0));
293    }
294}