feat(gamebook): full-height scrolling text + real pad mapping #41

Merged
electron merged 1 commits from feat/master-gamebook-fullnav into main 2026-06-19 21:57:21 +00:00
3 changed files with 116 additions and 58 deletions
+57 -24
View File
@@ -255,9 +255,12 @@ static lv_obj_t *s_gb_title; // page title, top
static lv_obj_t *s_gb_body; // full passage text, wrapped
static lv_obj_t *s_gb_menu; // choice lines, bottom
static volatile bool s_gamebook_open = false;
static lv_obj_t *s_gb_body_cont; // scrollable container holding s_gb_body
static char s_gb_title_buf[64];
static char s_gb_body_buf[1100];
static char s_gb_menu_buf[160];
static volatile int s_gb_scroll_req = 0; // pending scroll: +pages down / -pages up
static volatile bool s_gb_scroll_home = false; // reset scroll to top on new passage
// ── Gamebook library (tile grid) ────────────────────────────────────────────
#define LIB_TILES 6
@@ -824,39 +827,49 @@ static void build_gamebook_screen(void) {
lv_obj_set_style_bg_color(s_scr_gamebook, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_scr_gamebook, LV_OPA_COVER, 0);
// Compact title line at the very top.
s_gb_title = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_24, 0);
lv_obj_set_style_text_color(s_gb_title, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_text_font(s_gb_title, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_title, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 16);
lv_obj_set_width(s_gb_title, DUI_HOR_RES - 12);
lv_obj_set_style_text_align(s_gb_title, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_title, "");
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 6);
lv_obj_align(s_gb_title, LV_ALIGN_TOP_MID, 0, 4);
// Body band: top area, height-bounded so it can never run into the menu.
s_gb_body = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0);
// Wrap to width AND scroll vertically so long passages are never clipped:
// the whole text scrolls slowly through the band while the WAV narrates it.
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_SCROLL_CIRCULAR);
lv_obj_set_size(s_gb_body, DUI_HOR_RES - 24, 196);
lv_label_set_text(s_gb_body, "");
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 12, 44);
// Menu band: reserved strip at the bottom, drawn last (foreground), with a
// faint background so the choices stand out from the body text.
// Choice selector: a single line pinned at the very bottom. Left/Right cycle
// the selection, click confirms. Drawn last (foreground).
s_gb_menu = lv_label_create(s_scr_gamebook);
lv_obj_set_style_text_color(s_gb_menu, lv_color_hex(COL_CODE), 0);
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x101820), 0);
lv_obj_set_style_bg_color(s_gb_menu, lv_color_hex(0x182230), 0);
lv_obj_set_style_bg_opa(s_gb_menu, LV_OPA_COVER, 0);
lv_obj_set_style_pad_all(s_gb_menu, 6, 0);
lv_obj_set_style_pad_all(s_gb_menu, 5, 0);
lv_obj_set_style_text_font(s_gb_menu, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_WRAP);
lv_label_set_long_mode(s_gb_menu, LV_LABEL_LONG_DOT);
lv_obj_set_width(s_gb_menu, DUI_HOR_RES);
lv_obj_set_style_text_align(s_gb_menu, LV_TEXT_ALIGN_CENTER, 0);
lv_label_set_text(s_gb_menu, "");
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_LEFT, 0, 0);
lv_obj_move_foreground(s_gb_menu);
lv_obj_align(s_gb_menu, LV_ALIGN_BOTTOM_MID, 0, 0);
// Reading area: a vertically-scrollable container filling the whole height
// between the title and the choice line. Up/Down scroll it by half-pages.
s_gb_body_cont = lv_obj_create(s_scr_gamebook);
lv_obj_set_style_bg_color(s_gb_body_cont, lv_color_hex(0x000000), 0);
lv_obj_set_style_bg_opa(s_gb_body_cont, LV_OPA_COVER, 0);
lv_obj_set_style_border_width(s_gb_body_cont, 0, 0);
lv_obj_set_style_pad_all(s_gb_body_cont, 6, 0);
lv_obj_set_size(s_gb_body_cont, DUI_HOR_RES, 264); // 24 (title) .. 288 (menu)
lv_obj_set_pos(s_gb_body_cont, 0, 24);
lv_obj_set_scroll_dir(s_gb_body_cont, LV_DIR_VER);
lv_obj_set_scrollbar_mode(s_gb_body_cont, LV_SCROLLBAR_MODE_AUTO);
s_gb_body = lv_label_create(s_gb_body_cont);
lv_obj_set_style_text_color(s_gb_body, lv_color_hex(COL_VALUE), 0);
lv_obj_set_style_text_font(s_gb_body, &lv_font_montserrat_14, 0);
lv_label_set_long_mode(s_gb_body, LV_LABEL_LONG_WRAP);
lv_obj_set_width(s_gb_body, DUI_HOR_RES - 24); // container width minus padding
lv_label_set_text(s_gb_body, "");
lv_obj_align(s_gb_body, LV_ALIGN_TOP_LEFT, 0, 0);
}
static void build_status_screen(void) {
@@ -951,11 +964,22 @@ static void apply_status(const display_status_t *s) {
// Active step → scene view; idle → status view. The 5-way buttons can
// override (1=force status, 2=force scene; 0=auto). Fade 240 ms, the
// original ui_manager default transition (SceneTransition::kFade, 240).
// Gamebook page: refresh its labels from the latest show() buffers.
// Gamebook page: refresh its labels from the latest show() buffers, then
// apply any pending manual scroll (Up/Down) on the reading container.
if (s_gamebook_open) {
lv_label_set_text(s_gb_title, s_gb_title_buf);
lv_label_set_text(s_gb_body, s_gb_body_buf);
lv_label_set_text(s_gb_menu, s_gb_menu_buf);
if (s_gb_scroll_home) {
lv_obj_scroll_to_y(s_gb_body_cont, 0, LV_ANIM_OFF);
s_gb_scroll_home = false;
}
int req = s_gb_scroll_req;
if (req != 0) {
s_gb_scroll_req = 0;
// +1 page = read further down (content moves up): negative dy.
lv_obj_scroll_by(s_gb_body_cont, 0, -req * 130, LV_ANIM_ON);
}
}
// Library grid: refresh tile titles + highlight the selected one.
if (s_library_open) {
@@ -1280,18 +1304,27 @@ extern "C" void display_ui_set_key_hook(display_ui_key_hook_t hook) {
}
extern "C" void display_ui_gamebook_show(const char *title, const char *body,
const char *menu) {
const char *menu, bool reset_scroll) {
if (s_mutex) xSemaphoreTake(s_mutex, portMAX_DELAY);
snprintf(s_gb_title_buf, sizeof(s_gb_title_buf), "%s", title ? title : "");
snprintf(s_gb_body_buf, sizeof(s_gb_body_buf), "%s", body ? body : "");
snprintf(s_gb_menu_buf, sizeof(s_gb_menu_buf), "%s", menu ? menu : "");
if (reset_scroll) s_gb_scroll_home = true; // new passage → back to top
s_gamebook_open = true;
s_dirty = true;
if (s_mutex) xSemaphoreGive(s_mutex);
}
extern "C" void display_ui_gamebook_scroll(int dir) {
// dir > 0 : read further down ; dir < 0 : back up. Accumulated and applied
// on the display task (this runs on the buttons task).
s_gb_scroll_req += (dir > 0) ? 1 : -1;
s_dirty = true;
}
extern "C" void display_ui_gamebook_hide(void) {
s_gamebook_open = false;
s_gb_scroll_req = 0;
s_dirty = true;
}
@@ -103,7 +103,13 @@ void display_ui_set_key_hook(display_ui_key_hook_t hook);
* @param menu choice lines (bottom), e.g. "[OK] ...\n[<>] ..."
*/
void display_ui_gamebook_show(const char *title, const char *body,
const char *menu);
const char *menu, bool reset_scroll);
/**
* @brief Scroll the gamebook reading area (Up/Down buttons).
* @param dir >0 = read further down, <0 = back up. Half-page per call.
*/
void display_ui_gamebook_scroll(int dir);
/** @brief Leave the gamebook view and return to the normal scene/status flow. */
void display_ui_gamebook_hide(void);
+52 -33
View File
@@ -112,8 +112,10 @@ static int cur_choice_count(void)
return cJSON_IsArray(ch) ? cJSON_GetArraySize(ch) : 0;
}
/* (Re)draw the current page: title + wrapped text + choice list with a cursor. */
static void render_page(void)
/* (Re)draw the current page. `home` true = new passage (reset scroll to top);
* false = same passage, only the choice cursor moved (keep scroll position).
* The bottom line shows ONE choice with < > arrows (Left/Right cycles). */
static void render_page(bool home)
{
const cJSON *p = cur_passage();
if (!cJSON_IsObject(p)) return;
@@ -122,23 +124,25 @@ static void render_page(void)
const cJSON *choices = cJSON_GetObjectItem(p, "choices");
int n = cJSON_IsArray(choices) ? cJSON_GetArraySize(choices) : 0;
char menu[256];
char menu[160];
if (n == 0) {
snprintf(menu, sizeof(menu), "~ Fin ~ (clic = bibliotheque)");
} else {
size_t off = 0;
for (int i = 0; i < n && off < sizeof(menu); i++) {
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, i),
"label");
off += snprintf(menu + off, sizeof(menu) - off, "%s%s\n",
i == s_sel ? "> " : " ",
cJSON_IsString(lbl) ? lbl->valuestring : "?");
const cJSON *lbl = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel),
"label");
const char *txt = cJSON_IsString(lbl) ? lbl->valuestring : "?";
if (n > 1) {
/* Down cycles the answer, Click validates. */
snprintf(menu, sizeof(menu), "%d/%d %s (bas / clic)",
s_sel + 1, n, txt);
} else {
snprintf(menu, sizeof(menu), "%s (clic)", txt);
}
}
display_ui_gamebook_show(
cJSON_IsString(screen) ? screen->valuestring : s_title,
cJSON_IsString(text) ? text->valuestring : "",
menu);
menu, home);
}
static void enter_passage(const char *pid)
@@ -147,7 +151,7 @@ static void enter_passage(const char *pid)
if (!cJSON_IsObject(p)) { ESP_LOGW(TAG, "passage '%s' not found", pid); return; }
snprintf(s_current, sizeof(s_current), "%s", pid);
s_sel = 0;
render_page();
render_page(true);
const cJSON *wav = cJSON_GetObjectItem(p, "wav");
if (cJSON_IsString(wav) && wav->valuestring[0]) {
@@ -191,43 +195,58 @@ static void load_book(int idx)
// ── Input ─────────────────────────────────────────────────────────────────────
/* Library grid nav: 2 columns. up/down = ±2 (row), left/right = ±1 (col),
* click = open the highlighted story. Codes: 1=click 2=down 3=left 4=right
* 5=up (5-way ladder). */
/* MEASURED pad mapping on this hardware (ADC ladder, see calibration):
* key1 = CLICK (centre, ~0 mV)
* key2 = LEFT (gauche, ~700 mV)
* key4 = DOWN (bas, ~1350 mV)
* key5 = RIGHT (droite, ~1994 mV)
* key3 is never produced; the physical UP button is electrically dead
* (held = no voltage change), so nothing can depend on it. */
#define PAD_CLICK 1
#define PAD_LEFT 2
#define PAD_DOWN 4
#define PAD_RIGHT 5
/* Library nav: Down/Right → next tile, Left → previous, Click → open.
* Selection wraps, so the working buttons reach every story without Up. */
static bool library_key(uint8_t key)
{
int s = s_lib_sel;
if (s_lib_n <= 0) return true;
switch (key) {
case 5: s -= 2; break; /* up */
case 2: s += 2; break; /* down */
case 3: s -= 1; break; /* left */
case 4: s += 1; break; /* right */
case 1: /* click → open */
load_book(s_lib_sel);
return true;
default: return true;
case PAD_DOWN: case PAD_RIGHT:
s_lib_sel = (s_lib_sel + 1) % s_lib_n; render_library(); break;
case PAD_LEFT:
s_lib_sel = (s_lib_sel - 1 + s_lib_n) % s_lib_n; render_library(); break;
case PAD_CLICK:
load_book(s_lib_sel); break;
default: break;
}
if (s >= 0 && s < s_lib_n) { s_lib_sel = s; render_library(); }
return true;
}
/* Story nav: D-pad moves the choice cursor, click confirms. Ending + click
* returns to the library. */
/* Story nav (Up is dead, so it's never used):
* Left → scroll the reading text UP
* Right → scroll the reading text DOWN
* Down → next answer (cycles through all choices, wraps around)
* Click → validate the highlighted answer
* On an ending page (no choices): Left/Right still scroll, Click → library. */
static bool story_key(uint8_t key)
{
if (key == PAD_LEFT) { display_ui_gamebook_scroll(-1); return true; } /* scroll up */
if (key == PAD_RIGHT) { display_ui_gamebook_scroll(+1); return true; } /* scroll down */
int n = cur_choice_count();
if (n == 0) { /* ending → back to library on click */
if (key == 1) open_library();
if (n == 0) { /* ending → click returns to library */
if (key == PAD_CLICK) open_library();
return true;
}
switch (key) {
case 5: case 3: s_sel = (s_sel - 1 + n) % n; render_page(); break; /* up/left */
case 2: case 4: s_sel = (s_sel + 1) % n; render_page(); break; /* down/right */
case 1: { /* click → confirm */
case PAD_DOWN: s_sel = (s_sel + 1) % n; render_page(false); break; /* next answer */
case PAD_CLICK: { /* validate */
const cJSON *choices = cJSON_GetObjectItem(cur_passage(), "choices");
const cJSON *g = cJSON_GetObjectItem(cJSON_GetArrayItem(choices, s_sel), "goto");
if (cJSON_IsString(g)) {
ESP_LOGI(TAG, "select #%d -> '%s'", s_sel, g->valuestring);
ESP_LOGI(TAG, "validate #%d -> '%s'", s_sel, g->valuestring);
enter_passage(g->valuestring);
}
break;