feat(ui): scene IR metadata + fx glitch/gyro

This commit is contained in:
Claude Worker claude2
2026-06-10 22:01:17 +02:00
parent 29c0eecfb9
commit d0f217f5d9
8 changed files with 299 additions and 23 deletions
+109 -18
View File
@@ -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
@@ -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;
/**
@@ -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) {
@@ -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
@@ -1,5 +1,6 @@
// puzzle_binding.h — extract a step's optional puzzle binding from Runtime 3 IR.
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#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);
@@ -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;
}
@@ -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 <string.h>
// ── 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));
}
+15 -4
View File
@@ -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();