Files
ESP32_ZACUS/idf_zacus/components/gamebook/gamebook.c
T
2026-06-20 09:31:05 +02:00

307 lines
11 KiB
C

// 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 "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 <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; }
/* 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");
}