// 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 #include #include #include "cJSON.h" #include "esp_log.h" #include "esp_heap_caps.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 (256 * 1024) /* expanded books are ~80 KB of JSON */ #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 .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; } /* PSRAM allocators for cJSON — book trees are large (see gamebook_init). */ 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); } /* 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 = 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; } // ── 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. `home` true = new passage (reset scroll to top); * false = same passage, only the choice cursor moved (keep scroll position). * The bottom line shows ONE choice with < > arrows (Left/Right cycles). */ static void render_page(bool home) { 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[160]; if (n == 0) { snprintf(menu, sizeof(menu), "~ Fin ~ (clic = bibliotheque)"); } else { const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel), "label"); const char *txt = cJSON_IsString(lbl) ? lbl->valuestring : "?"; if (n > 1) { /* Down cycles the answer, Click validates. */ snprintf(menu, sizeof(menu), "%d/%d %s (bas / clic)", s_sel + 1, n, txt); } else { snprintf(menu, sizeof(menu), "%s (clic)", txt); } } display_ui_gamebook_show( cJSON_IsString(screen) ? screen->valuestring : s_title, cJSON_IsString(text) ? text->valuestring : "", menu, home); } 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(true); 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 ───────────────────────────────────────────────────────────────────── /* MEASURED pad mapping on this hardware (ADC ladder, see calibration): * key1 = CLICK (centre, ~0 mV) * key2 = LEFT (gauche, ~700 mV) * key4 = DOWN (bas, ~1350 mV) * key5 = RIGHT (droite, ~1994 mV) * key3 is never produced; the physical UP button is electrically dead * (held = no voltage change), so nothing can depend on it. */ #define PAD_CLICK 1 #define PAD_LEFT 2 #define PAD_DOWN 4 #define PAD_RIGHT 5 /* Library nav: Down/Right → next tile, Left → previous, Click → open. * Selection wraps, so the working buttons reach every story without Up. */ static bool library_key(uint8_t key) { if (s_lib_n <= 0) return true; switch (key) { case PAD_DOWN: case PAD_RIGHT: s_lib_sel = (s_lib_sel + 1) % s_lib_n; render_library(); break; case PAD_LEFT: s_lib_sel = (s_lib_sel - 1 + s_lib_n) % s_lib_n; render_library(); break; case PAD_CLICK: load_book(s_lib_sel); break; default: break; } return true; } /* Story nav (Up is dead, so it's never used): * Left → scroll the reading text UP * Right → scroll the reading text DOWN * Down → next answer (cycles through all choices, wraps around) * Click → validate the highlighted answer * On an ending page (no choices): Left/Right still scroll, Click → library. */ static bool story_key(uint8_t key) { if (key == PAD_LEFT) { display_ui_gamebook_scroll(-1); return true; } /* scroll up */ if (key == PAD_RIGHT) { display_ui_gamebook_scroll(+1); return true; } /* scroll down */ int n = cur_choice_count(); if (n == 0) { /* ending → click returns to library */ if (key == PAD_CLICK) open_library(); return true; } switch (key) { case PAD_DOWN: s_sel = (s_sel + 1) % n; render_page(false); break; /* next answer */ case PAD_CLICK: { /* validate */ 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, "validate #%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) { /* Keep the large book trees in PSRAM — an expanded book is ~80 KB of JSON, * whose parsed node tree would exhaust the internal heap on this firmware. */ static cJSON_Hooks hooks = { .malloc_fn = gb_malloc, .free_fn = gb_free, }; cJSON_InitHooks(&hooks); /* 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"); }