body.rsannotatedbody.rssource294 lines · 12.3 KB · raw

How a character's whole body moves, the same for all thirteen: the three drawn ones (the two cats and the bunny) and the ten pictures of the pack. This is the one definition. The site plays it as CSS keyframes sampled from [Motion::at], the app plays the Kotlin twin of it (art/Motion.kt and CharacterMotion.kt), and fixtures/poses.txt holds the two to the same numbers.

A pose is worked out in ninths: one ninth is a pixel of a pack picture, and a drawn character stands in the same square (12 grid units, so a ninth is NINTH grid units). Offsets are in ninths, angles in degrees, and the pivot says where the scale and the turn are about, as a fraction of that square (0, 0 is its top left).

What a drawn character adds (blinking, gaze, ears, the shape of its mouth) is layered on top of this by its own drawing; when an old motion of a drawing disagreed with the body, the body won and the old motion was removed.

15use std::f32::consts::{PI, TAU};
17use whiskers_pet::Mood;
18
19use crate::motion::{Motion, Xf};
20use crate::scene::Mouth;
21use pixel_mouth::flap_open;

Where the square every character stands in is, in grid units: its left, its top and its side. The 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);

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;

Seconds one bob of listening takes.

31pub const BOB_SECONDS: f32 = 0.7;

Seconds one slow tilt of thinking takes.

33pub const TILT_SECONDS: f32 = 3.0;

Seconds one full turn of dizzy takes.

35pub const SPIN_SECONDS: f32 = 1.1;

Seconds one breath at rest takes.

37pub const BREATH_SECONDS: f32 = 3.0;

Seconds one breath of sleep takes.

39pub const SLEEP_BREATH_SECONDS: f32 = 3.5;

Seconds one sway of the sad droop takes.

41pub const DROOP_SECONDS: f32 = 4.0;

How fast the shake of a tickled (poked) character goes, in radians a second.

43pub const SHAKE_RATE: f32 = 40.0;

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 {

A slow breath about the feet.

49    Resting,

A bob: up and back to the ground, never below it.

51    Listening,

A slow tilt to one side and the other, about the feet.

53    Thinking,

A droop: lower and wider than it should be, leaning, hardly swaying.

55    Sad,

A spin about the middle, round and round.

57    Dizzy,

Lying on its side, breathing: for a picture, which cannot draw itself lying down.

59    Asleep,

Lying, breathing: for a character whose drawing already lies down, so it is not turned again.

61    AsleepFlat,

A shake with laughter, for a poke.

63    Tickled,
64}
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    }

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}

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),

Speaking, with the mouth in a shape, or not knowing the shape (it flaps on a beat).

101    Speaking(Option<Mouth>),
102}

How a character in mood moves. mouth is the shape of the voice's mouth when known and matters only while speaking. 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}

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}
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 };

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    }

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}

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}

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}

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}

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}
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}