/* The DeskLock face: black screen, glyph expressions, matrix rain. * Contract: sim/face/index.html + docs/architecture.md "Face design". * All lv_timer callbacks run in the LVGL task; face_set() locks for other tasks. */ #include #include #include #include "esp_random.h" #include "bsp/esp-bsp.h" #include "lvgl.h" #include "desklock.h" LV_FONT_DECLARE(font_face_140); LV_FONT_DECLARE(font_rain_22); LV_FONT_DECLARE(font_rage_64); #define SCREEN 800 #define RAIN_MAX 40 #define RAIN_CELL 20 #define RAIN_COLS (SCREEN / RAIN_CELL) #define TICK_MS 33 /* power ladder (docs/architecture.md "Power management") */ #define AMBIENT_AFTER_MS (2 * 60 * 1000) #define DORMANT_AFTER_MS (10 * 60 * 1000) typedef struct { const char *eyes; const char *mouth; bool blink; bool thought; bool talk; int rain_streams; int rain_speed; /* px per tick, before per-stream jitter */ } face_def_t; static const face_def_t DEFS[] = { [FACE_BOOT] = { "- -", "\\_/", false, false, false, 6, 3 }, [FACE_IDLE] = { "- -", "\\_/", true, false, false, 2, 2 }, [FACE_LISTENING] = { "O O", "o", true, false, false, 16, 4 }, [FACE_PENSIVE] = { "\xC2\xB7 \xC2\xB7", "~", false, true, false, 7, 3 }, [FACE_EFFORT] = { "> <", "~", false, false, false, 40, 9 }, [FACE_SPEAKING] = { "^ ^", "o", true, false, true, 14, 5 }, [FACE_RAGE] = { NULL, "", false, false, false, 34, 12 }, [FACE_ERROR] = { "x x", "-", false, false, false, 0, 0 }, }; static const char *TALK[] = { "o", "O", "-", "O", "=", "o" }; static const struct { const char *text; int ms; } RAGE_FRAMES[] = { { "(\xC2\xB0\xE2\x96\xA1\xC2\xB0) \xE2\x94\xAC\xE2\x94\x80\xE2\x94\xAC", 900 }, { "(\xE2\x95\xAF\xC2\xB0\xE2\x96\xA1\xC2\xB0)\xE2\x95\xAF\xEF\xB8\xB5 \xE2\x94\xBB\xE2\x94\x81\xE2\x94\xBB", 1300 }, { "\xE2\x94\xAC\xE2\x94\x80\xE2\x94\xAC \xE3\x83\x8E( \xC2\xBA_\xC2\xBA \xE3\x83\x8E)", 1400 }, }; #define RAGE_LOOP_MS (900 + 1300 + 1400) #define RAGE_AUTO_EXIT_LOOPS 2 /* rain glyph pool: ASCII + katakana (must exist in font_rain_22) */ static const char *GLYPHS[] = { "0","1","2","3","4","5","6","7","8","9","A","C","E","F","H","K","Z", "$","#","%","*","+","=","<",">", "\xE3\x82\xA2","\xE3\x82\xA4","\xE3\x82\xA6","\xE3\x82\xA8","\xE3\x82\xAA", "\xE3\x82\xAB","\xE3\x82\xAD","\xE3\x82\xAF","\xE3\x82\xB1","\xE3\x82\xB3", "\xE3\x82\xB5","\xE3\x82\xB7","\xE3\x82\xB9","\xE3\x82\xBB","\xE3\x82\xBD", "\xE3\x82\xBF","\xE3\x83\x81","\xE3\x83\x84","\xE3\x83\x86","\xE3\x83\x88", "\xE3\x83\x8A","\xE3\x83\x8B","\xE3\x83\x8C","\xE3\x83\x8D","\xE3\x83\x8E", }; #define NGLYPHS (sizeof(GLYPHS) / sizeof(GLYPHS[0])) typedef struct { lv_obj_t *tail; lv_obj_t *head; int x, y, len, speed; bool alive; } stream_t; static struct { face_state_t state; lv_obj_t *rain_layer; lv_obj_t *shade; lv_obj_t *eyes; lv_obj_t *mouth; lv_obj_t *thought; lv_obj_t *clock_lbl; lv_obj_t *status; lv_obj_t *elapsed; lv_obj_t *arc; stream_t streams[RAIN_MAX]; int arc_rot; uint32_t state_since; uint32_t last_activity; int power; /* 0 active, 1 ambient, 2 dormant */ int blink_in_ms; int blink_hold_ms; int talk_i; int talk_ms; int dots_i; int dots_ms; int rage_ms; int mutate; } F; static uint32_t now_ms(void) { return lv_tick_get(); } static const char *rand_glyph(void) { return GLYPHS[esp_random() % NGLYPHS]; } static void stream_fill(stream_t *s) { char buf[16 * 4 + 16]; int pos = 0; for (int i = 0; i < s->len; i++) { pos += snprintf(buf + pos, sizeof(buf) - pos, "%s%s", i ? "\n" : "", rand_glyph()); } lv_label_set_text(s->tail, buf); lv_label_set_text(s->head, rand_glyph()); } static void stream_spawn(stream_t *s, int speed) { s->x = (esp_random() % RAIN_COLS) * RAIN_CELL; s->len = 6 + esp_random() % 9; s->speed = speed + esp_random() % 4; s->y = -(s->len + 1) * 26; if (s->tail == NULL) { s->tail = lv_label_create(F.rain_layer); lv_obj_set_style_text_font(s->tail, &font_rain_22, 0); lv_obj_set_style_text_color(s->tail, lv_color_hex(0x00A050), 0); lv_obj_set_style_text_opa(s->tail, 170, 0); s->head = lv_label_create(F.rain_layer); lv_obj_set_style_text_font(s->head, &font_rain_22, 0); lv_obj_set_style_text_color(s->head, lv_color_hex(0xC3FFD7), 0); } lv_obj_clear_flag(s->tail, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(s->head, LV_OBJ_FLAG_HIDDEN); stream_fill(s); s->alive = true; } static void stream_hide(stream_t *s) { s->alive = false; if (s->tail != NULL) { lv_obj_add_flag(s->tail, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(s->head, LV_OBJ_FLAG_HIDDEN); } } static void rain_tick(void) { const face_def_t *d = &DEFS[F.state]; int target = (F.power == 2) ? 0 : d->rain_streams; int alive = 0; for (int i = 0; i < RAIN_MAX; i++) { stream_t *s = &F.streams[i]; if (!s->alive) { continue; } alive++; s->y += s->speed; lv_obj_set_pos(s->tail, s->x, s->y); lv_obj_set_pos(s->head, s->x, s->y + s->len * 26); if (F.mutate == 0 && (esp_random() % 3) == 0) { lv_label_set_text(s->head, rand_glyph()); } if (s->y > SCREEN + 26) { if (alive > target) { stream_hide(s); alive--; } else { stream_spawn(s, d->rain_speed); } } } for (int i = 0; i < RAIN_MAX && alive < target; i++) { if (!F.streams[i].alive) { stream_spawn(&F.streams[i], d->rain_speed); alive++; } } F.mutate = (F.mutate + 1) % 4; } static void apply_power(int level) { if (level == F.power) { return; } F.power = level; bsp_display_brightness_set(level == 0 ? 100 : level == 1 ? 35 : 5); if (level == 2) { for (int i = 0; i < RAIN_MAX; i++) { stream_hide(&F.streams[i]); } } } static void power_tick(void) { /* conversation states hold the ladder at active */ if (F.state != FACE_IDLE && F.state != FACE_ERROR) { F.last_activity = now_ms(); } uint32_t idle_ms = now_ms() - F.last_activity; apply_power(idle_ms > DORMANT_AFTER_MS ? 2 : idle_ms > AMBIENT_AFTER_MS ? 1 : 0); } static void set_eyes_mouth(void) { const face_def_t *d = &DEFS[F.state]; if (F.state == FACE_RAGE) { lv_obj_set_style_text_font(F.eyes, &font_rage_64, 0); lv_label_set_text(F.eyes, RAGE_FRAMES[0].text); lv_obj_add_flag(F.mouth, LV_OBJ_FLAG_HIDDEN); return; } lv_obj_set_style_text_font(F.eyes, &font_face_140, 0); lv_label_set_text(F.eyes, d->eyes); lv_label_set_text(F.mouth, d->mouth); lv_obj_clear_flag(F.mouth, LV_OBJ_FLAG_HIDDEN); } static void blink_tick(void) { const face_def_t *d = &DEFS[F.state]; if (!d->blink || F.power == 2) { return; } if (F.blink_hold_ms > 0) { F.blink_hold_ms -= TICK_MS; if (F.blink_hold_ms <= 0) { lv_label_set_text(F.eyes, d->eyes); } return; } F.blink_in_ms -= TICK_MS; if (F.blink_in_ms <= 0) { char blinked[24]; int j = 0; for (const char *p = d->eyes; *p && j < 23; p++) { blinked[j++] = (*p == ' ') ? ' ' : '_'; } blinked[j] = '\0'; lv_label_set_text(F.eyes, blinked); F.blink_hold_ms = 130; F.blink_in_ms = 2600 + esp_random() % 3600; } } static void rage_tick(void) { if (F.state != FACE_RAGE) { return; } F.rage_ms += TICK_MS; if (F.rage_ms >= RAGE_AUTO_EXIT_LOOPS * RAGE_LOOP_MS) { F.state = FACE_IDLE; /* auto-compose: put the table back, return to idle */ set_eyes_mouth(); lv_obj_add_flag(F.arc, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); return; } int t = F.rage_ms % RAGE_LOOP_MS; int idx = t < 900 ? 0 : t < 2200 ? 1 : 2; lv_label_set_text(F.eyes, RAGE_FRAMES[idx].text); } static void tick_cb(lv_timer_t *timer) { (void)timer; rain_tick(); blink_tick(); rage_tick(); power_tick(); if (F.state == FACE_EFFORT) { F.arc_rot = (F.arc_rot + 9) % 360; lv_arc_set_rotation(F.arc, F.arc_rot); char buf[20]; snprintf(buf, sizeof(buf), "[ %lus ]", (unsigned long)((now_ms() - F.state_since) / 1000)); lv_label_set_text(F.elapsed, buf); } const face_def_t *d = &DEFS[F.state]; if (d->talk) { F.talk_ms += TICK_MS; if (F.talk_ms >= 150) { F.talk_ms = 0; lv_label_set_text(F.mouth, TALK[F.talk_i++ % 6]); } } if (d->thought) { F.dots_ms += TICK_MS; if (F.dots_ms >= 480) { F.dots_ms = 0; static const char *DOTS[] = { ".", "..", "..." }; lv_label_set_text(F.thought, DOTS[F.dots_i++ % 3]); } } else { lv_label_set_text(F.thought, ""); } } static void clock_cb(lv_timer_t *timer) { (void)timer; if (F.state != FACE_IDLE) { lv_obj_add_flag(F.clock_lbl, LV_OBJ_FLAG_HIDDEN); return; } time_t t = time(NULL); struct tm tm; localtime_r(&t, &tm); if (tm.tm_year > 100) { char buf[8]; snprintf(buf, sizeof(buf), "%02d:%02d", tm.tm_hour, tm.tm_min); lv_label_set_text(F.clock_lbl, buf); lv_obj_clear_flag(F.clock_lbl, LV_OBJ_FLAG_HIDDEN); } } static void touch_cb(lv_event_t *e) { (void)e; app_on_touch(); } void face_init(void) { bsp_display_lock(UINT32_MAX); lv_obj_t *scr = lv_screen_active(); lv_obj_set_style_bg_color(scr, lv_color_black(), 0); F.rain_layer = lv_obj_create(scr); lv_obj_remove_style_all(F.rain_layer); lv_obj_set_size(F.rain_layer, SCREEN, SCREEN); /* radial-ish shade so rain doesn't run through the face */ F.shade = lv_obj_create(scr); lv_obj_remove_style_all(F.shade); lv_obj_set_size(F.shade, 520, 520); lv_obj_center(F.shade); lv_obj_set_style_radius(F.shade, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(F.shade, lv_color_black(), 0); lv_obj_set_style_bg_opa(F.shade, 215, 0); F.eyes = lv_label_create(scr); lv_obj_set_style_text_font(F.eyes, &font_face_140, 0); lv_obj_set_style_text_color(F.eyes, lv_color_hex(0xADFFC8), 0); lv_obj_align(F.eyes, LV_ALIGN_CENTER, 0, -90); F.mouth = lv_label_create(scr); lv_obj_set_style_text_font(F.mouth, &font_face_140, 0); lv_obj_set_style_text_color(F.mouth, lv_color_hex(0xADFFC8), 0); lv_obj_align(F.mouth, LV_ALIGN_CENTER, 0, 70); F.thought = lv_label_create(scr); lv_obj_set_style_text_font(F.thought, &font_face_140, 0); lv_obj_set_style_text_color(F.thought, lv_color_hex(0x63CF8B), 0); lv_obj_align(F.thought, LV_ALIGN_CENTER, 170, -170); F.clock_lbl = lv_label_create(scr); lv_obj_set_style_text_font(F.clock_lbl, &lv_font_montserrat_48, 0); lv_obj_set_style_text_color(F.clock_lbl, lv_color_hex(0x2F7A4B), 0); lv_obj_align(F.clock_lbl, LV_ALIGN_CENTER, 0, 200); lv_obj_add_flag(F.clock_lbl, LV_OBJ_FLAG_HIDDEN); F.status = lv_label_create(scr); lv_obj_set_style_text_font(F.status, &lv_font_montserrat_28, 0); lv_obj_set_style_text_color(F.status, lv_color_hex(0x2F7A4B), 0); lv_label_set_text(F.status, "CONNECTING"); lv_obj_align(F.status, LV_ALIGN_CENTER, 0, 270); F.elapsed = lv_label_create(scr); lv_obj_set_style_text_font(F.elapsed, &lv_font_montserrat_28, 0); lv_obj_set_style_text_color(F.elapsed, lv_color_hex(0x2F7A4B), 0); lv_obj_set_style_bg_color(F.elapsed, lv_color_black(), 0); lv_obj_set_style_bg_opa(F.elapsed, 205, 0); lv_obj_set_style_pad_all(F.elapsed, 8, 0); lv_obj_align(F.elapsed, LV_ALIGN_CENTER, 0, 300); lv_obj_add_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); F.arc = lv_arc_create(scr); lv_obj_set_size(F.arc, 780, 780); lv_obj_center(F.arc); lv_arc_set_bg_angles(F.arc, 0, 40); lv_arc_set_value(F.arc, 100); lv_obj_remove_style(F.arc, NULL, LV_PART_KNOB); lv_obj_clear_flag(F.arc, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_width(F.arc, 6, LV_PART_MAIN); lv_obj_set_style_arc_color(F.arc, lv_color_hex(0x37FF8B), LV_PART_MAIN); lv_obj_set_style_arc_opa(F.arc, LV_OPA_COVER, LV_PART_MAIN); lv_obj_set_style_arc_opa(F.arc, LV_OPA_TRANSP, LV_PART_INDICATOR); lv_obj_add_flag(F.arc, LV_OBJ_FLAG_HIDDEN); /* full-screen touch catcher, topmost */ lv_obj_t *touch = lv_obj_create(scr); lv_obj_remove_style_all(touch); lv_obj_set_size(touch, SCREEN, SCREEN); lv_obj_add_flag(touch, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(touch, touch_cb, LV_EVENT_CLICKED, NULL); F.state = FACE_BOOT; F.last_activity = now_ms(); F.state_since = now_ms(); F.blink_in_ms = 3000; set_eyes_mouth(); lv_timer_create(tick_cb, TICK_MS, NULL); lv_timer_create(clock_cb, 1000, NULL); bsp_display_unlock(); } void face_set(face_state_t state) { bsp_display_lock(UINT32_MAX); if (state != F.state) { F.state = state; F.state_since = now_ms(); F.rage_ms = 0; set_eyes_mouth(); bool effort = (state == FACE_EFFORT); lv_obj_set_style_opa(F.eyes, state == FACE_ERROR ? 115 : 255, 0); lv_obj_set_style_opa(F.mouth, state == FACE_ERROR ? 115 : 255, 0); if (effort) { lv_obj_clear_flag(F.arc, LV_OBJ_FLAG_HIDDEN); lv_obj_clear_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(F.arc, LV_OBJ_FLAG_HIDDEN); lv_obj_add_flag(F.elapsed, LV_OBJ_FLAG_HIDDEN); } if (state != FACE_BOOT) { lv_obj_add_flag(F.status, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_clear_flag(F.status, LV_OBJ_FLAG_HIDDEN); } } bsp_display_unlock(); } face_state_t face_get(void) { return F.state; } void face_status(const char *text) { bsp_display_lock(UINT32_MAX); lv_label_set_text(F.status, text); lv_obj_clear_flag(F.status, LV_OBJ_FLAG_HIDDEN); bsp_display_unlock(); } void face_activity(void) { F.last_activity = now_ms(); if (F.power != 0) { bsp_display_lock(UINT32_MAX); apply_power(0); bsp_display_unlock(); } }