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}