From d0f217f5d99e08f00c446414a55e1fc4072afafb Mon Sep 17 00:00:00 2001 From: Claude Worker claude2 Date: Wed, 10 Jun 2026 22:01:17 +0200 Subject: [PATCH] feat(ui): scene IR metadata + fx glitch/gyro --- .../components/display_ui/display_ui.cpp | 127 +++++++++++++++--- .../display_ui/include/display_ui.h | 8 ++ .../components/game_endpoint/game_endpoint.c | 18 ++- .../game_endpoint/include/game_endpoint.h | 10 ++ .../game_endpoint/include/puzzle_binding.h | 33 +++++ .../components/game_endpoint/puzzle_binding.c | 65 +++++++++ .../game_endpoint/test/test_puzzle_binding.c | 42 ++++++ idf_zacus/main/main.c | 19 ++- 8 files changed, 299 insertions(+), 23 deletions(-) diff --git a/idf_zacus/components/display_ui/display_ui.cpp b/idf_zacus/components/display_ui/display_ui.cpp index f288a9f..4b71e9d 100644 --- a/idf_zacus/components/display_ui/display_ui.cpp +++ b/idf_zacus/components/display_ui/display_ui.cpp @@ -50,6 +50,7 @@ #include "driver/ledc.h" #include "esp_heap_caps.h" #include "esp_log.h" +#include "esp_random.h" #include "esp_timer.h" #include "freertos/FreeRTOS.h" #include "freertos/semphr.h" @@ -220,11 +221,84 @@ static lv_obj_t *make_row(lv_obj_t *parent, int y, const char *label_txt, return val; } -// Pulse animation on the scene code — SceneEffect::kPulse equivalent. +// ─── Scene effects (ports of the original ui_manager SceneEffect set) ──────── + +// Pulse — SceneEffect::kPulse equivalent (opacity breathe on the symbol/code). static void pulse_anim_cb(void *obj, int32_t v) { lv_obj_set_style_opa((lv_obj_t *) obj, (lv_opa_t) v, 0); } +// Glitch — jitter + flicker on the title (text-artifact evocation of the +// original glitch overlay; the full pixel-shred shader is a later phase). +static lv_timer_t *s_glitch_timer; +static void glitch_timer_cb(lv_timer_t *t) { + (void) t; + const int dx = (int) (esp_random() % 7) - 3; + lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, dx, 24); + lv_obj_set_style_opa(s_scene_title, + (esp_random() % 8) ? LV_OPA_COVER : LV_OPA_60, 0); +} + +// Gyrophare — rotating beacon ring around the scene. +static lv_obj_t *s_gyro_arc; +static void gyro_anim_cb(void *obj, int32_t v) { + lv_arc_set_rotation((lv_obj_t *) obj, (uint16_t) v); +} + +static uint8_t s_fx_applied = 0xFF; // force first apply + +static void start_pulse(void) { + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_scene_code); + lv_anim_set_exec_cb(&a, pulse_anim_cb); + lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50); + lv_anim_set_time(&a, 600); + lv_anim_set_playback_time(&a, 600); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&a); +} + +static void apply_effect(uint8_t fx) { + if (fx == s_fx_applied) return; + s_fx_applied = fx; + + // Stop everything, restore neutral state. + lv_anim_del(s_scene_code, pulse_anim_cb); + lv_obj_set_style_opa(s_scene_code, LV_OPA_COVER, 0); + if (s_glitch_timer) lv_timer_pause(s_glitch_timer); + lv_obj_align(s_scene_title, LV_ALIGN_TOP_MID, 0, 24); + lv_obj_set_style_opa(s_scene_title, LV_OPA_COVER, 0); + if (s_gyro_arc) { + lv_anim_del(s_gyro_arc, gyro_anim_cb); + lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN); + } + + switch (fx) { + case 0: // pulse (default) + start_pulse(); + break; + case 1: // glitch + if (s_glitch_timer) lv_timer_resume(s_glitch_timer); + break; + case 2: // gyro + if (s_gyro_arc) { + lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN); + lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_var(&a, s_gyro_arc); + lv_anim_set_exec_cb(&a, gyro_anim_cb); + lv_anim_set_values(&a, 0, 360); + lv_anim_set_time(&a, 1200); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&a); + } + break; + default: // none + break; + } +} + static void build_scene_screen(void) { s_scr_scene = lv_obj_create(NULL); lv_obj_set_style_bg_color(s_scr_scene, lv_color_hex(0x000000), 0); @@ -267,16 +341,23 @@ static void build_scene_screen(void) { lv_label_set_text(s_scene_subtitle, ""); lv_obj_align(s_scene_subtitle, LV_ALIGN_BOTTOM_LEFT, 8, -10); - // Pulse on the code (original default scene effect). - lv_anim_t a; - lv_anim_init(&a); - lv_anim_set_var(&a, s_scene_code); - lv_anim_set_exec_cb(&a, pulse_anim_cb); - lv_anim_set_values(&a, LV_OPA_COVER, LV_OPA_50); - lv_anim_set_time(&a, 600); - lv_anim_set_playback_time(&a, 600); - lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); - lv_anim_start(&a); + // Effect plumbing: glitch timer (paused until selected) + gyro ring + // (hidden until selected). Pulse starts via apply_effect on first status. + s_glitch_timer = lv_timer_create(glitch_timer_cb, 120, NULL); + lv_timer_pause(s_glitch_timer); + + s_gyro_arc = lv_arc_create(s_scr_scene); + lv_obj_set_size(s_gyro_arc, 300, 300); + lv_obj_center(s_gyro_arc); + lv_arc_set_bg_angles(s_gyro_arc, 0, 360); + lv_arc_set_angles(s_gyro_arc, 0, 60); // 60° beacon segment + lv_obj_remove_style(s_gyro_arc, NULL, LV_PART_KNOB); + lv_obj_clear_flag(s_gyro_arc, LV_OBJ_FLAG_CLICKABLE); + lv_obj_set_style_arc_opa(s_gyro_arc, LV_OPA_TRANSP, LV_PART_MAIN); + lv_obj_set_style_arc_color(s_gyro_arc, lv_color_hex(0xFF3300), + LV_PART_INDICATOR); + lv_obj_set_style_arc_width(s_gyro_arc, 8, LV_PART_INDICATOR); + lv_obj_add_flag(s_gyro_arc, LV_OBJ_FLAG_HIDDEN); } static void build_status_screen(void) { @@ -330,15 +411,25 @@ static void apply_status(const display_status_t *s) { snprintf(code, sizeof(code), "Code: %s", s->code[0] ? s->code : "---"); lv_label_set_text(s_lbl_code, code); - // Scene screen - lv_label_set_text(s_scene_title, s->step_id); - if (s->armed[0] && strcmp(s->armed, "none") != 0) { - snprintf(tmp, sizeof(tmp), "enigme: %s", s->armed); + // Scene screen — scene metadata wins, with sensible fallbacks. + lv_label_set_text(s_scene_title, + s->scene_title[0] ? s->scene_title : s->step_id); + if (s->scene_subtitle[0]) { + lv_label_set_text(s_scene_subtitle, s->scene_subtitle); } else { - snprintf(tmp, sizeof(tmp), "resolues: %u", (unsigned) s->solved_count); + if (s->armed[0] && strcmp(s->armed, "none") != 0) { + snprintf(tmp, sizeof(tmp), "enigme: %s", s->armed); + } else { + snprintf(tmp, sizeof(tmp), "resolues: %u", + (unsigned) s->solved_count); + } + lv_label_set_text(s_scene_subtitle, tmp); } - lv_label_set_text(s_scene_subtitle, tmp); - lv_label_set_text(s_scene_code, s->code[0] ? s->code : ""); + // Center slot: scenario symbol when provided, else the assembled code. + lv_label_set_text(s_scene_code, + s->scene_symbol[0] ? s->scene_symbol + : (s->code[0] ? s->code : "")); + apply_effect(s->scene_effect); // Viewfinder: visible only while the QR puzzle is armed. When visible, // the centered code label yields its slot to the canvas (code moves into diff --git a/idf_zacus/components/display_ui/include/display_ui.h b/idf_zacus/components/display_ui/include/display_ui.h index 42e904c..0e2c31b 100644 --- a/idf_zacus/components/display_ui/include/display_ui.h +++ b/idf_zacus/components/display_ui/include/display_ui.h @@ -14,6 +14,14 @@ typedef struct { uint8_t solved_count; char ip[16]; // STA IP ("" = none) bool wake_active; // wake-word detector up + + // Scene metadata (from the step's optional `scene` IR object; empty + // strings = fall back to step_id / armed / code). Sizes mirror + // puzzle_binding.h SB_MAX_*. + char scene_title[48]; + char scene_subtitle[64]; + char scene_symbol[16]; + uint8_t scene_effect; // scene_effect_t value (0=pulse 1=glitch 2=gyro 3=none) } display_status_t; /** diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index 5b90225..764fb1f 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -38,6 +38,8 @@ static puzzle_state_t *s_pstate = NULL; static char s_current_step_id[64] = {0}; // Last armed puzzle type: "qr" | "sound" | "none" | "" (empty = nothing armed yet). static char s_current_armed[8] = {0}; +// Display scene metadata of the current step (lenient parse; defaults safe). +static scene_binding_t s_current_scene = {0}; // Whitelist mirrored in the 4xx error message so the operator can // recover without grepping the source. Keep aligned with @@ -329,6 +331,7 @@ static esp_err_t scenario_apply_buffer(const char *body, size_t len, // New scenario supersedes any live puzzle — disarm and clear remembered step. local_puzzles_disarm(); s_current_step_id[0] = '\0'; + memset(&s_current_scene, 0, sizeof(s_current_scene)); // Hot-reload-via-reboot until scenario_engine_reload() lands (Phase 3). schedule_restart(); @@ -713,9 +716,14 @@ static esp_err_t handle_step_post(httpd_req_t *req) { // Disarm current puzzle before re-arming. local_puzzles_disarm(); - // Parse binding for this step. + // Parse binding for this step (+ display scene metadata, lenient). puzzle_binding_t binding; esp_err_t berr = puzzle_binding_from_ir(ir_json, step_id, &binding); + if (berr == ESP_OK) { + // Scene parse can only fail on malformed JSON, which binding parse + // already screened — ignore its return; defaults are safe. + (void) scene_binding_from_ir(ir_json, step_id, &s_current_scene); + } free(ir_json); if (berr == ESP_ERR_NOT_FOUND) { @@ -869,6 +877,14 @@ void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap, } } +void game_endpoint_get_scene(scene_binding_t *out) { + if (!out) return; + // Lock-free snapshot, same class as get_puzzle_status: written on the + // httpd task per step change, polled by main's status loop. A torn read + // of display text during the copy is cosmetic and self-heals next poll. + memcpy(out, &s_current_scene, sizeof(*out)); +} + // ─── public init ──────────────────────────────────────────────────────────── esp_err_t game_endpoint_init(httpd_handle_t server) { diff --git a/idf_zacus/components/game_endpoint/include/game_endpoint.h b/idf_zacus/components/game_endpoint/include/game_endpoint.h index 620c38c..9df190e 100644 --- a/idf_zacus/components/game_endpoint/include/game_endpoint.h +++ b/idf_zacus/components/game_endpoint/include/game_endpoint.h @@ -25,6 +25,7 @@ #include "esp_err.h" #include "esp_http_server.h" #include "puzzle_state.h" +#include "puzzle_binding.h" // scene_binding_t (display scene metadata) #ifdef __cplusplus extern "C" { @@ -91,6 +92,15 @@ void game_endpoint_set_puzzle_state(puzzle_state_t *state); void game_endpoint_get_puzzle_status(char *step_id, size_t step_cap, char *armed, size_t armed_cap); +/** + * @brief Snapshot the display scene metadata of the current step. + * + * Filled from the step's optional `scene` IR object at POST /game/step time + * (lenient parse — see puzzle_binding.h). Zeroed when no step is armed or a + * new scenario is pushed. Lock-free copy; cosmetic torn reads possible. + */ +void game_endpoint_get_scene(scene_binding_t *out); + #ifdef __cplusplus } #endif diff --git a/idf_zacus/components/game_endpoint/include/puzzle_binding.h b/idf_zacus/components/game_endpoint/include/puzzle_binding.h index b9c8389..2b16c88 100644 --- a/idf_zacus/components/game_endpoint/include/puzzle_binding.h +++ b/idf_zacus/components/game_endpoint/include/puzzle_binding.h @@ -1,5 +1,6 @@ // puzzle_binding.h — extract a step's optional puzzle binding from Runtime 3 IR. #pragma once +#include #include #include #include "esp_err.h" @@ -38,3 +39,35 @@ typedef struct { // Tolerance defaults to 1 when absent (sound only). esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, puzzle_binding_t *out); + +// ─── Scene metadata (display) ──────────────────────────────────────────────── +// Optional per-step `scene` object mirroring the original ui_manager scene +// frame: {"title": "...", "subtitle": "...", "symbol": "...", +// "effect": "pulse"|"glitch"|"gyro"|"none"}. +// LENIENT parsing (unlike the strict puzzle constraints): scene fields are +// display decoration and must never block a step change — over-long strings +// are silently TRUNCATED, an unknown effect falls back to pulse. + +#define SB_MAX_TITLE 48 +#define SB_MAX_SUBTITLE 64 +#define SB_MAX_SYMBOL 16 + +typedef enum { + SB_FX_PULSE = 0, // default (original SceneEffect::kPulse) + SB_FX_GLITCH, + SB_FX_GYRO, + SB_FX_NONE, +} scene_effect_t; + +typedef struct { + bool present; // step has a scene object + char title[SB_MAX_TITLE]; + char subtitle[SB_MAX_SUBTITLE]; + char symbol[SB_MAX_SYMBOL]; + scene_effect_t effect; +} scene_binding_t; + +// Same return contract as puzzle_binding_from_ir for ESP_OK/NOT_FOUND/ +// INVALID_ARG(malformed JSON only — scene content itself never fails). +esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id, + scene_binding_t *out); diff --git a/idf_zacus/components/game_endpoint/puzzle_binding.c b/idf_zacus/components/game_endpoint/puzzle_binding.c index 96b7d5e..3a56079 100644 --- a/idf_zacus/components/game_endpoint/puzzle_binding.c +++ b/idf_zacus/components/game_endpoint/puzzle_binding.c @@ -165,3 +165,68 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id, cJSON_Delete(root); return ESP_OK; } + +// ─── Scene metadata (lenient — see header) ─────────────────────────────────── + +static void copy_scene_str(char *dst, size_t cap, const cJSON *node) { + if (cJSON_IsString(node) && node->valuestring) { + strncpy(dst, node->valuestring, cap - 1); // silent truncation OK + dst[cap - 1] = '\0'; + } +} + +esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id, + scene_binding_t *out) +{ + if (!ir_json || !step_id || !out) return ESP_ERR_INVALID_ARG; + + memset(out, 0, sizeof(*out)); + out->effect = SB_FX_PULSE; + + cJSON *root = cJSON_Parse(ir_json); + if (!root) return ESP_ERR_INVALID_ARG; + if (!cJSON_IsObject(root)) { cJSON_Delete(root); return ESP_ERR_INVALID_ARG; } + + const cJSON *steps = cJSON_GetObjectItemCaseSensitive(root, "steps"); + if (!cJSON_IsArray(steps) || cJSON_GetArraySize(steps) == 0) { + cJSON_Delete(root); + return ESP_ERR_NOT_FOUND; + } + + const cJSON *step = NULL; + const cJSON *item = NULL; + cJSON_ArrayForEach(item, steps) { + const cJSON *sid = cJSON_GetObjectItemCaseSensitive(item, "id"); + if (cJSON_IsString(sid) && strcmp(sid->valuestring, step_id) == 0) { + step = item; + break; + } + } + if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; } + + const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene"); + if (!scene || !cJSON_IsObject(scene)) { + // No scene — present stays false, ESP_OK. + cJSON_Delete(root); + return ESP_OK; + } + + out->present = true; + copy_scene_str(out->title, sizeof(out->title), + cJSON_GetObjectItemCaseSensitive(scene, "title")); + copy_scene_str(out->subtitle, sizeof(out->subtitle), + cJSON_GetObjectItemCaseSensitive(scene, "subtitle")); + copy_scene_str(out->symbol, sizeof(out->symbol), + cJSON_GetObjectItemCaseSensitive(scene, "symbol")); + + const cJSON *fx = cJSON_GetObjectItemCaseSensitive(scene, "effect"); + if (cJSON_IsString(fx) && fx->valuestring) { + if (strcmp(fx->valuestring, "glitch") == 0) out->effect = SB_FX_GLITCH; + else if (strcmp(fx->valuestring, "gyro") == 0) out->effect = SB_FX_GYRO; + else if (strcmp(fx->valuestring, "none") == 0) out->effect = SB_FX_NONE; + else out->effect = SB_FX_PULSE; // unknown → default, never an error + } + + cJSON_Delete(root); + return ESP_OK; +} diff --git a/idf_zacus/components/game_endpoint/test/test_puzzle_binding.c b/idf_zacus/components/game_endpoint/test/test_puzzle_binding.c index c95ef15..fececd6 100644 --- a/idf_zacus/components/game_endpoint/test/test_puzzle_binding.c +++ b/idf_zacus/components/game_endpoint/test/test_puzzle_binding.c @@ -1,6 +1,7 @@ // test_puzzle_binding.c — IDF-style host tests for puzzle_binding_from_ir. #include "unity.h" #include "puzzle_binding.h" +#include // ── 1. QR step parses fully ────────────────────────────────────────────────── TEST_CASE("pbind: qr step parses fully", "[pbind]") @@ -110,3 +111,44 @@ TEST_CASE("pbind: fragment value 12 yields INVALID_ARG", "[pbind]") puzzle_binding_from_ir(ir, "STEP_X", &b)); TEST_ASSERT_EQUAL(PB_NONE, b.type); } + +// ── 8-10. Scene metadata (lenient parser) ──────────────────────────────────── +TEST_CASE("scene parses title/subtitle/symbol/effect", "[pbind]") +{ + const char *ir = + "{\"steps\":[" + "{\"id\":\"STEP_X\",\"scene\":{\"title\":\"MISSION\"," + "\"subtitle\":\"trouvez les QR\",\"symbol\":\"RUN\"," + "\"effect\":\"gyro\"}}" + "]}"; + scene_binding_t s; + TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s)); + TEST_ASSERT_TRUE(s.present); + TEST_ASSERT_EQUAL_STRING("MISSION", s.title); + TEST_ASSERT_EQUAL_STRING("trouvez les QR", s.subtitle); + TEST_ASSERT_EQUAL_STRING("RUN", s.symbol); + TEST_ASSERT_EQUAL(SB_FX_GYRO, s.effect); +} + +TEST_CASE("scene absent leaves present=false, defaults intact", "[pbind]") +{ + const char *ir = "{\"steps\":[{\"id\":\"STEP_X\"}]}"; + scene_binding_t s; + TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s)); + TEST_ASSERT_FALSE(s.present); + TEST_ASSERT_EQUAL(SB_FX_PULSE, s.effect); +} + +TEST_CASE("scene unknown effect -> pulse; long title truncated", "[pbind]") +{ + const char *ir = + "{\"steps\":[" + "{\"id\":\"STEP_X\",\"scene\":{\"effect\":\"discoball\"," + "\"title\":\"0123456789012345678901234567890123456789012345678901234\"}}" + "]}"; + scene_binding_t s; + TEST_ASSERT_EQUAL(ESP_OK, scene_binding_from_ir(ir, "STEP_X", &s)); + TEST_ASSERT_TRUE(s.present); + TEST_ASSERT_EQUAL(SB_FX_PULSE, s.effect); + TEST_ASSERT_EQUAL(SB_MAX_TITLE - 1, (int) strlen(s.title)); +} diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index a3e35ea..edbb689 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -649,10 +649,11 @@ void app_main(void) { for (;;) { // Sleep in 2 s increments so the display stays responsive. // Every 30 sub-ticks (~60 s) we do the full heartbeat log. - vTaskDelay(pdMS_TO_TICKS(2000)); + // 500 ms so scene changes (POST /game/step) reach the display fast. + vTaskDelay(pdMS_TO_TICKS(500)); disp_sub++; - // ── Display status refresh (every 2 s) ────────────────────────── + // ── Display status refresh (every 500 ms) ─────────────────────── if (disp_err == ESP_OK) { display_status_t ds; memset(&ds, 0, sizeof(ds)); @@ -684,11 +685,21 @@ void app_main(void) { } puzzle_state_code(&s_pstate, ds.code, sizeof(ds.code)); + // Scene metadata (title/subtitle/symbol/effect) of the step. + scene_binding_t sb; + game_endpoint_get_scene(&sb); + if (sb.present) { + memcpy(ds.scene_title, sb.title, sizeof(ds.scene_title)); + memcpy(ds.scene_subtitle, sb.subtitle, sizeof(ds.scene_subtitle)); + memcpy(ds.scene_symbol, sb.symbol, sizeof(ds.scene_symbol)); + } + ds.scene_effect = (uint8_t) sb.effect; + display_ui_set_status(&ds); } - // ── 60 s heartbeat (every 30 × 2 s sub-ticks) ─────────────────── - if (disp_sub >= 30) { + // ── 60 s heartbeat (every 120 × 500 ms sub-ticks) ─────────────── + if (disp_sub >= 120) { disp_sub = 0; tick++; const uint32_t uptime_ms = (uint32_t) esp_log_timestamp();