Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5519ed2f72 | |||
| a4efce4c20 | |||
| a8af29068b | |||
| c2527a0a66 |
@@ -1,5 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c"
|
||||
SRCS "main.c" "voice_ws_client.c" "scenario_server.c" "plip_virtual.c" "plip_ui.c" "stimulus.c" "cmd_exec.c" "gamebook.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
// gamebook.c — see gamebook.h. Touch CYOA for the ESP32-S3-BOX-3.
|
||||
#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 "bsp/esp-bsp.h"
|
||||
#include "lvgl.h"
|
||||
|
||||
static const char *TAG = "gamebook";
|
||||
|
||||
#define GB_DIR "/sdcard/gamebook"
|
||||
#define GB_LIBRARY GB_DIR "/library.json"
|
||||
#define GB_JSON_MAX (256 * 1024) // expanded books are ~80 KB of JSON
|
||||
|
||||
// Colours (Workbench-ish palette).
|
||||
#define COL_BG 0x101820
|
||||
#define COL_TITLE 0xFFCC55
|
||||
#define COL_TEXT 0xF0F0F0
|
||||
#define COL_BTN 0x224466
|
||||
|
||||
static cJSON *s_lib_root = NULL; // owns library.json
|
||||
static cJSON *s_lib = NULL; // borrowed: root->"library"
|
||||
static int s_lib_n = 0;
|
||||
|
||||
static cJSON *s_book = NULL; // owns the current <id>.json
|
||||
static cJSON *s_passages = NULL; // borrowed: book->"passages"
|
||||
|
||||
static lv_obj_t *s_root = NULL; // scrollable full-screen column
|
||||
|
||||
// ── PSRAM allocators for cJSON (book trees are large) ───────────────────────
|
||||
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); }
|
||||
|
||||
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 > GB_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;
|
||||
}
|
||||
|
||||
// ── Root container (created once, cleared per view) ─────────────────────────
|
||||
// All UI functions below assume the LVGL lock is held (gamebook_init takes it;
|
||||
// button callbacks already run inside the LVGL task / lock).
|
||||
static void ensure_root(void)
|
||||
{
|
||||
if (s_root) { lv_obj_clean(s_root); return; }
|
||||
lv_obj_t *scr = lv_screen_active();
|
||||
s_root = lv_obj_create(scr);
|
||||
lv_obj_set_size(s_root, LV_PCT(100), LV_PCT(100));
|
||||
lv_obj_set_pos(s_root, 0, 0);
|
||||
lv_obj_set_style_bg_color(s_root, lv_color_hex(COL_BG), 0);
|
||||
lv_obj_set_style_bg_opa(s_root, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(s_root, 0, 0);
|
||||
lv_obj_set_style_radius(s_root, 0, 0);
|
||||
lv_obj_set_style_pad_all(s_root, 8, 0);
|
||||
lv_obj_set_style_pad_row(s_root, 8, 0);
|
||||
lv_obj_set_flex_flow(s_root, LV_FLEX_FLOW_COLUMN);
|
||||
lv_obj_set_scroll_dir(s_root, LV_DIR_VER);
|
||||
}
|
||||
|
||||
static lv_obj_t *add_title(const char *txt)
|
||||
{
|
||||
lv_obj_t *l = lv_label_create(s_root);
|
||||
lv_obj_set_style_text_font(l, &lv_font_montserrat_24, 0);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(COL_TITLE), 0);
|
||||
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(l, LV_PCT(100));
|
||||
lv_label_set_text(l, txt);
|
||||
return l;
|
||||
}
|
||||
|
||||
static lv_obj_t *add_text(const char *txt)
|
||||
{
|
||||
lv_obj_t *l = lv_label_create(s_root);
|
||||
lv_obj_set_style_text_font(l, &lv_font_montserrat_14, 0);
|
||||
lv_obj_set_style_text_color(l, lv_color_hex(COL_TEXT), 0);
|
||||
lv_label_set_long_mode(l, LV_LABEL_LONG_WRAP);
|
||||
lv_obj_set_width(l, LV_PCT(100));
|
||||
lv_label_set_text(l, txt);
|
||||
return l;
|
||||
}
|
||||
|
||||
static lv_obj_t *add_button(const char *txt, lv_event_cb_t cb, void *user)
|
||||
{
|
||||
lv_obj_t *b = lv_button_create(s_root);
|
||||
lv_obj_set_width(b, LV_PCT(100));
|
||||
lv_obj_set_style_bg_color(b, lv_color_hex(COL_BTN), 0);
|
||||
lv_obj_set_style_pad_all(b, 10, 0);
|
||||
lv_obj_add_event_cb(b, cb, LV_EVENT_CLICKED, user);
|
||||
lv_obj_t *l = lv_label_create(b);
|
||||
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, LV_PCT(100));
|
||||
lv_label_set_text(l, txt);
|
||||
return b;
|
||||
}
|
||||
|
||||
// Forward decls
|
||||
static void show_library(void);
|
||||
static void enter_passage(const char *pid);
|
||||
|
||||
static void tile_cb(lv_event_t *e);
|
||||
static void choice_cb(lv_event_t *e);
|
||||
static void menu_cb(lv_event_t *e);
|
||||
|
||||
// ── Library menu ────────────────────────────────────────────────────────────
|
||||
static void show_library(void)
|
||||
{
|
||||
if (s_book) { cJSON_Delete(s_book); s_book = NULL; s_passages = NULL; }
|
||||
ensure_root();
|
||||
add_title("Choisis ton histoire");
|
||||
for (int i = 0; i < s_lib_n; i++) {
|
||||
const cJSON *t = cJSON_GetObjectItem(cJSON_GetArrayItem(s_lib, i), "title");
|
||||
add_button(cJSON_IsString(t) ? t->valuestring : "?",
|
||||
tile_cb, (void *)(intptr_t)i);
|
||||
}
|
||||
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
|
||||
}
|
||||
|
||||
// ── Passage ─────────────────────────────────────────────────────────────────
|
||||
static void enter_passage(const char *pid)
|
||||
{
|
||||
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
|
||||
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' missing", pid); return; }
|
||||
const cJSON *screen = cJSON_GetObjectItem(p, "screen");
|
||||
const cJSON *text = cJSON_GetObjectItem(p, "text");
|
||||
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
|
||||
|
||||
ensure_root();
|
||||
add_title(cJSON_IsString(screen) ? screen->valuestring : "");
|
||||
add_text(cJSON_IsString(text) ? text->valuestring : "");
|
||||
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
|
||||
if (n == 0) {
|
||||
add_button("Revenir au menu", menu_cb, NULL);
|
||||
} else {
|
||||
for (int i = 0; i < n; i++) {
|
||||
const cJSON *c = cJSON_GetArrayItem(choices, i);
|
||||
const cJSON *lbl = cJSON_GetObjectItem(c, "label");
|
||||
const cJSON *g = cJSON_GetObjectItem(c, "goto");
|
||||
// goto valuestring stays valid while s_book is alive → use as user_data
|
||||
add_button(cJSON_IsString(lbl) ? lbl->valuestring : "?",
|
||||
choice_cb, cJSON_IsString(g) ? g->valuestring : NULL);
|
||||
}
|
||||
}
|
||||
lv_obj_scroll_to_y(s_root, 0, LV_ANIM_OFF);
|
||||
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid, n);
|
||||
}
|
||||
|
||||
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)) return;
|
||||
char path[128];
|
||||
snprintf(path, sizeof(path), "%s/%s", GB_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");
|
||||
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
|
||||
cJSON_Delete(book); return;
|
||||
}
|
||||
if (s_book) cJSON_Delete(s_book);
|
||||
s_book = book;
|
||||
s_passages = (cJSON *)passages;
|
||||
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
|
||||
enter_passage(start->valuestring);
|
||||
}
|
||||
|
||||
// ── Touch callbacks (run inside the LVGL task → lock already held) ───────────
|
||||
static void tile_cb(lv_event_t *e) { load_book((int)(intptr_t)lv_event_get_user_data(e)); }
|
||||
static void menu_cb(lv_event_t *e) { (void)e; show_library(); }
|
||||
static void choice_cb(lv_event_t *e)
|
||||
{
|
||||
const char *goto_id = (const char *)lv_event_get_user_data(e);
|
||||
if (goto_id) enter_passage(goto_id);
|
||||
}
|
||||
|
||||
// ── Public init ─────────────────────────────────────────────────────────────
|
||||
void gamebook_init(void)
|
||||
{
|
||||
cJSON_Hooks hooks = { .malloc_fn = gb_malloc, .free_fn = gb_free };
|
||||
cJSON_InitHooks(&hooks); // keep the big book trees in PSRAM
|
||||
|
||||
if (bsp_sdcard_mount() != ESP_OK) {
|
||||
ESP_LOGW(TAG, "no SD card — gamebook disabled");
|
||||
return;
|
||||
}
|
||||
s_lib_root = load_json(GB_LIBRARY);
|
||||
if (!s_lib_root) { ESP_LOGW(TAG, "no library.json — gamebook disabled"); return; }
|
||||
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, "empty library");
|
||||
return;
|
||||
}
|
||||
s_lib_n = cJSON_GetArraySize(s_lib);
|
||||
|
||||
bsp_display_brightness_set(80);
|
||||
if (bsp_display_lock(1000)) {
|
||||
show_library();
|
||||
bsp_display_unlock();
|
||||
}
|
||||
ESP_LOGI(TAG, "touch gamebook up (%d stories)", s_lib_n);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#pragma once
|
||||
// gamebook — "livre dont tu es le héros" TACTILE pour l'ESP32-S3-BOX-3.
|
||||
//
|
||||
// Écran 320x240 + tactile : on lit le passage à l'écran et on TOUCHE un bouton
|
||||
// pour choisir. Réutilise le pack gamebook du master sur la SD :
|
||||
// /sdcard/gamebook/{library.json, <id>.json}
|
||||
// où chaque passage porte {screen, text, choices:[{label, goto}]} (le champ
|
||||
// "wav" éventuel est ignoré — version texte/tactile, pas d'audio requis).
|
||||
//
|
||||
// S'appuie sur la stack LVGL déjà démarrée par bsp_display_start() : on
|
||||
// construit une UUI (menu d'histoires en liste, puis pages tactiles) et la
|
||||
// navigation se fait dans les callbacks de boutons LVGL. 100% local, hors-ligne.
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Monte la SD, charge la bibliothèque et affiche le menu tactile des histoires.
|
||||
// À appeler une fois depuis app_main, après bsp_display_start() et le serveur
|
||||
// de fichiers (pour que la SD soit accessible). No-op si aucun pack présent.
|
||||
void gamebook_init(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -28,6 +28,7 @@
|
||||
#include "scenario_server.h"
|
||||
#include "plip_virtual.h"
|
||||
#include "plip_ui.h"
|
||||
#include "gamebook.h"
|
||||
#include "stimulus.h"
|
||||
#include "scenario_mesh.h"
|
||||
#include "cmd_exec.h"
|
||||
@@ -463,6 +464,11 @@ void app_main(void)
|
||||
} else {
|
||||
ESP_LOGW(TAG, "stimulus_init failed — QR/melody generator off");
|
||||
}
|
||||
|
||||
/* Touch gamebook: if a story pack is on the SD (/sdcard/gamebook/),
|
||||
* take over the screen with a tap-to-choose "livre dont tu es le
|
||||
* héros". No-op (and the phone UI stays) when no pack is present. */
|
||||
gamebook_init();
|
||||
}
|
||||
|
||||
/* Start the ESP-NOW receiver so the master can relay scenarios to us even
|
||||
|
||||
@@ -255,9 +255,12 @@ 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 lv_obj_t *s_gb_body_cont; // scrollable container holding s_gb_body
|
||||
static char s_gb_title_buf[64];
|
||||
static char s_gb_body_buf[1100];
|
||||
static char s_gb_menu_buf[160];
|
||||
static volatile int s_gb_scroll_req = 0; // pending scroll: +pages down / -pages up
|
||||
static volatile bool s_gb_scroll_home = false; // reset scroll to top on new passage
|
||||
|
||||
// ── Gamebook library (tile grid) ────────────────────────────────────────────
|
||||
#define LIB_TILES 6
|
||||
@@ -824,39 +827,49 @@ static void build_gamebook_screen(void) {
|
||||
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);
|
||||
|
||||
// Compact title line at the very top.
|
||||
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_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_CODE), 0);
|
||||
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_14, 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_width(s_gb_title, DUI_HOR_RES - 12);
|
||||
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);
|
||||
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 4);
|
||||
|
||||
// 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.
|
||||
// Choice selector: a single line pinned at the very bottom. Left/Right cycle
|
||||
// the selection, click confirms. Drawn last (foreground).
|
||||
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_color(s_gb_menu, lv_color_hex(0x182230), 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_pad_all(s_gb_menu, 5, 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_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_DOT);
|
||||
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
|
||||
lv_obj_set_style_text_align(s_gb_menu, LV_TEXT_ALIGN_CENTER, 0);
|
||||
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);
|
||||
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_MID, 0, 0);
|
||||
|
||||
// Reading area: a vertically-scrollable container filling the whole height
|
||||
// between the title and the choice line. Up/Down scroll it by half-pages.
|
||||
s_gb_body_cont = lv_obj_create(s_scr_gamebook);
|
||||
lv_obj_set_style_bg_color(s_gb_body_cont, lv_color_hex(0x000000), 0);
|
||||
lv_obj_set_style_bg_opa(s_gb_body_cont, LV_OPA_COVER, 0);
|
||||
lv_obj_set_style_border_width(s_gb_body_cont, 0, 0);
|
||||
lv_obj_set_style_pad_all(s_gb_body_cont, 6, 0);
|
||||
lv_obj_set_size(s_gb_body_cont, DUI_HOR_RES, 264); // 24 (title) .. 288 (menu)
|
||||
lv_obj_set_pos(s_gb_body_cont, 0, 24);
|
||||
lv_obj_set_scroll_dir(s_gb_body_cont, LV_DIR_VER);
|
||||
lv_obj_set_scrollbar_mode(s_gb_body_cont, LV_SCROLLBAR_MODE_AUTO);
|
||||
|
||||
s_gb_body = lv_label_create(s_gb_body_cont);
|
||||
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_width(s_gb_body, DUI_HOR_RES - 24); // container width minus padding
|
||||
lv_label_set_text(s_gb_body, "");
|
||||
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 0, 0);
|
||||
}
|
||||
|
||||
static void build_status_screen(void) {
|
||||
@@ -951,11 +964,22 @@ 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.
|
||||
// Gamebook page: refresh its labels from the latest show() buffers, then
|
||||
// apply any pending manual scroll (Up/Down) on the reading container.
|
||||
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);
|
||||
if (s_gb_scroll_home) {
|
||||
lv_obj_scroll_to_y(s_gb_body_cont, 0, LV_ANIM_OFF);
|
||||
s_gb_scroll_home = false;
|
||||
}
|
||||
int req = s_gb_scroll_req;
|
||||
if (req != 0) {
|
||||
s_gb_scroll_req = 0;
|
||||
// +1 page = read further down (content moves up): negative dy.
|
||||
lv_obj_scroll_by(s_gb_body_cont, 0, -req * 130, LV_ANIM_ON);
|
||||
}
|
||||
}
|
||||
// Library grid: refresh tile titles + highlight the selected one.
|
||||
if (s_library_open) {
|
||||
@@ -1280,18 +1304,27 @@ extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
|
||||
}
|
||||
|
||||
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
|
||||
const char *menu) {
|
||||
const char *menu, bool reset_scroll) {
|
||||
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 : "");
|
||||
if (reset_scroll) s_gb_scroll_home = true; // new passage → back to top
|
||||
s_gamebook_open = true;
|
||||
s_dirty = true;
|
||||
if (s_mutex) xSemaphoreGive(s_mutex);
|
||||
}
|
||||
|
||||
extern "C" void display_ui_gamebook_scroll(int dir) {
|
||||
// dir > 0 : read further down ; dir < 0 : back up. Accumulated and applied
|
||||
// on the display task (this runs on the buttons task).
|
||||
s_gb_scroll_req += (dir > 0) ? 1 : -1;
|
||||
s_dirty = true;
|
||||
}
|
||||
|
||||
extern "C" void display_ui_gamebook_hide(void) {
|
||||
s_gamebook_open = false;
|
||||
s_gb_scroll_req = 0;
|
||||
s_dirty = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,13 @@ void display_ui_set_key_hook(display_ui_key_hook_t hook);
|
||||
* @param menu choice lines (bottom), e.g. "[OK] ...\n[<>] ..."
|
||||
*/
|
||||
void display_ui_gamebook_show(const char *title, const char *body,
|
||||
const char *menu);
|
||||
const char *menu, bool reset_scroll);
|
||||
|
||||
/**
|
||||
* @brief Scroll the gamebook reading area (Up/Down buttons).
|
||||
* @param dir >0 = read further down, <0 = back up. Half-page per call.
|
||||
*/
|
||||
void display_ui_gamebook_scroll(int dir);
|
||||
|
||||
/** @brief Leave the gamebook view and return to the normal scene/status flow. */
|
||||
void display_ui_gamebook_hide(void);
|
||||
|
||||
@@ -112,8 +112,10 @@ static int cur_choice_count(void)
|
||||
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)
|
||||
/* (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;
|
||||
@@ -122,23 +124,25 @@ static void render_page(void)
|
||||
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
|
||||
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
|
||||
|
||||
char menu[256];
|
||||
char menu[160];
|
||||
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 : "?");
|
||||
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);
|
||||
menu, home);
|
||||
}
|
||||
|
||||
static void enter_passage(const char *pid)
|
||||
@@ -147,7 +151,7 @@ static void enter_passage(const char *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();
|
||||
render_page(true);
|
||||
|
||||
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
|
||||
if (cJSON_IsString(wav) && wav->valuestring[0]) {
|
||||
@@ -191,43 +195,58 @@ static void load_book(int idx)
|
||||
|
||||
// ── 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). */
|
||||
/* 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)
|
||||
{
|
||||
int s = s_lib_sel;
|
||||
if (s_lib_n <= 0) return true;
|
||||
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;
|
||||
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;
|
||||
}
|
||||
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. */
|
||||
/* 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 → back to library on click */
|
||||
if (key == 1) open_library();
|
||||
if (n == 0) { /* ending → click returns to library */
|
||||
if (key == PAD_CLICK) 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 */
|
||||
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, "select #%d -> '%s'", s_sel, g->valuestring);
|
||||
ESP_LOGI(TAG, "validate #%d -> '%s'", s_sel, g->valuestring);
|
||||
enter_passage(g->valuestring);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -13,6 +13,7 @@ idf_component_register(
|
||||
"dtmf.c"
|
||||
"turn_client.c"
|
||||
"slic.c"
|
||||
"plip_gamebook.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "phone.h"
|
||||
#include "slic.h"
|
||||
#include "turn_client.h"
|
||||
#include "plip_gamebook.h"
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
#include "dtmf.h"
|
||||
#endif
|
||||
@@ -68,6 +69,7 @@ typedef enum {
|
||||
STATE_BUSY,
|
||||
STATE_GREET, /* fetching + playing NPC greeting (Stage 2) */
|
||||
STATE_CONNECTED, /* in-call — Stage 3 will add listen loop */
|
||||
STATE_GAMEBOOK, /* audio "livre dont vous êtes le héros" */
|
||||
} conv_state_t;
|
||||
|
||||
static volatile conv_state_t s_state = STATE_IDLE;
|
||||
@@ -129,6 +131,7 @@ static void go_idle(void)
|
||||
audio_stop();
|
||||
audio_pa_set(false);
|
||||
dialer_reset();
|
||||
plip_gamebook_end(); /* no-op if the gamebook wasn't running */
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
dtmf_stop();
|
||||
#endif
|
||||
@@ -185,6 +188,17 @@ static void conv_task(void *arg)
|
||||
if (s_incoming_armed) {
|
||||
/* INCOMING call answered (phone.c detected the edge). */
|
||||
enter_incoming_greet();
|
||||
} else if (plip_gamebook_available()) {
|
||||
/* Gamebook phone: a story pack is on the SD → pick up
|
||||
* launches the audio "livre dont vous êtes le héros".
|
||||
* Digits dialed choose the story then the answers. */
|
||||
dialer_reset();
|
||||
#if CONFIG_PLIP_DIAL_DTMF
|
||||
dtmf_start();
|
||||
#endif
|
||||
plip_gamebook_begin();
|
||||
s_state = STATE_GAMEBOOK;
|
||||
ESP_LOGI(TAG, "off-hook -> GAMEBOOK");
|
||||
} else {
|
||||
/* Outgoing call: dial tone, wait for digits. */
|
||||
dialer_reset();
|
||||
@@ -224,6 +238,21 @@ static void conv_task(void *arg)
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_GAMEBOOK:
|
||||
if (!s_offhook) {
|
||||
go_idle();
|
||||
break;
|
||||
}
|
||||
/* Each dialed digit drives one choice. Consume a single digit at a
|
||||
* time (take the first, reset) so the player can dial again right
|
||||
* away — even over the narration, which gets interrupted. */
|
||||
if (!dialer_idle()) {
|
||||
int d = dialer_current()[0] - '0';
|
||||
dialer_reset();
|
||||
plip_gamebook_feed_digit(d);
|
||||
}
|
||||
break;
|
||||
|
||||
case STATE_DIALING:
|
||||
if (!s_offhook) {
|
||||
go_idle();
|
||||
|
||||
@@ -54,8 +54,9 @@
|
||||
|
||||
#define SPIFFS_LABEL "storage"
|
||||
#define SPIFFS_BASE "/spiffs"
|
||||
#define MAX_BODY (512 * 1024) /* streamed in 2 KB chunks → RAM-safe; raised
|
||||
so full-length say() hint WAVs fit on SD */
|
||||
#define MAX_BODY (3 * 1024 * 1024) /* streamed in 2 KB chunks → RAM-safe;
|
||||
raised to fit full-length neural-TTS
|
||||
(Kyutai) gamebook narration WAVs */
|
||||
|
||||
static volatile bool s_connected = false;
|
||||
static httpd_handle_t s_httpd = NULL;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// plip_gamebook.c — see plip_gamebook.h. Audio CYOA for the PLIP phone.
|
||||
#include "plip_gamebook.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cJSON.h"
|
||||
#include "esp_log.h"
|
||||
|
||||
#include "audio.h"
|
||||
|
||||
static const char *TAG = "plip_gb";
|
||||
|
||||
#define GB_DIR "/sdcard/gamebook"
|
||||
#define GB_LIBRARY GB_DIR "/library.json"
|
||||
#define GB_MENU_WAV GB_DIR "/menu.wav"
|
||||
#define GB_JSON_MAX (64 * 1024)
|
||||
|
||||
typedef enum { GB_OFF, GB_MENU, GB_STORY } gb_mode_t;
|
||||
|
||||
static cJSON *s_lib_root = NULL; // owns library.json
|
||||
static cJSON *s_lib = NULL; // borrowed: root->"library"
|
||||
static int s_lib_n = 0;
|
||||
|
||||
static cJSON *s_book = NULL; // owns the current <id>.json
|
||||
static cJSON *s_passages = NULL; // borrowed: book->"passages"
|
||||
static char s_current[48] = {0};
|
||||
|
||||
static gb_mode_t s_mode = GB_OFF;
|
||||
|
||||
bool plip_gamebook_active(void) { return s_mode != GB_OFF; }
|
||||
|
||||
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 > GB_JSON_MAX) { fclose(f); 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;
|
||||
}
|
||||
|
||||
static void play_wav(const char *file) // file = bare name under GB_DIR
|
||||
{
|
||||
char path[96];
|
||||
snprintf(path, sizeof(path), "%s/%s", GB_DIR, file);
|
||||
audio_stop(); // interrupt any current narration
|
||||
audio_play_async(path);
|
||||
}
|
||||
|
||||
bool plip_gamebook_available(void)
|
||||
{
|
||||
if (!audio_ensure_sd()) return false;
|
||||
FILE *f = fopen(GB_LIBRARY, "rb");
|
||||
if (!f) return false;
|
||||
fclose(f);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void free_story(void)
|
||||
{
|
||||
if (s_book) { cJSON_Delete(s_book); s_book = NULL; }
|
||||
s_passages = NULL;
|
||||
s_current[0] = '\0';
|
||||
}
|
||||
|
||||
static void enter_menu(void)
|
||||
{
|
||||
free_story();
|
||||
s_mode = GB_MENU;
|
||||
audio_stop();
|
||||
audio_play_async(GB_MENU_WAV); // absolute path
|
||||
ESP_LOGI(TAG, "menu (%d stories)", s_lib_n);
|
||||
}
|
||||
|
||||
// Play a passage by id (its WAV narrates the text + numbered choices).
|
||||
static void enter_passage(const char *pid)
|
||||
{
|
||||
const cJSON *p = cJSON_GetObjectItem(s_passages, pid);
|
||||
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' missing", pid); return; }
|
||||
snprintf(s_current, sizeof(s_current), "%s", pid);
|
||||
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
|
||||
if (cJSON_IsString(wav) && wav->valuestring[0]) play_wav(wav->valuestring);
|
||||
const cJSON *ch = cJSON_GetObjectItem(p, "choices");
|
||||
ESP_LOGI(TAG, "passage '%s' (%d choices)", pid,
|
||||
cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0);
|
||||
}
|
||||
|
||||
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)) return;
|
||||
char path[128];
|
||||
snprintf(path, sizeof(path), "%s/%s", GB_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");
|
||||
if (!cJSON_IsObject(passages) || !cJSON_IsString(start)) {
|
||||
cJSON_Delete(book); return;
|
||||
}
|
||||
free_story();
|
||||
s_book = book;
|
||||
s_passages = (cJSON *)passages;
|
||||
s_mode = GB_STORY;
|
||||
ESP_LOGI(TAG, "load book #%d @ '%s'", idx, start->valuestring);
|
||||
enter_passage(start->valuestring);
|
||||
}
|
||||
|
||||
void plip_gamebook_begin(void)
|
||||
{
|
||||
plip_gamebook_end(); // clean any previous run
|
||||
|
||||
s_lib_root = load_json(GB_LIBRARY);
|
||||
if (!s_lib_root) return;
|
||||
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;
|
||||
return;
|
||||
}
|
||||
s_lib_n = cJSON_GetArraySize(s_lib);
|
||||
audio_pa_set(true); // earpiece amplifier on
|
||||
enter_menu();
|
||||
}
|
||||
|
||||
void plip_gamebook_feed_digit(int d)
|
||||
{
|
||||
if (s_mode == GB_MENU) {
|
||||
if (d >= 1 && d <= s_lib_n) load_book(d - 1);
|
||||
return;
|
||||
}
|
||||
if (s_mode == GB_STORY) {
|
||||
const cJSON *p = cJSON_GetObjectItem(s_passages, s_current);
|
||||
const cJSON *ch = cJSON_GetObjectItem(p, "choices");
|
||||
int n = cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
|
||||
if (n == 0) { // ending: 0 (or any) → back to menu
|
||||
enter_menu();
|
||||
return;
|
||||
}
|
||||
if (d >= 1 && d <= n) {
|
||||
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(ch, d - 1),
|
||||
"goto");
|
||||
if (cJSON_IsString(g)) {
|
||||
ESP_LOGI(TAG, "choice %d -> '%s'", d, g->valuestring);
|
||||
enter_passage(g->valuestring);
|
||||
}
|
||||
}
|
||||
// out-of-range digit: ignore (player can dial again)
|
||||
}
|
||||
}
|
||||
|
||||
void plip_gamebook_end(void)
|
||||
{
|
||||
if (s_mode == GB_OFF && !s_lib_root && !s_book) return;
|
||||
s_mode = GB_OFF;
|
||||
audio_stop();
|
||||
free_story();
|
||||
if (s_lib_root) { cJSON_Delete(s_lib_root); s_lib_root = NULL; s_lib = NULL; }
|
||||
ESP_LOGI(TAG, "ended");
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
#pragma once
|
||||
// plip_gamebook — "livre dont vous êtes le héros" AUDIO pour le téléphone PLIP.
|
||||
//
|
||||
// Pas d'écran : tout passe par l'écouteur. On décroche, le narrateur lit le
|
||||
// menu des histoires ("pour le dragon, faites le 1..."), on compose un chiffre
|
||||
// au cadran pour choisir une histoire, puis à chaque passage le narrateur lit
|
||||
// le texte ET les choix numérotés ("pour examiner la machine, faites le 1..."),
|
||||
// et on compose le chiffre du choix. Raccrocher arrête tout.
|
||||
//
|
||||
// Lit /sdcard/gamebook/{library.json, menu.wav, <id>.json, <id>_<passage>.wav}
|
||||
// (pack audio "téléphone" produit par tools/gamebook/build_phone_gamebook.py,
|
||||
// où la narration des WAV inclut déjà les invites "faites le N"). 100% local,
|
||||
// aucun modèle, aucun réseau.
|
||||
//
|
||||
// Intégration : conversation.c lance plip_gamebook_begin() au décroché (si un
|
||||
// pack est présent sur la SD), pousse chaque chiffre composé via
|
||||
// plip_gamebook_feed_digit(), et appelle plip_gamebook_end() au raccroché.
|
||||
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// La SD est-elle montée et un pack gamebook présent ? (à tester au décroché)
|
||||
bool plip_gamebook_available(void);
|
||||
|
||||
// Démarre le mode livre-jeu : charge la bibliothèque et joue le menu audio.
|
||||
void plip_gamebook_begin(void);
|
||||
|
||||
// Traite un chiffre composé (0..9). Menu → choisit l'histoire ; en histoire →
|
||||
// choisit la réponse ; sur une fin → 0 revient au menu. Interrompt la
|
||||
// narration en cours pour enchaîner.
|
||||
void plip_gamebook_feed_digit(int digit);
|
||||
|
||||
// Quitte le mode livre-jeu (raccroché) : stoppe l'audio, libère la mémoire.
|
||||
void plip_gamebook_end(void);
|
||||
|
||||
// Vrai tant qu'une partie est en cours.
|
||||
bool plip_gamebook_active(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user