Architecture logicielle & boucle de jeu
L'ossature du programme - environnement de développement, réglages, types et variables globales, machine à états - puis l'affichage des écrans, le HUD et la boucle de jeu.
Architecture logicielle et machine à états
Le programme main.c constitue le cœur du jeu : il orchestre l'initialisation du microcontrôleur, l'affichage, la lecture des commandes et le déroulement de la partie. Sa structure a été pensée autour d'une contrainte centrale rappelée tout au long de ce rapport : le PIC16F18877 est un microcontrôleur 8 bits cadencé à FOSC = 32 MHz (l'horloge interne HFINTOSC), soit un cycle d'instruction de FOSC/4 = 8 MHz, c'est-à-dire 125 ns par cycle. Surtout, ce cœur ne dispose d'aucune unité de calcul flottant matérielle : les opérations float (cos, sin, sqrt) sont émulées par logiciel et donc lentes. Le fil rouge de toute la conception logicielle découle de ce constat : éviter les calculs flottants pendant le jeu, ne jamais bloquer la boucle inutilement, ne redessiner que ce qui change, et comparer les distances au carré pour éviter les racines carrées. Chaque choix d'architecture vise ainsi la lisibilité du code et l'économie des ressources. Ce chapitre présente, de haut en bas, l'organisation de main.c.
Pas de virgule flottante matérielle : éviter les calculs flottants pendant le jeu, ne jamais bloquer la boucle inutilement, ne redessiner que ce qui change, et comparer les distances au carré pour éviter les racines carrées.
Environnement de développement : MPLAB X, XC8 et MCC
Le programme est écrit, compilé et téléversé depuis l'environnement de développement intégré MPLAB X IDE de Microchip. La compilation est assurée par le compilateur XC8, spécialisé pour les microcontrôleurs PIC 8 bits, qui traduit le code C en code machine optimisé pour l'architecture du PIC16.
Une partie de l'initialisation n'est pas écrite à la main mais générée automatiquement par le MCC (MPLAB Code Configurator). Cet outil graphique permet de configurer les périphériques (oscillateur HFINTOSC, convertisseur ADCC, bus SPI1/MSSP1, générateur de son NCO1, broches d'entrée-sortie) via une interface, puis produit un ensemble de fichiers rangés dans le répertoire mcc_generated_files. C'est ce dossier qui fournit notamment la fonction SYSTEM_Initialize, les fonctions du convertisseur ADCC_*, l'ouverture du bus SPI SPI1_Open, ainsi que les macros d'accès aux broches (par exemple BTN_B_GetValue()). L'intérêt de cette approche est double : elle réduit le risque d'erreur dans la configuration des registres (souvent fastidieuse et source de bogues) et elle permet de se concentrer sur la logique du jeu plutôt que sur les détails du matériel. Le fichier d'en-tête mcc_generated_files/mcc.h est donc inclus dans main.c pour donner accès à toutes ces fonctions et macros.
À côté de cet en-tête généré, main.c inclut plusieurs bibliothèques C standard, chacune choisie pour un rôle précis et compatible avec les contraintes de la cible : <stdint.h> pour les types entiers de taille fixe (uint8_t, uint16_t, int8_t) qui permettent de doser finement l'occupation de la RAM ; <stdbool.h> pour le type bool ; <stdlib.h> pour rand(), utilisé pour faire apparaître les astéroïdes de manière aléatoire ; <stdio.h> pour sprintf, qui fabrique les chaînes de caractères du score ; et <math.h> pour sqrtf et la constante M_PI. Ces fonctions flottantes restent volontairement cantonnées aux phases non critiques (préparation, affichage du score) et non au cœur de la boucle de jeu.
Les #define de réglage
Le programme regroupe en tête de fichier un ensemble de constantes définies par directive #define. Plutôt que de disperser des nombres magiques au fil du code, on leur donne un nom parlant. Les principales constantes sont :
#define SCREEN_W 480
#define SCREEN_H 320
#define HUD_H 24
#define CX 240 // centre X = SCREEN_W/2
#define CY 172 // centre Y = HUD_H + (SCREEN_H-HUD_H)/2
#define STAR_COUNT 18
#define COL_ORANGE 0xFD20
#define COL_ORANGE_DARK 0xCB20
#define JOY_DEADZONE 32
#define JOY_Y_SCALE 1.04
#define ANGLE_REDRAW_DOT 0.9997
#define SHIP_FRONT 18
#define SHIP_BACK 11
#define SHIP_RADIUS 18
#define MAX_BULLETS 4
#define BULLET_SPEED 9.0
#define BULLET_RADIUS 2
#define BULLET_LIFE 60
#define MAX_ASTEROIDS 4
#define AST_RADIUS 12
#define AST_SPEED 4.0
#define SPAWN_DELAY 19Le recours systématique aux #define répond à trois objectifs précis. D'abord la lisibilité : lire if (dist2 < AST_RADIUS*AST_RADIUS) est beaucoup plus parlant que if (dist2 < 144). Ensuite le réglage en un seul endroit : pour rendre le jeu plus difficile, il suffit de modifier AST_SPEED ou SPAWN_DELAY à un unique emplacement, sans risquer d'oublier une occurrence ailleurs dans le code. Enfin l'économie de mémoire : une directive #define est traitée par le préprocesseur avant la compilation. Le nom est purement et simplement remplacé par sa valeur dans le code source ; il ne consomme donc aucun octet de RAM ni de ROM à l'exécution, contrairement à une variable globale qui occuperait une case mémoire. Sur un microcontrôleur dont la mémoire est comptée, cet aspect n'est pas anecdotique.
Ces constantes traduisent également directement les règles du jeu et la géométrie de l'écran. SCREEN_W = 480 et SCREEN_H = 320 décrivent la dalle TFT en mode paysage ; HUD_H = 24 réserve une bande de score en haut ; CX = 240 et CY = 172 matérialisent le fait que le vaisseau reste fixe au centre de la zone de jeu, sous la bande de score (CY = HUD_H + (SCREEN_H - HUD_H)/2). STAR_COUNT = 18 fixe le nombre d'étoiles du fond. Les limites MAX_BULLETS = 4 et MAX_ASTEROIDS = 4 bornent les tableaux d'objets et donc la RAM mobilisée, tandis que JOY_DEADZONE = 32 définit la zone morte du joystick (en deçà de laquelle on ignore le mouvement pour ne pas faire tourner le vaisseau en permanence) et ANGLE_REDRAW_DOT = 0.9997 fixe le seuil de produit scalaire au-delà duquel l'orientation est considérée comme inchangée : si le vaisseau n'a tourné que de moins d'environ 1,4 degré, on ne le redessine pas, ce qui évite un travail graphique inutile.
Les types : enum d'état et structures
Le jeu fonctionne comme une machine à quatre états. Pour représenter proprement cet état, le programme définit un type énuméré :
typedef enum { ETAT_MENU, ETAT_REGLAGES, ETAT_JEU, ETAT_GAMEOVER } EtatJeu;Un enum associe un nom symbolique à une valeur entière (ici 0, 1, 2, 3 dans l'ordre). Manipuler etat == ETAT_JEU est à la fois plus lisible et plus sûr qu'un test sur une valeur numérique brute, car le compilateur connaît l'ensemble des valeurs possibles.
Les objets mobiles du jeu sont décrits par deux structures qui regroupent les données liées à un même objet :
typedef struct { float x, y, vx, vy; uint8_t active, life; } Bullet;
typedef struct { float x, y, vx, vy; uint8_t active; } Asteroid;Une structure rassemble dans une seule entité la position (x, y), le vecteur vitesse (vx, vy) et l'état de l'objet. Le projectile (Bullet) possède en plus un champ life, une durée de vie en nombre de tours de boucle (limitée à BULLET_LIFE = 60) au-delà de laquelle il disparaît. Le champ active indique si l'emplacement est occupé par un objet vivant. On remarque le choix du type uint8_t (entier non signé sur 8 bits, issu de <stdint.h>) pour active et life : un seul octet suffit à coder ces valeurs, ce qui économise la RAM par rapport à un int classique. Regrouper ainsi les données facilite la manipulation : on peut parcourir un tableau d'objets et traiter chacun comme un tout cohérent.
Les variables globales
Plusieurs variables sont déclarées au niveau global du fichier, donc accessibles par toutes les fonctions :
etat: l'état courant de la machine (de typeEtatJeu) ;bullets[4]etasteroids[4]: les tableaux des projectiles et des astéroïdes, dimensionnés parMAX_BULLETSetMAX_ASTEROIDS;shipCos,shipSin: les deux composantes du vecteur unitaire d'orientation du vaisseau ;shipX1..shipX3etshipY1..shipY3: les coordonnées écran des trois sommets du triangle du vaisseau, mémorisées pour pouvoir effacer l'ancienne position ;score: le score courant du joueur (chaque astéroïde détruit rapporte 10 points).
Ces variables sont déclarées globales parce qu'elles sont partagées par plusieurs fonctions appelées à des moments différents. Par exemple, etat est modifié dans main() mais lu également dans boucleJeu() ; les tableaux bullets et asteroids sont remplis lors d'un tir ou d'une apparition, déplacés à chaque tour de boucle, puis testés pour les collisions. Faire transiter toutes ces données en paramètres de fonction alourdirait inutilement le code et consommerait de la pile à chaque appel. La mémorisation des anciens sommets du vaisseau (shipX1..shipY3) illustre directement le principe d'optimisation graphique du projet : pour déplacer le vaisseau sans rafraîchir tout l'écran, on efface le triangle précédent grâce à ses coordonnées mémorisées, puis on dessine le nouveau. De même, le couple shipCos/shipSin évite tout recours à cos() ou sin() pendant le jeu : ces deux composantes sont issues directement du vecteur normalisé du joystick et représentent déjà le cosinus et le sinus de l'angle de visée.
La fonction main() et la boucle infinie
La fonction main() suit le squelette caractéristique des programmes pour microcontrôleur : une phase d'initialisation exécutée une seule fois, suivie d'une boucle infinie while(1) qui ne s'arrête jamais (un microcontrôleur n'a pas de système d'exploitation vers lequel rendre la main).
void main(void){
SYSTEM_Initialize();
ADCC_Initialize();
SPI1_Open(SPI1_DEFAULT);
ILI9488_Init();
setRotation(3);
NCO1INC = 0;
afficherMenu();
while(1){
if(etat == ETAT_MENU){
if(appuiC()){ initJeu(); etat = ETAT_JEU; }
if(appuiD()){ afficherReglages(); etat = ETAT_REGLAGES; }
}
else if(etat == ETAT_REGLAGES){
if(appuiC()){ afficherMenu(); etat = ETAT_MENU; }
}
else if(etat == ETAT_JEU){
boucleJeu();
}
else if(etat == ETAT_GAMEOVER){
if(appuiC()){ afficherMenu(); etat = ETAT_MENU; }
}
}
}La phase d'initialisation se lit ligne par ligne. SYSTEM_Initialize() configure l'horloge et les périphériques générés par le MCC. ADCC_Initialize() prépare le convertisseur analogique-numérique 10 bits pour la lecture des deux axes du joystick. SPI1_Open(SPI1_DEFAULT) ouvre le bus SPI1 en mode maître avec la configuration par défaut (SPI Mode 0), indispensable au dialogue à sens unique PIC vers écran. ILI9488_Init() envoie à l'écran la séquence d'initialisation (gamma, contrôle d'alimentation, registre MADCTL, format de pixel 18 bits, sortie de veille, allumage). setRotation(3) place l'écran en mode paysage 480x320 en écrivant le registre d'orientation MADCTL. NCO1INC = 0 met à zéro l'incrémenteur du générateur de son (NCO1) afin de garantir le silence au démarrage : la fréquence produite valant NCO1INC x 7,6294 Hz, une valeur nulle correspond à l'absence de signal. Enfin afficherMenu() dessine l'écran d'accueil avant l'entrée dans la boucle.
La boucle while(1) implémente la machine à états sous la forme d'une cascade de tests if / else if. À chaque tour, le programme regarde la valeur de etat et n'exécute que le bloc correspondant. Les transitions sont déclenchées par les boutons : depuis ETAT_MENU, un appui sur le bouton C lance une partie (initJeu() réinitialise le jeu, puis etat passe à ETAT_JEU) et un appui sur le bouton D ouvre les réglages ; depuis ETAT_REGLAGES, l'appui sur C ramène au menu ; en ETAT_JEU, le bloc délègue tout le travail à boucleJeu() ; en ETAT_GAMEOVER, l'appui sur C renvoie au menu. On observe que chaque transition associe systématiquement un changement d'affichage et une mise à jour de la variable etat, ce qui garantit la cohérence entre ce qui est dessiné et l'état logique du jeu. Le passage en ETAT_GAMEOVER, lui, n'est pas déclenché par un bouton mais à l'intérieur de boucleJeu(), lorsqu'un astéroïde entre en collision avec le vaisseau.
La gestion des boutons par détection de front
Les boutons sont actifs à l'état bas : une résistance de tirage (pull-up) maintient l'entrée à 1 au repos, et un appui force l'entrée à 0. Pour ne réagir qu'une seule fois au moment où l'utilisateur enfonce le bouton (et non en continu tant qu'il reste appuyé), le programme détecte le front descendant 1 vers 0. La fonction appuiB() illustre ce mécanisme :
bool appuiB(void){
static uint8_t ancien = 1;
uint8_t actuel = BTN_B_GetValue();
bool appui = (ancien == 1 && actuel == 0);
ancien = actuel;
return appui;
}La variable ancien est déclarée static : elle conserve sa valeur d'un appel à l'autre, contrairement à une variable locale ordinaire qui serait réinitialisée à chaque entrée dans la fonction. Elle mémorise donc l'état du bouton lors du tour de boucle précédent, initialisé à 1 (repos). À chaque appel, actuel récupère l'état présent via BTN_B_GetValue(), la macro générée par le MCC qui lit la broche RD6 (patte 29, le bouton de tir). La condition (ancien == 1 && actuel == 0) n'est vraie que lors de la transition relâché vers appui, c'est-à-dire le front descendant : un seul true est renvoyé au moment précis de l'enfoncement, même si le doigt reste posé plusieurs tours de boucle. La ligne ancien = actuel met à jour la mémoire pour le prochain tour. Cette technique constitue un anti-rebond logique simple et joue le rôle d'une détection d'événement : elle évite, par exemple, qu'un seul appui sur le bouton de tir ne génère une rafale ininterrompue de projectiles. Les fonctions appuiC() (bouton C sur RD5, patte 28 : JOUER/RETOUR/MENU) et appuiD() (bouton D sur RD4, patte 27 : RÉGLAGES) reposent exactement sur le même principe pour leurs boutons respectifs. On retrouve ici, une fois encore, le souci d'un code léger et réactif, adapté à un microcontrôleur lent : la détection de front ne coûte que la comparaison de deux octets et une affectation, sans aucun calcul flottant ni temporisation bloquante.
L'affichage des écrans, le HUD et la boucle de jeu
Après avoir décrit séparément le joystick, le vaisseau, les projectiles, les astéroïdes et les collisions, il reste à assembler ces briques dans une expérience cohérente. Ce chapitre traite de deux aspects complémentaires : d'une part la fabrication des écrans fixes du jeu (menu, réglages, fin de partie) et de l'élément d'interface persistant qu'est le compteur de score (HUD) ; d'autre part la boucleJeu(), qui orchestre image après image l'ensemble des traitements pendant une partie. Comme dans tout le projet, le fil rouge demeure le ménagement du PIC16F18877 : un microcontrôleur 8 bits cadencé à FOSC = 32 MHz (cycle instruction Tcy = 125 ns), dépourvu d'unité de calcul flottant matérielle, qui ne peut se permettre ni des calculs lourds inutiles, ni des réaffichages complets de l'écran à chaque tour. Pour mémoire, l'écran ILI9488 affiche 480 x 320 pixels en mode paysage et chaque pixel envoyé coûte trois octets (couleur 18 bits) transmis sur le bus SPI : économiser des pixels réaffichés revient donc directement à économiser du temps machine.
Le fond étoilé : dessinerFondEtoiles()
Tous les écrans du jeu partagent un même décor : un ciel noir parsemé d'étoiles. La constante STAR_COUNT=18 fixe le nombre d'étoiles. Plutôt que de tirer ces positions au hasard à chaque affichage (ce qui ferait scintiller le décor et consommerait des appels à rand()), les 18 étoiles occupent des positions fixes, décidées une fois pour toutes. Une étoile sur trois est dessinée en orange (COL_ORANGE=0xFD20), les autres en blanc, ce qui donne un léger relief visuel sans surcoût. Le tracé lui-même se réduit à un point, voire un très petit motif, placé via les primitives display_drawPixel ou display_fillRect de la bibliothèque graphique. Le choix de positions fixes illustre directement le principe « ne calculer que le strict nécessaire » : un décor immobile n'a aucune raison d'être recalculé, on se contente de le redessiner à l'identique lorsqu'on repeint un écran complet.
Le menu d'accueil : afficherMenu()
La fonction afficherMenu() compose le premier écran rencontré par le joueur. Elle commence par remplir l'écran en noir, puis appelle dessinerFondEtoiles() pour poser le décor. Vient ensuite un cadre orange épais, obtenu non pas par une primitive de bordure dédiée mais par quatre rectangles imbriqués tracés successivement : en superposant quatre contours de tailles légèrement décroissantes, on obtient visuellement un trait épais et net, alors que display_drawRect ne dessine qu'un trait d'un pixel. Cette astuce évite d'avoir à remplir une zone pleine puis à la recreuser, opération qui serait plus coûteuse en pixels écrits sur le bus SPI.
À l'intérieur du cadre figurent le titre ASTEROIDS, les crédits du binôme (Emma Grosmaire Sausse Dorian) et un petit vaisseau décoratif, dessiné avec les mêmes primitives de triangle que le vaisseau de jeu. Enfin, deux lignes rappellent les commandes de navigation : la touche C lance une partie (C -> JOUER) et la touche D ouvre l'écran des réglages (D -> REGLAGES). Ces libellés correspondent au brochage réel : BTN_C est câblé sur RD5 (patte 28) et BTN_D sur RD4 (patte 27), tous deux actifs à l'état bas (résistance de tirage pull-up : relâché = 1, appuyé = 0).

L'écran des commandes : afficherReglages()
La fonction afficherReglages() reprend la même trame (fond noir, étoiles, cadre) et y affiche la notice des commandes sous le titre COMMANDES. Trois lignes résument l'usage : le bouton B sert à tirer (.B -> TIR), le joystick sert à orienter le vaisseau (.JOYSTICK -> VISER) et la touche C ramène au menu (C -> RETOUR). Cet écran est purement informatif : aucune logique de jeu n'y tourne, le programme se contente d'attendre l'appui sur C pour revenir à l'état ETAT_MENU. Sa présence répond au besoin pédagogique de rendre le jeu auto-explicatif, sans documentation externe.

La fin de partie : afficherGameOver()
Lorsqu'un astéroïde percute le vaisseau, le jeu bascule dans l'état ETAT_GAMEOVER et appelle afficherGameOver(). Cet écran affiche le message GAME OVER ainsi que le score final. Le score, qui est une valeur entière variable, ne peut pas être écrit en dur : on le met en forme dans une chaîne de caractères à l'aide de sprintf :
sprintf(buf, "SCORE : %d", score);sprintf écrit dans le tampon buf le texte littéral SCORE : suivi de la valeur entière de score (format %d). La chaîne ainsi fabriquée est ensuite affichée par display_printText. Le format spécifié %d (entier décimal) est cohérent avec le type de score. Enfin, un rappel C -> MENU indique au joueur comment relancer le cycle. Cet usage de sprintf provient de la bibliothèque standard <stdio.h> incluse dans main.c.

Le compteur de score : afficherScore() et son optimisation
Pendant une partie, le score s'affiche en permanence dans une bande supérieure (le HUD, de hauteur HUD_H=24 pixels, courant sur toute la largeur SCREEN_W=480). Le rafraîchir entièrement à chaque tour de boucle, alors qu'il ne change que lorsqu'un astéroïde est détruit (gain de +10 points), provoquerait un clignotement disgracieux et gaspillerait du temps de calcul. La fonction afficherScore() résout ce problème par une optimisation classique : ne réafficher que si la valeur a réellement changé.
void afficherScore(void){
char buf[10];
if(score==oldScore) return;
oldScore=score;
display_fillRect(82,4,65,18,ILI9488_BLACK);
sprintf(buf,"%d",score);
display_printText(buf,82,4,COL_ORANGE,ILI9488_BLACK,2);
}La première ligne déclare un tampon buf de dix caractères, largement suffisant pour représenter le score. Le test if(score==oldScore) return; est le cœur de l'optimisation : oldScore mémorise la dernière valeur affichée, et tant que le score n'a pas bougé, la fonction sort immédiatement sans rien redessiner. Dès qu'un changement survient, on met à jour oldScore=score pour le prochain tour. On efface ensuite l'ancien nombre en peignant un rectangle noir aux coordonnées fixes (display_fillRect(82,4,65,18,ILI9488_BLACK)) : ce rectangle, de 65 pixels de large sur 18 de haut et placé à partir du point (82, 4), couvre exactement la zone du HUD réservée à la valeur numérique. On reconstruit alors la chaîne du score avec sprintf(buf,"%d",score) (ici sans libellé, juste le nombre), puis on l'affiche en orange sur fond noir, en taille 2, via display_printText(buf,82,4,COL_ORANGE,ILI9488_BLACK,2). Effacer puis réécrire uniquement la zone du nombre, et seulement quand c'est utile, évite à la fois le clignotement et l'écriture inutile d'octets sur le bus SPI.

Le compte à rebours : compteARebours()
Avant que l'action ne démarre, un compte à rebours affiche successivement les chiffres 4, 3, 2 puis 1, à raison d'un chiffre par seconde, pour laisser au joueur le temps de saisir le joystick. La difficulté technique vient de la macro __delay_ms du compilateur XC8 : sa durée maximale est bornée, et l'on ne peut pas lui demander directement une attente d'une seconde pleine. La solution consiste à fractionner chaque seconde en dix attentes de 100 ms (soit 10 x 100 ms = 1000 ms) : on boucle dix fois sur un __delay_ms(100) pour totaliser une seconde par chiffre. Entre deux chiffres, on efface l'ancien et on dessine le suivant, toujours selon le principe « effacer puis redessiner » appliqué à une zone restreinte de l'écran.




La réinitialisation : initJeu()
Chaque nouvelle partie doit repartir d'un état propre. La fonction initJeu() réalise cette remise à zéro complète : elle repeint l'écran de jeu, remet le score (et oldScore) à zéro, réinitialise les compteurs internes (cadence de tir shootCooldown, compteur d'apparition d'astéroïdes spawnCounter, compteur de redessin du vaisseau shipDrawCounter), coupe le son en cours et oriente le vaisseau vers le haut en imposant shipCos=0 et shipSin=-1. Ce vecteur unitaire (0, -1) correspond à une direction « plein haut » : à l'écran, l'axe Y croît vers le bas, donc une composante verticale négative pointe vers le sommet de l'écran. Tous les tirs et tous les astéroïdes sont désactivés (drapeaux active remis à 0). La fonction recalibre également le joystick au repos, ce qui fixe la référence du centre pour la zone morte JOY_DEADZONE=32, puis lance le compte à rebours décrit ci-dessus. À son issue, le jeu peut entrer dans ETAT_JEU avec des variables parfaitement cohérentes. La coupure du son passe par le périphérique NCO1 (qui pilote la sortie sonore sur RA6, patte 14) : remettre l'incrément NCO1INC à zéro arrête la génération de fréquence, évitant qu'un bip de la partie précédente ne se prolonge.
Le chef d'orchestre : boucleJeu()
La fonction boucleJeu() est appelée en continu tant que l'on reste dans l'état ETAT_JEU. Elle enchaîne les traitements dans un ordre précis, chaque tour de boucle correspondant à une image du jeu.
void boucleJeu(void){
afficherScore();
bool angleChange = lireJoystick();
if(angleChange){
tracerVaisseau(ILI9488_BLACK);
calculerSommetsVaisseau();
tracerVaisseau(COL_ORANGE);
shipDrawCounter=0;
}
if(shootCooldown>0) shootCooldown--;
if(appuiB() && shootCooldown==0){ tirer(); shootCooldown=3; }
spawnCounter++;
if(spawnCounter>=SPAWN_DELAY){ spawnCounter=0; spawnAsteroide(); }
majAsteroides();
majTirs();
shipDrawCounter++;
if(shipDrawCounter>=12){ tracerVaisseau(COL_ORANGE); shipDrawCounter=0; }
collisions();
}L'ordre des étapes n'est pas arbitraire. On commence par afficherScore(), qui ne fait rien si le score est inchangé (cf. le compteur de score). On lit ensuite le joystick : lireJoystick() renvoie un booléen angleChange indiquant si l'orientation du vaisseau a suffisamment varié pour justifier un redessin. Le seuil est gouverné par la constante ANGLE_REDRAW_DOT=0.9997 : on compare le produit scalaire de l'ancien et du nouveau vecteur d'orientation (tous deux unitaires) à cette valeur. Comme pour deux vecteurs unitaires le produit scalaire vaut le cosinus de l'angle entre eux, redessiner uniquement quand ce produit descend sous 0,9997 revient à exiger un écart angulaire supérieur à arccos(0,9997), soit environ 1,4 degré. En deçà, le vaisseau bougerait si peu que le redessin serait imperceptible et inutilement coûteux. Si le seuil est franchi, on applique le triptyque effacer / déplacer / redessiner : tracerVaisseau(ILI9488_BLACK) efface l'ancien vaisseau en le repeignant en noir, calculerSommetsVaisseau() recalcule ses trois sommets à partir du nouveau vecteur (shipCos, shipSin), puis tracerVaisseau(COL_ORANGE) le redessine à sa nouvelle orientation. On remet aussi shipDrawCounter à zéro, car le vaisseau vient d'être repeint.
Vient la gestion du tir. La variable shootCooldown impose un délai minimal entre deux tirs : à chaque tour, si elle est positive, on la décrémente. Lorsque le bouton B est enfoncé (appuiB(), câblé sur RD6, patte 29) et que le cooldown est nul, on déclenche tirer() puis on recharge shootCooldown=3. Ce mécanisme empêche une rafale ininterrompue tant que le joueur maintient le bouton, sans avoir besoin de détecter un front montant complexe.
On gère ensuite l'apparition des astéroïdes via un compteur : spawnCounter augmente d'une unité par tour, et lorsqu'il atteint SPAWN_DELAY=19, on le remet à zéro et l'on fait surgir un astéroïde avec spawnAsteroide(). Un astéroïde naît donc tous les dix-neuf tours de boucle, ce qui régule la difficulté (jusqu'à MAX_ASTEROIDS=4 simultanément). Puis majAsteroides() et majTirs() déplacent et redessinent respectivement les astéroïdes et les projectiles encore actifs.
Le bloc suivant illustre un point subtil du rendu incrémentiel. Comme les astéroïdes et les tirs effacent au passage les pixels du vaisseau (chacun gérant sa propre zone par effacement/redessin), le vaisseau finirait par se trouver partiellement « grignoté ». Pour réparer ces dégâts sans repeindre le vaisseau à chaque image, on incrémente shipDrawCounter, et tous les douze tours seulement on force un tracerVaisseau(COL_ORANGE) puis on remet le compteur à zéro. C'est un compromis économique : redessiner le vaisseau en permanence coûterait cher, ne jamais le redessiner laisserait des trous, le faire périodiquement maintient un rendu propre à moindre frais. Enfin, collisions() clôt le tour en testant les rencontres entre tirs et astéroïdes (gain de 10 points, destruction) et entre astéroïdes et vaisseau (déclenchement du GAME OVER), en comparant des distances au carré pour éviter les racines carrées coûteuses.
Lire les entrées, mettre à jour les objets mobiles, ne redessiner que ce qui change ou ce qui a été abîmé, et réserver les calculs coûteux aux seuls cas où ils sont indispensables. C'est ce qui permet à un microcontrôleur 8 bits sans virgule flottante matérielle de faire tourner un jeu d'action fluide.