diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 8ca00d8..b003ffc 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -28,6 +28,7 @@ set(srcs "modes/clock.c" "modes/games.c" "modes/balloons.c" + "modes/maze.c" "modes/histoires.c" "modes/wifi_setup.c" "modes/lire.c" @@ -61,6 +62,7 @@ set(reqs esp_http_server # SoftAP captive provisioning portal mbedtls # esp_crt_bundle_attach (CA bundle for HTTPS) esp-box-3 # BSP: display, touch, codec, sdcard, sensors + espressif__icm42670 # IMU accelerometer (tilt maze game) json # IDF built-in cJSON component (provides cJSON.h) fatfs esp_driver_i2c diff --git a/main/modes/games.c b/main/modes/games.c index b0f5c70..20abc31 100644 --- a/main/modes/games.c +++ b/main/modes/games.c @@ -961,12 +961,19 @@ static void balloons_cb(lv_event_t *e) lv_screen_load_anim(lisael_screen_balloons(), LV_SCR_LOAD_ANIM_MOVE_LEFT, 250, 0, false); } +static void maze_cb(lv_event_t *e) +{ + (void)e; + // The maze screen is self-contained (own back button to HOME, IMU tick). + lv_screen_load_anim(lisael_screen_maze(), LV_SCR_LOAD_ANIM_MOVE_LEFT, + 250, 0, false); +} // --------------------------------------------------------------------------- // Games menu: horizontal tile carousel (mirrors home.c pattern). // --------------------------------------------------------------------------- #define GAMES_SLIDE_W 320 -#define GAMES_N_TILES 4 +#define GAMES_N_TILES 5 typedef struct { const lv_image_dsc_t *icon; @@ -1086,12 +1093,14 @@ lv_obj_t *lisael_screen_games(void) { &lisael_ic_apple, "Compter", {0}, count_cb }, { &lisael_ic_balloon, "Calme ta col\xc3\xa8re", {0}, balloons_cb }, { &lisael_ic_palette, "Coloriage", {0}, color_cb }, + { &lisael_ic_star, "Labyrinthe", {0}, maze_cb }, }; static const uint32_t colors[GAMES_N_TILES] = { - 0xFFC9C9, // pink — Memory - 0xB2F2BB, // green — Compter - 0xA5D8FF, // blue — Calme ta colère + 0xFFC9C9, // pink — Memory + 0xB2F2BB, // green — Compter + 0xA5D8FF, // blue — Calme ta colère 0xFFD8A8, // orange — Coloriage + 0xD0BFFF, // violet — Labyrinthe }; // Scrollable track (horizontal, snap-center, no scrollbar). diff --git a/main/modes/maze.c b/main/modes/maze.c new file mode 100644 index 0000000..130eaee --- /dev/null +++ b/main/modes/maze.c @@ -0,0 +1,424 @@ +// "Labyrinthe" — scrolling tilt-maze game for Lisael Box. +// +// Guide a ball to the star by TILTING the box: the on-board IMU (ICM42670, +// reached over the BSP I2C bus) gives the board's acceleration; its x/y +// components steer the ball. Walls block it; reaching the star wins with the +// festive celebration and generates a fresh maze. +// +// The maze is a real labyrinth (not a single corridor): a perfect maze is +// carved on a GW x GH grid by iterative backtracking, so there is exactly one +// path between the entrance (top-left cell) and the goal (bottom-right cell), +// which means plenty of branches and DEAD ENDS. The grid (12x8) is LARGER than +// the screen: only a 6x4 window is shown and the view SCROLLS to follow the +// ball, so you cannot see the whole maze at once — that is the difficulty. +// +// Rendering: the whole world (walls) is drawn ONCE into a single RGB565 canvas +// held in PSRAM, NOT one LVGL object per wall (172 objects would blow the 64 KB +// LVGL heap and wedge the render task — the failure mode seen elsewhere). The +// canvas + the ball + the goal are children of one "world" container that is +// scrolled with lv_obj_set_pos; the play-area parent clips it to the viewport. +// +// TILT DIRECTION calibrated on the real board (tilt right -> ball right, tilt +// toward -> ball down). Flip a sign / swap a source axis in MAZE_A*_* if needed. + +#include "ui/ui.h" +#include "ui/assets/lisael_icons.h" +#include "ui/fonts/lisael_fonts.h" +#include "audio/aac_player.h" + +#include +#include +#include "esp_log.h" +#include "esp_random.h" +#include "esp_heap_caps.h" +#include "bsp/esp-box-3.h" +#include "icm42670.h" + +static const char *TAG = "ui.maze"; + +// --- tilt -> screen axis mapping (calibrated on HW) -------------------------- +#define MAZE_AX_SRC x +#define MAZE_AX_SIGN (-1.0f) +#define MAZE_AY_SRC y +#define MAZE_AY_SIGN (-1.0f) + +// --- viewport + grid geometry ------------------------------------------------ +#define PA_X 10 +#define PA_Y 44 +#define PA_W 300 // viewport = 6 cells across +#define PA_H 184 // viewport = 4 cells down +#define GW 12 // full grid columns +#define GH 8 // full grid rows +#define CW 50 // cell width (PA_W / 6) +#define CH 46 // cell height (PA_H / 4) +#define WORLD_W (GW * CW) // 600 +#define WORLD_H (GH * CH) // 368 +#define WALL_TH 8 +#define BALL_R 9 +#define GOAL_R 18 +#define MAX_WALLS ((GW - 1) * GH + GW * (GH - 1)) // 88 + 84 = 172 + +// physics (pixels, ticks of TICK_MS) +#define TICK_MS 33 +#define ACC_GAIN 1.6f +#define FRICTION 0.86f +#define VMAX 7.0f +#define RESTITUTION 0.55f // bounce energy kept on impact (speed-proportional) +#define MIN_BOUNCE 1.2f // below this the ball settles instead of jittering + +// Reflect a velocity component on impact: faster in -> faster bounce out. +static inline float reflect_v(float v) +{ + float r = -v * RESTITUTION; + return (r > -MIN_BOUNCE && r < MIN_BOUNCE) ? 0.0f : r; +} + +#define COL_WALL 0x4ADA // RGB565 of #495ED4 (wall) +#define COL_BG 0xFFFF // white floor + +typedef struct { int x, y, w, h; } rect_t; + +static lv_obj_t *s_root; +static lv_obj_t *s_area; // viewport (clips the world) +static lv_obj_t *s_world; // canvas holding the maze; scrolled by camera +static lv_obj_t *s_ball; // child of s_world (world coords) +static lv_obj_t *s_goal; // child of s_world (world coords) +static uint16_t *s_wbuf; // PSRAM canvas buffer (WORLD_W*WORLD_H) +static lv_timer_t *s_tick; +static icm42670_handle_t s_imu; +static float s_bx, s_by, s_vx, s_vy; // ball centre + velocity (world coords) +static bool s_won; + +static rect_t s_walls[MAX_WALLS]; +static int s_nwalls; +static int s_goal_x, s_goal_y; + +// --- maze generation (iterative backtracker -> perfect maze) ----------------- +static bool s_vwall[GW][GH]; // wall on right edge of cell (x,y) +static bool s_hwall[GW][GH]; // wall on bottom edge of cell (x,y) +static bool s_visited[GW][GH]; + +static void generate_maze(void) +{ + for (int x = 0; x < GW; x++) { + for (int y = 0; y < GH; y++) { + s_vwall[x][y] = true; + s_hwall[x][y] = true; + s_visited[x][y] = false; + } + } + + // Iterative DFS (explicit stack) to avoid deep recursion on the LVGL task. + static uint8_t stx[GW * GH], sty[GW * GH]; + int sp = 0; + stx[sp] = 0; sty[sp] = 0; sp++; + s_visited[0][0] = true; + + while (sp > 0) { + int x = stx[sp - 1], y = sty[sp - 1]; + // collect unvisited neighbours + int nx[4], ny[4], rm[4], n = 0; // rm = which wall to remove + if (y > 0 && !s_visited[x][y - 1]) { nx[n]=x; ny[n]=y-1; rm[n]=0; n++; } + if (x < GW - 1 && !s_visited[x + 1][y]) { nx[n]=x+1; ny[n]=y; rm[n]=1; n++; } + if (y < GH - 1 && !s_visited[x][y + 1]) { nx[n]=x; ny[n]=y+1; rm[n]=2; n++; } + if (x > 0 && !s_visited[x - 1][y]) { nx[n]=x-1; ny[n]=y; rm[n]=3; n++; } + if (n == 0) { sp--; continue; } // dead end -> backtrack + int k = (int)(esp_random() % (uint32_t)n); + switch (rm[k]) { + case 0: s_hwall[x][y - 1] = false; break; // north + case 1: s_vwall[x][y] = false; break; // east + case 2: s_hwall[x][y] = false; break; // south + case 3: s_vwall[x - 1][y] = false; break; // west + } + s_visited[nx[k]][ny[k]] = true; + stx[sp] = nx[k]; sty[sp] = ny[k]; sp++; + } + + // Build the wall rectangle list (outer border handled by the clamp). + s_nwalls = 0; + for (int x = 0; x < GW - 1; x++) + for (int y = 0; y < GH; y++) + if (s_vwall[x][y]) + s_walls[s_nwalls++] = (rect_t){ (x + 1) * CW - WALL_TH / 2, y * CH, WALL_TH, CH }; + for (int x = 0; x < GW; x++) + for (int y = 0; y < GH - 1; y++) + if (s_hwall[x][y]) + s_walls[s_nwalls++] = (rect_t){ x * CW, (y + 1) * CH - WALL_TH / 2, CW, WALL_TH }; + + s_goal_x = (GW - 1) * CW + CW / 2; + s_goal_y = (GH - 1) * CH + CH / 2; +} + +// --- world canvas draw ------------------------------------------------------- +static void fill_rect565(int rx, int ry, int rw, int rh, uint16_t c) +{ + for (int y = ry; y < ry + rh; y++) { + if (y < 0 || y >= WORLD_H) continue; + uint16_t *row = s_wbuf + (size_t)y * WORLD_W; + for (int x = rx; x < rx + rw; x++) { + if (x < 0 || x >= WORLD_W) continue; + row[x] = c; + } + } +} + +static void draw_world(void) +{ + for (size_t i = 0; i < (size_t)WORLD_W * WORLD_H; i++) s_wbuf[i] = COL_BG; + for (int i = 0; i < s_nwalls; i++) { + fill_rect565(s_walls[i].x, s_walls[i].y, s_walls[i].w, s_walls[i].h, COL_WALL); + } + lv_obj_invalidate(s_world); +} + +// --- IMU --------------------------------------------------------------------- +static bool imu_init(void) +{ + if (s_imu) { + return true; + } + i2c_master_bus_handle_t bus = bsp_i2c_get_handle(); + if (!bus) { + return false; + } + if (icm42670_create(bus, ICM42670_I2C_ADDRESS, &s_imu) != ESP_OK || !s_imu) { + s_imu = NULL; + return false; + } + icm42670_cfg_t cfg = { + .acce_fs = ACCE_FS_2G, .acce_odr = ACCE_ODR_100HZ, + .gyro_fs = GYRO_FS_250DPS, .gyro_odr = GYRO_ODR_100HZ, + }; + icm42670_config(s_imu, &cfg); + icm42670_acce_set_pwr(s_imu, ACCE_PWR_LOWNOISE); + ESP_LOGI(TAG, "IMU ready"); + return true; +} + +// --- celebratory toast (spinning star + sound) ------------------------------- +static void cel_spin_cb(void *var, int32_t v) { lv_image_set_rotation((lv_obj_t *)var, v); } +static void cel_bounce_cb(void *var, int32_t v) { lv_obj_set_style_translate_y((lv_obj_t *)var, v, 0); } + +static void celebrate(lv_obj_t *parent) +{ + lv_obj_t *toast = lv_obj_create(parent); + lv_obj_set_size(toast, 220, 96); + 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, 10, 0); + lv_obj_clear_flag(toast, LV_OBJ_FLAG_SCROLLABLE); + + 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); + lv_image_set_scale(star, 256 * 5 / 4); + lv_image_set_pivot(star, 22, 22); + + lv_anim_t spin; + lv_anim_init(&spin); + lv_anim_set_var(&spin, star); + lv_anim_set_exec_cb(&spin, cel_spin_cb); + lv_anim_set_values(&spin, 0, 3600); + lv_anim_set_duration(&spin, 1400); + lv_anim_set_repeat_count(&spin, LV_ANIM_REPEAT_INFINITE); + lv_anim_start(&spin); + + lv_anim_t bounce; + lv_anim_init(&bounce); + lv_anim_set_var(&bounce, star); + lv_anim_set_exec_cb(&bounce, cel_bounce_cb); + lv_anim_set_values(&bounce, -10, 10); + lv_anim_set_duration(&bounce, 500); + lv_anim_set_reverse_duration(&bounce, 500); + lv_anim_set_repeat_count(&bounce, LV_ANIM_REPEAT_INFINITE); + lv_anim_set_path_cb(&bounce, lv_anim_path_ease_in_out); + lv_anim_start(&bounce); + + lisael_aac_play_file("/sdcard/routines/bravo.mp3"); + lv_obj_delete_delayed(toast, 2200); +} + +// --- camera (scroll the world so the ball stays centred, clamped) ------------ +static void update_camera(void) +{ + int camx = (int)s_bx - PA_W / 2; + int camy = (int)s_by - PA_H / 2; + if (camx < 0) camx = 0; + if (camx > WORLD_W - PA_W) camx = WORLD_W - PA_W; + if (camy < 0) camy = 0; + if (camy > WORLD_H - PA_H) camy = WORLD_H - PA_H; + lv_obj_set_pos(s_world, -camx, -camy); +} + +// --- maze build / load ------------------------------------------------------- +static void load_maze(void) +{ + generate_maze(); + draw_world(); + + lv_obj_set_pos(s_goal, s_goal_x - 16, s_goal_y - 16); // world coords + + s_bx = CW / 2; s_by = CH / 2; // start in the top-left cell + s_vx = s_vy = 0.0f; + lv_obj_set_pos(s_ball, (int)s_bx - BALL_R, (int)s_by - BALL_R); + update_camera(); + s_won = false; +} + +static float collide_axis(float pos, float other, bool is_x, float *vel) +{ + float bx = is_x ? pos : other; + float by = is_x ? other : pos; + for (int i = 0; i < s_nwalls; i++) { + const rect_t *w = &s_walls[i]; + float bl = bx - BALL_R, br = bx + BALL_R, bt = by - BALL_R, bb = by + BALL_R; + if (br > w->x && bl < w->x + w->w && bb > w->y && bt < w->y + w->h) { + if (is_x) { + if (*vel > 0) pos = w->x - BALL_R; + else if (*vel < 0) pos = w->x + w->w + BALL_R; + } else { + if (*vel > 0) pos = w->y - BALL_R; + else if (*vel < 0) pos = w->y + w->h + BALL_R; + } + *vel = reflect_v(*vel); // bounce, scaled by impact speed + } + } + return pos; +} + +static void next_maze_cb(lv_timer_t *t); // fwd decl + +static void tick_cb(lv_timer_t *t) +{ + (void)t; + if (s_won || !s_imu) { + return; + } + icm42670_value_t a; + if (icm42670_get_acce_value(s_imu, &a) != ESP_OK) { + return; + } + float ax = MAZE_AX_SIGN * a.MAZE_AX_SRC; + float ay = MAZE_AY_SIGN * a.MAZE_AY_SRC; + + s_vx = (s_vx + ax * ACC_GAIN) * FRICTION; + s_vy = (s_vy + ay * ACC_GAIN) * FRICTION; + if (s_vx > VMAX) s_vx = VMAX; else if (s_vx < -VMAX) s_vx = -VMAX; + if (s_vy > VMAX) s_vy = VMAX; else if (s_vy < -VMAX) s_vy = -VMAX; + + s_bx += s_vx; + if (s_bx < BALL_R) { s_bx = BALL_R; s_vx = reflect_v(s_vx); } + if (s_bx > WORLD_W - BALL_R) { s_bx = WORLD_W - BALL_R; s_vx = reflect_v(s_vx); } + s_bx = collide_axis(s_bx, s_by, true, &s_vx); + + s_by += s_vy; + if (s_by < BALL_R) { s_by = BALL_R; s_vy = reflect_v(s_vy); } + if (s_by > WORLD_H - BALL_R) { s_by = WORLD_H - BALL_R; s_vy = reflect_v(s_vy); } + s_by = collide_axis(s_by, s_bx, false, &s_vy); + + lv_obj_set_pos(s_ball, (int)s_bx - BALL_R, (int)s_by - BALL_R); + update_camera(); + + float dx = s_bx - s_goal_x, dy = s_by - s_goal_y; + if (dx * dx + dy * dy <= (float)(GOAL_R * GOAL_R)) { + s_won = true; + ESP_LOGI(TAG, "maze solved"); + celebrate(s_root); + lv_timer_t *adv = lv_timer_create(next_maze_cb, 2400, NULL); + lv_timer_set_repeat_count(adv, 1); + } +} + +static void next_maze_cb(lv_timer_t *t) +{ + load_maze(); + lv_timer_delete(t); +} + +static void back_cb(lv_event_t *e) +{ + (void)e; + if (s_tick) lv_timer_pause(s_tick); + lisael_ui_goto(LISAEL_MODE_HOME); +} + +lv_obj_t *lisael_screen_maze(void) +{ + if (s_root) { + if (s_tick) lv_timer_resume(s_tick); + load_maze(); // fresh maze on re-entry + return s_root; + } + + s_root = lv_obj_create(NULL); + lv_obj_set_style_bg_color(s_root, lv_color_hex(0xEAF3FF), 0); + lv_obj_set_style_bg_grad_color(s_root, lv_color_hex(0xFFFFFF), 0); + lv_obj_set_style_bg_grad_dir(s_root, LV_GRAD_DIR_VER, 0); + + lv_obj_t *back = lv_button_create(s_root); + lv_obj_add_style(back, lisael_ui_style_button(), 0); + lv_obj_set_size(back, 64, 56); + lv_obj_align(back, LV_ALIGN_TOP_LEFT, 6, 6); + lv_obj_add_event_cb(back, back_cb, LV_EVENT_CLICKED, NULL); + lv_obj_t *bl = lv_label_create(back); + lv_label_set_text(bl, LV_SYMBOL_LEFT); + lv_obj_center(bl); + + lv_obj_t *title = lv_label_create(s_root); + lv_label_set_text(title, "Penche pour guider"); + lv_obj_set_style_text_font(title, &lisael_font_18, 0); + lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16); + + // Viewport: bordered, clips the (larger) scrolling world. + s_area = lv_obj_create(s_root); + lv_obj_remove_style_all(s_area); + lv_obj_set_pos(s_area, PA_X, PA_Y); + lv_obj_set_size(s_area, PA_W, PA_H); + lv_obj_set_style_radius(s_area, 10, 0); + lv_obj_set_style_border_width(s_area, 3, 0); + lv_obj_set_style_border_color(s_area, lv_color_hex(0x495ED4), 0); + lv_obj_set_style_clip_corner(s_area, true, 0); + lv_obj_clear_flag(s_area, LV_OBJ_FLAG_SCROLLABLE); + + // World canvas (the whole maze), child of the viewport. + s_wbuf = heap_caps_malloc((size_t)WORLD_W * WORLD_H * sizeof(uint16_t), + MALLOC_CAP_SPIRAM); + s_world = lv_canvas_create(s_area); + if (s_wbuf) { + lv_canvas_set_buffer(s_world, s_wbuf, WORLD_W, WORLD_H, LV_COLOR_FORMAT_RGB565); + } + lv_obj_set_pos(s_world, 0, 0); + + // Goal star + ball are children of the world (so they scroll with it). + s_goal = lv_image_create(s_world); + lv_image_set_src(s_goal, &lisael_ic_star); + lv_obj_set_size(s_goal, 32, 32); + lv_image_set_inner_align(s_goal, LV_IMAGE_ALIGN_CONTAIN); + + s_ball = lv_obj_create(s_world); + lv_obj_remove_style_all(s_ball); + lv_obj_set_size(s_ball, BALL_R * 2, BALL_R * 2); + lv_obj_set_style_radius(s_ball, BALL_R, 0); + lv_obj_set_style_bg_opa(s_ball, LV_OPA_COVER, 0); + lv_obj_set_style_bg_color(s_ball, lv_color_hex(0xE8590C), 0); + lv_obj_set_style_shadow_width(s_ball, 6, 0); + lv_obj_set_style_shadow_color(s_ball, lv_color_hex(0x00000040), 0); + + if (!imu_init() || !s_wbuf) { + lv_obj_t *err = lv_label_create(s_root); + lv_label_set_text(err, "Capteur indisponible"); + lv_obj_set_style_text_align(err, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_align(err, LV_ALIGN_BOTTOM_MID, 0, -10); + } + + load_maze(); + s_tick = lv_timer_create(tick_cb, TICK_MS, NULL); + return s_root; +} diff --git a/main/ui/ui.h b/main/ui/ui.h index be3aa64..863c5dc 100644 --- a/main/ui/ui.h +++ b/main/ui/ui.h @@ -51,6 +51,7 @@ lv_obj_t *lisael_screen_calm(void); lv_obj_t *lisael_screen_clock(void); lv_obj_t *lisael_screen_games(void); lv_obj_t *lisael_screen_balloons(void); // "Calme ta colère" anger game +lv_obj_t *lisael_screen_maze(void); // "Labyrinthe" — tilt-maze (IMU) lv_obj_t *lisael_screen_histoires(void); // "Histoires" — vignette podcasts lv_obj_t *lisael_screen_wifi_setup(void); // SoftAP provisioning info screen lv_obj_t *lisael_screen_lire(void); // "Lire" learn-to-read game