Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5519ed2f72 | |||
| a4efce4c20 | |||
| a8af29068b | |||
| c2527a0a66 | |||
| 0027970907 | |||
| a70963f64a | |||
| f58b090a34 | |||
| f3d03c637a | |||
| 39e0498221 | |||
| da7b6ace9c | |||
| be369fa260 | |||
| ace740a629 | |||
| 4ac3981845 | |||
| 653a299ea4 | |||
| 54022ed6cc |
@@ -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
|
||||
|
||||
@@ -248,6 +248,30 @@ 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 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
|
||||
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 +785,93 @@ 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);
|
||||
|
||||
// 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_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 - 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, 4);
|
||||
|
||||
// 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(0x182230), 0);
|
||||
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 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_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_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) {
|
||||
lv_obj_t *scr = s_scr_status;
|
||||
lv_obj_set_style_bg_color(scr, lv_color_hex(COL_BG), 0);
|
||||
@@ -853,10 +964,49 @@ 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, 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) {
|
||||
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 +1114,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 +1297,67 @@ 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, 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;
|
||||
}
|
||||
|
||||
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,59 @@ 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, 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);
|
||||
|
||||
/**
|
||||
* @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,9 +17,11 @@ idf_component_register(
|
||||
puzzle_state
|
||||
media_manager
|
||||
sd_storage
|
||||
gamebook
|
||||
PRIV_REQUIRES
|
||||
local_puzzles
|
||||
p7_coffre
|
||||
p5_morse
|
||||
p6_nfc
|
||||
npc_engine
|
||||
)
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
#include "nvs_flash.h"
|
||||
|
||||
#include "hints_client.h"
|
||||
#include "gamebook.h"
|
||||
#include "npc_engine.h"
|
||||
#include "scenario_mesh.h"
|
||||
#include "puzzle_binding.h"
|
||||
#include "local_puzzles.h"
|
||||
@@ -807,6 +809,16 @@ esp_err_t game_endpoint_apply_step(const char *step_id,
|
||||
strncpy(s_current_step_id, step_id, sizeof(s_current_step_id) - 1);
|
||||
s_current_step_id[sizeof(s_current_step_id) - 1] = '\0';
|
||||
|
||||
// Sync the NPC/hints engine (and, through it, the voice gateway) with the
|
||||
// scene this step enters. This is what makes the phone NPCs hint on — and
|
||||
// scripted calls auto-ring for — the current scene, regardless of whether
|
||||
// the step was driven by the local game loop or an external POST /game/step.
|
||||
// Best-effort: notify is async + server-side idempotent and never blocks
|
||||
// the step change. Steps with no scene_id (or an unknown one) are skipped.
|
||||
if (s_current_scene.scene_id[0] != '\0') {
|
||||
(void) npc_engine_set_scene_by_id(s_current_scene.scene_id, 0);
|
||||
}
|
||||
|
||||
// P7 coffre: fire the actuator when the final win step is reached.
|
||||
// p7_coffre_unlock() is a no-op stub when CONFIG_ZACUS_P7_COFFRE_ENABLE=n.
|
||||
if (strcmp(step_id, "STEP_FINAL_WIN") == 0) {
|
||||
@@ -983,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"}.
|
||||
@@ -1138,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
|
||||
@@ -1209,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,
|
||||
@@ -1221,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
|
||||
@@ -1285,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));
|
||||
@@ -1297,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, "
|
||||
|
||||
@@ -55,6 +55,7 @@ esp_err_t puzzle_binding_from_ir(const char *ir_json, const char *step_id,
|
||||
#define SB_MAX_TITLE 48
|
||||
#define SB_MAX_SUBTITLE 64
|
||||
#define SB_MAX_SYMBOL 16
|
||||
#define SB_MAX_SCENE_ID 40
|
||||
|
||||
typedef enum {
|
||||
SB_FX_PULSE = 0, // default (original SceneEffect::kPulse)
|
||||
@@ -65,6 +66,8 @@ typedef enum {
|
||||
|
||||
typedef struct {
|
||||
bool present; // step has a scene object
|
||||
char scene_id[SB_MAX_SCENE_ID]; // canonical SCENE_* id (sibling of step id;
|
||||
// set even when no display scene object)
|
||||
char title[SB_MAX_TITLE];
|
||||
char subtitle[SB_MAX_SUBTITLE];
|
||||
char symbol[SB_MAX_SYMBOL];
|
||||
|
||||
@@ -239,9 +239,15 @@ esp_err_t scene_binding_from_ir(const char *ir_json, const char *step_id,
|
||||
}
|
||||
if (!step) { cJSON_Delete(root); return ESP_ERR_NOT_FOUND; }
|
||||
|
||||
// Canonical scene id (SCENE_*), sibling of the step "id". Captured
|
||||
// unconditionally so npc_engine / the voice gateway can be synced even
|
||||
// on steps that carry no display "scene" object.
|
||||
copy_scene_str(out->scene_id, sizeof(out->scene_id),
|
||||
cJSON_GetObjectItemCaseSensitive(step, "scene_id"));
|
||||
|
||||
const cJSON *scene = cJSON_GetObjectItemCaseSensitive(step, "scene");
|
||||
if (!scene || !cJSON_IsObject(scene)) {
|
||||
// No scene — present stays false, ESP_OK.
|
||||
// No scene object — present stays false, ESP_OK (scene_id may be set).
|
||||
cJSON_Delete(root);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
idf_component_register(
|
||||
SRCS
|
||||
"gamebook.c"
|
||||
INCLUDE_DIRS
|
||||
"include"
|
||||
REQUIRES
|
||||
json
|
||||
display_ui
|
||||
media_manager
|
||||
log
|
||||
)
|
||||
@@ -0,0 +1,293 @@
|
||||
// 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. `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)
|
||||
{
|
||||
/* 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
|
||||
@@ -22,6 +22,7 @@ idf_component_register(
|
||||
REQUIRES
|
||||
media_manager
|
||||
hints_client
|
||||
esp_http_client
|
||||
esp_timer
|
||||
esp_system
|
||||
nvs_flash
|
||||
|
||||
@@ -169,6 +169,16 @@ esp_err_t npc_engine_trigger_cue(const char *cue_id);
|
||||
// and primes the stuck timer.
|
||||
esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms);
|
||||
|
||||
// Same bridge as npc_engine_set_step(), but keyed by the canonical scene
|
||||
// string id (e.g. "SCENE_WARNING") rather than its numeric index. Resolves
|
||||
// the id against the engine's internal scene table and forwards to
|
||||
// npc_engine_set_step(). Lets the game endpoint sync the NPC/hints engine
|
||||
// (and the voice gateway) straight from the scenario IR's `scene_id`.
|
||||
// Returns ESP_ERR_NOT_FOUND when the id matches no known scene (no notify
|
||||
// is sent in that case), ESP_ERR_INVALID_ARG on a NULL/empty id.
|
||||
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
|
||||
uint32_t expected_duration_ms);
|
||||
|
||||
// Request a hint for `puzzle_id` at escalation `level` (0..3). The engine
|
||||
// invokes `cb` with the resulting text. The current implementation is a
|
||||
// LOCAL STUB: it returns a hardcoded French placeholder synchronously.
|
||||
@@ -184,6 +194,12 @@ const npc_state_t *npc_engine_state(void);
|
||||
// Thin wrapper kept here to give callers a single npc_engine_* surface.
|
||||
esp_err_t npc_engine_set_group_profile(const char *profile);
|
||||
|
||||
// Point the engine at the voice gateway (tools/zacus-gateway). On each scene
|
||||
// change the engine POSTs the active SCENE_* to {base_url}/game/step?scene=… so
|
||||
// the phone NPCs disguise that scene's hint. Best-effort; pass NULL/"" to
|
||||
// disable. `token` is sent as a Bearer header (gateway require_token).
|
||||
void npc_engine_set_gateway(const char *base_url, const char *token);
|
||||
|
||||
// Write the active puzzle id (e.g. "SCENE_LA_DETECTOR") into `out`,
|
||||
// truncated to `cap` bytes (NUL-terminated). Falls back to "SCENE_NPC"
|
||||
// when no scene is active or the engine is not initialised so callers
|
||||
|
||||
@@ -17,16 +17,78 @@
|
||||
#include "npc_engine.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_client.h"
|
||||
#include "esp_log.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
|
||||
#include "media_manager.h"
|
||||
#include "hints_client.h"
|
||||
|
||||
static const char *TAG = "npc_engine";
|
||||
|
||||
// Voice gateway (tools/zacus-gateway) — we push the active SCENE_* to it at each
|
||||
// scene change so the phone NPCs disguise THIS scene's hint. Best-effort, set by
|
||||
// npc_engine_set_gateway(); empty = disabled (no-op, e.g. CI / dry runs).
|
||||
static char s_gw_url[128] = {0};
|
||||
static char s_gw_token[80] = {0};
|
||||
|
||||
void npc_engine_set_gateway(const char *base_url, const char *token) {
|
||||
if (base_url) snprintf(s_gw_url, sizeof(s_gw_url), "%s", base_url);
|
||||
if (token) snprintf(s_gw_token, sizeof(s_gw_token), "%s", token);
|
||||
}
|
||||
|
||||
// One-shot worker: POST {gateway}/game/step?scene=<scene> then self-delete.
|
||||
// Runs on its own task so a slow/unreachable gateway NEVER stalls the scene
|
||||
// transition (the game must not lag 2 s on every scene change if the gateway is
|
||||
// offline). `arg` is a heap copy of the scene, freed here. Best-effort.
|
||||
static void gw_notify_task(void *arg) {
|
||||
char *scene = (char *) arg;
|
||||
char url[256];
|
||||
snprintf(url, sizeof(url), "%s/game/step?scene=%s", s_gw_url, scene);
|
||||
esp_http_client_config_t cfg = {
|
||||
.url = url,
|
||||
.method = HTTP_METHOD_POST,
|
||||
.timeout_ms = 2000,
|
||||
};
|
||||
esp_http_client_handle_t cli = esp_http_client_init(&cfg);
|
||||
if (cli) {
|
||||
if (s_gw_token[0]) {
|
||||
char auth[112];
|
||||
snprintf(auth, sizeof(auth), "Bearer %s", s_gw_token);
|
||||
esp_http_client_set_header(cli, "Authorization", auth);
|
||||
}
|
||||
esp_err_t err = esp_http_client_perform(cli);
|
||||
if (err != ESP_OK) {
|
||||
ESP_LOGW(TAG, "gateway /game/step notify failed: %s", esp_err_to_name(err));
|
||||
} else {
|
||||
ESP_LOGI(TAG, "gateway scene -> %s (HTTP %d)", scene,
|
||||
esp_http_client_get_status_code(cli));
|
||||
}
|
||||
esp_http_client_cleanup(cli);
|
||||
}
|
||||
free(scene);
|
||||
vTaskDelete(NULL);
|
||||
}
|
||||
|
||||
// Fire-and-forget the gateway scene notification on a detached task so a scene
|
||||
// change is never blocked by the network. No-op if no gateway configured.
|
||||
static void notify_gateway_scene(const char *scene) {
|
||||
if (!s_gw_url[0] || !scene || !scene[0]) return;
|
||||
size_t n = strlen(scene) + 1;
|
||||
char *copy = malloc(n);
|
||||
if (!copy) return;
|
||||
memcpy(copy, scene, n);
|
||||
if (xTaskCreate(gw_notify_task, "gw_notify", 4096, copy, 3, NULL) != pdPASS) {
|
||||
ESP_LOGW(TAG, "gw_notify task spawn failed");
|
||||
free(copy);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Core: scene/trigger/mood lookup tables (verbatim Arduino) ──────────────
|
||||
|
||||
static const char *const kSceneIds[] = {
|
||||
@@ -372,10 +434,25 @@ esp_err_t npc_engine_set_step(uint8_t step_id, uint32_t expected_duration_ms) {
|
||||
(unsigned) prev_scene, (unsigned) step_id);
|
||||
}
|
||||
(void) hints_client_puzzle_start(puzzle_id);
|
||||
// Keep the voice gateway in sync so the phone NPCs hint on THIS scene.
|
||||
notify_gateway_scene(puzzle_id);
|
||||
}
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t npc_engine_set_scene_by_id(const char *scene_id,
|
||||
uint32_t expected_duration_ms) {
|
||||
if (scene_id == NULL || scene_id[0] == '\0') return ESP_ERR_INVALID_ARG;
|
||||
for (uint8_t i = 0; i < kSceneCount; i++) {
|
||||
if (strcmp(scene_id, kSceneIds[i]) == 0) {
|
||||
return npc_engine_set_step(i, expected_duration_ms);
|
||||
}
|
||||
}
|
||||
ESP_LOGW(TAG, "set_scene_by_id: unknown scene \"%s\" (no kSceneIds match)",
|
||||
scene_id);
|
||||
return ESP_ERR_NOT_FOUND;
|
||||
}
|
||||
|
||||
esp_err_t npc_engine_set_group_profile(const char *profile) {
|
||||
// Thin pass-through. hints_client validates the value and logs the
|
||||
// outcome. Kept on npc_engine so the rest of the firmware doesn't
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,6 +23,7 @@ idf_component_register(
|
||||
esp_event
|
||||
espressif__mdns
|
||||
display_ui
|
||||
gamebook
|
||||
puzzle_state
|
||||
p7_coffre
|
||||
p5_morse
|
||||
|
||||
@@ -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"
|
||||
@@ -63,6 +64,12 @@
|
||||
// this to NVS so the field operator can repoint the firmware without a flash.
|
||||
#define ZACUS_HINTS_BASE_URL "http://192.168.0.150:8302"
|
||||
|
||||
// Voice gateway (tools/zacus-gateway) — the engine POSTs each scene change to
|
||||
// {URL}/game/step?scene=SCENE_* so the phone NPCs disguise that scene's hint.
|
||||
// Hardcoded for now like the URLs above — moves to NVS in the same follow-up.
|
||||
#define ZACUS_GATEWAY_BASE_URL "http://192.168.0.175:8401"
|
||||
#define ZACUS_GATEWAY_TOKEN "testtoken"
|
||||
|
||||
// Slice 7: voice-bridge WebSocket on the MacStudio (Tailscale address).
|
||||
// Hardcoded here for the same reason as ZACUS_HINTS_BASE_URL — moves to
|
||||
// NVS in a follow-up slice. The bridge endpoint is documented in
|
||||
@@ -430,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();
|
||||
@@ -514,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
|
||||
@@ -530,6 +546,9 @@ void app_main(void) {
|
||||
ESP_LOGE(TAG, "npc_engine_init failed: %s",
|
||||
esp_err_to_name(npc_err));
|
||||
}
|
||||
// Push each scene change to the voice gateway so the phone NPCs
|
||||
// disguise the CURRENT scene's hint (best-effort, see main.c URL).
|
||||
npc_engine_set_gateway(ZACUS_GATEWAY_BASE_URL, ZACUS_GATEWAY_TOKEN);
|
||||
|
||||
// Slice 5: bring up the hints HTTP client (so npc_engine can
|
||||
// route hint requests through the real backend) and the voice
|
||||
|
||||
@@ -13,6 +13,7 @@ idf_component_register(
|
||||
"dtmf.c"
|
||||
"turn_client.c"
|
||||
"slic.c"
|
||||
"plip_gamebook.c"
|
||||
INCLUDE_DIRS "."
|
||||
PRIV_REQUIRES
|
||||
driver
|
||||
|
||||
+14
-13
@@ -137,6 +137,12 @@ static void ensure_sd_mounted(void)
|
||||
}
|
||||
}
|
||||
|
||||
bool audio_ensure_sd(void)
|
||||
{
|
||||
ensure_sd_mounted();
|
||||
return s_sd_mounted;
|
||||
}
|
||||
|
||||
/* ── WAV parser ──────────────────────────────────────────────────────────── */
|
||||
|
||||
static esp_err_t parse_wav_header(const uint8_t *buf, size_t len, wav_info_t *out)
|
||||
@@ -599,19 +605,14 @@ int audio_capture_wav(uint8_t *out, size_t out_max, int max_ms, int silence_ms)
|
||||
const int max_frames = (max_ms / 20);
|
||||
const int silence_frames = (silence_ms / 20);
|
||||
|
||||
/* VAD thresholds (raw S16). Tuned for the SLIC handset mic at +24 dB PGA:
|
||||
* speech peaks ~5 % FS / RMS ~1-2 %, noise floor ~0.6 %. Onset must sit
|
||||
* between (catch quiet speech), silence ABOVE the noise floor (so the turn
|
||||
* ends when the caller stops instead of running to max_ms). */
|
||||
/* Measured on the SLIC handset (DC-blocked): speech body sits ~1 % FS RMS
|
||||
* with peaks ~18 %, noise floor ~0.3 %. The previous silence threshold
|
||||
* (1.2 %) was ABOVE the speech body, so the recorder classified the
|
||||
* caller's own voice as silence and cut the turn after ~0.6 s → empty STT.
|
||||
* Onset must clear noise (3-frame sustained confirm also guards it); silence
|
||||
* must sit between the noise floor and the speech body so the turn ends only
|
||||
* on a real pause. */
|
||||
const int32_t vad_onset_rms_sq = 450 * 450; /* ~1.4% FS² — speech onset above noise */
|
||||
const int32_t vad_silence_rms_sq = 200 * 200; /* ~0.6% FS² — below speech body, above noise floor */
|
||||
/* Calibrated to the QUIET handset voice (DC-blocked: body 0.16-0.8 % FS RMS,
|
||||
* loud syllables ~2-4 %, noise floor ~0.3 %). The old onset (1.4 %) NEVER
|
||||
* triggered → "no sustained voice"; the old silence (0.6 %) sat ABOVE the
|
||||
* body → cut mid-sentence. Now: onset catches a real syllable just above the
|
||||
* floor (3-frame confirm guards clicks); silence sits just above the floor so
|
||||
* the turn ends only on a true pause, holding the whole sentence. */
|
||||
const int32_t vad_onset_rms_sq = 230 * 230; /* ~0.70% FS² — quiet-voice onset */
|
||||
const int32_t vad_silence_rms_sq = 110 * 110; /* ~0.34% FS² — just above noise floor */
|
||||
|
||||
int silent_frames = 0;
|
||||
int total_frames = 0;
|
||||
|
||||
@@ -39,6 +39,12 @@ esp_err_t audio_play_async(const char *path);
|
||||
/* Stop current playback immediately. */
|
||||
esp_err_t audio_stop(void);
|
||||
|
||||
/* Mount the microSD card at /sdcard on demand (best-effort, idempotent) and
|
||||
* report whether it is mounted. Unlike playback, this does NOT depend on the
|
||||
* hook state — it lets the REST layer stage a voice pack onto the SD while the
|
||||
* handset is down. Returns true if /sdcard is usable. */
|
||||
bool audio_ensure_sd(void);
|
||||
|
||||
/* True while the audio worker is playing a clip (tone/WAV). The conversation
|
||||
* LISTEN loop polls this to stay half-duplex: never capture while playing
|
||||
* (avoids the earpiece→mic feedback that saturated the line). */
|
||||
|
||||
+110
-13
@@ -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
|
||||
@@ -47,7 +48,9 @@
|
||||
#define CAPTURE_MAX_IRAM (128 * 1024)
|
||||
#define CAPTURE_MAX_MS_PSRAM 8000
|
||||
#define CAPTURE_MAX_MS_IRAM 4000
|
||||
#define CAPTURE_SILENCE_MS 600 /* end-of-speech VAD: shorter = snappier reply (was 800) */
|
||||
#define CAPTURE_SILENCE_MS 900 /* end-of-speech: needs to ride through brief
|
||||
* mid-sentence pauses so a whole phrase is
|
||||
* captured (quiet handset voice). 900 ms. */
|
||||
#define REPLY_POLL_MS 200 /* interval for checking hook during playback */
|
||||
#define REPLY_PLAYBACK_EXTRA_MS 500 /* safety margin added to computed WAV duration */
|
||||
#define BETWEEN_TURNS_MS 300 /* short pause between capture rounds */
|
||||
@@ -66,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;
|
||||
@@ -82,12 +86,16 @@ static char s_sid[32] = {0};
|
||||
* NOT re-read dialer_current() for the greeting/reply — that polluted number
|
||||
* would 404 at the gateway. Capture the clean routed number here once. */
|
||||
static char s_number[16] = {0};
|
||||
/* Scene this call hints on (e.g. "SCENE_WARNING"), locked at pickup like the
|
||||
* number. Empty for player-dialled calls. Drives the local SD hint clip. */
|
||||
static char s_scene[40] = {0};
|
||||
|
||||
/* Incoming-call mode: an NPC "calls" the phone. conversation_arm_incoming()
|
||||
* stores the caller's persona number and rings; when the handset is then picked
|
||||
* up from IDLE, we skip the outgoing dialtone/dial/ringback and go straight to
|
||||
* GREET with this number (the NPC speaks first). */
|
||||
* stores the caller's persona number (and optional scene) and rings; when the
|
||||
* handset is then picked up from IDLE, we skip the outgoing dialtone/dial/
|
||||
* ringback and go straight to GREET with this number (the NPC speaks first). */
|
||||
static char s_incoming_number[16] = {0};
|
||||
static char s_incoming_scene[40] = {0};
|
||||
static volatile bool s_incoming_armed = false;
|
||||
|
||||
/* Known numbers: ringback when dialed */
|
||||
@@ -103,15 +111,32 @@ static bool is_known(const char *num)
|
||||
return false;
|
||||
}
|
||||
|
||||
/* "SCENE_WARNING" → "warning": strip the SCENE_ prefix and lowercase, matching
|
||||
* the SD pack filenames from tools/tts/generate_plip_sd_pack.py. */
|
||||
static void scene_slug(const char *scene, char *out, size_t cap)
|
||||
{
|
||||
const char *p = scene;
|
||||
if (strncmp(p, "SCENE_", 6) == 0) p += 6;
|
||||
size_t i = 0;
|
||||
for (; p[i] && i + 1 < cap; i++) {
|
||||
char c = p[i];
|
||||
out[i] = (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c;
|
||||
}
|
||||
out[i] = '\0';
|
||||
}
|
||||
|
||||
static void go_idle(void)
|
||||
{
|
||||
tones_stop();
|
||||
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
|
||||
s_scene[0] = '\0'; /* clear scripted-call scene so a later dial-out call
|
||||
* doesn't inherit it (CONNECTED would skip listening). */
|
||||
s_state = STATE_IDLE;
|
||||
ESP_LOGI(TAG, "-> IDLE");
|
||||
}
|
||||
@@ -124,10 +149,15 @@ static void enter_incoming_greet(void)
|
||||
{
|
||||
s_incoming_armed = false;
|
||||
phone_ring_stop();
|
||||
slic_ring_stop(); /* belt-and-suspenders: kill the physical bell directly in
|
||||
* case phone.c's s_ringing flag desynced from slic.c's
|
||||
* (otherwise phone_ring_stop early-returns and the bell
|
||||
* keeps ringing through the answered call). */
|
||||
tones_stop();
|
||||
dialer_reset();
|
||||
s_offhook = true;
|
||||
snprintf(s_number, sizeof(s_number), "%s", s_incoming_number);
|
||||
snprintf(s_scene, sizeof(s_scene), "%s", s_incoming_scene);
|
||||
snprintf(s_sid, sizeof(s_sid), "%lld", (long long)esp_timer_get_time());
|
||||
audio_pa_set(true);
|
||||
s_state = STATE_GREET;
|
||||
@@ -158,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();
|
||||
@@ -197,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();
|
||||
@@ -251,18 +307,57 @@ static void conv_task(void *arg)
|
||||
go_idle();
|
||||
break;
|
||||
}
|
||||
/* Fetch greeting WAV from gateway and enqueue playback */
|
||||
if (turn_client_greeting(s_sid, s_number,
|
||||
"/spiffs/turn.wav")) {
|
||||
audio_play_async("/spiffs/turn.wav");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "turn_client_greeting failed — proceeding silent");
|
||||
/* Prefer the local SD voice pack (no gateway, no model in RAM):
|
||||
* play /sdcard/voice/greet_<number>.wav when present. Fall back to
|
||||
* the live gateway greeting only when the pack has no clip for this
|
||||
* NPC number (see tools/tts/generate_plip_sd_pack.py). */
|
||||
{
|
||||
char sd_greet[64];
|
||||
snprintf(sd_greet, sizeof(sd_greet),
|
||||
"/sdcard/voice/greet_%s.wav", s_number);
|
||||
FILE *tf = NULL;
|
||||
if (audio_ensure_sd() && (tf = fopen(sd_greet, "rb")) != NULL) {
|
||||
fclose(tf);
|
||||
audio_play_async(sd_greet);
|
||||
ESP_LOGI(TAG, "GREET: local SD pack %s", sd_greet);
|
||||
} else if (turn_client_greeting(s_sid, s_number,
|
||||
"/spiffs/turn.wav")) {
|
||||
audio_play_async("/spiffs/turn.wav");
|
||||
} else {
|
||||
ESP_LOGW(TAG, "no SD clip + gateway greeting failed — silent");
|
||||
}
|
||||
}
|
||||
/* Scripted offline call: chain this scene's hint from the SD pack
|
||||
* right after the greeting (the audio queue plays them back-to-back).
|
||||
* Fully local — no gateway, no model. */
|
||||
if (s_scene[0] != '\0' && audio_ensure_sd()) {
|
||||
char slug[40], hint[80];
|
||||
scene_slug(s_scene, slug, sizeof(slug));
|
||||
snprintf(hint, sizeof(hint),
|
||||
"/sdcard/voice/hint_%s_l1_0.wav", slug);
|
||||
FILE *hf = fopen(hint, "rb");
|
||||
if (hf != NULL) {
|
||||
fclose(hf);
|
||||
audio_play_async(hint);
|
||||
ESP_LOGI(TAG, "GREET: chained SD hint %s", hint);
|
||||
} else {
|
||||
ESP_LOGI(TAG, "GREET: no SD hint for scene %s (%s)",
|
||||
s_scene, hint);
|
||||
}
|
||||
}
|
||||
s_state = STATE_CONNECTED;
|
||||
ESP_LOGI(TAG, "-> CONNECTED");
|
||||
break;
|
||||
|
||||
case STATE_CONNECTED:
|
||||
/* Scripted offline call (greeting + SD hint already queued): there's
|
||||
* no live two-way conversation to run — just let the clips play out
|
||||
* and wait for the player to hang up. Skips the gateway/model listen
|
||||
* loop entirely. The global on-hook check returns us to IDLE. */
|
||||
if (s_scene[0] != '\0') {
|
||||
vTaskDelay(pdMS_TO_TICKS(REPLY_POLL_MS));
|
||||
break;
|
||||
}
|
||||
#if CONFIG_PLIP_VOICE_REPLY
|
||||
/*
|
||||
* Stage 3 — LISTEN loop.
|
||||
@@ -398,12 +493,14 @@ void conversation_on_hook_change(bool offhook)
|
||||
s_hook_changed = true;
|
||||
}
|
||||
|
||||
void conversation_arm_incoming(const char *number)
|
||||
void conversation_arm_incoming(const char *number, const char *scene)
|
||||
{
|
||||
snprintf(s_incoming_number, sizeof(s_incoming_number), "%s",
|
||||
(number && number[0]) ? number : "17");
|
||||
snprintf(s_incoming_scene, sizeof(s_incoming_scene), "%s",
|
||||
(scene && scene[0]) ? scene : "");
|
||||
s_incoming_armed = true;
|
||||
phone_ring_start();
|
||||
ESP_LOGI(TAG, "incoming call armed (num=%s) — ringing until pickup",
|
||||
s_incoming_number);
|
||||
ESP_LOGI(TAG, "incoming call armed (num=%s scene=%s) — ringing until pickup",
|
||||
s_incoming_number, s_incoming_scene[0] ? s_incoming_scene : "-");
|
||||
}
|
||||
|
||||
@@ -6,5 +6,8 @@ void conversation_on_hook_change(bool offhook);
|
||||
|
||||
/* Arm an INCOMING call from NPC persona `number` and start ringing. When the
|
||||
* handset is picked up from idle, the conversation jumps straight to the
|
||||
* greeting (persona speaks first) instead of the outgoing dial-tone path. */
|
||||
void conversation_arm_incoming(const char *number);
|
||||
* greeting (persona speaks first) instead of the outgoing dial-tone path.
|
||||
* `scene` (optional, e.g. "SCENE_WARNING") makes the phone play that scene's
|
||||
* hint from its local SD pack right after the greeting; pass NULL/"" for a
|
||||
* greeting-only call. */
|
||||
void conversation_arm_incoming(const char *number, const char *scene);
|
||||
|
||||
+84
-10
@@ -22,6 +22,7 @@
|
||||
#include <string.h>
|
||||
#include <stdio.h>
|
||||
#include <errno.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include "audio.h"
|
||||
#include "tones.h"
|
||||
@@ -53,7 +54,9 @@
|
||||
|
||||
#define SPIFFS_LABEL "storage"
|
||||
#define SPIFFS_BASE "/spiffs"
|
||||
#define MAX_BODY (256 * 1024)
|
||||
#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;
|
||||
@@ -209,14 +212,39 @@ static esp_err_t handle_file_post(httpd_req_t *req)
|
||||
if (req->content_len <= 0 || req->content_len > MAX_BODY)
|
||||
return send_json(req, "413 Payload Too Large", "{\"error\":\"too large\"}");
|
||||
|
||||
ensure_spiffs();
|
||||
/* Route to the microSD when the path is sd-prefixed ("sd/…", "sdcard/…"
|
||||
* or "/sdcard/…"); otherwise stage in SPIFFS (default). The SD is mounted
|
||||
* on demand here so a voice pack can be staged without an off-hook play. */
|
||||
const char *base;
|
||||
const char *rel = fpath;
|
||||
bool to_sd = false;
|
||||
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
|
||||
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
|
||||
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
|
||||
|
||||
/* Build full SPIFFS path. */
|
||||
char full[160];
|
||||
if (fpath[0] == '/') {
|
||||
snprintf(full, sizeof(full), "%s%s", SPIFFS_BASE, fpath);
|
||||
if (to_sd) {
|
||||
if (!audio_ensure_sd())
|
||||
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
|
||||
base = PLIP_SD_MOUNT; /* "/sdcard" */
|
||||
} else {
|
||||
snprintf(full, sizeof(full), "%s/%s", SPIFFS_BASE, fpath);
|
||||
ensure_spiffs();
|
||||
base = SPIFFS_BASE;
|
||||
}
|
||||
while (*rel == '/') rel++; /* avoid a double slash when joining */
|
||||
|
||||
char full[192];
|
||||
snprintf(full, sizeof(full), "%s/%s", base, rel);
|
||||
|
||||
/* Create the immediate parent dir (one level) so a pack can live in a
|
||||
* subfolder like /sdcard/voice/<file>.wav — fopen won't create it. FAT
|
||||
* supports mkdir; SPIFFS is flat so we only do this for the SD path. */
|
||||
if (to_sd) {
|
||||
char *slash = strrchr(full, '/');
|
||||
if (slash && slash != full) {
|
||||
*slash = '\0';
|
||||
mkdir(full, 0775); /* best-effort; ignore EEXIST */
|
||||
*slash = '/';
|
||||
}
|
||||
}
|
||||
|
||||
FILE *f = fopen(full, "wb");
|
||||
@@ -254,6 +282,44 @@ static esp_err_t handle_file_post(httpd_req_t *req)
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
/* ── DELETE /game/file?path=... (remove a staged file; SD or SPIFFS) ─────── */
|
||||
/* Same path routing as the POST handler (sd/ prefix → /sdcard, else /spiffs).
|
||||
* Lets the host clean stale clips when regenerating the SD voice pack. */
|
||||
static esp_err_t handle_file_delete(httpd_req_t *req)
|
||||
{
|
||||
char query[128] = {0}, fpath[128] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
if (httpd_query_key_value(query, "path", fpath, sizeof(fpath)) != ESP_OK)
|
||||
return send_json(req, "400 Bad Request", "{\"error\":\"missing path param\"}");
|
||||
|
||||
const char *base, *rel = fpath;
|
||||
bool to_sd = false;
|
||||
if (strncmp(fpath, "sd/", 3) == 0) { rel = fpath + 3; to_sd = true; }
|
||||
else if (strncmp(fpath, "sdcard/", 7) == 0) { rel = fpath + 7; to_sd = true; }
|
||||
else if (strncmp(fpath, "/sdcard/", 8) == 0) { rel = fpath + 8; to_sd = true; }
|
||||
|
||||
if (to_sd) {
|
||||
if (!audio_ensure_sd())
|
||||
return send_json(req, "503 Service Unavailable", "{\"error\":\"no sd card\"}");
|
||||
base = PLIP_SD_MOUNT;
|
||||
} else {
|
||||
ensure_spiffs();
|
||||
base = SPIFFS_BASE;
|
||||
}
|
||||
while (*rel == '/') rel++;
|
||||
|
||||
char full[192];
|
||||
snprintf(full, sizeof(full), "%s/%s", base, rel);
|
||||
if (remove(full) != 0) {
|
||||
ESP_LOGW(TAG, "remove %s failed: errno=%d", full, errno);
|
||||
return send_json(req, "404 Not Found", "{\"error\":\"remove failed\"}");
|
||||
}
|
||||
ESP_LOGI(TAG, "file removed: %s", full);
|
||||
char resp[256];
|
||||
snprintf(resp, sizeof(resp), "{\"status\":\"ok\",\"removed\":\"%s\"}", full);
|
||||
return send_json(req, "200 OK", resp);
|
||||
}
|
||||
|
||||
/* ── POST /voice/capture ──────────────────────────────────────────────────── */
|
||||
|
||||
#define CAP_MAX_MS_DEFAULT 5000
|
||||
@@ -483,10 +549,13 @@ static esp_err_t handle_debug_ring(httpd_req_t *req)
|
||||
{
|
||||
/* /debug/ring[?number=NN] — arm an INCOMING call from persona NN (default
|
||||
* 17) and ring. On pickup the conversation jumps straight to the greeting. */
|
||||
char query[48] = {0}, number[16] = {0};
|
||||
char query[96] = {0}, number[16] = {0}, scene[40] = {0};
|
||||
httpd_req_get_url_query_str(req, query, sizeof(query));
|
||||
httpd_query_key_value(query, "number", number, sizeof(number));
|
||||
conversation_arm_incoming(number); /* defaults to "17" if empty; starts ringing */
|
||||
httpd_query_key_value(query, "scene", scene, sizeof(scene));
|
||||
/* scene (optional) lets the phone play that scene's hint from its SD pack
|
||||
* after the greeting — fully offline, no model. */
|
||||
conversation_arm_incoming(number, scene[0] ? scene : NULL);
|
||||
char resp[64];
|
||||
snprintf(resp, sizeof(resp), "{\"ok\":true,\"ringing\":true,\"incoming\":\"%s\"}",
|
||||
number[0] ? number : "17");
|
||||
@@ -812,7 +881,7 @@ esp_err_t net_init(void)
|
||||
/* Start HTTP server. */
|
||||
httpd_config_t hcfg = HTTPD_DEFAULT_CONFIG();
|
||||
hcfg.server_port = 80;
|
||||
hcfg.max_uri_handlers = 17;
|
||||
hcfg.max_uri_handlers = 18;
|
||||
hcfg.stack_size = 8192;
|
||||
|
||||
esp_err_t ret = httpd_start(&s_httpd, &hcfg);
|
||||
@@ -833,6 +902,10 @@ esp_err_t net_init(void)
|
||||
.uri = "/game/file", .method = HTTP_POST,
|
||||
.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_cmd = {
|
||||
.uri = "/game/cmd", .method = HTTP_POST,
|
||||
.handler = handle_cmd_post, .user_ctx = NULL,
|
||||
@@ -888,6 +961,7 @@ esp_err_t net_init(void)
|
||||
httpd_register_uri_handler(s_httpd, &uri_status);
|
||||
httpd_register_uri_handler(s_httpd, &uri_scenario);
|
||||
httpd_register_uri_handler(s_httpd, &uri_file);
|
||||
httpd_register_uri_handler(s_httpd, &uri_file_delete);
|
||||
httpd_register_uri_handler(s_httpd, &uri_cmd);
|
||||
httpd_register_uri_handler(s_httpd, &uri_capture);
|
||||
httpd_register_uri_handler(s_httpd, &uri_debug_regs);
|
||||
|
||||
@@ -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
|
||||
@@ -52,3 +52,7 @@ CONFIG_PLIP_HOOK_ACTIVE_HIGH=y
|
||||
# Example: CONFIG_PLIP_GATEWAY_URL="http://192.168.0.175:8401"
|
||||
# CONFIG_PLIP_GATEWAY_TOKEN="testtoken"
|
||||
# Default (Kconfig): http://192.168.0.50:8401 with empty token.
|
||||
|
||||
# Long file names on the SD (voice sample pack uses >8.3 names)
|
||||
CONFIG_FATFS_LFN_HEAP=y
|
||||
CONFIG_FATFS_MAX_LFN=255
|
||||
|
||||
Reference in New Issue
Block a user