diff --git a/idf_zacus/components/display_ui/display_ui.cpp b/idf_zacus/components/display_ui/display_ui.cpp index 768a998..6ac4354 100644 --- a/idf_zacus/components/display_ui/display_ui.cpp +++ b/idf_zacus/components/display_ui/display_ui.cpp @@ -258,6 +258,17 @@ static volatile bool s_gamebook_open = false; static char s_gb_title_buf[64]; static char s_gb_body_buf[512]; static char s_gb_menu_buf[160]; + +// ── Gamebook library (tile grid) ──────────────────────────────────────────── +#define LIB_TILES 6 +static lv_obj_t *s_scr_library; +static lv_obj_t *s_lib_tile[LIB_TILES]; +static lv_obj_t *s_lib_label[LIB_TILES]; +static volatile bool s_library_open = false; +static char s_lib_title_buf[LIB_TILES][48]; +static int s_lib_count_buf = 0; +static int s_lib_sel_buf = 0; + static volatile bool s_browser_reload = false; // ─── Intro — faithful port of the original cracktro ────────────────────────── @@ -771,6 +782,43 @@ scroller: lv_obj_set_pos(s_intro_scroll, sx, sy); } +static void build_library_screen(void) { + s_scr_library = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_scr_library, lv_color_hex(0x000000), 0); + lv_obj_set_style_bg_opa(s_scr_library, LV_OPA_COVER, 0); + lv_obj_clear_flag(s_scr_library, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *hdr = lv_label_create(s_scr_library); + lv_obj_set_style_text_color(hdr, lv_color_hex(COL_CODE), 0); + lv_obj_set_style_text_font(hdr, &lv_font_montserrat_14, 0); + lv_label_set_text(hdr, "Choisis ton histoire"); + lv_obj_align(hdr, LV_ALIGN_TOP_MID, 0, 4); + + for (int i = 0; i < LIB_TILES; i++) { + int col = i % 2, row = i / 2; + lv_obj_t *t = lv_obj_create(s_scr_library); + lv_obj_set_size(t, 228, 84); + lv_obj_set_pos(t, 8 + col * 236, 26 + row * 94); + lv_obj_set_style_radius(t, 8, 0); + lv_obj_set_style_bg_color(t, lv_color_hex(0x101820), 0); + lv_obj_set_style_bg_opa(t, LV_OPA_COVER, 0); + lv_obj_set_style_border_width(t, 2, 0); + lv_obj_set_style_border_color(t, lv_color_hex(0x445566), 0); + lv_obj_set_style_pad_all(t, 6, 0); + lv_obj_clear_flag(t, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_t *l = lv_label_create(t); + lv_obj_set_style_text_color(l, lv_color_hex(COL_VALUE), 0); + 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, 208); + lv_obj_set_style_text_align(l, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(l, ""); + lv_obj_center(l); + s_lib_tile[i] = t; + s_lib_label[i] = l; + } +} + static void build_gamebook_screen(void) { s_scr_gamebook = lv_obj_create(NULL); lv_obj_set_style_bg_color(s_scr_gamebook, lv_color_hex(0x000000), 0); @@ -907,11 +955,30 @@ static void apply_status(const display_status_t *s) { lv_label_set_text(s_gb_body, s_gb_body_buf); lv_label_set_text(s_gb_menu, s_gb_menu_buf); } + // Library grid: refresh tile titles + highlight the selected one. + if (s_library_open) { + for (int i = 0; i < LIB_TILES; i++) { + if (i < s_lib_count_buf) { + lv_label_set_text(s_lib_label[i], s_lib_title_buf[i]); + lv_obj_clear_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN); + } else { + lv_obj_add_flag(s_lib_tile[i], LV_OBJ_FLAG_HIDDEN); + } + bool on = (i == s_lib_sel_buf); + lv_obj_set_style_border_color(s_lib_tile[i], + lv_color_hex(on ? COL_CODE : 0x445566), 0); + lv_obj_set_style_border_width(s_lib_tile[i], on ? 4 : 2, 0); + lv_obj_set_style_bg_color(s_lib_tile[i], + lv_color_hex(on ? 0x223040 : 0x101820), 0); + } + } extern volatile uint8_t g_dui_view_override; lv_obj_t *want; if (!s_intro_done) { want = s_scr_intro; // boot intro plays out first + } else if (s_library_open) { + want = s_scr_library; // story picker owns the screen } else if (s_gamebook_open) { want = s_scr_gamebook; // gamebook owns the screen while running } else if (s_browser_open) { @@ -1022,6 +1089,7 @@ static void display_task(void *arg) { build_scene_screen(); build_shell_screen(); build_gamebook_screen(); + build_library_screen(); build_browser_screen(); build_intro_screen(); @@ -1225,6 +1293,28 @@ extern "C" void display_ui_gamebook_hide(void) { s_dirty = true; } +extern "C" void display_ui_library_show(const char *const *titles, int count, + int sel) { + if (count > LIB_TILES) count = LIB_TILES; + if (count < 0) count = 0; + if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY); + s_lib_count_buf = count; + s_lib_sel_buf = (count > 0) ? ((sel % count + count) % count) : 0; + for (int i = 0; i < LIB_TILES; i++) { + snprintf(s_lib_title_buf[i], sizeof(s_lib_title_buf[i]), "%s", + (i < count && titles && titles[i]) ? titles[i] : ""); + } + s_library_open = true; + s_gamebook_open = false; // library and a running story are exclusive + s_dirty = true; + if (s_mutex) xSemaphoreGive(s_mutex); +} + +extern "C" void display_ui_library_hide(void) { + s_library_open = false; + s_dirty = true; +} + extern "C" void display_ui_handle_key(uint8_t key) { static uint8_t s_brightness = 100; diff --git a/idf_zacus/components/display_ui/include/display_ui.h b/idf_zacus/components/display_ui/include/display_ui.h index 7fa7e67..952bbdc 100644 --- a/idf_zacus/components/display_ui/include/display_ui.h +++ b/idf_zacus/components/display_ui/include/display_ui.h @@ -108,6 +108,23 @@ void display_ui_gamebook_show(const char *title, const char *body, /** @brief Leave the gamebook view and return to the normal scene/status flow. */ void display_ui_gamebook_hide(void); +/** + * @brief Show the gamebook library as a grid of up to 6 tiles. + * + * Each tile shows a story title; the tile at index `sel` is highlighted. The + * gamebook owns the pad and calls this on every cursor move. Forces the + * library view until a story is loaded or display_ui_library_hide(). Titles + * should be ASCII. + * + * @param titles array of `count` story titles + * @param count number of stories (clamped to 6) + * @param sel highlighted tile index + */ +void display_ui_library_show(const char *const *titles, int count, int sel); + +/** @brief Leave the library view. */ +void display_ui_library_hide(void); + /** * @brief Pop the pending shell-app launch request, if any. * diff --git a/idf_zacus/components/gamebook/gamebook.c b/idf_zacus/components/gamebook/gamebook.c index 1469ea8..6b867e4 100644 --- a/idf_zacus/components/gamebook/gamebook.c +++ b/idf_zacus/components/gamebook/gamebook.c @@ -1,4 +1,6 @@ -// gamebook.c — see gamebook.h. Standalone "livre dont vous êtes le héros". +// gamebook.c — see gamebook.h. Standalone gamebook player + library for the +// Freenove master. On boot it shows the library (a grid of story tiles); +// picking one loads and plays that book; finishing returns to the library. #include "gamebook.h" #include @@ -14,34 +16,103 @@ static const char *TAG = "gamebook"; #define GAMEBOOK_DIR "/sdcard/gamebook" -#define GAMEBOOK_JSON GAMEBOOK_DIR "/gamebook.json" -#define GAMEBOOK_MAX (32 * 1024) /* JSON sanity cap */ +#define LIBRARY_JSON GAMEBOOK_DIR "/library.json" +#define JSON_MAX (64 * 1024) /* per-file JSON sanity cap */ +#define LIB_MAX 6 /* matches the display tile grid */ -/* 5-way pad codes (mirror display_ui): 1=SELECT 2=DOWN 3=MENU 4=L/R 5=UP. */ -#define KEY_MENU 3 +typedef enum { GB_OFF, GB_LIBRARY, GB_STORY } gb_mode_t; -static cJSON *s_root = NULL; /* owns the parsed JSON while active */ -static cJSON *s_passages = NULL; /* borrowed: s_root->"passages" */ +// Library state +static cJSON *s_lib_root = NULL; /* owns library.json while loaded */ +static cJSON *s_lib = NULL; /* borrowed: root->"library" array */ +static int s_lib_n = 0; +static int s_lib_sel = 0; + +// Story state +static cJSON *s_book = NULL; /* owns the current .json */ +static cJSON *s_passages = NULL; /* borrowed: book->"passages" */ static char s_title[48] = {0}; static char s_current[48] = {0}; -static int s_sel = 0; /* highlighted choice index */ -static volatile bool s_active = false; +static int s_sel = 0; /* highlighted choice index */ -bool gamebook_active(void) { return s_active; } +static volatile gb_mode_t s_mode = GB_OFF; -static const cJSON *cur_passage(void) +bool gamebook_active(void) { return s_mode != GB_OFF; } + +/* Read a whole JSON file from the SD into a cJSON tree (caller frees). */ +static cJSON *load_json(const char *path) { - return cJSON_GetObjectItem(s_passages, s_current); + 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 > JSON_MAX) { fclose(f); ESP_LOGW(TAG, "%s size %ld", path, sz); return NULL; } + char *buf = malloc((size_t)sz + 1); + 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); + free(buf); + if (!root) ESP_LOGW(TAG, "malformed JSON: %s", path); + return root; } +// ── Library ────────────────────────────────────────────────────────────────── + +static void render_library(void) +{ + const char *titles[LIB_MAX] = {0}; + int n = (s_lib_n < LIB_MAX) ? s_lib_n : LIB_MAX; + for (int i = 0; i < n; i++) { + const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title"); + titles[i] = cJSON_IsString(t) ? t->valuestring : "?"; + } + display_ui_library_show(titles, n, s_lib_sel); +} + +static void free_story(void) +{ + media_manager_stop(); + if (s_book) { cJSON_Delete(s_book); s_book = NULL; } + s_passages = NULL; + s_current[0] = '\0'; +} + +static esp_err_t open_library(void) +{ + free_story(); + if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; } + + s_lib_root = load_json(LIBRARY_JSON); + if (!s_lib_root) return ESP_ERR_NOT_FOUND; + 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, "library.json has no stories"); + return ESP_ERR_INVALID_ARG; + } + s_lib_n = cJSON_GetArraySize(s_lib); + s_lib_sel = 0; + s_mode = GB_LIBRARY; + display_ui_gamebook_hide(); + render_library(); + ESP_LOGI(TAG, "library open (%d stories)", s_lib_n); + return ESP_OK; +} + +// ── Story ───────────────────────────────────────────────────────────────────── + +static const cJSON *cur_passage(void) { return cJSON_GetObjectItem(s_passages, s_current); } + static int cur_choice_count(void) { const cJSON *ch = cJSON_GetObjectItem(cur_passage(), "choices"); return cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0; } -/* (Re)draw the current page: title + wrapped text + choice list with a "> " - * cursor on s_sel. Does NOT touch audio — call on every cursor move. */ +/* (Re)draw the current page: title + wrapped text + choice list with a cursor. */ static void render_page(void) { const cJSON *p = cur_passage(); @@ -53,7 +124,7 @@ static void render_page(void) char menu[256]; if (n == 0) { - snprintf(menu, sizeof(menu), "~ Fin ~ (clic = recommencer)"); + snprintf(menu, sizeof(menu), "~ Fin ~ (clic = bibliotheque)"); } else { size_t off = 0; for (int i = 0; i < n && off < sizeof(menu); i++) { @@ -70,14 +141,10 @@ static void render_page(void) menu); } -/* Enter a passage: reset the cursor, draw the page, play its WAV from the SD. */ static void enter_passage(const char *pid) { const cJSON *p = cJSON_GetObjectItem(s_passages, pid); - if (!cJSON_IsObject(p)) { - ESP_LOGW(TAG, "passage '%s' not found", pid); - return; - } + if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' not found", pid); return; } snprintf(s_current, sizeof(s_current), "%s", pid); s_sel = 0; render_page(); @@ -86,38 +153,79 @@ static void enter_passage(const char *pid) if (cJSON_IsString(wav) && wav->valuestring[0]) { char path[96]; snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_DIR, wav->valuestring); - media_manager_stop(); /* a choice skips the narration */ + media_manager_stop(); /* a choice skips the narration */ media_manager_play(path); } ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, cur_choice_count()); } -/* Key hook (we own the 5-way pad while active). D-pad navigates the choice - * list, click (SELECT) confirms. Ladder codes: 1=click 2=down 3/4=left/right - * 5=up. up/left → previous, down/right → next. On an ending (no choices) the - * click restarts the book. Returns true to consume the key. */ -static bool gamebook_key_hook(uint8_t key) +/* Load story #idx from the library and play it. */ +static void load_book(int idx) { - if (!s_active) return false; + const cJSON *e = cJSON_GetArrayItem(s_lib, idx); + const cJSON *file = cJSON_GetObjectItem(e, "book"); + if (!cJSON_IsString(file)) { ESP_LOGW(TAG, "library[%d] has no book", idx); return; } + char path[128]; + snprintf(path, sizeof(path), "%s/%s", GAMEBOOK_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"); + const cJSON *title = cJSON_GetObjectItem(book, "title"); + if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) { + ESP_LOGW(TAG, "%s missing passages/start", path); + cJSON_Delete(book); + return; + } + free_story(); + s_book = book; + s_passages = (cJSON *)passages; + snprintf(s_title, sizeof(s_title), "%s", + cJSON_IsString(title) ? title->valuestring : ""); + s_mode = GB_STORY; + display_ui_library_hide(); + ESP_LOGI(TAG, "load book \"%s\" @ '%s'", s_title, start->valuestring); + enter_passage(start->valuestring); +} + +// ── Input ───────────────────────────────────────────────────────────────────── + +/* Library grid nav: 2 columns. up/down = ±2 (row), left/right = ±1 (col), + * click = open the highlighted story. Codes: 1=click 2=down 3=left 4=right + * 5=up (5-way ladder). */ +static bool library_key(uint8_t key) +{ + int s = s_lib_sel; + switch (key) { + case 5: s -= 2; break; /* up */ + case 2: s += 2; break; /* down */ + case 3: s -= 1; break; /* left */ + case 4: s += 1; break; /* right */ + case 1: /* click → open */ + load_book(s_lib_sel); + return true; + default: return true; + } + if (s >= 0 && s < s_lib_n) { s_lib_sel = s; render_library(); } + return true; +} + +/* Story nav: D-pad moves the choice cursor, click confirms. Ending + click + * returns to the library. */ +static bool story_key(uint8_t key) +{ int n = cur_choice_count(); - if (n == 0) { /* ending page */ - if (key == 1) { /* click → restart */ - const cJSON *start = cJSON_GetObjectItem(s_root, "start"); - if (cJSON_IsString(start)) enter_passage(start->valuestring); - } + if (n == 0) { /* ending → back to library on click */ + if (key == 1) open_library(); return true; } - switch (key) { - case 5: case 3: /* up / left → previous */ - s_sel = (s_sel - 1 + n) % n; render_page(); break; - case 2: case 4: /* down / right → next */ - s_sel = (s_sel + 1) % n; render_page(); break; - case 1: { /* click → confirm */ + case 5: case 3: s_sel = (s_sel - 1 + n) % n; render_page(); break; /* up/left */ + case 2: case 4: s_sel = (s_sel + 1) % n; render_page(); break; /* down/right */ + case 1: { /* click → confirm */ const cJSON *choices = cJSON_GetObjectItem(cur_passage(), "choices"); - const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel), - "goto"); + const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel), "goto"); if (cJSON_IsString(g)) { ESP_LOGI(TAG, "select #%d -> '%s'", s_sel, g->valuestring); enter_passage(g->valuestring); @@ -129,60 +237,38 @@ static bool gamebook_key_hook(uint8_t key) return true; } +static bool gamebook_key_hook(uint8_t key) +{ + switch (s_mode) { + case GB_LIBRARY: return library_key(key); + case GB_STORY: return story_key(key); + default: return false; /* not active → let the shell have it */ + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + esp_err_t gamebook_start(void) { - gamebook_stop(); /* clean any previous run */ - - FILE *f = fopen(GAMEBOOK_JSON, "rb"); - if (!f) { - ESP_LOGW(TAG, "open %s failed — SD card / pack missing?", GAMEBOOK_JSON); - return ESP_ERR_NOT_FOUND; - } - fseek(f, 0, SEEK_END); - long sz = ftell(f); - rewind(f); - if (sz <= 0 || sz > GAMEBOOK_MAX) { fclose(f); return ESP_ERR_INVALID_SIZE; } - char *buf = malloc((size_t)sz + 1); - if (!buf) { fclose(f); return ESP_ERR_NO_MEM; } - size_t rd = fread(buf, 1, (size_t)sz, f); - fclose(f); - buf[rd] = '\0'; - - s_root = cJSON_Parse(buf); - free(buf); - if (!s_root) { ESP_LOGW(TAG, "malformed gamebook.json"); return ESP_ERR_INVALID_ARG; } - - s_passages = cJSON_GetObjectItem(s_root, "passages"); - const cJSON *start = cJSON_GetObjectItem(s_root, "start"); - const cJSON *title = cJSON_GetObjectItem(s_root, "title"); - if (!cJSON_IsObject(s_passages) || !cJSON_IsString(start)) { - ESP_LOGW(TAG, "gamebook.json missing passages/start"); - cJSON_Delete(s_root); s_root = NULL; s_passages = NULL; - return ESP_ERR_INVALID_ARG; - } - snprintf(s_title, sizeof(s_title), "%s", - cJSON_IsString(title) ? title->valuestring : ""); - - s_active = true; - ESP_LOGI(TAG, "start \"%s\" @ '%s'", s_title, start->valuestring); - enter_passage(start->valuestring); - return ESP_OK; + return open_library(); } void gamebook_stop(void) { - if (!s_active && !s_root) return; - s_active = false; - media_manager_stop(); + if (s_mode == GB_OFF) return; + s_mode = GB_OFF; + free_story(); + if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; } display_ui_gamebook_hide(); - if (s_root) { cJSON_Delete(s_root); s_root = NULL; } - s_passages = NULL; - s_current[0] = '\0'; + display_ui_library_hide(); ESP_LOGI(TAG, "stopped"); } void gamebook_init(void) { + /* Only register the pad hook here — the SD and media_manager aren't up yet + * at this point in boot. main.c calls gamebook_start() once they are, to + * boot into the library. */ display_ui_set_key_hook(gamebook_key_hook); - ESP_LOGI(TAG, "ready (key hook installed)"); + ESP_LOGI(TAG, "key hook installed"); } diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index bda3867..445b7eb 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -523,6 +523,13 @@ void app_main(void) { ESP_LOGI(TAG, "media smoke play -> %s", esp_err_to_name(play_err)); + // Boot into the gamebook library (the 6-story tile picker) when the + // SD pack is present. Best-effort: no /sdcard/gamebook/library.json + // -> the normal shell stays in charge. + if (gamebook_start() == ESP_OK) { + ESP_LOGI(TAG, "gamebook library up (boot picker)"); + } + // Slice 4: bring up the ported npc_engine. Cue table is empty // at this stage — wiring the scenario IR-driven cue catalog is // a follow-up slice. The engine still boots, accepts ticks