c2527a0a66
Context:
The Freenove plays a screen+buttons "livre dont vous etes le heros".
The PLIP retro phone has no screen, so we want a phone-native version:
an audio choose-your-own-adventure where the narrator reads the passage
and its numbered choices, and the player dials a digit to choose.
Approach:
A self-contained plip_gamebook module reads a phone audio pack from the
SD (narration WAVs already include the spoken "faites le N" prompts),
plays passages through the earpiece, and maps each dialed digit to a
choice. It hooks into the existing conversation state machine and the
existing dialer (rotary/DTMF) — fully offline, no model, no network.
Changes:
- New plip_gamebook.c/.h: loads /sdcard/gamebook/{library.json,
menu.wav, <id>.json, <id>_<passage>.wav}; pickup plays the story
menu, digit picks a story, then each passage plays its WAV and a
dialed digit jumps to that choice; an ending + any digit returns to
the menu. Interrupts narration so a dial is instant.
- conversation.c: new STATE_GAMEBOOK; off-hook from idle launches the
gamebook when a pack is on the SD (else the normal dialtone/NPC flow,
so the phone still works without a pack); each dialed digit is fed to
the gamebook; go_idle() tears it down on hang-up.
- CMakeLists: build plip_gamebook.c.
Impact:
With a phone pack staged on its SD, the PLIP becomes an audio gamebook:
pick up, dial to choose your story and your path, hang up to stop.
Non-breaking when no pack is present. Builds clean.
172 lines
5.2 KiB
C
172 lines
5.2 KiB
C
// 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");
|
|
}
|