1//! How the cat behaves, with no drawing: where it looks, when it gets dizzy 2//! or tickled, when it dozes off. The shell reports touches and the 3//! conversation phase and draws whatever `PetView` says. Time is always 4//! passed in, so behaviour is deterministic and testable. 5 6use std::collections::VecDeque; 7use std::f32::consts::{PI, TAU}; 8 9use log::{debug, trace}; 10 11/// What the conversation is doing, as far as the cat's face is concerned. 12#[derive(Clone, Copy, Debug, PartialEq, Eq)] 13pub enum Phase { 14 Resting, 15 /// The child is talking. 16 Listening, 17 /// Waiting for the answer. 18 Thinking, 19 /// A tool call is running: the cat types on its computer. 20 Working, 21 /// The answer is being spoken. 22 Speaking, 23 /// A safe fallback line is being spoken after something failed. 24 Tangled, 25 /// Busy and not listening: starting up, or waiting for the microphone to be ready. The cat 26 /// juggles, so a child can see it cannot hear her yet. 27 Busy, 28 /// The cat cannot reach the cloud it thinks with. 29 Offline, 30} 31 32#[derive(Clone, Copy, Debug, PartialEq, Eq)] 33pub enum Mood { 34 Idle, 35 Listening, 36 Thinking, 37 Working, 38 Speaking, 39 Tangled, 40 Busy, 41 Offline, 42 Dizzy, 43 Tickled, 44 Sleeping, 45} 46 47#[derive(Clone, Copy, Debug, PartialEq, Eq)] 48pub enum TouchKind { 49 Down, 50 Move, 51 Up, 52} 53 54/// A touch, in the cat's own units: (0, 0) is the middle of the cat and a 55/// distance of 1 is its half-width, so the shell only has to scale. 56#[derive(Clone, Copy, Debug, PartialEq)] 57pub struct Touch { 58 pub kind: TouchKind, 59 pub x: f32, 60 pub y: f32, 61 pub at_ms: u64, 62} 63 64#[derive(Clone, Copy, Debug, PartialEq)] 65pub struct PetView { 66 pub mood: Mood, 67 /// Where the eyes look, each axis -1..1. 68 pub gaze: (f32, f32), 69 /// Counts up each time the cat is poked. The shell plays the giggle when 70 /// this changes, so a sound can never play without the animation. 71 pub pokes: u32, 72 /// Counts up each time the cat gets dizzy. 73 pub dizzy_spells: u32, 74} 75 76const TICKLE_MS: u64 = 1500; 77const DIZZY_MS: u64 = 3000; 78const SLEEP_AFTER_MS: u64 = 90_000; 79/// A touch that lifts within this long, and barely moves, is a poke. 80const POKE_MAX_MS: u64 = 300; 81const POKE_MAX_TRAVEL: f32 = 0.15; 82/// Two full turns inside this window is "round and round". 83const SPIN_WINDOW_MS: u64 = 2000; 84const SPIN_ANGLE: f32 = 2.0 * TAU; 85/// Touches this close to the middle have no meaningful angle. 86const SPIN_MIN_RADIUS: f32 = 0.25; 87/// Gaze eases toward its target with this time constant. 88const GAZE_TAU_MS: f32 = 100.0; 89 90pub struct Pet { 91 phase: Phase, 92 gaze: (f32, f32), 93 last_tick: Option<u64>, 94 last_activity: u64, 95 finger: Option<(f32, f32)>, 96 down: Option<Down>, 97 spin: Spin, 98 tickled_until: u64, 99 dizzy_until: u64, 100 pokes: u32, 101 dizzy_spells: u32, 102 /// The mood last reported, so that only changes are logged. 103 last_mood: Option<Mood>, 104} 105 106struct Down { 107 at_ms: u64, 108 x: f32, 109 y: f32, 110 travel: f32, 111 on_body: bool, 112} 113 114#[derive(Default)] 115struct Spin { 116 /// (time, cumulative signed angle) samples inside the window. 117 samples: VecDeque<(u64, f32)>, 118 angle: f32, 119 last: Option<f32>, 120} 121 122impl Spin { 123 fn reset(&mut self) { 124 *self = Spin::default(); 125 } 126 127 fn push(&mut self, at_ms: u64, x: f32, y: f32) -> bool { 128 if x.hypot(y) < SPIN_MIN_RADIUS { 129 self.last = None; 130 return false; 131 } 132 let a = y.atan2(x); 133 if let Some(prev) = self.last { 134 let mut d = a - prev; 135 if d > PI { 136 d -= TAU; 137 } else if d <= -PI { 138 d += TAU; 139 } 140 self.angle += d; 141 } 142 self.last = Some(a); 143 self.samples.push_back((at_ms, self.angle)); 144 while self.samples.front().is_some_and(|&(t, _)| at_ms.saturating_sub(t) > SPIN_WINDOW_MS) { 145 self.samples.pop_front(); 146 } 147 self.samples.front().is_some_and(|&(_, first)| (self.angle - first).abs() >= SPIN_ANGLE) 148 } 149} 150 151impl Default for Pet { 152 fn default() -> Self { 153 Self::new() 154 } 155} 156 157impl Pet { 158 pub fn new() -> Self { 159 Self { 160 phase: Phase::Resting, 161 gaze: (0.0, 0.0), 162 last_tick: None, 163 last_activity: 0, 164 finger: None, 165 down: None, 166 spin: Spin::default(), 167 tickled_until: 0, 168 dizzy_until: 0, 169 pokes: 0, 170 dizzy_spells: 0, 171 last_mood: None, 172 } 173 } 174 175 pub fn set_phase(&mut self, phase: Phase, now_ms: u64) { 176 if phase != self.phase { 177 debug!("pet phase {:?} -> {phase:?} at {now_ms} ms", self.phase); 178 } 179 self.phase = phase; 180 self.last_activity = now_ms; 181 } 182 183 pub fn touch(&mut self, t: Touch) { 184 trace!("touch {:?} at ({:.2}, {:.2}) at {} ms", t.kind, t.x, t.y, t.at_ms); 185 self.last_activity = t.at_ms; 186 match t.kind { 187 TouchKind::Down => { 188 self.finger = Some((t.x, t.y)); 189 self.down = Some(Down { 190 at_ms: t.at_ms, 191 x: t.x, 192 y: t.y, 193 travel: 0.0, 194 on_body: t.x.abs() <= 1.0 && t.y.abs() <= 1.0, 195 }); 196 self.spin.reset(); 197 self.spin.push(t.at_ms, t.x, t.y); 198 } 199 TouchKind::Move => { 200 self.finger = Some((t.x, t.y)); 201 if let Some(d) = &mut self.down { 202 d.travel = d.travel.max((t.x - d.x).hypot(t.y - d.y)); 203 } 204 if self.spin.push(t.at_ms, t.x, t.y) && t.at_ms >= self.dizzy_until { 205 self.dizzy_until = t.at_ms + DIZZY_MS; 206 self.dizzy_spells += 1; 207 debug!("pet got dizzy (spell {})", self.dizzy_spells); 208 self.tickled_until = 0; 209 self.spin.reset(); 210 } 211 } 212 TouchKind::Up => { 213 self.finger = None; 214 if let Some(d) = self.down.take() 215 && d.on_body 216 && d.travel <= POKE_MAX_TRAVEL 217 && t.at_ms.saturating_sub(d.at_ms) <= POKE_MAX_MS 218 && t.at_ms >= self.dizzy_until 219 { 220 self.pokes += 1; 221 debug!("pet was poked (poke {})", self.pokes); 222 self.tickled_until = t.at_ms + TICKLE_MS; 223 } 224 self.spin.reset(); 225 } 226 } 227 } 228 229 pub fn tick(&mut self, now_ms: u64) -> PetView { 230 let dt = self.last_tick.map_or(0, |p| now_ms.saturating_sub(p)) as f32; 231 self.last_tick = Some(now_ms); 232 let target = self.finger.map_or((0.0, 0.0), |(x, y)| { 233 let r = x.hypot(y); 234 if r > 1.0 { (x / r, y / r) } else { (x, y) } 235 }); 236 let k = 1.0 - (-dt / GAZE_TAU_MS).exp(); 237 self.gaze.0 += (target.0 - self.gaze.0) * k; 238 self.gaze.1 += (target.1 - self.gaze.1) * k; 239 240 let mood = if now_ms < self.dizzy_until { 241 Mood::Dizzy 242 } else if now_ms < self.tickled_until { 243 Mood::Tickled 244 } else { 245 match self.phase { 246 Phase::Listening => Mood::Listening, 247 Phase::Thinking => Mood::Thinking, 248 Phase::Working => Mood::Working, 249 Phase::Speaking => Mood::Speaking, 250 Phase::Tangled => Mood::Tangled, 251 Phase::Busy => Mood::Busy, 252 Phase::Offline => Mood::Offline, 253 Phase::Resting if now_ms.saturating_sub(self.last_activity) >= SLEEP_AFTER_MS => Mood::Sleeping, 254 Phase::Resting => Mood::Idle, 255 } 256 }; 257 if self.last_mood != Some(mood) { 258 debug!("pet mood {:?} -> {mood:?} at {now_ms} ms", self.last_mood); 259 self.last_mood = Some(mood); 260 } 261 trace!("pet tick at {now_ms} ms: gaze ({:.2}, {:.2})", self.gaze.0, self.gaze.1); 262 PetView { mood, gaze: self.gaze, pokes: self.pokes, dizzy_spells: self.dizzy_spells } 263 } 264}