How the cat behaves, with no drawing: where it looks, when it gets dizzy
or tickled, when it dozes off. The shell reports touches and the
conversation phase and draws whatever PetView says. Time is always
passed in, so behaviour is deterministic and testable.
9use log::{debug, trace};
What the conversation is doing, as far as the cat's face is concerned.
The child is talking.
16 Listening,
Waiting for the answer.
18 Thinking,
A tool call is running: the cat types on its computer.
20 Working,
The answer is being spoken.
22 Speaking,
A safe fallback line is being spoken after something failed.
24 Tangled,
Busy and not listening: starting up, or waiting for the microphone to be ready. The cat juggles, so a child can see it cannot hear her yet.
27 Busy,
A touch, in the cat's own units: (0, 0) is the middle of the cat and a distance of 1 is its half-width, so the shell only has to scale.
Where the eyes look, each axis -1..1.
68 pub gaze: (f32, f32),
Counts up each time the cat is poked. The shell plays the giggle when this changes, so a sound can never play without the animation.
71 pub pokes: u32,
A touch that lifts within this long, and barely moves, is a poke.
Two full turns inside this window is "round and round".
Touches this close to the middle have no meaningful angle.
86const SPIN_MIN_RADIUS: f32 = 0.25;
Gaze eases toward its target with this time constant.
88const GAZE_TAU_MS: f32 = 100.0;
(time, cumulative signed angle) samples inside the window.
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}