Arcade — les 8 consoles du Tab5¶
Déplacé de
Tab5/README.mdle 25/09/2026 (audit, lot 7) : les jeux faisaient ≈ 80 % de ce README, queAGENTS.mdimpose de lire avant toute modification. À lire ici avant de toucher un jeu ; le reste du firmware n'en dépend pas. Les chemins cités (tab5-arcade.yaml,ui_components/…,jeux_registre.cpp) sont relatifs àTab5/.
Un module, absent par défaut sans IMU¶
Depuis le 18/10/2026 (ADR-0065), les jeux sont un
module : Tab5/paquets/tab5-arcade.yaml apporte tout ce qu'ils ajoutent au firmware —
leurs fichiers C++ (esphome: includes:, dont la table des consoles
Tab5/jeux/jeux_registre.cpp), leurs couleurs et la police d'échecs, leurs 9 pages LVGL,
les scripts d'ouverture et la traduction de leurs pages au démarrage. La substitution
tab5_jeux (oui / non, dans Tab5/user_entities.yaml ou -s tab5_jeux …) le choisit :
oui par défaut sur le Tab5, non sur une carte sans IMU (la Waveshare 7B : la moitié des
consoles se jouent en inclinant). Sans lui, tab5-arcade-absente.yaml donne au reste du
firmware les mêmes prises, vides (scripts tab5_arcade_open et tab5_jeux_traduire, table de
consoles vide jeux_absents.cpp) : l'Arcade n'est plus un écran, « Jeux » quitte la roue de
navigation et le tap « auto » de la manette ouvre le lecteur audio (popup Musique ; l'appui
long reste la télécommande). Le reste du firmware ne nomme
aucun jeu : il passe par GameRegistry (tab5_registry.h) ; tests/test_module_jeux.py
le vérifie, et la CI compile le Tab5 avec les jeux (build) et la 7B sans eux
(build-carte-7b).
Architecture commune¶
Toutes les consoles suivent la même architecture : chacune est sa propre
page LVGL plein écran 1280×720 (page_marble, page_chess… déclarées
skip: true dans tab5-arcade.yaml, pour que le swipe ne puisse pas y naviguer)
— et non un overlay empilé sur page_main, comme c'était le cas avant la
migration en pages (cbfe8d1). Exception ADR-0009 : pas de chrome modal. YAML
réduit à des conteneurs vides, tout le contenu construit en C++, lv_timer créé
à l'ouverture et détruit à la fermeture, persistance NVS, zéro dépendance Home
Assistant ou réseau.
Jeu fermé, rien ne reste réservé (26/09/2026, lot 4 de l'audit des ressources) :
l'état d'une console — tableaux, pointeurs LVGL, tampons texte, brouillons d'IA — vit
dans des blocs pris par open() et rendus par close() (game_mem_new() /
game_mem_free(), game_common.h) : MemPref::Internal pour ce qui est lu à chaque
tick, MemPref::Psram pour ce qui ne l'est qu'une fois par coup (historiques, piles
d'annulation). Les objets LVGL du jeu sont détruits à la fermeture (ui_destroy(),
conteneurs YAML conservés) et reconstruits à l'ouverture. Ne survivent, hors blocs,
que quelques octets par jeu : l'état ouvert/fermé lu par le registre, les dernières
lectures IMU (dispatch_imu() les envoie aussi aux jeux fermés), le NvsSlot (le
recréer ferait fuir un backend de préférences à chaque ouverture) et les filtres ou
graines d'aléa dont la remise à zéro changerait le jeu. Exception voulue : une
partie d'échecs ou de Trial Poursuite en cours garde son état (bloc Play) pour
reprendre à l'identique ; il est rendu dès qu'elle est finie. Mesure : 46,8 Ko de RAM
interne et 34,3 Ko de PSRAM statiques → 377 o pour les 8 consoles.
Une exception à l'orientation : « Neon Apron » (#3) bascule LVGL en
portrait 720×1280 à l'ouverture et restaure rotation: 270 à la fermeture —
un flipper couché sur le côté ne ressemble à rien. C'est la seule console qui
touche à l'orientation ; voir sa section plus bas avant d'en écrire une autre.
| # | Console | Module C++ | Script d'ouverture | Icône MDI |
|---|---|---|---|---|
| 1 | Fil d'Or — roguelite de bille | marble_game |
tab5_marble_open |
F0E95 circle-double |
| 2 | Arcanoïde — casse-briques | arkanoid_game |
tab5_arkanoid_open |
F0570 view-grid |
| 3 | Neon Apron — flipper portrait | pinball_game |
tab5_pinball_open |
F05DD bullseye |
| 4 | Coureur d'Or — Lode Runner | lode_game |
tab5_lode_open |
F15A2 ladder |
| 5 | Go Tab — jeu de Go | go_engine + go_ai + go_game |
tab5_go_open |
F0B38 circle-multiple |
| 6 | Trial Poursuite — quiz | trivia_game |
tab5_trivia_open |
F134A head-question |
| 7 | Dames Tab — dames 10×10 | draughts_engine + draughts_ai + draughts_game |
tab5_draughts_open |
F013A checkerboard |
| 8 | Roi Noir — échecs FIDE | chess_ai + chess_game |
tab5_chess_open |
F0857 chess-king |
Page Arcade (ui_components/game_selector.yaml)¶
Page LVGL dédiée page_arcade. Grille régulière 4 × 2, toutes les cartes au
même format (298 × 252). Colonnes x = 20 / 334 / 648 / 962, rangées
y = 132 / 402. Ouverture : le script tab5_arcade_open, appelé par le tap du
bouton manette (geste « auto ») et par « Jeux » dans la roue de navigation (passant
par tab5_ecran_ouvrir). Le tap sur la température de la serre (btn_serre_games)
l'ouvrait jusqu'au 10/10/2026 ; il change désormais la zone à gauche de l'horloge
(ADR-0051).
La croix en haut à droite du sélecteur ramène à page_main.
Chaque carte fait trois choses, dans cet ordre :
script.execute: tab5_games_close_all— ferme le jeu en cours ;lvgl.page.show: page_<jeu>— navigue vers la page du jeu ;script.execute: tab5_<jeu>_open— construit et ouvre la console.
Ne pas recopier la liste des fermetures dans les cartes : elle vit dans
GameRegistry::kGames (jeux_registre.cpp), que tab5_games_close_all
(tab5-navigation.yaml) se contente d'appeler — un seul endroit à maintenir.
Règles à respecter pour ajouter une 9ᵉ console¶
Une console n'est intégrée que si les sept points suivants sont faits. Un seul
oubli et le jeu est invisible, ou le firmware ne compile pas. Tout se passe dans le
module (tab5-arcade.yaml, Tab5/jeux/, ses composants ui_components/) : le reste du
firmware ne change pas, et ne doit nommer ni la page, ni le script, ni le namespace du jeu
(tests/test_module_jeux.py), sinon le firmware sans jeux ne compile plus :
tab5-arcade.yaml→includes:: tous les.het.cpp, y compris les en-têtes de données inclus par le.cpp(c'est ce qui manquait pourtrivia_questions.h, et la compilation échouait dessus) ;tab5-arcade.yaml→ une nouvelle entrée souslvgl: pages::- id: page_<jeu>avecskip: trueetscrollbar_mode: "OFF", dont l'unique widget est!include ui_components/<jeu>_game.yaml. Leskip: truen'est pas cosmétique : sans lui, un swipe sur le dashboard peut atterrir sur la page du jeu, timer non démarré et pointeurs non injectés ;tab5-arcade.yaml→ scripttab5_<jeu>_openqui injecte les pointeurs LVGL ;jeux_registre.cpp→ une ligne dansGameRegistry::kGames(libellé,is_open,close,on_imu, etimu_fastàtrueuniquement si le jeu pilote à l'inclinaison). C'est la seule liste : la fermeture globale (tab5_games_close_all), le poll IMU 1/10/30 Hz, le dispatch des 3 axes et le text_sensor « Écran courant » la lisent tous.tools/check_tab5_registry.py(joué parpytest) échoue si un*_game.hn'y figure pas, ou si un<Namespace>::is_open()réapparaît dans un YAML ;game_selector.yaml→ une ligne!include { file: arcade_card.yaml, vars: { game, x, y, bg, icon, title, subtitle } }dans la grille (template des cartes depuis le lot 8e ;color_arcade_<jeu>_accentdoit exister) ;- le
.cppinclutgame_common.h(helpers partagés :mk_rect/mk_label,show/set_bg/set_border/set_text_if/set_text_color_if,clampf,xorshift32_next,NvsSlot<T>pour la NVS ; depuis le lot 8f :timer_period_syncpour un tick adaptatif,topn_insertpour un classement,tilt_calibrate/tilt_smoothpour l'inclinaison,accel_delta_norm/shake_firepour une secousse — le jeu garde ses seuils) au lieu de les recopier, et garde sa palette locale<Jeu>::Paldans son.h—tab5_custom.hn'est jamais touché pour un jeu (ADR-0014). Un moteur de règles (échecs, Go, dames) a son test hôte C++ danstools/(test_*.cpp, compilé par g++ dans le jobpythonde la CI, qui fait foi) ; échecs et dames gardent aussi un miroir Python, pour le poste de dev qui n'a pas de g++. - Langue (lot 4b, 27/09/2026) : chaque texte affiché passe par
tr("…")(tr_noop("…")dans une table, puistr(table[i])à l'affichage), avec sa ligne dansTab5/lang/en.yaml; la page du jeu s'ajoute à la liste d'i18n_apply_boot()du scripttab5_jeux_traduire(tab5-arcade.yaml, lancé par l'on_boot-100) pour les textes posés par son YAML. Le nom de la console ne se traduit pas : c'est aussi le libellé « Écran courant » lu par HA. Voirtranslations.md.
Ses couleurs (color_<jeu>_*, color_arcade_<jeu>_accent) vont dans le color: de
tab5-arcade.yaml. Les icônes MDI utilisées doivent en outre figurer dans la liste glyphs de
mdi_font_56 / mdi_font_45 (tab5-styles.yaml, polices partagées) : une icône absente de la
sous-police ne s'affiche pas du tout, sans le moindre message d'erreur. La
règle 7 de tools/check_tab5_code_rules.py (pytest) l'attrape : icône affichée
sans son glyphe, glyphe déclaré jamais affiché, ou icône posée depuis une fonction
C++ absente de MDI_CODE_TARGETS.
Marble Roguelite — « Fil d'Or »¶
Petit roguelite de bille piloté à l'inclinaison (BMI270), plein écran 1280×720, entièrement local : il tourne sans Home Assistant et sans réseau.
Lancer / quitter¶
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → carte « Fil d'Or » (slot 1) |
| Quitter | Hub du jeu → « Quitter » (retour propre à page_arcade : timer arrêté, run banquée) |
| Pause | Toucher le bandeau HUD pendant une partie (il n'y a volontairement pas de croix : le jeu est un flux plein cadre) |
| Calibrer | Hub → Réglages → « Calibrer à plat », ou Pause → « Recalibrer à plat ». Poser la tablette puis appuyer. |
Le tactile ne sert qu'aux menus — la bille se pilote uniquement à l'inclinaison. Élan : une secousse brève de la tablette relance la bille dans le sens où l'on penche (à défaut, dans le sens où elle roule), avec 0,9 s de recharge ; pencher, même fort, ne le déclenche pas (avant le 29/09/2026, il partait tout seul au-delà de ~38°).
Difficulté et mode dieu¶
Hub → Réglages. Les deux sont persistés et s'appliquent au lancement de la run suivante (les changer en cours de partie n'aurait pas de sens — l'écran n'est joignable que depuis le hub).
| Difficulté | PV | Pièges | Vitesse bille | Invulnérabilité | Fragments |
|---|---|---|---|---|---|
| Calme | +1 | ×0,75 | ×0,92 | 1800 ms | ×0,80 |
| Normal | — | ×1,00 | ×1,00 | 1200 ms | ×1,00 |
| Impitoyable | −1 | ×1,35 | ×1,10 | 800 ms | ×1,60 |
Monter d'un cran rapporte davantage — sinon personne ne le ferait. La difficulté ne change que le timing, jamais la géométrie : les passages restent les mêmes (garde-fou ci-dessous), un piège plus rapide reste franchissable.
Mode dieu : invulnérable (les trous se contentent de replacer la bille au départ). La run reste entièrement jouable, mais elle est hors concours — aucun fragment crédité, aucune statistique enregistrée, runs non incrémenté. Sinon l'invulnérabilité viderait la méta-progression de son sens. Le HUD l'affiche en clair ([ DIEU ] + « PV invulnérable »).
Progression façon Dark Souls¶
Une seule monnaie : les âmes, gagnées en ramassant de l'or, en ouvrant des coffres et en battant les boss. Elles servent à la fois à monter de niveau et à commercer — c'est le même arbitrage que dans Dark Souls.
Feu de camp (hub → Feu de camp) — 6 caractéristiques :
| Caractéristique | Effet par niveau | Max |
|---|---|---|
| Vitalité | +1 point de vie | 5 |
| Résistance | +300 ms d'invulnérabilité ; bouclier par salle dès le niveau 3 | 5 |
| Finesse | −1 px de rayon (bille plus difficile à toucher) | 4 |
| Agilité | +12 % de réponse à l'inclinaison | 5 |
| Élan | +60 de vitesse maximale | 5 |
| Découverte | +15 % d'âmes et +12 % de chance de butin en coffre | 5 |
Le coût d'un point suit le niveau total — 60 + 14·L + L² — donc monter n'importe quelle caractéristique renchérit toutes les autres. Il faut choisir une orientation, exactement comme un build DS.
Finesse ne peut que réduire le rayon, jamais l'augmenter. C'est délibéré : le garde-fou de traversabilité prouve les parcours au rayon maximal (11 px), donc sa preuve reste valable quelle que soit la progression.
Objets — 10 pièces, découvertes dans les coffres au trésor, lâchées par les boss (Némésis en salle 5, le Trône en salle 6, butin garanti), ou achetées chez le Marchand. On en équipe 2 à la fois (hub → Équipement, un appui fait défiler). Revente à la moitié du prix — un objet vendu est automatiquement déséquipé. La Couronne fêlée est volontairement à double tranchant (+50 % d'âmes, −1 PV).
Un coffre donne 25 à 60 âmes, plus un jet de butin modulé par Découverte. Si la collection est complète, le butin est converti en âmes plutôt que perdu.
Ce qui est persisté (NVS, survit aux reboots et aux OTA)¶
MarbleSave (voir marble_game.h) via esphome::global_preferences — aucune dépendance HA :
âmes, runs, victoires, meilleur temps, salle la plus profonde, les 6 caractéristiques, le masque des objets possédés, les 2 emplacements d'équipement, la teinte de bille, la difficulté, le mode dieu et l'offset de calibration.
Le layout est validé par un magic (SAVE_MAGIC) : le modifier oblige à bumper la constante, sinon une vieille sauvegarde serait relue de travers (elle est alors rejetée et remise à zéro). Historique : FOR1 initial → FOR2 (difficulté/dieu) → FOR3 (âmes, caractéristiques, objets).
Design note v1¶
- Boucle : hub → 6 salles enchaînées → mort ou victoire → retour hub. Cible 2 à 5 min par run.
- Seed :
lv_tick_get() ^ 0x9E3779B9 ^ (runs × 2654435761), xorshift32. Il pilote (a) le décalage ±28 px des pickups — annulé si la nouvelle position tombe dans un mur, (b) le déphasage des scies/orbes, (c) le tirage des 3 boons proposés. Les layouts eux-mêmes restent fixes : c'est le contenu qui varie, pas la lisibilité. - Salles : 1 Seuil (★☆☆☆, tuto implicite) · 2 Couloirs (★★☆☆, serpentin + scie) · 3 Forge (★★☆☆, tapis d'accélération, or au contact des pointes) · 4 Sanctuaire (★★★☆, route haute sûre vs route basse à trous mieux dotée) · 5 Némésis (★★★★, 2 orbes en orbite + chasseuse + glu) · 6 Trône (★★★★, 3 runes puis portail central gardé).
- Pièges (6) : pointes fixes, scie oscillante, trou/vide, zone de glu, tapis d'accélération, orbe en orbite — plus la chasseuse qui poursuit la bille (salles 5-6).
- Bonus (6) : or, bouclier (1 coup), aimant, frein, dash (recharge l'élan tout de suite), rune d'objectif.
- Boons intra-run (10), 3 proposés au choix après les salles 2 et 4 : Main d'Ariane, Cœur de braise, Bourse tressée, Aimant mineur, Semelles lourdes, Élan, Peau de bronze, Œil du dédale, Seconde chance, Pas de velours.
- Méta (5) : Vigueur (+1 PV, ×3), Filon (+12 % fragments, ×3), Main sûre (pilotage plus doux, ×2), Relique (bouclier au départ, ×1), Teinte (cosmétique, ×2).
- Balance v1 : 3 PV de base (+1 par Vigueur, ± la difficulté), or = 10/pickup, dégât = retour au départ de la salle + invulnérabilité. Salle 1 volontairement généreuse (1 seul piège, sortie visible) ; salle 6 exigeante mais lisible (orbes télégraphiés par leur orbite régulière autour du portail). Une run perdue rapporte quand même ses fragments — y compris si le jeu est quitté en cours de partie (sauf en mode dieu).
Garde-fou : toutes les salles restent traversables¶
python tools/check_marble_rooms.py
Lit les 6 salles directement dans marble_game.cpp (pas de duplication : le test suit le contenu) et vérifie, pour chacune, que le départ n'est pas dans un mur, que la sortie est atteignable, que chaque bonus et chaque rune l'est aussi, et que les scies laissent un passage à au moins une phase de leur course.
Méthode : BFS sur une grille d'occupation du centre de la bille, murs dilatés du rayon (11 px). La dilatation utilise le rectangle et non le vrai arrondi de Minkowski aux coins — elle bloque donc un peu plus que la réalité, ce qui rend le résultat sûr : un chemin trouvé par le test existe forcément en jeu. À lancer après toute modification d'une salle (testé négativement : un mur qui scelle la sortie de la salle 1 est bien détecté).
Le décalage de position des bonus par le seed est en plus contraint côté C++ par segment_clear() : la nouvelle position doit être reliée en ligne droite à l'ancienne, bille dilatée comprise. « Ne pas être dans un mur » ne suffisait pas — un bonus aurait pu sauter de l'autre côté d'une paroi fine et devenir inatteignable.
Notes techniques / perf¶
- Physique à 30 Hz (
lv_timer33 ms) créé à l'ouverture et détruit à la fermeture → zéro tick gameplay hors jeu. - 3 sous-pas de collision par frame (anti-tunnelling à 650 px/s), résolution cercle/AABB avec réflexion sur la normale.
- Objets LVGL préalloués à l'ouverture (pool de 48 entités + bille + 4 bandes de vignette) puis recyclés par
show/hide+set_pos: aucune allocation dans la boucle. Les libellés du HUD ne sont réécrits que si leur valeur change. - IMU : les 3 axes d'accélération sont
internal: true(ils saturaient l'API HA pour rien) ; la cadence de poll est adaptative — 100 ms au repos, 33 ms quand le jeu est ouvert (stop_poller()/start_poller(), carset_update_interval()seul ne re-régle pas le poller déjà enregistré). - Feedback de dégât : 4 bandes de bord fines + clignotement de la bille + micro-tremblement — pas de shake plein écran (il invaliderait 1280×672 à chaque frame).
Arcanoïde — casse-briques rétro Atari¶
Casse-briques style Arkanoid / Breakout, plein écran 1280×720, esthétique rétro Atari 80's (fond sombre, briques colorées par rangée, contraste fort). Entièrement local : aucun réseau ni HA requis.
Lancer / quitter¶
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → carte « Arcanoïde » (slot 2) |
| Quitter | Hub du jeu → « Quitter » (retour page_arcade : timer arrêté, score sauvegardé) |
| Pause | Toucher le bandeau HUD pendant une partie (pas de croix : flux plein cadre, exception ADR-0009) |
| Lancer la balle | Tap sur l'écran quand la balle est collée à la raquette |
Contrôles (réglables dans le hub → Réglages)¶
| Mode | Principe |
|---|---|
| Inclinaison (BMI270) | Incliner la tablette gauche/droite déplace la raquette. Mapping rotation 270° : X_écran = −tilt_Y. Deadzone radiale + lissage + calibration « à plat » (offset NVS). |
| Boutons tactiles | Deux zones semi-transparentes (opa 50 %) dans les coins bas gauche/droite du playfield. Hold = déplacer, release = stop. |
| Les deux (défaut) | Somme clampée des deux entrées. |
Sensibilité IMU réglable (5 crans). Calibration dans Réglages ou Pause.
Gameplay¶
- 8 niveaux (mur plein, pyramide, colonnes, damier, couloirs, forteresse, zigzag, boss final).
- 3 vies, score + multiplicateur combo (cassages rapides < 1,2 s).
- Types de briques : normales (1 coup, couleur par rangée), renforcées (2-3 coups), indestructibles, bonus (lâchent un power-up).
- Power-ups : élargir / rétrécir raquette, balle lente / rapide, multi-balles (max 3), colle, extra vie (rare).
- Collision balle/raquette : angle selon le point d'impact (pas de rebond vertical monotone).
- Difficulté progressive : vitesse balle + densité par niveau.
Classement¶
Top 10 local en NVS (score, niveau atteint, mode de contrôle, uptime). Écran « Classement » dans le hub, avec bouton « Effacer les scores » (demande confirmation ; « Annuler » prend la place du bouton, un double appui n'efface rien).
Notes techniques¶
- Physique 30 Hz (
lv_timer33 ms), 3 sous-pas anti-tunnelling. - Pool LVGL préalloué : 120 briques + 3 balles + 1 raquette + 8 power-ups. Zéro allocation dans le tick.
- HUD réécrit seulement si valeur change.
- Persistance
ArkanoidSave(magicARK1) viaesphome::global_preferences. - Fichiers :
arkanoid_game.h,arkanoid_game.cpp,ui_components/arkanoid_game.yaml. - Couleurs :
Arkanoid::Pal::*danstab5_custom.h.
Neon Apron — flipper portrait¶
Flipper (pinball) néon, plein écran PORTRAIT 720×1280. C'est la seule console de l'Arcade qui ne se joue pas dans l'orientation du dashboard : on tient la tablette debout, comme devant une borne. Entièrement local, aucun réseau ni HA.
Remplace « Flip Noir » (supprimé en
697e2e9). L'ancien jeu était une table paysage dessinée avec des rectangles LVGL pivotés à chaque frame (transform_rotation) : laid et cher. Rien n'en a été réutilisé, sauf les principes de collision. Ne pas ressusciter cette approche.
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → carte « Neon Apron » (slot 3) |
| Quitter | Hub du jeu → « Quitter » (restaure le paysage) |
| Pause | Toucher le fronton pendant une partie |
| Calibrer | Hub → Réglages → « Calibrer à plat », ou Pause → « Recalibrer à plat » |
Orientation — ce qu'il faut savoir¶
À l'ouverture, le jeu appelle LvglComponent::set_rotation(0) : LVGL passe en
720×1280 et l'écran devient portrait. À la fermeture, set_rotation(270)
restaure exactement l'état de repos du dashboard.
Trois conséquences non évidentes :
rotation: 270doit rester danstab5-styles.yaml. ESPHome ne compile le support de rotation (buffer dédié + client PPA) que si la clé existe. Sans elle,set_rotation()se contente d'unESP_LOGWet le flipper resterait couché. Le bloclvgl:porte aussi unid: tab5_lvglexplicite — c'est le seul moyen d'atteindre le composant depuis une lambda.- Le tactile suit tout seul.
LVTouchListenerappliquerotate_coordinates(), qui lit la rotation courante à chaque lecture d'indev. La calibration native 720×1280 detab5-hardware.yamlreste valable dans les deux sens — rien à recalibrer. - Le portrait est plus rapide que le paysage. À
rotation: 0, le chemin de flush d'ESPHome tombe dans sondefault:et ne fait aucune rotation logicielle ni passage PPA. Le dashboard paysage, lui, paie une rotation 270 à chaque flush (c'est la piste deslvgl took a long timeobservés au boot).
Si l'écran apparaît à l'envers dans les mains d'Axel : Réglages → Orientation → retournée (bascule 0 ↔ 180, persistée, effet immédiat). Le sens dépend du côté vers lequel on tourne la tablette, il n'y a pas de « bon » réglage universel.
Contrôles¶
- Moitié gauche / moitié droite de l'écran (maintien) = flipper gauche / droit
- Bas au centre (maintien puis relâcher) = lanceur ; la jauge de puissance s'affiche dans le fronton. Un tir faible ne sort pas du couloir, seul un tir fort fait le tour de l'arche et atteint le skill shot.
- Secouer la tablette = nudge. La gravité de la table est constante : l'IMU ne sert qu'au nudge, jamais à piloter la bille. On peut donc jouer tablette posée à plat, tenue droite ou inclinée.
- 3 nudges en moins de 2,6 s = TILT : flippers morts 2,5 s.
Gameplay¶
- 3 billes (+1 bille bonus à 250k et 750k).
- 3 bumpers, 2 slingshots, banque de 3 cibles drop, 3 rollovers (skill shot).
- Banque complétée : alternance Bumper Frenzy (bumpers ×3 pendant 9 s) et Multiball 2 billes (tout ×2 tant que 2 billes sont en jeu).
- Skill shot : une lane tirée au sort à chaque service, 25 000 points.
- Top 10 NVS + carrière (parties, cumul, meilleure bille, tilts, multiballs).
- Une partie quittée en cours est comptée : sinon on pourrait quitter pour effacer un mauvais score.
Notes techniques¶
- Physique 50 Hz (
lv_timer20 ms), 4 sous-pas anti-tunnelling ; tick ralenti à 200 ms dans les menus. - Collision : segments (rails, guides), contrainte circulaire pour l'arche
(un seul
sqrtfau lieu de 18 tests de segment, et aucune facette), cercles (bumpers), capsules (cibles), segments mobiles pour les flippers (l'impulsion vient de la vitesse du bras au point de contact). - Clapet anti-retour en haut du couloir du lanceur : segment qui n'existe que pour une bille qui descend.
- Rendu : zéro
transform_rotation. Table construite une fois —lv_arcpour l'arche,lv_lineà boîte englobante serrée pour rails et flippers, dégradés verticaux pour le volume. Seuls bille, flippers, flashs et ressort bougent. Lampes et inserts sont mis en cache (repeints uniquement au changement d'état). LV_USE_LINEn'est activé par ESPHome que si un widgetline:figure dans la config :pinball_game.yamlen déclare un, masqué, uniquement pour ça. Ne pas le supprimer, la compilation échouerait surlv_line_create.- Persistance
PinballSave(magicPIN2, pasPIN1— layout incompatible avec l'ancien Flip Noir) viaesphome::global_preferences. - Sons : stubs
sfx_*()vides (même convention qu'Arcanoïde). Le haut-parleur est monopolisé par le pipeline vocal HA ; l'octetsfxde la sauvegarde est réservé pour le jour où un bip local sera possible, sans bump de magic. - Fichiers :
pinball_game.h,pinball_game.cpp,ui_components/pinball_game.yaml. - Couleurs :
Pinball::Pal::*danstab5_custom.h, miroirscolor_pinball_*.
Coureur d'Or — Lode Runner (1983)¶
Clone du Lode Runner de Broderbund, plein écran 1280×720 sur page_lode, 100 % local.
Ramasser tout l'or d'un niveau fait apparaître l'échelle de sortie ; les gardes vous
poursuivent, et la seule arme est le trou creusé dans une brique.
Lancer / quitter¶
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → carte « Coureur d'Or » (slot 4) |
| Quitter | Hub du jeu → « Quitter » (retour page_arcade : timer arrêté, progression sauvée) |
| Pause | Toucher le bandeau HUD pendant une partie |
Contrôles¶
Pad tactile construit en C++ (calque pad de ui_components/lode_game.yaml, qui ne
déclare que 5 conteneurs vides — un de plus que les autres jeux, justement pour ce pad) :
D-pad 4 directions + deux zones de creusement. Lode::on_dig(bool right) creuse la brique
en bas à droite ou en bas à gauche du personnage. Pas de saut : c'est du Lode Runner, on
grimpe aux échelles et on se suspend aux barres.
Niveaux¶
10 niveaux nommés, débloqués dans l'ordre (LODE_N_LEVELS) :
Premier filon · Escalier · Le pendu · Le puits · Le peigne · Faux chemin · Le nid ·
Cathédrale · Dédale · Coffre-fort. La progression (unlocked) et le top 10 sont en NVS.
Vitesse réglable¶
Hub → Réglages → Vitesse. 5 paliers (SPEEDS[]), qui changent le nombre de pixels
par tick — pas la cadence de rendu :
| Palier | Rythme |
|---|---|
| Normale | 4,4 cases/s — rythme d'origine |
| Vive | 5,7 cases/s |
| Rapide | 6,7 cases/s |
| Très rapide | 8,0 cases/s |
| Fulgurante | 10,0 cases/s — réflexes exigés |
Les gardes sautent 1 tick sur 5 et restent donc à 4/5 de la vitesse du joueur à tous les paliers : l'équilibre du jeu ne change pas, seul le tempo change.
Notes techniques¶
Les règles de grille (passable / supported / can_step / arête de creusement) vivent
dans une section isolée du .cpp et sont rejouées à l'identique par le garde-fou
tools/check_lode_levels.py (joué par pytest et la CI) sur les 10 cartes — c'est ce qui garantit qu'aucun niveau ne devient
infaisable après un ajustement. Rendu : cache par acteur (ajouté en 349baee), aucune
réallocation LVGL dans le tick.
Go Tab — Go 9×9 / 13×13 / 19×19¶
Jeu de Go plein écran 1280×720, 100 % local (NVS). Nom produit : Go Tab.
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → carte « Go Tab » |
| Quitter | Menu → « Quitter » (ADR-0009, pas de croix) |
| Menu / pause | Tap sur le bandeau HUD, ou bouton « Menu » du panneau |
Écran¶
go_game.yaml ne déclare que 4 conteneurs vides — HUD 1280×60, aire de jeu
1280×660 à y=60, calque de menus plein cadre. Tout le reste est construit en
C++ (Go::open). Le C++ suppose exactement cette géométrie : changer une valeur
dans le YAML impose de changer HUD_H / FIELD_H dans go_game.cpp.
- Goban : plateau en dégradé bois, liseré, lignes de bord épaissies, points
étoiles, coordonnées
A..T(sans le I) et1..19. Taille recalculée à chaque changement de plateau (écart 64 / 46 / 32 px en 9×9 / 13×13 / 19×19). - Pierres : un seul objet LVGL chacune, relief obtenu par dégradé vertical
(
bg_grad_dir). Le même pool de 361 objets sert de pastilles de territoire pendant le comptage — aucun widget n'est créé en cours de partie. - HUD : pastille Noir / statut central / pastille Blanc, la pastille au trait s'allume en or.
- Panneau latéral : liste des coups en police mono (2 colonnes), barre de réflexion, bouton de validation, et 4 boutons Passer / Annuler / Indice / Menu.
Règles implémentées¶
- Placement sur intersections ; capture par libertés à 0.
- Suicide interdit, sauf si le coup capture d'abord.
- Ko simple (pas de superko positionnel — assumé, documenté).
- Passe ; deux passes consécutives → écran de marquage des pierres mortes.
- Score chinois (aire) : pierres vivantes + territoire + komi 6,5 aux Blancs (komi 0,5 en partie à handicap ≥ 2).
- Handicap 2 à 9 pierres (placements standards, Blanc commence).
- Vie/mort non résolue automatiquement — c'est le joueur qui marque les groupes morts en fin de partie (toucher un groupe le bascule mort/vivant, « Tout vivant » remet à zéro), avec aperçu du territoire en direct. Une pause pendant le marquage conserve les groupes déjà marqués. C'est le fonctionnement de toutes les applications de Go : un solveur de vie/mort n'a pas sa place dans 2 Mo de firmware.
- L'IA abandonne si l'écart d'aire estimé est désespéré (≥ 45 pts, niveaux Amateur et plus) — évite de remplir le goban en salon.
Modes & IA¶
| Mode | |
|---|---|
| Joueur contre Tab | Humain Noir ou Blanc, handicap possible |
| Joueur contre joueur | Hot-seat sur le même Tab |
| Tab contre Tab | Démo automatique |
Niveaux : Débutant (choix pondéré), Amateur (1 pli), Confirmé (2 plis),
Expert (3 plis en 9×9, 2 au-delà). La recherche est bornée par le temps,
jamais par un compteur de nœuds : Ai::step(ms) rend la main au bout de la
tranche demandée et un budget CPU total (80 / 350 / 900 / 1900 ms) garantit
qu'un coup sort toujours. L'évaluation tient en trois termes — matière, sécurité
des chaînes (atari), influence par diffusion — tous calculés en **O(N) par
position grâce à une table des chaînes construite une seule fois par nœud
(chain_liberties en compteurs de génération, pas de memset par chaîne).
Contrôles¶
- Tap sur une intersection → fantôme ; second tap (ou bouton « Jouer XX ») → coup joué. La confirmation est désactivable dans les réglages, mais elle est active par défaut : en 19×19 l'écart entre intersections tombe à 32 px, soit moins qu'un doigt.
- Passer / Annuler (remonte aussi la réponse du Tab) / Indice (ou secousse BMI270) / Menu.
- Réglages : confirmation, coordonnées, marqueur du dernier coup, aperçu du territoire, secousse = indice.
Fichiers¶
go_engine.h/.cpp— règles pures (aucun LVGL/ESPHome, testable host). Tous les scratchs sont des statiques de module : le moteur tourne dans le contexte LVGL mono-thread et ne doit rien mettre de gros sur la pile.go_ai.h/.cpp— IA time-slicéego_game.h/.cpp+ui_components/go_game.yaml— UI / NVS- Tests :
tools/test_go_engine.cpp, contre le vrai C++, compilé par g++ et exécuté en CI (jobpython). Son miroir Python, qui faisait double emploi, est retiré depuis le 08/10/2026 (audit du 07/10/2026, OUT-2) : une règle modifiée se teste dans ce fichier.
NVS¶
Une partie de Go fait des centaines de coups : l'écriture flash est différée
(drapeau interne + fenêtre de 15 s) et forcée seulement à l'ouverture d'un menu,
en fin de partie et à la fermeture. Réglages, statistiques par taille/niveau et
position en cours sont conservés ; la liste des coups et la pile d'annulation ne
le sont pas (reprendre une sauvegarde restitue la position, pas l'historique).
La pile d'annulation (30 positions, 11 Ko) vit dans le bloc Cold, pris en PSRAM à
l'ouverture et rendu à la fermeture : elle n'est touchée qu'une fois par coup joué ou
annulé. Les brouillons du moteur et de l'IA (Engine::scratch_acquire(),
Ai::scratch_acquire(), ≈ 9 Ko) suivent le même cycle ; tools/test_go_engine.cpp
prend celui du moteur lui-même.
Build¶
$env:ESPHOME_ESP_IDF_PREFIX = "C:\espidf"
esphome clean tab5-ha-hmi.yaml
esphome run tab5-ha-hmi.yaml --device 192.168.x.x
Le test du moteur (tools/test_go_engine.cpp) tourne en CI : le poste de dev n'a pas de g++.
Trial Poursuite — quiz rétro-salon¶
Clone de Trivial Pursuit, plein écran 1280×720 sur page_trivia, 1 à 6 équipes,
100 % local (aucune question n'est téléchargée).
Lancer / quitter¶
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → carte « Trial Poursuite » (slot 6) |
| Quitter | Hub → « Quitter » (ADR-0009, pas de croix ; partie en cours conservée en NVS) |
Plateau¶
Une vraie roue, pas un anneau simplifié — topologie dans trivia_game.h :
- couronne extérieure de 42 cases, dont 6 QG de catégorie (indices 0, 7, 14, 21, 28, 35) ;
- 6 rayons de 5 cases reliant chaque QG au centre ;
- 1 case centrale (QG final).
Soit 73 nœuds numérotés [0..41] couronne, [42..71] rayons, [72] centre.
Chaque équipe a son camembert 6 parts ; la question finale se joue au centre.
Banque de questions¶
720 questions françaises en flash (trivia_questions.h), 120 par catégorie —
Géographie · Divertissement · Histoire · Arts & Littérature · Sciences & Nature ·
Sports & Loisirs — réparties ~40 % Facile / ~40 % Moyen / ~20 % Difficile.
QCM à 4 choix (1 bonne réponse + 3 leurres).
Un static_assert refuse la compilation en dessous de 720 questions, et le gameplay
n'en hardcode aucune : pour en ajouter, on édite trivia_questions.h et on incrémente
les compteurs TRIVIA_Q_*.
Notes techniques¶
Réglages, statistiques et partie en cours sont persistés en NVS : rouvrir la console
au milieu d'un tour restitue l'état exact (corrigé en ac12a68, la réouverture
réinitialisait le tour). Roue, HUD, modales et camemberts sont construits en C++ ; le
YAML ne déclare que 4 conteneurs vides.
Dames Tab — dames internationales 10×10¶
Plein écran 1280×720 sur page_draughts, IA embarquée time-slicée, 100 % local.
Moteur pur (règles, génération des coups, nulles) dans draughts_engine.*, IA dans
draughts_ai.*, UI et machine à états dans draughts_game.*.
Tests : tools/test_draughts_engine.cpp — le vrai générateur de coups (Draughts::Engine,
draughts_engine.cpp compilé tel quel, sans LVGL ni ESPHome), compilé par g++
sous ASan + UBSan dans le job python de la CI : perft de référence des deux variantes
(10×10 : 9, 81, 658, 4 265, 27 117… jusqu'à 1 049 442 ; 8×8 : 7, 49, 302, 1 469… jusqu'à
845 931) et tests de règles. Il fait foi ; tools/test_draughts_engine.py, son miroir Python,
rejoue les mêmes cas sans g++ (pytest).
Lancer / quitter¶
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → carte « Dames Tab » (slot 7) |
| Quitter | Hub → « Quitter » (ADR-0009, pas de croix) |
Variantes¶
Deux jeux de règles, jamais mélangés (c'est Pos.variant qui pilote tout) :
| Variante | Plateau | Règles |
|---|---|---|
Internationales (défaut, VAR_INTL10) |
10×10 | prise majoritaire obligatoire, dames volantes, le pion peut prendre en arrière |
Anglaises / checkers (VAR_ENG8) |
8×8 | pion vers l'avant uniquement, dame à portée 1 |
Les pièces capturées ne sont retirées qu'en fin de rafle (règle internationale
standard) — d'où le champ must_from qui verrouille la case de départ tant qu'une
rafle est en cours.
Nulle : quand aucun pion n'a bougé et que rien n'a été pris pendant 25 coups de chaque
camp en international (FFJD/FMJD), 40 en anglais (WCDF). Le compteur no_progress
avance d'un demi-coup à chaque coup de dame sans prise et revient à 0 à chaque coup de
pion ou prise. Aussi nulles (règles FMJD) : la même position, même trait, pour la
3ᵉ fois (les deux variantes, suivi par l'interface depuis le dernier coup irréversible) ;
en international, une dame seule contre au plus deux pièces dont une dame après
5 coups de chaque camp, contre trois pièces dont une dame après 16 coups
(eg_limit / eg_plies dans Pos, recalculés à chaque prise ou promotion, donc vus
aussi par l'IA). L'écran de fin affiche la raison de la nulle.
Niveaux d'IA¶
4 niveaux (DRAUGHTS_N_LEVELS), tous découpés en tranches par le lv_timer — jamais de
recherche bloquante :
| Niveau | Recherche |
|---|---|
| Débutant | coups légaux aléatoires (préfère les prises) |
| Amateur | profondeur 1–2 + évaluation |
| Confirmé | profondeur 2–3 + alpha-bêta |
| Expert | profondeur 3–4 + quiescence sur les prises |
Budget de nœuds par tranche : 600 (Amateur), 1 200 (Confirmé), 2 000 (Expert). Si un coup racine ne tient pas dans une tranche complète, le budget double (jusqu'à ×4), puis l'IA joue le meilleur coup de la dernière profondeur complète. Sans cette règle, le même sous-arbre était repris à l'identique à chaque tranche et l'IA ne jouait jamais.
Les listes de coups de la recherche ne sont jamais sur la pile : une liste de 96 coups
pèse 4,7 Ko, et la tâche ESPHome n'avait que 8 Ko (16 Ko depuis #150). Elles vivent
dans un tampon de 42 Ko, une ligne par ply, alloué au premier coup réfléchi (RAM
interne, PSRAM en repli) et rendu par Ai::release() à la fermeture. Les coups
racine, les coups légaux de l'interface et la pile d'annulation (12,8 Ko en tout) sont
dans des blocs pris en PSRAM à l'ouverture (Cold, Ai::Cold) et rendus à la
fermeture : ils ne sont touchés qu'une fois par coup (les coups racine, une fois par
coup racine et par profondeur), jamais à chaque nœud. Chaque coup
de l'IA est tracé dans les logs (tag dames), avec la pile libre minimale de la tâche.
Notes techniques¶
Palette locale Draughts::Pal — ni tab5_custom.h ni tab5-styles.yaml ne sont
touchés, exactement comme Chess::Pal, Go::Pal et Lode::Pal. Pool de widgets
dimensionné pour le 10×10 et réutilisé tel quel en 8×8 : 100 cases, mais pièces,
couronnes et surbrillances sur les 50 cases foncées seulement (dark_slot()). Chaque
case foncée garde ce qu'elle affiche (drawn[]) : un coup ne restyle que les 2 ou 3
cases qui changent (plus les prises). Restyler les 40 pièces à chaque tap dépassait
les 32 zones d'invalidation de LVGL, qui repeignait alors tout l'écran.
Options, statistiques et partie en cours en NVS (bumper DRAUGHTS_SAVE_MAGIC à tout
changement de layout).
Roi Noir — Échecs FIDE¶
Échiquier plein écran 1280×720, 100 % local (NVS), IA embarquée time-slicée. Nom produit : Roi Noir. Aucune dépendance HA / réseau / tablebase.
| Action | Où |
|---|---|
| Ouvrir | Sélecteur Arcade → barre « Roi Noir » |
| Quitter | Hub → « Quitter » (ADR-0009, pas de croix) |
| Pause | Tap sur le bandeau HUD, ou bouton « Menu » du panneau |
Règles implémentées¶
- Génération de coups légaux complète (pseudo-légaux filtrés : le roi ne peut
pas rester en prise) — validée par
perft(voir plus bas). - Roque (4 cas), avec pertes de droits sur déplacement/capture de tour.
- Prise en passant, posée uniquement si un pion adverse peut réellement capturer (sinon deux positions identiques auraient des clés différentes et la triple répétition raterait).
- Promotion D / T / F / C, avec fenêtre de choix ; les 4 sous-promotions sont générées (conformité perft).
- Échec, mat, pat.
- Nulles : matériel insuffisant (R/R, R+mineure/R, R+F/R+F même couleur de case), règle des 50 coups (activable), triple répétition (exacte, via clés Zobrist sur l'historique de la partie).
Limitations assumées :
- R+C+C contre R n'est pas déclaré nul (le mat y est possible avec une aide adverse) — conforme FIDE, mais surprenant pour un joueur occasionnel.
- La reprise après reboot restaure la position et les pendules (FEN en NVS), pas l'historique des coups : « Annuler » et le compteur de répétition repartent de zéro après une reprise. Reprendre depuis le hub dans la même session conserve, lui, tout l'historique.
- Pas de table de transposition (choix : RAM et simplicité) ; pas de superko.
Encodage du plateau¶
Mailbox 0x88 : board[128], index = rang*16 + colonne, hors-échiquier ssi
(sq & 0x88). Choix documenté face aux bitboards : l'ESP32-P4 est un cœur 32
bits, un test hors-plateau en un seul AND y bat un jeu de masques 64 bits, et
l'empreinte mémoire reste triviale. Pièce sur 4 bits (bit 3 = couleur).
Les tampons de coups sont globaux et indexés par ply (g_mbuf[16][220],
~20,6 Ko) : à ~11 plies de profondeur, un tableau de 220 coups par ply sur la pile
ferait déborder la stack de la tâche ESPHome. Ils ne sont pas statiques : un bloc de
~24 Ko (tampons de coups + état de recherche) est alloué au premier search_start(),
search_quick() ou perft(), en RAM interne (PSRAM en repli, tracé dans les logs,
tag chess), et rendu par search_release() à la fermeture du jeu. Aucune
allocation dans le hot-path pour autant : le bloc est pris une fois par session.
Empreinte (26/09/2026, lot 4) : jeu fermé sans partie en cours, les échecs ne
gardent que 78 o (nm) ; une partie suspendue garde son bloc Play (≈ 1,2 Ko
de RAM interne : position, coups légaux, pendules) et son historique PlayHist
(10 Ko en PSRAM) pour reprendre à l'identique, « Annuler » et triple répétition
compris. Les tables SQ64, CASTLE_MASK et Zobrist sont calculées à la compilation
et vivent en flash (plus d'init()) ; les coups « tueurs » sont dans le bloc de
recherche ; le brouillon de la notation SAN (880 o) n'est pris que le temps de
l'appel. L'historique de partie (320 demi-coups × 32 o) n'est touché qu'une fois par
coup, jamais par la recherche. Ne pas augmenter MAX_PLY_BUF ni
MAX_HIST sans re-mesurer.
Modes & niveaux d'IA¶
| Mode | |
|---|---|
| Joueur contre Tab | Humain Blancs ou Noirs |
| Joueur contre joueur | Hot-seat, « Trait aux Blancs/Noirs » |
| Tab contre Tab | Démo auto, vitesse réglable |
| Niveau | Profondeur | Quiescence | Budget CPU | Fenêtre aléatoire | Elo fictif |
|---|---|---|---|---|---|
| Pion | 1 | — | 120 ms | 160 cp | 600 |
| Cavalier | 2 | — | 350 ms | 60 cp | 900 |
| Fou | 3 | 4 plies | 800 ms | 20 cp | 1250 |
| Dame | 4 | 6 plies | 1800 ms | 0 | 1600 |
| Roi | 5 | 6 plies | 3500 ms | 0 | 1900 |
La « fenêtre aléatoire » fait tirer au sort parmi les coups dont le score est à moins de N centièmes de pion du meilleur : c'est ce qui rend le niveau Pion battable sans le rendre absurde.
Le budget est du temps CPU, pas du temps mural. La recherche est découpée en tranches de ~18 ms (deadline dure 22 ms, élargie à 40 puis 80 ms si un coup racine seul dépasse), appelées une par tick de 33 ms : le temps réel d'un coup vaut donc ~1,8× le budget. Découpage au niveau des coups racine ; dès que la profondeur 1 est terminée, un coup jouable est toujours disponible.
Contrôles¶
- Tap case d'origine → surbrillance des coups légaux (pastille = case vide, anneau = capture) → tap destination. Re-tap sur la pièce = désélection.
- Panneau : Annuler (vs Tab : annule votre coup et la réponse), Indice (recherche profondeur 2 bornée à 30 ms, recharge 8 s), Menu.
- Menu de pause : Reprendre / Annuler / Proposer nulle / Abandonner / Réglages / Quitter.
- IMU : secousse franche = indice (anti-rebond 900 ms), désactivable dans
Réglages. Le jeu n'est pas ajouté à la liste de poll rapide 30 Hz du
BMI270 (
tab5-imu.yaml) : 10 Hz suffisent pour une secousse et une partie d'échecs peut durer une demi-heure.
Figurines : police dédiée ChessPieces.ttf¶
Les polices roboto_* de tab5-styles.yaml n'embarquent que du Latin-1 : ♔♕♖ y
rendraient un carré vide. On ajoute donc une police dédiée, taillée selon la
même méthode que IconeMeteo.ttf — on n'embarque que ce qu'on affiche :
| Fichier | Tab5/fonts/ChessPieces.ttf — 16,8 Ko (source DejaVu Sans : 757 Ko) |
| Glyphes | 12 : U+2654–2659 (creux) + U+265A–265F (pleins) |
| Déclaration | chess_pieces_80, size: 80, bpp: 4 |
| Coût flash | ~26 Ko rastérisés (12 × 72 × 60 px à 4 bpp) |
| Régénération | python tools/make_chess_font.py |
| Licence | Bitstream Vera — voir Tab5/fonts/ChessPieces.LICENSE.txt |
Rendu en deux calques (technique lichess / chess.com) :
- calque corps = glyphe plein
U+265A–265F, coloré ivoire ou anthracite ; - calque contour = glyphe creux
U+2654–2659, anthracite, affiché seulement pour les pièces blanches.
Sans ce contour, une pièce ivoire disparaîtrait sur une case crème. Les 12
glyphes ont des boîtes identiques dans cette police (vérifié), la superposition
est donc pixel-parfaite. piece_utf8() encode le codepoint à la volée : l'enum
va PAWN=1 … KING=6 alors qu'Unicode range roi → pion, d'où l'index 6 - type.
bpp: 4 est obligatoire : en bpp: 1 les traits fins (couronne, crinière du
cavalier) crénellent. size: 80 est calibré — l'encre fait 72 × 60 px dans une
case de 84, et la boîte du label (94 px de hauteur de ligne) déborde uniquement
sur des pixels transparents. PIECE_DY = +1 recentre optiquement l'encre ; la
valeur est recalculée et affichée par tools/make_chess_font.py.
La notation du panneau reste textuelle (SAN français : R Roi, D Dame,
T Tour, F Fou, C Cavalier, avec désambiguïsation, x, =D,
+ / #, O-O / O-O-O) — une liste de coups se lit mieux en lettres.
L'écran de promotion affiche les 4 choix comme de vraies cases d'échiquier (crème / vert alternées) portant la figurine rendue exactement comme sur le plateau, avec le nom sous chaque tuile.
Validation du générateur : perft¶
Chess::perft_log(depth) lance perft(1..depth) sur la position initiale et
compare aux valeurs FIDE connues (20 / 400 / 8902 / 197281 / 4865609), avec le
temps en ms, via ESP_LOGI. À appeler ponctuellement en phase de debug, par
exemple depuis un on_boot de priorité basse :
- priority: 100
then:
- lambda: 'Chess::perft_log(3);'
À retirer ensuite : perft(4) bloque la boucle plusieurs centaines de ms.
Fichiers¶
chess_ai.h/.cpp— moteur pur : 0x88, make/unmake, eval, négamax αβ + quiescence + killers, FEN, SAN, perft. Aucun LVGL, aucun HA.chess_game.h/.cpp+ui_components/chess_game.yaml— UI LVGL, machine à états, NVS. Le YAML ne déclare que 4 conteneurs vides ; le calque des menus est créé en C++ comme enfant dechess_root.ChessPieces.ttf+ChessPieces.LICENSE.txt— figurines (12 glyphes).tools/make_chess_font.py— régénère le sous-ensemble de police.tools/test_chess_engine.cpp— le vrai générateur contre la suite perft, sous ASan + UBSan, en CI (jobpython) : il fait foi.tools/test_chess_perft.py, son miroir Python, rejoue la même suite sans g++ (pytest).- Palette locale
Chess::Pal:tab5_custom.hn'est pas modifié. Seul ajout àtab5-styles.yaml: la policechess_pieces_80(aucun token de couleur).
Checklist de test manuel¶
| Cas | Attendu |
|---|---|
| Mat du berger (Dh5×f7#) | « Échec et mat », partie comptabilisée |
| Pat (roi seul sans coup légal) | « Pat », nulle |
| Petit / grand roque, 2 couleurs | Roi + tour bougent, droits perdus ensuite |
| Roque interdit en échec / à travers une case attaquée | Case g1/c1 non proposée |
| Prise en passant | Le pion capturé disparaît de sa case, pas de celle d'arrivée |
| Promotion (poussée et capture) | Fenêtre D/T/F/C, la pièce choisie apparaît |
| Triple répétition (Cf3 Cf6 Cg1 Cg8 ×2) | Nulle annoncée |
| 50 coups | Nulle si l'option est active |
| Chute de pendule | Perte au temps, ou nulle si matériel insuffisant |
| Annuler pendant que le Tab réfléchit | Réflexion abandonnée, trait rendu au joueur |
| Reboot en pleine partie | « Reprendre » présent au hub, position et pendules restaurées |
Build¶
$env:ESPHOME_ESP_IDF_PREFIX = "C:\espidf"
esphome clean tab5-ha-hmi.yaml
esphome run tab5-ha-hmi.yaml --device 192.168.x.x
esphome clean est obligatoire : chess_ai.cpp et chess_game.cpp sont de
nouveaux .cpp ajoutés à includes:.