lib.rsannotatedlib.rssource264 lines · 8.0 KB · raw

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.

6use std::collections::VecDeque;
7use std::f32::consts::{PI, TAU};
9use log::{debug, trace};

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,

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,

The cat cannot reach the cloud it thinks with.

29    Offline,
30}
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}

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.

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}
64#[derive(Clone, Copy, Debug, PartialEq)]
65pub struct PetView {
66    pub mood: Mood,

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,

Counts up each time the cat gets dizzy.

73    pub dizzy_spells: u32,
74}
76const TICKLE_MS: u64 = 1500;
77const DIZZY_MS: u64 = 3000;
78const SLEEP_AFTER_MS: u64 = 90_000;

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;

Two full turns inside this window is "round and round".

83const SPIN_WINDOW_MS: u64 = 2000;
84const SPIN_ANGLE: f32 = 2.0 * TAU;

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;
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,

The mood last reported, so that only changes are logged.

103    last_mood: Option<Mood>,
104}
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 {

(time, cumulative signed angle) samples inside the window.

117    samples: VecDeque<(u64, f32)>,
118    angle: f32,
119    last: Option<f32>,
120}
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}