1use 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
18pub 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#[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#[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#[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#[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 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 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}