From a8af29068b4deaf081bbba604cc7108eca3da808 Mon Sep 17 00:00:00 2001 From: clement Date: Fri, 19 Jun 2026 23:56:45 +0200 Subject: [PATCH] feat(gamebook): full-height scrolling text + real pad mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Context: Two issues on the Freenove gamebook: (1) long passages were clipped — the reading band was small and text overflowed; (2) the 5-way pad was decoded wrong, so navigation felt random, and on this unit the physical Up button is electrically dead (held = no voltage change). Approach: Give the reading text the whole screen height in a vertically scrollable container, split selection from scrolling, and remap the keys to the pad's MEASURED voltages. Changes: - display_ui: gamebook view reworked — compact title, a full-height scrollable reading container (the body wraps and scrolls, never clipped), and a one-line choice selector pinned at the bottom; new display_ui_gamebook_scroll() + reset-on-new-passage; show() gains a reset_scroll arg; body buffer 512 -> 1100. - gamebook.c: controls matched to the measured ladder (key2=LEFT, key4=DOWN, key5=RIGHT, key1=CLICK; key3 unused; Up dead): Left/Right scroll the text up/down, Down cycles the answer, Click validates; library nav cycles tiles without needing Up. Impact: Long passages are fully readable (they scroll), and all four working pad buttons do the right thing; nothing depends on the dead Up button. Hardware-calibrated against the real pad voltages. --- .../components/display_ui/display_ui.cpp | 81 ++++++++++++------ .../display_ui/include/display_ui.h | 8 +- idf_zacus/components/gamebook/gamebook.c | 85 ++++++++++++------- 3 files changed, 116 insertions(+), 58 deletions(-) diff --git a/idf_zacus/components/display_ui/display_ui.cpp b/idf_zacus/components/display_ui/display_ui.cpp index e5e393c..a32e6df 100644 --- a/idf_zacus/components/display_ui/display_ui.cpp +++ b/idf_zacus/components/display_ui/display_ui.cpp @@ -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; } diff --git a/idf_zacus/components/display_ui/include/display_ui.h b/idf_zacus/components/display_ui/include/display_ui.h index 952bbdc..f5c86ef 100644 --- a/idf_zacus/components/display_ui/include/display_ui.h +++ b/idf_zacus/components/display_ui/include/display_ui.h @@ -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); diff --git a/idf_zacus/components/gamebook/gamebook.c b/idf_zacus/components/gamebook/gamebook.c index 6b867e4..1562d0e 100644 --- a/idf_zacus/components/gamebook/gamebook.c +++ b/idf_zacus/components/gamebook/gamebook.c @@ -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; -- 2.52.0