Compare commits

...

3 Commits

Author SHA1 Message Date
clement 0027970907 fix(gamebook): scroll long passages + DELETE /game/file
CI / platformio (push) Failing after 5m21s
CI / platformio (pull_request) Failing after 5m25s
Context:
Two issues surfaced with the longer kid stories: (1) long passages were
clipped on screen — the body label wrapped in a fixed 196px band and
the tail was simply cut off; (2) old orphan WAVs (demo + archived
adventures) piled up on the SD with no way to remove them, since the
master had POST /game/file but no delete.

Approach:
Make the gamebook body label scroll vertically so the whole passage
goes by, and add a DELETE verb to /game/file mirroring the POST routing
so the host can clean stale assets off the SD.

Changes:
- display_ui: gamebook body label LV_LABEL_LONG_WRAP ->
  LV_LABEL_LONG_SCROLL_CIRCULAR; body buffer 512 -> 1100 so a full
  ~1000-char passage is held and scrolled, not truncated.
- game_endpoint: new DELETE /game/file?path=sd/<…>|apps/<…> (same
  whitelist/traversal guard as POST; unlink + 200/404).
- ota_server: max_uri_handlers 22 -> 24 for the extra verb.

Impact:
Long passages are fully readable on the LCD (they scroll), and the SD
gamebook folder can be cleaned remotely. Hardware-validated: 121 orphan
files deleted, library still opens 6 stories.
2026-06-19 16:30:11 +02:00
clement a70963f64a feat(gamebook): library tile picker on boot
CI / platformio (pull_request) Failing after 3m56s
CI / platformio (push) Failing after 9m50s
Context:
The gamebook played a single hard-coded book. We want the master to
boot into a library of several stories, shown as tiles, and let the
player pick one with the pad.

Approach:
Turn the gamebook component into a two-mode machine (LIBRARY / STORY).
On boot it loads /sdcard/gamebook/library.json and shows the stories as
a tile grid; selecting one loads that book's <id>.json and plays it;
finishing a story returns to the library.

Changes:
- display_ui: new library view — a 2x3 grid of title tiles with the
  selected one highlighted (display_ui_library_show/hide); it wins the
  view selector while open.
- gamebook.c: LIBRARY/STORY modes; load library.json + per-book
  <id>.json from the SD; grid navigation (up/down +/-2, left/right
  +/-1, click opens); story ending click returns to the library.
- main.c: gamebook_init only installs the pad hook; gamebook_start()
  (which opens the library) is now called after the SD + media_manager
  are up, so the boot picker actually finds the SD pack.

Impact:
The Freenove boots straight into a story picker and runs any book from
the SD library, fully offline. Hardware-validated: boots into a
6-story library.
2026-06-19 15:24:17 +02:00
clement f58b090a34 feat: gamebook mode (CYOA) on the master
CI / platformio (pull_request) Failing after 4m23s
CI / platformio (push) Failing after 9m52s
Context:
We wanted a "livre dont vous etes le heros" playable on the Freenove
master with NO model in RAM: narration as say()-rendered WAV staged on
the SD card, and navigation on the physical 5-way pad. Nothing in the
firmware drove a branching, button-navigated story before this.

Approach:
A self-contained gamebook component reads a JSON book from the SD,
drives the display and audio, and owns the 5-way pad through a
registered display_ui key hook (so display_ui keeps no dependency on
gamebook — no cycle). A dedicated full-screen gamebook view renders
each page (title / wrapped body / choice list).

Changes:
- New gamebook component: loads /sdcard/gamebook/gamebook.json; per
  passage plays its WAV (media_manager, absolute /sdcard path) and
  renders the title, wrapped text, and a choice list with a ">" cursor.
- Navigation: D-pad up/down (and left/right) move the cursor, the
  center click confirms; endings (no choices) restart on click; a
  choice interrupts the current narration (media_manager_stop + play).
- display_ui: add display_ui_set_key_hook (consume keys when a takeover
  mode is active) and a dedicated gamebook view (title + height-bounded
  body band + reserved bottom menu band) with display_ui_gamebook_show
  / display_ui_gamebook_hide; the gamebook view wins the view selector
  while open.
- game_endpoint: POST /game/gamebook[?action=stop] to start/stop.
- ota_server: bump max_uri_handlers 20 -> 22 for the new endpoint.
- main: call gamebook_init() once the buttons are up.

Impact:
The Freenove plays a branching audio gamebook driven entirely by the
pad, fully offline (no gateway, no model). Hardware-validated end to
end: cursor navigation, click select, WAV narration from SD, endings
restart.
2026-06-19 14:30:37 +02:00
10 changed files with 647 additions and 2 deletions
@@ -248,6 +248,27 @@ 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[1100];
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 ──────────────────────────
@@ -761,6 +782,83 @@ 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);
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);
// Wrap to width AND scroll vertically so long passages are never clipped:
// the whole text scrolls slowly through the band while the WAV narrates it.
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_SCROLL_CIRCULAR);
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 +951,38 @@ 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);
}
// 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) {
want = s_scr_browser; // file browser on top of everything
} else if (s_shell_open) {
@@ -964,6 +1090,8 @@ static void display_task(void *arg) {
build_status_screen();
build_scene_screen();
build_shell_screen();
build_gamebook_screen();
build_library_screen();
build_browser_screen();
build_intro_screen();
@@ -1145,9 +1273,58 @@ 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_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;
// 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;
@@ -78,6 +78,53 @@ 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 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.
*
@@ -17,6 +17,7 @@ idf_component_register(
puzzle_state
media_manager
sd_storage
gamebook
PRIV_REQUIRES
local_puzzles
p7_coffre
@@ -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"}.
@@ -1149,6 +1173,43 @@ static esp_err_t handle_file_post(httpd_req_t *req) {
return send_json(req, "200 OK", resp);
}
// ─── DELETE /game/file?path=sd/<…>|apps/<…> — remove a staged file ───────────
// Same path routing/whitelist as the POST handler. Lets the host clean stale
// assets (e.g. orphan gamebook WAVs) off the SD without reflashing.
static esp_err_t handle_file_delete(httpd_req_t *req) {
char query[160], path_param[96];
if (httpd_req_get_url_query_str(req, query, sizeof(query)) != ESP_OK ||
httpd_query_key_value(query, "path", path_param,
sizeof(path_param)) != ESP_OK) {
return send_error(req, "400 Bad Request", "missing path param");
}
const bool to_sd = (strncmp(path_param, "sd/", 3) == 0);
const bool to_apps = (strncmp(path_param, "apps/", 5) == 0);
if ((!to_sd && !to_apps) || strstr(path_param, "..") ||
path_param[strlen(path_param) - 1] == '/') {
return send_error(req, "403 Forbidden", "path must be under apps/ or sd/");
}
char full[192];
if (to_sd) {
if (!sd_storage_ready()) {
return send_error(req, "503 Service Unavailable", "sd_not_mounted");
}
snprintf(full, sizeof(full), "/sdcard/%s", path_param + 3);
} else {
if (mount_storage_lazy() != ESP_OK) {
return send_error(req, "503 Service Unavailable", "storage_unavailable");
}
snprintf(full, sizeof(full), "/littlefs/%s", path_param);
}
if (unlink(full) != 0) {
return send_error(req, "404 Not Found", "remove failed");
}
ESP_LOGI(TAG, "file removed: %s", full);
char resp[192];
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", path_param);
return send_json(req, "200 OK", resp);
}
void game_endpoint_get_scene(scene_binding_t *out) {
if (!out) return;
// Lock-free snapshot, same class as get_puzzle_status: written on the
@@ -1220,6 +1281,12 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
.handler = handle_file_post,
.user_ctx = NULL,
};
static const httpd_uri_t uri_file_delete = {
.uri = "/game/file",
.method = HTTP_DELETE,
.handler = handle_file_delete,
.user_ctx = NULL,
};
static const httpd_uri_t uri_espnow_cmd_post = {
.uri = "/game/espnow/cmd",
.method = HTTP_POST,
@@ -1232,6 +1299,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
@@ -1296,6 +1369,10 @@ esp_err_t game_endpoint_init(httpd_handle_t server) {
if (err != ESP_OK) {
ESP_LOGW(TAG, "register POST /game/step: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_file_delete);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register DELETE /game/file: %s", esp_err_to_name(err));
}
err = httpd_register_uri_handler(server, &uri_file_post);
if (err != ESP_OK) {
ESP_LOGW(TAG, "register /game/file: %s", esp_err_to_name(err));
@@ -1308,6 +1385,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, "
@@ -0,0 +1,11 @@
idf_component_register(
SRCS
"gamebook.c"
INCLUDE_DIRS
"include"
REQUIRES
json
display_ui
media_manager
log
)
+274
View File
@@ -0,0 +1,274 @@
// 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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#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 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 */
typedef enum { GB_OFF, GB_LIBRARY, GB_STORY } gb_mode_t;
// 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 <book>.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 gb_mode_t s_mode = GB_OFF;
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)
{
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. */
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 = bibliotheque)");
} 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);
}
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());
}
/* Load story #idx from the library and play it. */
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)) { 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 → back to library on click */
if (key == 1) open_library();
return true;
}
switch (key) {
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");
if (cJSON_IsString(g)) {
ESP_LOGI(TAG, "select #%d -> '%s'", s_sel, g->valuestring);
enter_passage(g->valuestring);
}
break;
}
default: break;
}
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)
{
return open_library();
}
void gamebook_stop(void)
{
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();
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, "key hook installed");
}
@@ -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 <stdbool.h>
#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
+3 -2
View File
@@ -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 = 24; // ota + voice_hook + game (incl. /game/step,
// /game/puzzle_state, /game/file POST+DELETE,
// relay, /game/gamebook)
// + headroom
config.uri_match_fn = httpd_uri_match_wildcard;
config.stack_size = 8192;
+1
View File
@@ -23,6 +23,7 @@ idf_component_register(
esp_event
espressif__mdns
display_ui
gamebook
puzzle_state
p7_coffre
p5_morse
+10
View File
@@ -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();
@@ -520,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