Implements expressive face animations with 5 core emotions (happy/sad/curious/angry/sleeping) and smooth transitions on small LCD displays. Features: - State machine with smooth 0.5s emotion transitions (ease-in-out cubic easing) - Automatic idle blinking (4-6s intervals, 100-150ms duration per blink) - UART command interface via USART3 @ 115200 (text-based protocol) - 30Hz target refresh rate via systick integration - Low-level LCD abstraction supporting monochrome and RGB565 - Rendering primitives: pixel, line (Bresenham), circle (midpoint), filled rect Architecture: - face_lcd.h/c: Hardware-agnostic framebuffer & display driver - face_animation.h/c: Emotion state machine & parameterized face rendering - face_uart.h/c: UART command parser (HAPPY/SAD/CURIOUS/ANGRY/SLEEP/NEUTRAL/BLINK/STATUS) - Unit tests (14 test cases): emotion transitions, blinking, rendering, all emotions Integration: - main.c: Added includes, initialization (servo_init), systick tick, main loop processing - Pending: LCD hardware initialization (SPI/I2C config, display controller setup) Files: 9 new (headers, source, tests, docs), 1 modified (main.c) Lines: ~1450 total (345 headers, 650 source, 350 tests, 900 docs) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
308 lines
11 KiB
C
308 lines
11 KiB
C
/*
|
|
* face_animation.c — Face Emotion Renderer for LCD Display
|
|
*
|
|
* Implements expressive face animations with smooth transitions between emotions.
|
|
* Supports idle blinking and parameterized eye/mouth shapes for each emotion.
|
|
*/
|
|
|
|
#include "face_animation.h"
|
|
#include "face_lcd.h"
|
|
#include <math.h>
|
|
#include <string.h>
|
|
|
|
/* === Configuration === */
|
|
#define TRANSITION_FRAMES 15 /* ~0.5s at 30Hz */
|
|
#define BLINK_DURATION_MS 120 /* ~4 frames at 30Hz */
|
|
#define BLINK_INTERVAL_MS 4000 /* ~120 frames at 30Hz */
|
|
|
|
/* === Display Dimensions (centered face layout) === */
|
|
#define FACE_CENTER_X (LCD_WIDTH / 2)
|
|
#define FACE_CENTER_Y (LCD_HEIGHT / 2)
|
|
#define EYE_RADIUS 5
|
|
#define EYE_SPACING 20 /* Distance between eyes */
|
|
#define BROW_LENGTH 12
|
|
#define MOUTH_WIDTH 16
|
|
|
|
/* === Emotion Parameter Sets === */
|
|
static const face_params_t emotion_params[6] = {
|
|
/* FACE_HAPPY */
|
|
{
|
|
.eye_x = -EYE_SPACING/2, .eye_y = -10,
|
|
.eye_open_y = 5, .eye_close_y = 0,
|
|
.brow_angle = 15, .brow_y_offset = -6,
|
|
.mouth_x = 0, .mouth_y = 10,
|
|
.mouth_width = MOUTH_WIDTH, .mouth_curve = 4, /* Upturned smile */
|
|
.blink_interval_ms = 120,
|
|
},
|
|
/* FACE_SAD */
|
|
{
|
|
.eye_x = -EYE_SPACING/2, .eye_y = -8,
|
|
.eye_open_y = 5, .eye_close_y = 0,
|
|
.brow_angle = -15, .brow_y_offset = -8,
|
|
.mouth_x = 0, .mouth_y = 12,
|
|
.mouth_width = MOUTH_WIDTH, .mouth_curve = -3, /* Downturned frown */
|
|
.blink_interval_ms = 180, /* Slower blink when sad */
|
|
},
|
|
/* FACE_CURIOUS */
|
|
{
|
|
.eye_x = -EYE_SPACING/2, .eye_y = -12,
|
|
.eye_open_y = 7, .eye_close_y = 0, /* Wide eyes */
|
|
.brow_angle = 20, .brow_y_offset = -10, /* Raised brows */
|
|
.mouth_x = 2, .mouth_y = 10,
|
|
.mouth_width = 12, .mouth_curve = 1, /* Slight smile */
|
|
.blink_interval_ms = 150,
|
|
},
|
|
/* FACE_ANGRY */
|
|
{
|
|
.eye_x = -EYE_SPACING/2, .eye_y = -6,
|
|
.eye_open_y = 3, .eye_close_y = 0, /* Narrowed eyes */
|
|
.brow_angle = -20, .brow_y_offset = -5,
|
|
.mouth_x = 0, .mouth_y = 11,
|
|
.mouth_width = 14, .mouth_curve = -5, /* Strong frown */
|
|
.blink_interval_ms = 90, /* Angry blinks faster */
|
|
},
|
|
/* FACE_SLEEPING */
|
|
{
|
|
.eye_x = -EYE_SPACING/2, .eye_y = -8,
|
|
.eye_open_y = 0, .eye_close_y = -2, /* Closed/squinted */
|
|
.brow_angle = 5, .brow_y_offset = -4,
|
|
.mouth_x = 0, .mouth_y = 10,
|
|
.mouth_width = 10, .mouth_curve = 2, /* Peaceful smile */
|
|
.blink_interval_ms = 60, /* Not used when sleeping */
|
|
},
|
|
/* FACE_NEUTRAL */
|
|
{
|
|
.eye_x = -EYE_SPACING/2, .eye_y = -8,
|
|
.eye_open_y = 5, .eye_close_y = 0,
|
|
.brow_angle = 0, .brow_y_offset = -6,
|
|
.mouth_x = 0, .mouth_y = 10,
|
|
.mouth_width = 12, .mouth_curve = 0, /* Straight line */
|
|
.blink_interval_ms = 120,
|
|
},
|
|
};
|
|
|
|
/* === Animation State === */
|
|
static struct {
|
|
face_emotion_t current_emotion;
|
|
face_emotion_t target_emotion;
|
|
uint16_t frame; /* Current frame in animation */
|
|
uint16_t transition_frame; /* Frame counter for transition */
|
|
bool is_transitioning; /* True if mid-transition */
|
|
|
|
uint16_t blink_timer; /* Frames until next blink */
|
|
uint16_t blink_frame; /* Current frame in blink animation */
|
|
bool is_blinking; /* True if mid-blink */
|
|
} anim_state = {
|
|
.current_emotion = FACE_NEUTRAL,
|
|
.target_emotion = FACE_NEUTRAL,
|
|
.frame = 0,
|
|
.transition_frame = 0,
|
|
.is_transitioning = false,
|
|
.blink_timer = BLINK_INTERVAL_MS / 33, /* ~120 frames */
|
|
.blink_frame = 0,
|
|
.is_blinking = false,
|
|
};
|
|
|
|
/* === Easing Functions === */
|
|
|
|
/**
|
|
* Ease-in-out cubic interpolation [0, 1].
|
|
* Smooth acceleration/deceleration for transitions.
|
|
*/
|
|
static float ease_in_out_cubic(float t) {
|
|
if (t < 0.5f)
|
|
return 4.0f * t * t * t;
|
|
else {
|
|
float f = 2.0f * t - 2.0f;
|
|
return 0.5f * f * f * f + 1.0f;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Interpolate two emotion parameters by factor [0, 1].
|
|
*/
|
|
static face_params_t interpolate_params(const face_params_t *a,
|
|
const face_params_t *b,
|
|
float t) {
|
|
face_params_t result;
|
|
result.eye_x = (int16_t)(a->eye_x + (b->eye_x - a->eye_x) * t);
|
|
result.eye_y = (int16_t)(a->eye_y + (b->eye_y - a->eye_y) * t);
|
|
result.eye_open_y = (int16_t)(a->eye_open_y + (b->eye_open_y - a->eye_open_y) * t);
|
|
result.eye_close_y = (int16_t)(a->eye_close_y + (b->eye_close_y - a->eye_close_y) * t);
|
|
result.brow_angle = (int16_t)(a->brow_angle + (b->brow_angle - a->brow_angle) * t);
|
|
result.brow_y_offset = (int16_t)(a->brow_y_offset + (b->brow_y_offset - a->brow_y_offset) * t);
|
|
result.mouth_x = (int16_t)(a->mouth_x + (b->mouth_x - a->mouth_x) * t);
|
|
result.mouth_y = (int16_t)(a->mouth_y + (b->mouth_y - a->mouth_y) * t);
|
|
result.mouth_width = (int16_t)(a->mouth_width + (b->mouth_width - a->mouth_width) * t);
|
|
result.mouth_curve = (int16_t)(a->mouth_curve + (b->mouth_curve - a->mouth_curve) * t);
|
|
return result;
|
|
}
|
|
|
|
/* === Drawing Functions === */
|
|
|
|
/**
|
|
* Draw an eye (circle) with optional closure (eyelid).
|
|
*/
|
|
static void draw_eye(int16_t x, int16_t y, int16_t open_y, int16_t close_y,
|
|
bool is_blinking) {
|
|
lcd_color_t color = LCD_WHITE;
|
|
|
|
/* Eye position accounts for blink closure */
|
|
int16_t eye_h = is_blinking ? close_y : open_y;
|
|
if (eye_h <= 0) {
|
|
/* Closed: draw horizontal line instead */
|
|
face_lcd_line(x - EYE_RADIUS, y, x + EYE_RADIUS, y, color);
|
|
} else {
|
|
/* Open: draw circle (simplified ellipse) */
|
|
face_lcd_circle(x, y, EYE_RADIUS, color);
|
|
/* Fill iris pupil */
|
|
face_lcd_fill_rect(x - 2, y - 1, 4, 2, color);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Draw an eyebrow with angle and offset.
|
|
*/
|
|
static void draw_brow(int16_t x, int16_t y, int16_t angle, int16_t y_offset) {
|
|
/* Approximate angled line by adjusting endpoints */
|
|
int16_t brow_y = y + y_offset;
|
|
int16_t angle_offset = (angle * BROW_LENGTH) / 45; /* ~1 pixel per 45 degrees */
|
|
|
|
face_lcd_line(x - BROW_LENGTH/2 - angle_offset, brow_y,
|
|
x + BROW_LENGTH/2 + angle_offset, brow_y,
|
|
LCD_WHITE);
|
|
}
|
|
|
|
/**
|
|
* Draw mouth (curved line or bezier approximation).
|
|
*/
|
|
static void draw_mouth(int16_t x, int16_t y, int16_t width, int16_t curve) {
|
|
/* Simplified mouth: two diagonal lines forming a V or inverted V */
|
|
int16_t mouth_left = x - width / 2;
|
|
int16_t mouth_right = x + width / 2;
|
|
int16_t mouth_bottom = y + (curve > 0 ? 3 : 0);
|
|
|
|
if (curve > 0) {
|
|
/* Smile: V shape upturned */
|
|
face_lcd_line(mouth_left, y + 2, x, mouth_bottom, LCD_WHITE);
|
|
face_lcd_line(x, mouth_bottom, mouth_right, y + 2, LCD_WHITE);
|
|
} else if (curve < 0) {
|
|
/* Frown: ^ shape downturned */
|
|
face_lcd_line(mouth_left, y - 2, x, y + 2, LCD_WHITE);
|
|
face_lcd_line(x, y + 2, mouth_right, y - 2, LCD_WHITE);
|
|
} else {
|
|
/* Neutral: straight line */
|
|
face_lcd_line(mouth_left, y, mouth_right, y, LCD_WHITE);
|
|
}
|
|
}
|
|
|
|
/* === Public API Implementation === */
|
|
|
|
void face_animation_init(void) {
|
|
anim_state.current_emotion = FACE_NEUTRAL;
|
|
anim_state.target_emotion = FACE_NEUTRAL;
|
|
anim_state.frame = 0;
|
|
anim_state.transition_frame = 0;
|
|
anim_state.is_transitioning = false;
|
|
anim_state.blink_timer = BLINK_INTERVAL_MS / 33;
|
|
anim_state.blink_frame = 0;
|
|
anim_state.is_blinking = false;
|
|
}
|
|
|
|
void face_animation_set_emotion(face_emotion_t emotion) {
|
|
if (emotion < 6) {
|
|
anim_state.target_emotion = emotion;
|
|
anim_state.is_transitioning = true;
|
|
anim_state.transition_frame = 0;
|
|
}
|
|
}
|
|
|
|
void face_animation_tick(void) {
|
|
anim_state.frame++;
|
|
|
|
/* Handle transition */
|
|
if (anim_state.is_transitioning) {
|
|
anim_state.transition_frame++;
|
|
if (anim_state.transition_frame >= TRANSITION_FRAMES) {
|
|
/* Transition complete */
|
|
anim_state.current_emotion = anim_state.target_emotion;
|
|
anim_state.is_transitioning = false;
|
|
}
|
|
}
|
|
|
|
/* Handle idle blink */
|
|
if (!anim_state.is_blinking) {
|
|
anim_state.blink_timer--;
|
|
if (anim_state.blink_timer == 0) {
|
|
anim_state.is_blinking = true;
|
|
anim_state.blink_frame = 0;
|
|
/* Reset timer for next blink */
|
|
anim_state.blink_timer = BLINK_INTERVAL_MS / 33;
|
|
}
|
|
} else {
|
|
/* In blink */
|
|
anim_state.blink_frame++;
|
|
if (anim_state.blink_frame >= BLINK_DURATION_MS / 33) {
|
|
/* Blink complete */
|
|
anim_state.is_blinking = false;
|
|
anim_state.blink_frame = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
void face_animation_render(void) {
|
|
/* Clear display */
|
|
face_lcd_clear();
|
|
|
|
/* Get current emotion parameters (interpolated if transitioning) */
|
|
face_params_t params;
|
|
if (anim_state.is_transitioning) {
|
|
float t = ease_in_out_cubic((float)anim_state.transition_frame /
|
|
TRANSITION_FRAMES);
|
|
params = interpolate_params(
|
|
&emotion_params[anim_state.current_emotion],
|
|
&emotion_params[anim_state.target_emotion],
|
|
t);
|
|
} else {
|
|
params = emotion_params[anim_state.current_emotion];
|
|
}
|
|
|
|
/* Draw left eye */
|
|
draw_eye(FACE_CENTER_X + params.eye_x, FACE_CENTER_Y + params.eye_y,
|
|
params.eye_open_y, params.eye_close_y, anim_state.is_blinking);
|
|
|
|
/* Draw right eye */
|
|
draw_eye(FACE_CENTER_X - params.eye_x, FACE_CENTER_Y + params.eye_y,
|
|
params.eye_open_y, params.eye_close_y, anim_state.is_blinking);
|
|
|
|
/* Draw left brow */
|
|
draw_brow(FACE_CENTER_X + params.eye_x, FACE_CENTER_Y + params.brow_y_offset,
|
|
params.brow_angle, 0);
|
|
|
|
/* Draw right brow (mirrored) */
|
|
draw_brow(FACE_CENTER_X - params.eye_x, FACE_CENTER_Y + params.brow_y_offset,
|
|
-params.brow_angle, 0);
|
|
|
|
/* Draw mouth */
|
|
draw_mouth(FACE_CENTER_X + params.mouth_x, FACE_CENTER_Y + params.mouth_y,
|
|
params.mouth_width, params.mouth_curve);
|
|
|
|
/* Push framebuffer to display */
|
|
face_lcd_flush();
|
|
}
|
|
|
|
face_emotion_t face_animation_get_emotion(void) {
|
|
return anim_state.is_transitioning ? anim_state.target_emotion
|
|
: anim_state.current_emotion;
|
|
}
|
|
|
|
void face_animation_blink_now(void) {
|
|
anim_state.is_blinking = true;
|
|
anim_state.blink_frame = 0;
|
|
anim_state.blink_timer = BLINK_INTERVAL_MS / 33;
|
|
}
|
|
|
|
bool face_animation_is_idle(void) {
|
|
return !anim_state.is_transitioning && !anim_state.is_blinking;
|
|
}
|