lib.rsannotatedlib.rssource264 lines · 8.0 KB · raw
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}