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};
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]).
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,
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.
Speaking, with the mouth in a shape, or not knowing the shape (it flaps on a beat).
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.
The same as the motion's transform in grid units, about [Pose::origin].
The point the scale and the turn are about, in grid units.
Squash and stretch: shut, a little low and wide; wide open, tall and narrow.
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.
Where the speaking pose stands when nothing plays (reduced motion): the mouth half open, which is no squash at all.
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}