307 lines
11 KiB
C
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");
|
|
}
|