e35058f53d
Backfills the working firmware that had stayed uncommitted across the build sessions, so the branch is self-contained. - audio: audio_player, radio_pipeline, sd_player, calm_tone, aac_player - net: podcast (RSS), udp_log - modes: balloons, plus calm/games/radio tweaks - ui: fonts header, icons; build files (CMakeLists, idf_component.yml, sdkconfig.defaults) - docs/superpowers: Histoires design + plan Note: sdkconfig stays gitignored (Wi-Fi creds + tuned LFN/FS/mbedtls settings); a fresh checkout must reconfigure those.
363 lines
13 KiB
C
363 lines
13 KiB
C
// Jeux mode for Lisael Box: a small menu -> Memory, Compter, (Coloriage stub).
|
|
//
|
|
// Design rule: no losing, no scores that shame — only positive feedback (a star
|
|
// / "Bravo !"). All games are LVGL-only and fit the 320x240 screen.
|
|
//
|
|
// * Memory : 3x2 grid of 6 cards (3 emoji pairs). Flip two; if they match
|
|
// they stay revealed; otherwise they flip back. Win = all matched.
|
|
// * Compter : show N emoji objects, offer 3 number buttons, tap the right
|
|
// count -> "Bravo !". New round on success.
|
|
// * Coloriage: documented STUB (free-draw on a canvas is heavier on 320x240
|
|
// and needs an lv_canvas + touch drawing loop; left as a labelled
|
|
// placeholder screen). See note in build_coloring_stub().
|
|
|
|
#include "ui/ui.h"
|
|
#include "ui/icons.h"
|
|
#include "ui/assets/lisael_icons.h"
|
|
#include "ui/fonts/lisael_fonts.h"
|
|
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <stdint.h>
|
|
#include "esp_log.h"
|
|
#include "esp_random.h"
|
|
|
|
static const char *TAG = "ui.games";
|
|
|
|
static lv_obj_t *s_menu_root;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared: a positive-feedback toast ("Bravo ! ⭐").
|
|
// ---------------------------------------------------------------------------
|
|
static void show_bravo(lv_obj_t *parent)
|
|
{
|
|
lv_obj_t *toast = lv_obj_create(parent);
|
|
lv_obj_set_size(toast, 200, 70);
|
|
lv_obj_center(toast);
|
|
lv_obj_set_style_bg_color(toast, lv_color_hex(0xFFF3BF), 0);
|
|
lv_obj_set_style_radius(toast, 16, 0);
|
|
lv_obj_set_flex_flow(toast, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(toast, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER,
|
|
LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_style_pad_column(toast, 8, 0);
|
|
lv_obj_t *lbl = lv_label_create(toast);
|
|
lv_label_set_text(lbl, "Bravo !");
|
|
lv_obj_set_style_text_font(lbl, &lisael_font_24, 0);
|
|
lv_obj_t *star = lv_image_create(toast);
|
|
lv_image_set_src(star, &lisael_ic_star);
|
|
// Auto-dismiss after 1.5 s.
|
|
lv_obj_delete_delayed(toast, 1500);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// MEMORY
|
|
// ===========================================================================
|
|
#define MEM_PAIRS 3
|
|
#define MEM_CARDS (MEM_PAIRS * 2)
|
|
|
|
static const lv_image_dsc_t *const MEM_FACES[MEM_PAIRS] = {
|
|
&lisael_ic_cat, // 🐱
|
|
&lisael_ic_dog, // 🐶
|
|
&lisael_ic_frog, // 🐸
|
|
};
|
|
|
|
typedef struct {
|
|
lv_obj_t *cards[MEM_CARDS];
|
|
int face[MEM_CARDS]; // index into MEM_FACES
|
|
bool matched[MEM_CARDS];
|
|
int first; // currently revealed card, or -1
|
|
int matched_count;
|
|
lv_obj_t *root;
|
|
} memory_state_t;
|
|
|
|
static void mem_set_revealed(memory_state_t *st, int i, bool revealed)
|
|
{
|
|
lv_obj_t *img = lv_obj_get_child(st->cards[i], 0);
|
|
if (revealed) {
|
|
lv_image_set_src(img, MEM_FACES[st->face[i]]);
|
|
lv_obj_set_style_image_opa(img, LV_OPA_COVER, 0);
|
|
} else {
|
|
lv_obj_set_style_image_opa(img, LV_OPA_TRANSP, 0); // face down
|
|
}
|
|
lv_obj_set_style_bg_color(st->cards[i],
|
|
revealed ? lv_color_hex(0xB2F2BB) : lv_color_hex(0x99C8FF), 0);
|
|
}
|
|
|
|
static void mem_flip_back_cb(lv_timer_t *t)
|
|
{
|
|
memory_state_t *st = lv_timer_get_user_data(t);
|
|
for (int i = 0; i < MEM_CARDS; i++) {
|
|
if (!st->matched[i]) {
|
|
mem_set_revealed(st, i, false);
|
|
}
|
|
}
|
|
st->first = -1;
|
|
lv_timer_delete(t);
|
|
}
|
|
|
|
static void mem_card_cb(lv_event_t *e)
|
|
{
|
|
memory_state_t *st = lv_event_get_user_data(e);
|
|
lv_obj_t *card = lv_event_get_target_obj(e);
|
|
|
|
int idx = -1;
|
|
for (int i = 0; i < MEM_CARDS; i++) {
|
|
if (st->cards[i] == card) { idx = i; break; }
|
|
}
|
|
if (idx < 0 || st->matched[idx] || idx == st->first) {
|
|
return; // ignore taps on matched / already-open card
|
|
}
|
|
|
|
mem_set_revealed(st, idx, true);
|
|
|
|
if (st->first < 0) {
|
|
st->first = idx; // first of the pair
|
|
return;
|
|
}
|
|
|
|
// Second card: check match.
|
|
if (st->face[idx] == st->face[st->first]) {
|
|
st->matched[idx] = st->matched[st->first] = true;
|
|
st->matched_count += 2;
|
|
st->first = -1;
|
|
if (st->matched_count == MEM_CARDS) {
|
|
show_bravo(st->root);
|
|
}
|
|
} else {
|
|
// Flip both back after a short delay.
|
|
lv_timer_t *t = lv_timer_create(mem_flip_back_cb, 800, st);
|
|
lv_timer_set_repeat_count(t, 1);
|
|
}
|
|
}
|
|
|
|
static void mem_build(lv_obj_t *parent)
|
|
{
|
|
memory_state_t *st = lv_malloc(sizeof(memory_state_t));
|
|
memset(st, 0, sizeof(*st));
|
|
st->first = -1;
|
|
st->root = parent;
|
|
|
|
// Build and shuffle the deck.
|
|
for (int i = 0; i < MEM_CARDS; i++) {
|
|
st->face[i] = i % MEM_PAIRS;
|
|
}
|
|
for (int i = MEM_CARDS - 1; i > 0; i--) {
|
|
int j = (int)(esp_random() % (uint32_t)(i + 1));
|
|
int tmp = st->face[i]; st->face[i] = st->face[j]; st->face[j] = tmp;
|
|
}
|
|
|
|
lv_obj_t *grid = lv_obj_create(parent);
|
|
lv_obj_remove_style_all(grid);
|
|
lv_obj_set_size(grid, LV_PCT(100), 180);
|
|
lv_obj_align(grid, LV_ALIGN_BOTTOM_MID, 0, -4);
|
|
lv_obj_set_flex_flow(grid, LV_FLEX_FLOW_ROW_WRAP);
|
|
lv_obj_set_flex_align(grid, LV_FLEX_ALIGN_SPACE_EVENLY,
|
|
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
|
|
for (int i = 0; i < MEM_CARDS; i++) {
|
|
lv_obj_t *card = lv_button_create(grid);
|
|
lv_obj_set_size(card, 90, 80);
|
|
lv_obj_set_style_radius(card, 12, 0);
|
|
lv_obj_add_event_cb(card, mem_card_cb, LV_EVENT_CLICKED, st);
|
|
lv_obj_t *img = lv_image_create(card);
|
|
lv_obj_center(img);
|
|
st->cards[i] = card;
|
|
mem_set_revealed(st, i, false);
|
|
}
|
|
// NOTE: `st` intentionally lives for the screen's lifetime (freed when the
|
|
// screen object is deleted). For a long-lived single-screen app this is
|
|
// fine; if screens are recreated, attach a delete handler to free `st`.
|
|
}
|
|
|
|
// ===========================================================================
|
|
// COMPTER ("combien d'objets ?")
|
|
// ===========================================================================
|
|
typedef struct {
|
|
lv_obj_t *objects_box;
|
|
lv_obj_t *buttons[3];
|
|
int answer;
|
|
lv_obj_t *root;
|
|
} count_state_t;
|
|
|
|
static void count_new_round(count_state_t *st);
|
|
|
|
static void count_btn_cb(lv_event_t *e)
|
|
{
|
|
count_state_t *st = lv_event_get_user_data(e);
|
|
lv_obj_t *btn = lv_event_get_target_obj(e);
|
|
lv_obj_t *lbl = lv_obj_get_child(btn, 0);
|
|
int chosen = atoi(lv_label_get_text(lbl));
|
|
if (chosen == st->answer) {
|
|
show_bravo(st->root);
|
|
count_new_round(st);
|
|
}
|
|
// Wrong answer: no penalty, no message — child just tries again.
|
|
}
|
|
|
|
static void count_new_round(count_state_t *st)
|
|
{
|
|
// 1..5 objects.
|
|
st->answer = 1 + (int)(esp_random() % 5);
|
|
|
|
// Render the objects.
|
|
lv_obj_clean(st->objects_box);
|
|
for (int i = 0; i < st->answer; i++) {
|
|
lv_obj_t *o = lv_image_create(st->objects_box); // 🍎 apple icon
|
|
lv_image_set_src(o, &lisael_ic_apple);
|
|
}
|
|
|
|
// Offer the right answer + two distractors in a shuffled set.
|
|
int opts[3];
|
|
opts[0] = st->answer;
|
|
for (int i = 1; i < 3; i++) {
|
|
int v;
|
|
do { v = 1 + (int)(esp_random() % 5); }
|
|
while (v == opts[0] || (i == 2 && v == opts[1]));
|
|
opts[i] = v;
|
|
}
|
|
for (int i = 2; i > 0; i--) {
|
|
int j = (int)(esp_random() % (uint32_t)(i + 1));
|
|
int tmp = opts[i]; opts[i] = opts[j]; opts[j] = tmp;
|
|
}
|
|
for (int i = 0; i < 3; i++) {
|
|
lv_obj_t *lbl = lv_obj_get_child(st->buttons[i], 0);
|
|
lv_label_set_text_fmt(lbl, "%d", opts[i]);
|
|
}
|
|
}
|
|
|
|
static void count_build(lv_obj_t *parent)
|
|
{
|
|
count_state_t *st = lv_malloc(sizeof(count_state_t));
|
|
memset(st, 0, sizeof(*st));
|
|
st->root = parent;
|
|
|
|
lv_obj_t *q = lv_label_create(parent);
|
|
lv_label_set_text(q, "Combien ?");
|
|
lv_obj_align(q, LV_ALIGN_TOP_MID, 0, 50);
|
|
|
|
st->objects_box = lv_obj_create(parent);
|
|
lv_obj_remove_style_all(st->objects_box);
|
|
lv_obj_set_size(st->objects_box, LV_PCT(90), 80);
|
|
lv_obj_align(st->objects_box, LV_ALIGN_CENTER, 0, -10);
|
|
lv_obj_set_flex_flow(st->objects_box, LV_FLEX_FLOW_ROW_WRAP);
|
|
lv_obj_set_flex_align(st->objects_box, LV_FLEX_ALIGN_CENTER,
|
|
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
|
|
lv_obj_t *bar = lv_obj_create(parent);
|
|
lv_obj_remove_style_all(bar);
|
|
lv_obj_set_size(bar, LV_PCT(100), 64);
|
|
lv_obj_align(bar, LV_ALIGN_BOTTOM_MID, 0, -6);
|
|
lv_obj_set_flex_flow(bar, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(bar, LV_FLEX_ALIGN_SPACE_EVENLY,
|
|
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
|
|
for (int i = 0; i < 3; i++) {
|
|
lv_obj_t *b = lv_button_create(bar);
|
|
lv_obj_add_style(b, lisael_ui_style_button(), 0);
|
|
lv_obj_set_size(b, 72, 56);
|
|
lv_obj_add_event_cb(b, count_btn_cb, LV_EVENT_CLICKED, st);
|
|
lv_obj_t *l = lv_label_create(b);
|
|
lv_obj_set_style_text_font(l, &lv_font_montserrat_28, 0);
|
|
lv_label_set_text(l, "0");
|
|
lv_obj_center(l);
|
|
st->buttons[i] = b;
|
|
}
|
|
|
|
count_new_round(st);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// COLORIAGE (documented stub)
|
|
// ===========================================================================
|
|
static void build_coloring_stub(lv_obj_t *parent)
|
|
{
|
|
// STUB: a real coloring game would use an lv_canvas (e.g. 280x180 in PSRAM)
|
|
// plus an LV_EVENT_PRESSING handler reading the touch point and painting a
|
|
// brush of the selected color via lv_canvas_set_px / lv_draw on the canvas
|
|
// buffer. That is doable but heavier on a 320x240 screen and needs a tuned
|
|
// brush/undo UX for small children, so it is intentionally deferred.
|
|
lv_obj_t *lbl = lv_label_create(parent);
|
|
lv_label_set_text(lbl,
|
|
"Coloriage\n(bientôt)\n\n🎨");
|
|
lv_obj_set_style_text_align(lbl, LV_TEXT_ALIGN_CENTER, 0);
|
|
lv_obj_center(lbl);
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Game sub-screen wrapper + games menu
|
|
// ===========================================================================
|
|
typedef void (*game_builder_t)(lv_obj_t *parent);
|
|
|
|
static void open_game(game_builder_t builder, const char *title)
|
|
{
|
|
lv_obj_t *scr = lv_obj_create(NULL);
|
|
lv_obj_set_style_bg_color(scr, lv_color_hex(0xF3F0FF), 0);
|
|
lisael_ui_add_back_button(scr); // returns HOME
|
|
|
|
lv_obj_t *t = lv_label_create(scr);
|
|
lv_label_set_text(t, title);
|
|
lv_obj_align(t, LV_ALIGN_TOP_MID, 0, 16);
|
|
|
|
builder(scr);
|
|
lv_screen_load_anim(scr, LV_SCR_LOAD_ANIM_MOVE_LEFT, 250, 0, false);
|
|
ESP_LOGI(TAG, "game open: %s", title);
|
|
}
|
|
|
|
static void memory_cb(lv_event_t *e) { (void)e; open_game(mem_build, "Memory"); }
|
|
static void count_cb(lv_event_t *e) { (void)e; open_game(count_build, "Compter"); }
|
|
static void color_cb(lv_event_t *e) { (void)e; open_game(build_coloring_stub, "Coloriage"); }
|
|
static void balloons_cb(lv_event_t *e)
|
|
{
|
|
(void)e;
|
|
// The balloons screen is self-contained (own back button to HOME).
|
|
lv_screen_load_anim(lisael_screen_balloons(), LV_SCR_LOAD_ANIM_MOVE_LEFT,
|
|
250, 0, false);
|
|
}
|
|
|
|
lv_obj_t *lisael_screen_games(void)
|
|
{
|
|
if (s_menu_root) {
|
|
return s_menu_root;
|
|
}
|
|
s_menu_root = lv_obj_create(NULL);
|
|
lv_obj_set_style_bg_color(s_menu_root, lv_color_hex(0xF3F0FF), 0);
|
|
|
|
lisael_ui_add_back_button(s_menu_root);
|
|
|
|
lv_obj_t *title = lv_label_create(s_menu_root);
|
|
lv_label_set_text(title, "Jeux");
|
|
lv_obj_set_style_text_font(title, &lisael_font_24, 0);
|
|
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
|
|
|
|
lv_obj_t *list = lv_obj_create(s_menu_root);
|
|
lv_obj_remove_style_all(list);
|
|
lv_obj_set_size(list, LV_PCT(100), 150);
|
|
lv_obj_align(list, LV_ALIGN_CENTER, 0, 20);
|
|
lv_obj_set_flex_flow(list, LV_FLEX_FLOW_COLUMN);
|
|
lv_obj_set_flex_align(list, LV_FLEX_ALIGN_CENTER,
|
|
LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_style_pad_row(list, 10, 0);
|
|
|
|
struct { const char *txt; const lv_image_dsc_t *icon; lv_event_cb_t cb; } games[] = {
|
|
{ "Memory", &lisael_ic_cat, memory_cb },
|
|
{ "Compter", &lisael_ic_apple, count_cb },
|
|
{ "Calme ta colère", &lisael_ic_balloon, balloons_cb },
|
|
{ "Coloriage", &lisael_ic_palette, color_cb },
|
|
};
|
|
for (size_t i = 0; i < sizeof(games) / sizeof(games[0]); i++) {
|
|
lv_obj_t *b = lv_button_create(list);
|
|
lv_obj_add_style(b, lisael_ui_style_button(), 0);
|
|
lv_obj_set_width(b, LV_PCT(82));
|
|
lv_obj_set_flex_flow(b, LV_FLEX_FLOW_ROW);
|
|
lv_obj_set_flex_align(b, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_CENTER,
|
|
LV_FLEX_ALIGN_CENTER);
|
|
lv_obj_set_style_pad_column(b, 12, 0);
|
|
lv_obj_add_event_cb(b, games[i].cb, LV_EVENT_CLICKED, NULL);
|
|
lv_obj_t *ic = lv_image_create(b);
|
|
lv_image_set_src(ic, games[i].icon);
|
|
lv_obj_t *l = lv_label_create(b);
|
|
lv_label_set_text(l, games[i].txt);
|
|
}
|
|
return s_menu_root;
|
|
}
|