Skip to main content

m5unified_avatar/
lib.rs

1//! Rust-native renderer for the original M5Stack-Avatar face.
2//!
3//! The default geometry and expression behavior intentionally mirror
4//! `stack-chan/m5stack-avatar`'s default face model: white face parts on a
5//! black background, circular eyes, rectangular mouth, breath motion, blink,
6//! and the six original expressions.
7
8use core::f32::consts::PI;
9
10use m5unified::{Canvas, Point, TextDatum};
11
12const ORIGINAL_WIDTH: f32 = 320.0;
13const ORIGINAL_HEIGHT: f32 = 240.0;
14const PRIMARY_WHITE: u16 = 0xffff;
15const BACKGROUND_BLACK: u16 = 0x0000;
16const SPEECH_TEXT_LIMIT: usize = 40;
17
18/// Compile-time RGB888 to RGB565 conversion.
19pub const fn rgb565(r: u8, g: u8, b: u8) -> u16 {
20    (((r as u16) >> 3) << 11) | (((g as u16) >> 2) << 5) | ((b as u16) >> 3)
21}
22
23/// Original M5Stack-Avatar expression set.
24#[derive(Debug, Default, Copy, Clone, PartialEq, Eq)]
25pub enum Expression {
26    Happy,
27    Angry,
28    Sad,
29    Doubt,
30    Sleepy,
31    #[default]
32    Neutral,
33}
34
35/// Colors used by [`Avatar`].
36#[derive(Debug, Copy, Clone, PartialEq, Eq)]
37pub struct Palette {
38    pub background: u16,
39    pub shadow: u16,
40    pub face: u16,
41    pub outline: u16,
42    pub eye_white: u16,
43    pub iris: u16,
44    pub pupil: u16,
45    pub highlight: u16,
46    pub cheek: u16,
47    pub mouth: u16,
48    pub accent: u16,
49    pub balloon_foreground: u16,
50    pub balloon_background: u16,
51}
52
53impl Default for Palette {
54    fn default() -> Self {
55        Self {
56            background: BACKGROUND_BLACK,
57            shadow: BACKGROUND_BLACK,
58            face: BACKGROUND_BLACK,
59            outline: BACKGROUND_BLACK,
60            eye_white: PRIMARY_WHITE,
61            iris: PRIMARY_WHITE,
62            pupil: PRIMARY_WHITE,
63            highlight: PRIMARY_WHITE,
64            cheek: PRIMARY_WHITE,
65            mouth: PRIMARY_WHITE,
66            accent: PRIMARY_WHITE,
67            balloon_foreground: BACKGROUND_BLACK,
68            balloon_background: PRIMARY_WHITE,
69        }
70    }
71}
72
73/// Tunable geometry. Defaults match the original 320x240 avatar coordinates.
74#[derive(Debug, Copy, Clone, PartialEq)]
75pub struct AvatarStyle {
76    pub eye_radius: f32,
77    pub mouth_min_width: f32,
78    pub mouth_max_width: f32,
79    pub mouth_min_height: f32,
80    pub mouth_max_height: f32,
81    pub breath_pixels: f32,
82}
83
84impl Default for AvatarStyle {
85    fn default() -> Self {
86        Self {
87            eye_radius: 8.0,
88            mouth_min_width: 50.0,
89            mouth_max_width: 90.0,
90            mouth_min_height: 4.0,
91            mouth_max_height: 60.0,
92            breath_pixels: 3.0,
93        }
94    }
95}
96
97/// Mutable avatar state.
98#[derive(Debug, Clone)]
99pub struct Avatar {
100    width: i32,
101    height: i32,
102    palette: Palette,
103    style: AvatarStyle,
104    expression: Expression,
105    gaze_x: f32,
106    gaze_y: f32,
107    mouth_open: f32,
108    speech_text: String,
109    elapsed_ms: u32,
110}
111
112impl Avatar {
113    pub fn new(width: i32, height: i32) -> Self {
114        Self {
115            width: width.max(1),
116            height: height.max(1),
117            palette: Palette::default(),
118            style: AvatarStyle::default(),
119            expression: Expression::Neutral,
120            gaze_x: 0.0,
121            gaze_y: 0.0,
122            mouth_open: 0.0,
123            speech_text: String::new(),
124            elapsed_ms: 0,
125        }
126    }
127
128    pub fn width(&self) -> i32 {
129        self.width
130    }
131
132    pub fn height(&self) -> i32 {
133        self.height
134    }
135
136    pub fn expression(&self) -> Expression {
137        self.expression
138    }
139
140    pub fn palette(&self) -> Palette {
141        self.palette
142    }
143
144    pub fn style(&self) -> AvatarStyle {
145        self.style
146    }
147
148    pub fn set_size(&mut self, width: i32, height: i32) {
149        self.width = width.max(1);
150        self.height = height.max(1);
151    }
152
153    pub fn set_palette(&mut self, palette: Palette) {
154        self.palette = palette;
155    }
156
157    pub fn set_style(&mut self, style: AvatarStyle) {
158        self.style = style;
159    }
160
161    pub fn set_expression(&mut self, expression: Expression) {
162        self.expression = expression;
163    }
164
165    /// Set gaze offset in normalized `[-1.0, 1.0]` units.
166    ///
167    /// The original renderer moves each eye by at most three pixels on a
168    /// 320x240 display. This value is scaled with the active display size.
169    pub fn set_gaze(&mut self, x: f32, y: f32) {
170        self.gaze_x = clamp_unit(x);
171        self.gaze_y = clamp_unit(y);
172    }
173
174    /// Set mouth openness in normalized `[0.0, 1.0]` units.
175    pub fn set_mouth_open(&mut self, level: f32) {
176        self.mouth_open = level.clamp(0.0, 1.0);
177    }
178
179    pub fn set_speech_text(&mut self, text: &str) {
180        self.speech_text = speech_excerpt(text);
181    }
182
183    pub fn clear_speech_text(&mut self) {
184        self.speech_text.clear();
185    }
186
187    pub fn speech_text(&self) -> &str {
188        &self.speech_text
189    }
190
191    pub fn update(&mut self, delta_ms: u32) {
192        self.elapsed_ms = self.elapsed_ms.wrapping_add(delta_ms);
193    }
194
195    pub fn draw(&self, canvas: &mut Canvas) {
196        let layout = Layout::new(self.width, self.height, self.style);
197        let breath = self.breath();
198        let eye_open = self.auto_eye_open();
199        let gaze = self.gaze();
200
201        canvas.fill_screen(self.palette.background);
202        draw_eye(
203            canvas,
204            EyeSpec {
205                center: layout.right_eye,
206                radius: layout.eye_radius,
207                is_left: false,
208                expression: self.expression,
209                open_ratio: eye_open,
210                gaze,
211                palette: self.palette,
212            },
213        );
214        draw_eye(
215            canvas,
216            EyeSpec {
217                center: layout.left_eye,
218                radius: layout.eye_radius,
219                is_left: true,
220                expression: self.expression,
221                open_ratio: eye_open,
222                gaze,
223                palette: self.palette,
224            },
225        );
226        draw_mouth(canvas, &layout, breath, self.mouth_open, self.palette);
227        draw_balloon(canvas, &layout, &self.speech_text, self.palette);
228    }
229
230    fn breath(&self) -> f32 {
231        (self.elapsed_ms as f32 * 2.0 * PI / 3300.0).sin().min(1.0)
232    }
233
234    fn auto_eye_open(&self) -> f32 {
235        let phase = self.elapsed_ms % 4200;
236        if (3600..3900).contains(&phase) {
237            0.0
238        } else {
239            1.0
240        }
241    }
242
243    fn gaze(&self) -> (f32, f32) {
244        (self.gaze_x, self.gaze_y)
245    }
246}
247
248fn draw_balloon(canvas: &mut Canvas, layout: &Layout, text: &str, palette: Palette) {
249    if text.trim().is_empty() {
250        return;
251    }
252
253    let cx = sx(240.0, layout.display_scale_x);
254    let cy = sy(220.0, layout.display_scale_y);
255    let text_height = sy_len(16.0, layout.display_scale_y).max(8);
256    canvas.set_text_size(2);
257    canvas.set_text_color(palette.balloon_foreground, palette.balloon_background);
258    canvas.set_text_datum(TextDatum::MiddleCenter);
259    let text_width = canvas
260        .text_width(text)
261        .ok()
262        .filter(|width| *width > 0)
263        .unwrap_or_else(|| sx_len(text.chars().count() as f32 * 12.0, layout.display_scale_x));
264
265    canvas.fill_ellipse(
266        cx - sx_len(20.0, layout.display_scale_x),
267        cy,
268        text_width + 2,
269        text_height * 2 + 2,
270        palette.balloon_foreground,
271    );
272    canvas.fill_triangle(
273        Point {
274            x: cx - sx_len(62.0, layout.display_scale_x),
275            y: cy - sy_len(42.0, layout.display_scale_y),
276        },
277        Point {
278            x: cx - sx_len(8.0, layout.display_scale_x),
279            y: cy - sy_len(10.0, layout.display_scale_y),
280        },
281        Point {
282            x: cx - sx_len(41.0, layout.display_scale_x),
283            y: cy - sy_len(8.0, layout.display_scale_y),
284        },
285        palette.balloon_foreground,
286    );
287    canvas.fill_ellipse(
288        cx - sx_len(20.0, layout.display_scale_x),
289        cy,
290        text_width,
291        text_height * 2,
292        palette.balloon_background,
293    );
294    canvas.fill_triangle(
295        Point {
296            x: cx - sx_len(60.0, layout.display_scale_x),
297            y: cy - sy_len(40.0, layout.display_scale_y),
298        },
299        Point {
300            x: cx - sx_len(10.0, layout.display_scale_x),
301            y: cy - sy_len(10.0, layout.display_scale_y),
302        },
303        Point {
304            x: cx - sx_len(40.0, layout.display_scale_x),
305            y: cy - sy_len(10.0, layout.display_scale_y),
306        },
307        palette.balloon_background,
308    );
309
310    let x = cx - text_width / 6 - sx_len(15.0, layout.display_scale_x);
311    let _ = canvas.draw_string(text, x, cy);
312}
313
314#[derive(Debug, Copy, Clone)]
315struct Layout {
316    right_eye: Point,
317    left_eye: Point,
318    mouth: Point,
319    eye_radius: i32,
320    mouth_min_width: i32,
321    mouth_max_width: i32,
322    mouth_min_height: i32,
323    mouth_max_height: i32,
324    breath_pixels: i32,
325    display_scale_x: f32,
326    display_scale_y: f32,
327}
328
329impl Layout {
330    fn new(width: i32, height: i32, style: AvatarStyle) -> Self {
331        let scale_x = width as f32 / ORIGINAL_WIDTH;
332        let scale_y = height as f32 / ORIGINAL_HEIGHT;
333        let scale = scale_x.min(scale_y);
334
335        Self {
336            right_eye: Point {
337                x: sx(90.0, scale_x),
338                y: sy(93.0, scale_y),
339            },
340            left_eye: Point {
341                x: sx(230.0, scale_x),
342                y: sy(96.0, scale_y),
343            },
344            mouth: Point {
345                x: sx(163.0, scale_x),
346                y: sy(148.0, scale_y),
347            },
348            eye_radius: ss(style.eye_radius, scale).max(1),
349            mouth_min_width: sx_len(style.mouth_min_width, scale_x).max(1),
350            mouth_max_width: sx_len(style.mouth_max_width, scale_x).max(1),
351            mouth_min_height: sy_len(style.mouth_min_height, scale_y).max(1),
352            mouth_max_height: sy_len(style.mouth_max_height, scale_y).max(1),
353            breath_pixels: ss(style.breath_pixels, scale),
354            display_scale_x: scale_x,
355            display_scale_y: scale_y,
356        }
357    }
358}
359
360#[derive(Debug, Copy, Clone)]
361struct EyeSpec {
362    center: Point,
363    radius: i32,
364    is_left: bool,
365    expression: Expression,
366    open_ratio: f32,
367    gaze: (f32, f32),
368    palette: Palette,
369}
370
371fn draw_eye(canvas: &mut Canvas, spec: EyeSpec) {
372    let offset_x = (spec.gaze.0 * 3.0).round() as i32;
373    let offset_y = (spec.gaze.1 * 3.0).round() as i32;
374    let x = spec.center.x + offset_x;
375    let y = spec.center.y + offset_y;
376
377    if spec.open_ratio > 0.0 {
378        canvas.fill_circle(x, y, spec.radius, spec.palette.eye_white);
379        match spec.expression {
380            Expression::Angry | Expression::Sad => {
381                let x0 = x - spec.radius;
382                let y0 = y - spec.radius;
383                let x1 = x0 + spec.radius * 2;
384                let y1 = y0;
385                let x2 = if clipped_to_left(spec.is_left, spec.expression) {
386                    x0
387                } else {
388                    x1
389                };
390                let y2 = y0 + spec.radius;
391                canvas.fill_triangle(
392                    Point { x: x0, y: y0 },
393                    Point { x: x1, y: y1 },
394                    Point { x: x2, y: y2 },
395                    spec.palette.background,
396                );
397            }
398            Expression::Happy | Expression::Sleepy => {
399                let x0 = x - spec.radius;
400                let mut y0 = y - spec.radius;
401                let w = spec.radius * 2 + 4;
402                let h = spec.radius + 2;
403                if spec.expression == Expression::Happy {
404                    y0 += spec.radius;
405                    canvas.fill_circle(x, y, (spec.radius * 2 / 3).max(1), spec.palette.background);
406                }
407                canvas.fill_rect(x0, y0, w, h, spec.palette.background);
408            }
409            Expression::Doubt | Expression::Neutral => {}
410        }
411    } else {
412        canvas.fill_rect(
413            x - spec.radius,
414            y - 2,
415            spec.radius * 2,
416            4,
417            spec.palette.eye_white,
418        );
419    }
420}
421
422fn clipped_to_left(is_left: bool, expression: Expression) -> bool {
423    match expression {
424        Expression::Angry => is_left,
425        Expression::Sad => !is_left,
426        _ => false,
427    }
428}
429
430fn draw_mouth(
431    canvas: &mut Canvas,
432    layout: &Layout,
433    breath: f32,
434    mouth_open: f32,
435    palette: Palette,
436) {
437    let open = mouth_open.clamp(0.0, 1.0);
438    let h = layout.mouth_min_height
439        + ((layout.mouth_max_height - layout.mouth_min_height) as f32 * open) as i32;
440    let w = layout.mouth_min_width
441        + ((layout.mouth_max_width - layout.mouth_min_width) as f32 * (1.0 - open)) as i32;
442    let breath_y = (breath * layout.breath_pixels as f32 * 5.0 / 3.0).round() as i32;
443    let x = layout.mouth.x - w / 2;
444    let y = layout.mouth.y - h / 2 + breath_y;
445    canvas.fill_rect(x, y, w, h, palette.mouth);
446}
447
448fn sx(value: f32, scale_x: f32) -> i32 {
449    (value * scale_x).round() as i32
450}
451
452fn sy(value: f32, scale_y: f32) -> i32 {
453    (value * scale_y).round() as i32
454}
455
456fn sx_len(value: f32, scale_x: f32) -> i32 {
457    (value * scale_x).round() as i32
458}
459
460fn sy_len(value: f32, scale_y: f32) -> i32 {
461    (value * scale_y).round() as i32
462}
463
464fn ss(value: f32, scale: f32) -> i32 {
465    (value * scale).round() as i32
466}
467
468fn speech_excerpt(text: &str) -> String {
469    let mut output = String::new();
470    for ch in text.trim().chars().take(SPEECH_TEXT_LIMIT) {
471        if ch == '\0' || ch == '\n' || ch == '\r' || ch == '\t' {
472            output.push(' ');
473        } else {
474            output.push(ch);
475        }
476    }
477    if text.trim().chars().count() > SPEECH_TEXT_LIMIT {
478        output.push_str("...");
479    }
480    output
481}
482
483fn clamp_unit(value: f32) -> f32 {
484    value.clamp(-1.0, 1.0)
485}
486
487#[cfg(test)]
488mod tests {
489    use super::*;
490
491    #[test]
492    fn rgb565_converts_known_values() {
493        assert_eq!(rgb565(0, 0, 0), 0x0000);
494        assert_eq!(rgb565(255, 255, 255), 0xffff);
495        assert_eq!(rgb565(255, 0, 0), 0xf800);
496    }
497
498    #[test]
499    fn original_layout_matches_m5stack_avatar_coordinates() {
500        let layout = Layout::new(320, 240, AvatarStyle::default());
501        assert_eq!(layout.right_eye, Point { x: 90, y: 93 });
502        assert_eq!(layout.left_eye, Point { x: 230, y: 96 });
503        assert_eq!(layout.mouth, Point { x: 163, y: 148 });
504        assert_eq!(layout.eye_radius, 8);
505        assert_eq!(layout.mouth_min_width, 50);
506        assert_eq!(layout.mouth_max_width, 90);
507        assert_eq!(layout.mouth_min_height, 4);
508        assert_eq!(layout.mouth_max_height, 60);
509    }
510
511    #[test]
512    fn gaze_and_mouth_inputs_are_clamped() {
513        let mut avatar = Avatar::new(320, 240);
514        avatar.set_gaze(5.0, -5.0);
515        avatar.set_mouth_open(2.0);
516
517        assert_eq!(avatar.gaze_x, 1.0);
518        assert_eq!(avatar.gaze_y, -1.0);
519        assert_eq!(avatar.mouth_open, 1.0);
520    }
521
522    #[test]
523    fn angry_and_sad_eye_clips_match_original_sides() {
524        assert!(clipped_to_left(true, Expression::Angry));
525        assert!(!clipped_to_left(false, Expression::Angry));
526        assert!(!clipped_to_left(true, Expression::Sad));
527        assert!(clipped_to_left(false, Expression::Sad));
528    }
529
530    #[test]
531    fn update_advances_animation_clock() {
532        let mut avatar = Avatar::new(320, 240);
533        avatar.update(16);
534        avatar.update(34);
535        assert_eq!(avatar.elapsed_ms, 50);
536    }
537
538    #[test]
539    fn speech_text_is_sanitized_and_limited() {
540        let mut avatar = Avatar::new(320, 240);
541        avatar
542            .set_speech_text("hello\nstackchan\0this text is intentionally longer than the bubble");
543        assert!(!avatar.speech_text().contains('\n'));
544        assert!(!avatar.speech_text().contains('\0'));
545        assert!(avatar.speech_text().ends_with("..."));
546    }
547}