feat(games): tilt-maze game with IMU

New "Labyrinthe" game: guide a ball to the star by tilting the
box, read from the on-board ICM42670 accelerometer (BSP I2C bus,
added to REQUIRES). A perfect maze is carved by iterative
backtracking on a 12x8 grid, so there is one path with branches
and dead ends; only a 6x4 window is shown and the view scrolls to
follow the ball. The whole world is drawn into a single RGB565
PSRAM canvas (not one object per wall) and scrolled — layer-free,
keeps the render task light. The ball bounces off walls and edges
with speed-proportional restitution. Tilt axes calibrated on the
real board. Wired as the 5th tile in the games carousel; reuses
the festive bravo on win.
This commit is contained in:
L'électron rare
2026-06-21 15:04:06 +02:00
parent 14fff819f2
commit 076678fb45
4 changed files with 440 additions and 4 deletions
+2
View File
@@ -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
+10 -1
View File
@@ -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
0xFFD8A8, // orange — Coloriage
0xD0BFFF, // violet — Labyrinthe
};
// Scrollable track (horizontal, snap-center, no scrollbar).
+424
View File
@@ -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 <stdint.h>
#include <string.h>
#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;
}
+1
View File
@@ -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