diff --git a/idf_zacus/components/display_ui/display_ui.cpp b/idf_zacus/components/display_ui/display_ui.cpp index 94b52bf..768a998 100644 --- a/idf_zacus/components/display_ui/display_ui.cpp +++ b/idf_zacus/components/display_ui/display_ui.cpp @@ -248,6 +248,16 @@ static bool s_browser_isdir[BROWSER_MAX_ENTRIES]; static uint8_t s_browser_count = 0; static volatile uint8_t s_browser_sel = 0; static volatile bool s_browser_open = false; + +// ── Gamebook view ("livre dont vous êtes le héros") ───────────────────────── +static lv_obj_t *s_scr_gamebook; +static lv_obj_t *s_gb_title; // page title, top +static lv_obj_t *s_gb_body; // full passage text, wrapped +static lv_obj_t *s_gb_menu; // choice lines, bottom +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]; static volatile bool s_browser_reload = false; // ─── Intro — faithful port of the original cracktro ────────────────────────── @@ -761,6 +771,44 @@ scroller: lv_obj_set_pos(s_intro_scroll, sx, sy); } +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); + lv_obj_set_style_bg_opa(s_scr_gamebook, LV_OPA_COVER, 0); + + s_gb_title = lv_label_create(s_scr_gamebook); + lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_VALUE), 0); + lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_24, 0); + lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT); + lv_obj_set_width(s_gb_title, DUI_HOR_RES - 16); + lv_obj_set_style_text_align(s_gb_title, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(s_gb_title, ""); + lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 6); + + // Body band: top area, height-bounded so it can never run into the menu. + s_gb_body = lv_label_create(s_scr_gamebook); + lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0); + lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0); + lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_WRAP); + lv_obj_set_size(s_gb_body, DUI_HOR_RES - 24, 196); + lv_label_set_text(s_gb_body, ""); + lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 12, 44); + + // Menu band: reserved strip at the bottom, drawn last (foreground), with a + // faint background so the choices stand out from the body text. + s_gb_menu = lv_label_create(s_scr_gamebook); + lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0); + lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x101820), 0); + lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0); + lv_obj_set_style_pad_all(s_gb_menu, 6, 0); + lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0); + lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_WRAP); + lv_obj_set_width(s_gb_menu, DUI_HOR_RES); + lv_label_set_text(s_gb_menu, ""); + lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_LEFT, 0, 0); + lv_obj_move_foreground(s_gb_menu); +} + static void build_status_screen(void) { lv_obj_t *scr = s_scr_status; lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0); @@ -853,10 +901,19 @@ static void apply_status(const display_status_t *s) { // Active step → scene view; idle → status view. The 5-way buttons can // override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the // original ui_manager default transition (SceneTransition::kFade, 240). + // Gamebook page: refresh its labels from the latest show() buffers. + if (s_gamebook_open) { + lv_label_set_text(s_gb_title, s_gb_title_buf); + lv_label_set_text(s_gb_body, s_gb_body_buf); + lv_label_set_text(s_gb_menu, s_gb_menu_buf); + } + 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_gamebook_open) { + want = s_scr_gamebook; // gamebook owns the screen while running } else if (s_browser_open) { want = s_scr_browser; // file browser on top of everything } else if (s_shell_open) { @@ -964,6 +1021,7 @@ static void display_task(void *arg) { build_status_screen(); build_scene_screen(); build_shell_screen(); + build_gamebook_screen(); build_browser_screen(); build_intro_screen(); @@ -1145,9 +1203,36 @@ extern "C" void display_ui_set_status(const display_status_t *s) { } } +static display_ui_key_hook_t s_key_hook = nullptr; + +extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) { + s_key_hook = hook; +} + +extern "C" void display_ui_gamebook_show(const char *title, const char *body, + const char *menu) { + if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY); + snprintf(s_gb_title_buf, sizeof(s_gb_title_buf), "%s", title ? title : ""); + snprintf(s_gb_body_buf, sizeof(s_gb_body_buf), "%s", body ? body : ""); + snprintf(s_gb_menu_buf, sizeof(s_gb_menu_buf), "%s", menu ? menu : ""); + s_gamebook_open = true; + s_dirty = true; + if (s_mutex) xSemaphoreGive(s_mutex); +} + +extern "C" void display_ui_gamebook_hide(void) { + s_gamebook_open = false; + s_dirty = true; +} + extern "C" void display_ui_handle_key(uint8_t key) { static uint8_t s_brightness = 100; + // Takeover mode (e.g. gamebook): if the hook consumes the key, stop here. + if (s_key_hook && s_key_hook(key)) { + return; + } + // Any key skips the boot intro. if (!s_intro_done) { s_intro_done = true; diff --git a/idf_zacus/components/display_ui/include/display_ui.h b/idf_zacus/components/display_ui/include/display_ui.h index 2a64173..7fa7e67 100644 --- a/idf_zacus/components/display_ui/include/display_ui.h +++ b/idf_zacus/components/display_ui/include/display_ui.h @@ -78,6 +78,36 @@ void display_ui_camera_frame(const uint8_t *gray, int width, int height); */ void display_ui_handle_key(uint8_t key); +/** + * @brief Install an optional key interceptor. + * + * If a hook is set and returns true for a given key, the key is consumed and + * the normal shell/scene handling is skipped. Lets a takeover mode (e.g. the + * gamebook) own the 5-way pad without display_ui having to depend on it. + * Pass NULL to remove the hook. Thread-safe (single pointer write). + */ +typedef bool (*display_ui_key_hook_t)(uint8_t key); +void display_ui_set_key_hook(display_ui_key_hook_t hook); + +/** + * @brief Show a full-screen gamebook page (takes over the display). + * + * Dedicated "livre dont vous êtes le héros" view: a centred title, the full + * passage text wrapped over multiple lines, and a choice menu at the bottom. + * Forces this view until display_ui_gamebook_hide(). Thread-safe (buffers + * copied under the mutex; the actual LVGL render runs on the display task). + * Strings should be ASCII (the embedded fonts have no accents). + * + * @param title short page title (top) + * @param body full passage text (wrapped); may be long + * @param menu choice lines (bottom), e.g. "[OK] ...\n[<>] ..." + */ +void display_ui_gamebook_show(const char *title, const char *body, + const char *menu); + +/** @brief Leave the gamebook view and return to the normal scene/status flow. */ +void display_ui_gamebook_hide(void); + /** * @brief Pop the pending shell-app launch request, if any. * diff --git a/idf_zacus/components/game_endpoint/CMakeLists.txt b/idf_zacus/components/game_endpoint/CMakeLists.txt index 8f263a4..dafa341 100644 --- a/idf_zacus/components/game_endpoint/CMakeLists.txt +++ b/idf_zacus/components/game_endpoint/CMakeLists.txt @@ -17,6 +17,7 @@ idf_component_register( puzzle_state media_manager sd_storage + gamebook PRIV_REQUIRES local_puzzles p7_coffre diff --git a/idf_zacus/components/game_endpoint/game_endpoint.c b/idf_zacus/components/game_endpoint/game_endpoint.c index e0469a7..13ae0c6 100644 --- a/idf_zacus/components/game_endpoint/game_endpoint.c +++ b/idf_zacus/components/game_endpoint/game_endpoint.c @@ -27,6 +27,7 @@ #include "nvs_flash.h" #include "hints_client.h" +#include "gamebook.h" #include "npc_engine.h" #include "scenario_mesh.h" #include "puzzle_binding.h" @@ -994,6 +995,29 @@ static esp_err_t handle_media_play_post(httpd_req_t *req) { return send_error(req, "500 Internal Server Error", esp_err_to_name(err)); } +// ─── POST /game/gamebook[?action=stop] ─────────────────────────────────────── +// +// Start the "livre dont vous êtes le héros" mode (loads /sdcard/gamebook/ +// gamebook.json, plays the start passage, takes over the 5-way pad). With +// ?action=stop it leaves gamebook mode. No body required. +static esp_err_t handle_gamebook_post(httpd_req_t *req) { + char query[32] = {0}, action[16] = {0}; + if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) { + httpd_query_key_value(query, "action", action, sizeof(action)); + } + if (strcmp(action, "stop") == 0) { + gamebook_stop(); + return send_json(req, "200 OK", "{\"gamebook\":\"stopped\"}"); + } + esp_err_t err = gamebook_start(); + if (err != ESP_OK) { + return send_error(req, "503 Service Unavailable", + err == ESP_ERR_NOT_FOUND ? "gamebook_not_on_sd" + : "gamebook_load_failed"); + } + return send_json(req, "200 OK", "{\"gamebook\":\"started\"}"); +} + // ─── GET /game/puzzle_state ────────────────────────────────────────────────── // // Returns {"step_id":"STEP_X"|null, "solved":[1,3], "code":"125"}. @@ -1232,6 +1256,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { .handler = handle_media_play_post, .user_ctx = NULL, }; + static const httpd_uri_t uri_gamebook_post = { + .uri = "/game/gamebook", + .method = HTTP_POST, + .handler = handle_gamebook_post, + .user_ctx = NULL, + }; // Bring up the ESP-NOW mesh transport. The master is primarily a sender // (the relay handler) but we also register the apply adapter so a peer @@ -1308,6 +1338,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) { if (err != ESP_OK) { ESP_LOGW(TAG, "register POST /game/media/play: %s", esp_err_to_name(err)); } + err = httpd_register_uri_handler(server, &uri_gamebook_post); + if (err != ESP_OK) { + ESP_LOGW(TAG, "register POST /game/gamebook: %s", esp_err_to_name(err)); + } ESP_LOGI(TAG, "game endpoint registered " "(GET+POST /game/group_profile, POST /game/scenario%s, " diff --git a/idf_zacus/components/gamebook/CMakeLists.txt b/idf_zacus/components/gamebook/CMakeLists.txt new file mode 100644 index 0000000..7a08238 --- /dev/null +++ b/idf_zacus/components/gamebook/CMakeLists.txt @@ -0,0 +1,11 @@ +idf_component_register( + SRCS + "gamebook.c" + INCLUDE_DIRS + "include" + REQUIRES + json + display_ui + media_manager + log +) diff --git a/idf_zacus/components/gamebook/gamebook.c b/idf_zacus/components/gamebook/gamebook.c new file mode 100644 index 0000000..1469ea8 --- /dev/null +++ b/idf_zacus/components/gamebook/gamebook.c @@ -0,0 +1,188 @@ +// gamebook.c — see gamebook.h. Standalone "livre dont vous êtes le héros". +#include "gamebook.h" + +#include +#include +#include + +#include "cJSON.h" +#include "esp_log.h" + +#include "display_ui.h" +#include "media_manager.h" + +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 */ + +/* 5-way pad codes (mirror display_ui): 1=SELECT 2=DOWN 3=MENU 4=L/R 5=UP. */ +#define KEY_MENU 3 + +static cJSON *s_root = NULL; /* owns the parsed JSON while active */ +static cJSON *s_passages = NULL; /* borrowed: s_root->"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; + +bool gamebook_active(void) { return s_active; } + +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. */ +static void render_page(void) +{ + const cJSON *p = cur_passage(); + if (!cJSON_IsObject(p)) return; + const cJSON *screen = cJSON_GetObjectItem(p, "screen"); + const cJSON *text = cJSON_GetObjectItem(p, "text"); + const cJSON *choices = cJSON_GetObjectItem(p, "choices"); + int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0; + + char menu[256]; + if (n == 0) { + snprintf(menu, sizeof(menu), "~ Fin ~ (clic = recommencer)"); + } else { + size_t off = 0; + for (int i = 0; i < n && off < sizeof(menu); i++) { + const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, i), + "label"); + off += snprintf(menu + off, sizeof(menu) - off, "%s%s\n", + i == s_sel ? "> " : " ", + cJSON_IsString(lbl) ? lbl->valuestring : "?"); + } + } + display_ui_gamebook_show( + cJSON_IsString(screen) ? screen->valuestring : s_title, + cJSON_IsString(text) ? text->valuestring : "", + 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; + } + snprintf(s_current, sizeof(s_current), "%s", pid); + s_sel = 0; + render_page(); + + const cJSON *wav = cJSON_GetObjectItem(p, "wav"); + 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_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) +{ + if (!s_active) return false; + + 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); + } + 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 */ + const cJSON *choices = cJSON_GetObjectItem(cur_passage(), "choices"); + 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); + } + break; + } + default: break; + } + return true; +} + +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; +} + +void gamebook_stop(void) +{ + if (!s_active && !s_root) return; + s_active = false; + media_manager_stop(); + display_ui_gamebook_hide(); + if (s_root) { cJSON_Delete(s_root); s_root = NULL; } + s_passages = NULL; + s_current[0] = '\0'; + ESP_LOGI(TAG, "stopped"); +} + +void gamebook_init(void) +{ + display_ui_set_key_hook(gamebook_key_hook); + ESP_LOGI(TAG, "ready (key hook installed)"); +} diff --git a/idf_zacus/components/gamebook/include/gamebook.h b/idf_zacus/components/gamebook/include/gamebook.h new file mode 100644 index 0000000..9eeb2e1 --- /dev/null +++ b/idf_zacus/components/gamebook/include/gamebook.h @@ -0,0 +1,42 @@ +#pragma once +// gamebook — "livre dont vous êtes le héros" mode for the Freenove master. +// +// Reads /sdcard/gamebook/gamebook.json (built by tools/gamebook/build_gamebook.py), +// plays each passage's WAV narration from the SD via media_manager, shows the +// passage title + choice menu on the display, and navigates with the 5-way pad +// (it installs a display_ui key hook so it owns the buttons while active). +// Fully local: no model, no gateway. +// +// JSON shape: +// { "title": "...", "start": "intro", +// "passages": { "intro": { "wav": "intro.wav", "screen": "...", +// "menu": "[OK] ... | [<>] ...", +// "choices": [ {"key": 1, "goto": "machine"}, ... ] }, +// ... } } +// key codes match the 5-way pad: 1=SELECT 2=DOWN 3=MENU 4=LEFT/RIGHT 5=UP. +// MENU (3) always quits the gamebook. + +#include "esp_err.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Install the display_ui key hook. Call once at boot, after display_ui_init(). +void gamebook_init(void); + +// Load /sdcard/gamebook/gamebook.json and enter the start passage (show text + +// play its WAV). Returns ESP_ERR_* if the SD/JSON is missing or malformed. +esp_err_t gamebook_start(void); + +// Leave gamebook mode: stop playback, release the JSON, hand the pad back to +// the shell. Idempotent. +void gamebook_stop(void); + +// True while a gamebook is running (used by the key hook to claim the pad). +bool gamebook_active(void); + +#ifdef __cplusplus +} +#endif diff --git a/idf_zacus/components/ota_server/ota_server.c b/idf_zacus/components/ota_server/ota_server.c index 0754dd2..341fb28 100644 --- a/idf_zacus/components/ota_server/ota_server.c +++ b/idf_zacus/components/ota_server/ota_server.c @@ -310,8 +310,9 @@ esp_err_t ota_server_init(void) { // HTTP server config httpd_config_t config = HTTPD_DEFAULT_CONFIG(); config.server_port = OTA_SERVER_PORT; - config.max_uri_handlers = 20; // ota + voice_hook + game (incl. /game/step, - // /game/puzzle_state, /game/file, relay) + config.max_uri_handlers = 22; // ota + voice_hook + game (incl. /game/step, + // /game/puzzle_state, /game/file, relay, + // /game/gamebook) // + headroom config.uri_match_fn = httpd_uri_match_wildcard; config.stack_size = 8192; diff --git a/idf_zacus/main/CMakeLists.txt b/idf_zacus/main/CMakeLists.txt index 742b336..77fd30b 100644 --- a/idf_zacus/main/CMakeLists.txt +++ b/idf_zacus/main/CMakeLists.txt @@ -23,6 +23,7 @@ idf_component_register( esp_event espressif__mdns display_ui + gamebook puzzle_state p7_coffre p5_morse diff --git a/idf_zacus/main/main.c b/idf_zacus/main/main.c index 8967cfe..bda3867 100644 --- a/idf_zacus/main/main.c +++ b/idf_zacus/main/main.c @@ -55,6 +55,7 @@ #include "mic_broker.h" #include "board_pins_mediakit.h" #include "display_ui.h" +#include "gamebook.h" #include "p7_coffre.h" #include "p5_morse.h" #include "p6_nfc.h" @@ -436,6 +437,8 @@ void app_main(void) { if (btn_err != ESP_OK) { ESP_LOGW(TAG, "buttons_input_init: %s", esp_err_to_name(btn_err)); } + // Gamebook mode owns the 5-way pad when started (POST /game/gamebook). + gamebook_init(); } bool sta_ok = wifi_bring_up();