diff --git a/box3_voice/main/CMakeLists.txt b/box3_voice/main/CMakeLists.txt index 714a878..5ae71f9 100644 --- a/box3_voice/main/CMakeLists.txt +++ b/box3_voice/main/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c" + SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c" "gamebook.c" INCLUDE_DIRS "." PRIV_REQUIRES driver diff --git a/box3_voice/main/gamebook.c b/box3_voice/main/gamebook.c new file mode 100644 index 0000000..1ee14ce --- /dev/null +++ b/box3_voice/main/gamebook.c @@ -0,0 +1,223 @@ +// gamebook.c — see gamebook.h. Touch CYOA for the ESP32-S3-BOX-3. +#include "gamebook.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" +#include "esp_heap_caps.h" + +#include "bsp/esp-bsp.h" +#include "lvgl.h" + +static const char *TAG = "gamebook"; + +#define GB_DIR "/sdcard/gamebook" +#define GB_LIBRARY GB_DIR "/library.json" +#define GB_JSON_MAX (256 * 1024) // expanded books are ~80 KB of JSON + +// Colours (Workbench-ish palette). +#define COL_BG 0x101820 +#define COL_TITLE 0xFFCC55 +#define COL_TEXT 0xF0F0F0 +#define COL_BTN 0x224466 + +static cJSON *s_lib_root = NULL; // owns library.json +static cJSON *s_lib = NULL; // borrowed: root->"library" +static int s_lib_n = 0; + +static cJSON *s_book = NULL; // owns the current .json +static cJSON *s_passages = NULL; // borrowed: book->"passages" + +static lv_obj_t *s_root = NULL; // scrollable full-screen column + +// ── PSRAM allocators for cJSON (book trees are large) ─────────────────────── +static void *gb_malloc(size_t sz) { return heap_caps_malloc(sz, MALLOC_CAP_SPIRAM); } +static void gb_free(void *p) { heap_caps_free(p); } + +static cJSON *load_json(const char *path) +{ + FILE *f = fopen(path, "rb"); + if (!f) { ESP_LOGW(TAG, "open %s failed", path); return NULL; } + fseek(f, 0, SEEK_END); + long sz = ftell(f); + rewind(f); + if (sz <= 0 || sz > GB_JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; } + char *buf = heap_caps_malloc((size_t)sz + 1, MALLOC_CAP_SPIRAM); + if (!buf) { fclose(f); return NULL; } + size_t rd = fread(buf, 1, (size_t)sz, f); + fclose(f); + buf[rd] = '\0'; + cJSON *root = cJSON_Parse(buf); + heap_caps_free(buf); + if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path); + return root; +} + +// ── Root container (created once, cleared per view) ───────────────────────── +// All UI functions below assume the LVGL lock is held (gamebook_init takes it; +// button callbacks already run inside the LVGL task / lock). +static void ensure_root(void) +{ + if (s_root) { lv_obj_clean(s_root); return; } + lv_obj_t *scr = lv_screen_active(); + s_root = lv_obj_create(scr); + lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100)); + lv_obj_set_pos(s_root, 0, 0); + lv_obj_set_style_bg_color(s_root, lv_color_hex(COL_BG), 0); + lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(s_root, 0, 0); + lv_obj_set_style_radius(s_root, 0, 0); + lv_obj_set_style_pad_all(s_root, 8, 0); + lv_obj_set_style_pad_row(s_root, 8, 0); + lv_obj_set_flex_flow(s_root, LV_FLEX_FLOW_COLUMN); + lv_obj_set_scroll_dir(s_root, LV_DIR_VER); +} + +static lv_obj_t *add_title(const char *txt) +{ + lv_obj_t *l = lv_label_create(s_root); + lv_obj_set_style_text_font(l, &lv_font_montserrat_24, 0); + lv_obj_set_style_text_color(l, lv_color_hex(COL_TITLE), 0); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + lv_label_set_text(l, txt); + return l; +} + +static lv_obj_t *add_text(const char *txt) +{ + lv_obj_t *l = lv_label_create(s_root); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_obj_set_style_text_color(l, lv_color_hex(COL_TEXT), 0); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + lv_label_set_text(l, txt); + return l; +} + +static lv_obj_t *add_button(const char *txt, lv_event_cb_t cb, void *user) +{ + lv_obj_t *b = lv_button_create(s_root); + lv_obj_set_width(b, LV_PCT(100)); + lv_obj_set_style_bg_color(b, lv_color_hex(COL_BTN), 0); + lv_obj_set_style_pad_all(b, 10, 0); + lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, user); + lv_obj_t *l = lv_label_create(b); + lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0); + lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP); + lv_obj_set_width(l, LV_PCT(100)); + lv_label_set_text(l, txt); + return b; +} + +// Forward decls +static void show_library(void); +static void enter_passage(const char *pid); + +static void tile_cb(lv_event_t *e); +static void choice_cb(lv_event_t *e); +static void menu_cb(lv_event_t *e); + +// ── Library menu ──────────────────────────────────────────────────────────── +static void show_library(void) +{ + if (s_book) { cJSON_Delete(s_book); s_book = NULL; s_passages = NULL; } + ensure_root(); + add_title("Choisis ton histoire"); + for (int i = 0; i < s_lib_n; i++) { + const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title"); + add_button(cJSON_IsString(t) ? t->valuestring : "?", + tile_cb, (void *)(intptr_t)i); + } + lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF); +} + +// ── Passage ───────────────────────────────────────────────────────────────── +static void enter_passage(const char *pid) +{ + const cJSON *p = cJSON_GetObjectItem(s_passages, pid); + if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' missing", pid); return; } + const cJSON *screen = cJSON_GetObjectItem(p, "screen"); + const cJSON *text = cJSON_GetObjectItem(p, "text"); + const cJSON *choices = cJSON_GetObjectItem(p, "choices"); + + ensure_root(); + add_title(cJSON_IsString(screen) ? screen->valuestring : ""); + add_text(cJSON_IsString(text) ? text->valuestring : ""); + int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0; + if (n == 0) { + add_button("Revenir au menu", menu_cb, NULL); + } else { + for (int i = 0; i < n; i++) { + const cJSON *c = cJSON_GetArrayItem(choices, i); + const cJSON *lbl = cJSON_GetObjectItem(c, "label"); + const cJSON *g = cJSON_GetObjectItem(c, "goto"); + // goto valuestring stays valid while s_book is alive → use as user_data + add_button(cJSON_IsString(lbl) ? lbl->valuestring : "?", + choice_cb, cJSON_IsString(g) ? g->valuestring : NULL); + } + } + lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF); + ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, n); +} + +static void load_book(int idx) +{ + const cJSON *e = cJSON_GetArrayItem(s_lib, idx); + const cJSON *file = cJSON_GetObjectItem(e, "book"); + if (!cJSON_IsString(file)) return; + char path[128]; + snprintf(path, sizeof(path), "%s/%s", GB_DIR, file->valuestring); + cJSON *book = load_json(path); + if (!book) return; + const cJSON *passages = cJSON_GetObjectItem(book, "passages"); + const cJSON *start = cJSON_GetObjectItem(book, "start"); + if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) { + cJSON_Delete(book); return; + } + if (s_book) cJSON_Delete(s_book); + s_book = book; + s_passages = (cJSON *)passages; + ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring); + enter_passage(start->valuestring); +} + +// ── Touch callbacks (run inside the LVGL task → lock already held) ─────────── +static void tile_cb(lv_event_t *e) { load_book((int)(intptr_t)lv_event_get_user_data(e)); } +static void menu_cb(lv_event_t *e) { (void)e; show_library(); } +static void choice_cb(lv_event_t *e) +{ + const char *goto_id = (const char *)lv_event_get_user_data(e); + if (goto_id) enter_passage(goto_id); +} + +// ── Public init ───────────────────────────────────────────────────────────── +void gamebook_init(void) +{ + cJSON_Hooks hooks = { .malloc_fn = gb_malloc, .free_fn = gb_free }; + cJSON_InitHooks(&hooks); // keep the big book trees in PSRAM + + if (bsp_sdcard_mount() != ESP_OK) { + ESP_LOGW(TAG, "no SD card — gamebook disabled"); + return; + } + s_lib_root = load_json(GB_LIBRARY); + if (!s_lib_root) { ESP_LOGW(TAG, "no library.json — gamebook disabled"); return; } + s_lib = cJSON_GetObjectItem(s_lib_root, "library"); + if (!cJSON_IsArray(s_lib) || cJSON_GetArraySize(s_lib) == 0) { + cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; + ESP_LOGW(TAG, "empty library"); + return; + } + s_lib_n = cJSON_GetArraySize(s_lib); + + bsp_display_brightness_set(80); + if (bsp_display_lock(1000)) { + show_library(); + bsp_display_unlock(); + } + ESP_LOGI(TAG, "touch gamebook up (%d stories)", s_lib_n); +} diff --git a/box3_voice/main/gamebook.h b/box3_voice/main/gamebook.h new file mode 100644 index 0000000..3b4b2a4 --- /dev/null +++ b/box3_voice/main/gamebook.h @@ -0,0 +1,27 @@ +#pragma once +// gamebook — "livre dont tu es le héros" TACTILE pour l'ESP32-S3-BOX-3. +// +// Écran 320x240 + tactile : on lit le passage à l'écran et on TOUCHE un bouton +// pour choisir. Réutilise le pack gamebook du master sur la SD : +// /sdcard/gamebook/{library.json, .json} +// où chaque passage porte {screen, text, choices:[{label, goto}]} (le champ +// "wav" éventuel est ignoré — version texte/tactile, pas d'audio requis). +// +// S'appuie sur la stack LVGL déjà démarrée par bsp_display_start() : on +// construit une UUI (menu d'histoires en liste, puis pages tactiles) et la +// navigation se fait dans les callbacks de boutons LVGL. 100% local, hors-ligne. + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Monte la SD, charge la bibliothèque et affiche le menu tactile des histoires. +// À appeler une fois depuis app_main, après bsp_display_start() et le serveur +// de fichiers (pour que la SD soit accessible). No-op si aucun pack présent. +void gamebook_init(void); + +#ifdef __cplusplus +} +#endif diff --git a/box3_voice/main/main.c b/box3_voice/main/main.c index bf810af..b462c57 100644 --- a/box3_voice/main/main.c +++ b/box3_voice/main/main.c @@ -28,6 +28,7 @@ #include "scenario_server.h" #include "plip_virtual.h" #include "plip_ui.h" +#include "gamebook.h" #include "stimulus.h" #include "scenario_mesh.h" #include "cmd_exec.h" @@ -463,6 +464,11 @@ void app_main(void) } else { ESP_LOGW(TAG, "stimulus_init failed — QR/melody generator off"); } + + /* Touch gamebook: if a story pack is on the SD (/sdcard/gamebook/), + * take over the screen with a tap-to-choose "livre dont tu es le + * héros". No-op (and the phone UI stays) when no pack is present. */ + gamebook_init(); } /* Start the ESP-NOW receiver so the master can relay scenarios to us even