Portfolio GEII
Rapport · SAE Asteroids · 8 / 12

Le gameplay : joystick, vaisseau, tirs & collisions

Le moteur de jeu fonction par fonction : la trigonométrie sans trigonométrie du joystick et du vaisseau, les projectiles, les astéroïdes octogonaux et la détection des collisions.

Le joystick : lecture ADC et orientation du vaisseau

La fonction lireJoystick() constitue le cœur des commandes du jeu. C'est elle qui transforme le geste de la main du joueur en une orientation du vaisseau triangulaire affiché au centre de l'écran. Dans le jeu, le vaisseau ne se déplace pas : il reste au centre (CX = 240, CY = 172) et pivote sur place pour viser. C'est donc uniquement l'angle d'orientation qui est piloté par le joystick, et toute la difficulté consiste à le calculer de façon fiable sans surcharger un microcontrôleur lent. Ce chapitre détaille la chaîne complète, de la mesure des deux tensions analogiques jusqu'à l'astuce mathématique qui évite tout appel coûteux aux fonctions trigonométriques.

Le joystick analogique et le convertisseur ADC

Un joystick analogique est constitué de deux potentiomètres montés à angle droit, l'un pour l'axe X (horizontal) et l'autre pour l'axe Y (vertical). Chaque potentiomètre se comporte comme un diviseur de tension : la position du manche fixe une tension comprise entre 0 V (butée d'un côté) et la tension d'alimentation (butée de l'autre côté). Au repos, un ressort de rappel ramène le manche au centre, ce qui correspond théoriquement à la moitié de la plage.

Ces deux tensions sont mesurées par le convertisseur analogique-numérique du PIC16F18877, le périphérique ADCC, configuré en résolution 10 bits. Une mesure produit donc un nombre entier compris entre 0 et 1023 (soit 2^10 − 1). Sur la carte, l'axe X est câblé sur la broche RC7 (patte 22) et l'axe Y sur la broche RC6 (patte 21). Dans le code, ces deux canaux sont désignés par les symboles poussoire_x (axe X) et poussoire_y (axe Y).

La lecture d'un canal suit toujours le même protocole en trois temps : lancer la conversion, attendre qu'elle se termine, puis récupérer le résultat. C'est exactement ce que font les trois appels suivants, utilisés à plusieurs reprises dans ce chapitre :

ADCC_StartConversion(poussoire_x);
while(!ADCC_IsConversionDone());
int adcX = ADCC_GetConversionResult();

La première ligne déclenche la conversion du canal X. La deuxième est une boucle d'attente active (while) qui ne fait rien tant que la conversion n'est pas terminée : le ! inverse le booléen, donc la boucle tourne « tant que la conversion n'est PAS finie ». La troisième ligne lit la valeur numérisée (0 à 1023) et la range dans adcX. Ces fonctions ADCC_* sont fournies par le code généré par MCC (MPLAB Code Configurator), qui se charge d'avoir configuré au préalable le multiplexeur d'entrée, l'horloge de conversion et le format du résultat. Cette attente est très courte (la conversion dure quelques microsecondes) et constitue l'un des rares moments où le programme se permet de bloquer, car il n'a rien d'utile à faire entre-temps : c'est une exception assumée au principe « ne jamais bloquer inutilement ».

La calibration : calibrerJoystick()

Un joystick réel ne renvoie presque jamais exactement la valeur centrale de 512 au repos. Les tolérances mécaniques des potentiomètres, l'usure du ressort de rappel et de légères différences d'alimentation décalent ce point de repos. Si l'on supposait naïvement que le centre vaut 512, le vaisseau dériverait en permanence vers une direction, même manche relâché. La calibration mesure donc le vrai centre du joystick au démarrage, et le mémorise dans deux variables joyCx (centre X) et joyCy (centre Y).

void calibrerJoystick(void){
    long sx=0, sy=0;
    for(uint8_t i=0;i<12;i++){
        ADCC_StartConversion(poussoire_x);
        while(!ADCC_IsConversionDone());
        sx += ADCC_GetConversionResult();
        ADCC_StartConversion(poussoire_y);
        while(!ADCC_IsConversionDone());
        sy += ADCC_GetConversionResult();
        __delay_ms(2);
    }
    joyCx = sx/12;
    joyCy = sy/12;
}

Analysons ce code bloc par bloc. Les accumulateurs sx et sy sont déclarés en type long (entier signé large), et non en int. Ce choix est volontaire : on additionne 12 mesures pouvant chacune atteindre 1023, soit un total maximal de 12 × 1023 = 12 276. Un int 16 bits signé monte jusqu'à 32 767 et suffirait techniquement, mais le long garantit l'absence de débordement quelle que soit la configuration du compilateur, sans aucun risque. C'est une marge de sécurité peu coûteuse.

La boucle for itère 12 fois (le compteur i est un uint8_t, un entier 8 bits non signé, suffisant pour compter jusqu'à 12 et économe en RAM). À chaque tour, on lit l'axe X et on l'ajoute à sx, puis l'axe Y et on l'ajoute à sy. Le __delay_ms(2) introduit un délai de 2 ms entre deux mesures : il étale les lectures dans le temps afin que la moyenne capture le bruit réel du signal plutôt que 12 mesures quasi instantanées et corrélées.

Enfin, joyCx = sx/12 et joyCy = sy/12 calculent la moyenne arithmétique des 12 relevés. Moyenner lisse le bruit électrique : les petites fluctuations aléatoires positives et négatives se compensent, et l'on obtient une estimation stable du point de repos. Cette calibration suppose que le joueur ne touche pas au joystick pendant l'opération ; elle est donc réalisée une fois, au lancement.

lireJoystick() : la trigonométrie sans trigonométrie

C'est ici que se concentre l'idée la plus élégante du programme. Le PIC16 ne possède pas d'unité de calcul flottant matérielle : les fonctions cos, sin, atan2 ou sqrt y sont émulées en logiciel et sont lentes. Or, pour orienter le vaisseau, on a besoin du cosinus et du sinus de l'angle visé. L'astuce consiste à les obtenir sans jamais appeler ces fonctions trigonométriques.

bool lireJoystick(void){
    ADCC_StartConversion(poussoire_x);
    while(!ADCC_IsConversionDone());
    int adcX = ADCC_GetConversionResult();
    ADCC_StartConversion(poussoire_y);
    while(!ADCC_IsConversionDone());
    int adcY = ADCC_GetConversionResult();
    int dx = (adcX - joyCx) * JOY_X_SIGN;
    int dy = -(adcY - joyCy);
    long mag2 = (long)dx*dx + (long)dy*dy;
    if(mag2 < (long)JOY_DEADZONE*JOY_DEADZONE) return false;
    float fx=(float)dx;
    float fy=(float)dy*JOY_Y_SCALE;
    float mag=sqrtf(fx*fx+fy*fy);
    if(mag<1.0f) return false;
    float newCos=fx/mag;
    float newSin=fy/mag;
    float dot=newCos*shipCos+newSin*shipSin;
    shipCos=newCos;
    shipSin=newSin;
    return (dot < ANGLE_REDRAW_DOT);
}

(a) L'écart au centre

Après avoir lu adcX et adcY comme expliqué plus haut, on calcule la position relative du manche par rapport au centre calibré. La ligne int dx = (adcX - joyCx) * JOY_X_SIGN soustrait le centre mémorisé : si le manche est poussé d'un côté, dx est positif ; de l'autre, négatif ; au repos, proche de zéro. Le facteur JOY_X_SIGN est une constante de signe (+1 ou −1) qui permet d'inverser le sens de l'axe X selon l'orientation physique du joystick sur la carte (valeur à confirmer selon le câblage). Pour l'axe Y, int dy = -(adcY - joyCy) applique systématiquement un signe négatif. Cette inversion est nécessaire car, sur l'écran, l'axe vertical des pixels est orienté vers le bas (Y croît vers le bas), alors qu'en convention mathématique l'axe Y croît vers le haut. Le signe « − » remet l'axe Y dans le sens mathématique attendu, de sorte que pousser le manche vers le haut fasse pointer le vaisseau vers le haut de l'écran.

(b) La zone morte

Au repos, même après calibration, dx et dy oscillent légèrement à cause du bruit résiduel. Sans précaution, le vaisseau tremblerait en permanence. On calcule donc le carré de l'amplitude du déplacement : mag2 = dx*dx + dy*dy. Géométriquement, c'est le carré de la distance entre la position courante du manche et son centre. On compare ce carré au carré du seuil JOY_DEADZONE (qui vaut 32), c'est-à-dire 32 × 32 = 1024. Si mag2 < 1024, on considère que le manche est au repos et la fonction renvoie false sans rien modifier : le vaisseau ne bouge pas. Comparer les carrés (mag2 < JOY_DEADZONE²) plutôt que les distances elles-mêmes (mag < JOY_DEADZONE) évite une racine carrée coûteuse, tout en donnant exactement le même résultat puisque la fonction carré est strictement croissante sur les nombres positifs : c'est un principe directeur appliqué dans tout le code. On notera aussi les transtypages (long) qui forcent les multiplications à se faire en arithmétique large, là encore pour prévenir tout débordement entier (un dx de l'ordre de 500 donne déjà dx*dx = 250 000, bien au-delà de la capacité d'un int 16 bits).

(c) La normalisation, cœur de l'astuce

On convertit ensuite dx et dy en flottants fx et fy, en appliquant au passage le facteur JOY_Y_SCALE (1,04) sur l'axe Y pour compenser une légère asymétrie de course entre les deux axes du joystick. On calcule alors la norme du vecteur : mag = sqrtf(fx*fx + fy*fy), c'est-à-dire mag = √(fx² + fy²). Cette racine carrée, contrairement à celle que l'on a évitée à l'étape précédente, est indispensable ici, mais elle n'est exécutée que lorsque le manche est réellement poussé (le test de zone morte a déjà filtré tous les cas au repos). Le garde-fou if(mag < 1.0f) return false évite une division par zéro si la norme est négligeable.

Vient ensuite la double division newCos = fx/mag et newSin = fy/mag. Géométriquement, diviser un vecteur par sa norme le ramène à une longueur de 1 : on obtient un vecteur unitaire pointant dans la direction du joystick. Or, sur le cercle trigonométrique de rayon 1, un point situé à l'angle θ a précisément pour coordonnées (cos θ, sin θ). La définition même du cosinus est cos θ = côté adjacent / hypoténuse, et celle du sinus sin θ = côté opposé / hypoténuse ; dans notre triangle rectangle, l'hypoténuse est mag, le côté adjacent (horizontal) est fx et le côté opposé (vertical) est fy. Par conséquent :

newCos = fx / mag = cos θ et newSin = fy / mag = sin θ

Les composantes du vecteur unitaire sont donc directement le cosinus et le sinus de l'angle visé. On obtient (cos θ, sin θ) sans jamais calculer θ ni appeler atan2, cos ou sin. La direction normalisée porte à elle seule toute l'information angulaire dont le vaisseau a besoin pour être dessiné. C'est l'illustration la plus pure du fil rouge du projet : exploiter une identité géométrique pour supprimer un calcul que le PIC16, dépourvu de FPU, exécuterait lentement.

Maths du joystick : du vecteur au vecteur unitaire
Figure - Du déplacement brut du joystick au vecteur unitaire d'orientation : la normalisation fournit directement cos et sin de l'angle visé.

(d) Le produit scalaire et le seuil de redessin

Une fois le nouveau vecteur d'orientation obtenu, faut-il réellement effacer puis redessiner le vaisseau ? Si l'orientation n'a quasiment pas changé, le faire serait du gaspillage de temps de calcul et provoquerait un scintillement. On compare donc la nouvelle direction à l'ancienne (shipCos, shipSin) à l'aide d'un produit scalaire : dot = newCos*shipCos + newSin*shipSin. Pour deux vecteurs unitaires, le produit scalaire vaut exactement le cosinus de l'angle qui les sépare. En effet, si l'ancienne direction est l'angle α et la nouvelle l'angle β, alors :

dot = cos β · cos α + sin β · sin α = cos(β − α)

ce qui n'est autre que la formule d'addition cos(a − b) = cos a cos b + sin a sin b appliquée à l'écart angulaire (β − α). Ainsi, si la direction n'a pas bougé, dot vaut cos(0) = 1 ; plus l'écart angulaire grandit, plus dot diminue. La constante ANGLE_REDRAW_DOT vaut 0,9997, ce qui correspond à un écart d'environ 1,4 degré (puisque arccos(0,9997) ≈ 1,4°). La fonction met d'abord à jour shipCos et shipSin avec la nouvelle orientation, puis renvoie dot < ANGLE_REDRAW_DOT : le résultat vaut true (il faut redessiner) seulement si le vaisseau a tourné de plus de 1,4 degré environ, et false sinon. On évite ainsi des redessins inutiles tout en gardant une rotation parfaitement fluide à l'œil. Une nouvelle fois, c'est un produit scalaire - deux multiplications et une addition en flottant - qui remplace un calcul d'angle bien plus coûteux.

Une fonction taillée pour un microcontrôleur lent

lireJoystick() illustre à elle seule les quatre principes directeurs du projet. Elle évite les fonctions flottantes lourdes en exploitant l'identité entre vecteur unitaire et couple (cos, sin). Elle ne bloque que le strict nécessaire, le temps des conversions ADC. Elle ne déclenche un redessin que lorsque l'orientation change réellement, grâce au produit scalaire interprété comme un cosinus d'écart angulaire. Enfin, elle compare des distances au carré pour la zone morte afin d'épargner une racine carrée. La trigonométrie est ainsi entièrement « cachée » dans la géométrie du vecteur normalisé : c'est précisément ce qui permet au PIC16F18877, dépourvu de calcul flottant matériel, de faire pivoter le vaisseau de manière à la fois réactive et stable.

Le vaisseau : géométrie et rotation sans cos/sin

Un vaisseau immobile qui ne fait que pivoter

Le vaisseau du joueur est représenté par un triangle isocèle en forme de flèche. Contrairement à la version arcade originale d'Asteroids, où le vaisseau se déplace librement, le choix de conception retenu ici fige sa position : le vaisseau reste en permanence au centre géométrique de la zone de jeu, défini par les constantes CX = 240 et CY = 172. Ce point correspond à la moitié de la largeur de l'écran (SCREEN_W/2, soit 480/2) et au centre vertical de la zone située sous la bande de score (HUD_H + (SCREEN_H - HUD_H)/2, avec HUD_H = 24). Le vaisseau ne se translate jamais : sa seule liberté est la rotation sur place, commandée par le joystick analogique.

Ce parti pris a deux mérites. D'une part, il simplifie radicalement le rendu : puisque le centre du triangle ne bouge pas, seuls les trois sommets se déplacent autour d'un pivot fixe, ce qui limite la surface à effacer et à redessiner à chaque rotation. D'autre part, il colle au principe du jeu : les astéroïdes surgissent des bords et foncent vers le centre, le joueur doit donc orienter sa pointe vers la menace et tirer. La rotation seule suffit à viser dans toutes les directions.

Le vaisseau triangulaire
Figure - Le vaisseau triangulaire affiché au centre de l'écran, orienté par le joystick.

L'orientation du vaisseau est stockée non pas sous forme d'angle, mais sous forme d'un vecteur unitaire (shipCos, shipSin). Ce couple de valeurs flottantes représente directement le cosinus et le sinus de l'angle de visée. Ce détail, qui peut sembler anodin, est en réalité la clé de toute l'optimisation décrite plus loin : on ne manipule jamais l'angle lui-même, mais toujours son cosinus et son sinus. On évite ainsi les appels coûteux à atan2, cos et sin sur un PIC16F18877 dépourvu d'unité de calcul flottant matérielle, où ces fonctions sont réalisées logiciellement.

La fonction calculerSommetsVaisseau()

La géométrie du triangle est entièrement reconstruite par une seule fonction. La pointe avant est placée dans la direction de visée, à une distance SHIP_FRONT = 18 pixels du centre. Les deux coins arrière sont obtenus en faisant tourner la direction de visée de plus et moins 2,45 radians, puis en multipliant le résultat par SHIP_BACK = 11 pixels. Voici le code intégral :

void calculerSommetsVaisseau(void){
    shipX1 = CX + (int)(shipCos * SHIP_FRONT);
    shipY1 = CY + (int)(shipSin * SHIP_FRONT);
    float bx = shipCos*COS245 - shipSin*SIN245;
    float by = shipSin*COS245 + shipCos*SIN245;
    shipX2 = CX + (int)(bx * SHIP_BACK);
    shipY2 = CY + (int)(by * SHIP_BACK);
    float cx = shipCos*COS245 + shipSin*SIN245;
    float cy = shipSin*COS245 - shipCos*SIN245;
    shipX3 = CX + (int)(cx * SHIP_BACK);
    shipY3 = CY + (int)(cy * SHIP_BACK);
}

Détaillons bloc par bloc.

Les deux premières lignes calculent la pointe avant (shipX1, shipY1). Le vecteur de direction (shipCos, shipSin) étant unitaire, le multiplier par SHIP_FRONT donne un déplacement de 18 pixels exactement dans la direction de visée. On ajoute ce déplacement au centre (CX, CY). Le transtypage (int) convertit le résultat flottant en coordonnée entière de pixel, format que l'écran attend.

Le deuxième bloc calcule le premier coin arrière. Les variables intermédiaires bx et by représentent le vecteur de direction tourné d'un angle de plus 2,45 radians. On reconnaît la formule de rotation d'un vecteur (développée plus bas), où COS245 et SIN245 sont le cosinus et le sinus de cet angle, précalculés une fois pour toutes. Le vecteur tourné (bx, by) reste unitaire ; on le multiplie donc par SHIP_BACK = 11 pour placer le coin arrière à 11 pixels du centre, puis on l'ajoute au centre.

Le troisième bloc est rigoureusement symétrique : cx et cy représentent la direction tournée de moins 2,45 radians. Le signe affecté au terme en sinus s'inverse (on lit + shipSin*SIN245 au lieu de -, et - shipCos*SIN245 au lieu de +), ce qui correspond à une rotation dans l'autre sens. C'est cette symétrie autour de l'axe avant qui donne au triangle sa forme isocèle : les deux ailes arrière sont écartées du même angle de part et d'autre de l'axe de visée.

On notera qu'aucune fonction trigonométrique n'est appelée : la fonction ne contient que des multiplications, des additions et des soustractions de flottants. C'est exactement l'objectif recherché.

Les mathématiques de la rotation

La formule employée est celle de la rotation d'un vecteur dans le plan. Soit un vecteur unitaire (c, s) correspondant à un angle de départ, que l'on souhaite tourner d'un angle alpha. Les coordonnées du vecteur tourné sont :

x' = c * cos(alpha) - s * sin(alpha)
y' = s * cos(alpha) + c * sin(alpha)

Ces deux expressions découlent directement des formules d'addition des angles. En effet, si (c, s) = (cos theta, sin theta), alors le vecteur tourné de alpha a pour angle theta + alpha, et :

cos(theta + alpha) = cos theta cos alpha - sin theta sin alpha
sin(theta + alpha) = sin theta cos alpha + cos theta sin alpha

On retrouve mot pour mot les lignes de code. Le point essentiel est que l'angle de rotation alpha = 2,45 rad est une constante du programme : il ne dépend ni de la position du joystick, ni du temps. Par conséquent, cos(2,45) et sin(2,45) peuvent être évalués une seule fois, à la main ou par un calcul préalable, et stockés dans des constantes :

COS245 = -0.7705
SIN245 =  0.6374

La rotation se réduit alors à quatre multiplications et deux additions par coin, soit uniquement des opérations rapides. C'est la différence fondamentale avec une approche naïve qui appellerait cos(alpha) et sin(alpha) à chaque image : en l'absence de FPU sur le PIC16F18877, ces fonctions, réalisées logiciellement, coûtent un grand nombre de cycles. En précalculant les constantes, on transforme un calcul trigonométrique en simple combinaison linéaire.

On vérifie la cohérence des constantes : 2,45 radians correspondent à environ 140,4 degrés (puisque 2,45 x 180 / pi vaut à peu près 140,4). Le cosinus d'un angle de 140 degrés est bien négatif, son sinus bien positif, ce qui est conforme aux valeurs -0,7705 et 0,6374.

La fonction tracerVaisseau(c)

Une fois les six coordonnées mémorisées dans les variables globales shipX1..shipX3 et shipY1..shipY3, le dessin est trivial. La fonction tracerVaisseau se contente de relier les trois sommets par trois segments, à l'aide de la primitive display_drawLine de la bibliothèque graphique :

void tracerVaisseau(uint16_t c){
    display_drawLine(shipX1, shipY1, shipX2, shipY2, c);
    display_drawLine(shipX2, shipY2, shipX3, shipY3, c);
    display_drawLine(shipX3, shipY3, shipX1, shipY1, c);
}

Le paramètre c est la couleur du tracé. Pour afficher le vaisseau, on appelle la fonction avec la couleur orange du jeu ; pour l'effacer, on rappelle exactement la même fonction avec la couleur noire, ce qui repasse les mêmes pixels en couleur de fond. Aucun calcul flottant n'est effectué ici : la fonction ne manipule que des coordonnées entières déjà calculées. Le coût du dessin est donc réduit au tracé de trois lignes par l'algorithme de Bresenham, sans la moindre opération trigonométrique.

La séparation calcul / dessin

L'architecture du vaisseau illustre un principe récurrent du programme : séparer le calcul de la position du dessin proprement dit. La fonction calculerSommetsVaisseau() n'est appelée que lorsque l'orientation change réellement, c'est-à-dire lorsque le joueur tourne le joystick suffisamment pour franchir le seuil de redessin ANGLE_REDRAW_DOT = 0.9997. Ce seuil est un produit scalaire entre l'ancienne et la nouvelle direction : tant qu'il reste supérieur à 0,9997, la rotation est inférieure à environ 1,4 degré et l'on considère que le vaisseau pointe toujours dans la même direction. Ses sommets demeurent alors valides et il est inutile de les recalculer.

La fonction tracerVaisseau(), elle, peut être appelée plusieurs fois sans recalcul : une première fois en noir pour effacer l'ancien triangle, une seconde fois en orange pour dessiner le nouveau. Ce découpage « calculer une fois, dessiner ou effacer plusieurs fois » évite à la fois les recalculs inutiles et le scintillement, tout en ménageant le microcontrôleur.

Géométrie du vaisseau et rotation des ailes
Figure - Géométrie du vaisseau : pointe avant à SHIP_FRONT du centre, ailes arrière écartées de plus ou moins 2,45 rad de l'axe de visée.

Pour conclure, revenons sur la raison profonde pour laquelle un vecteur (cos, sin) normalisé suffit à décrire une direction. Un vecteur unitaire est, par définition, un point du cercle de rayon 1 ; or tout point de ce cercle s'écrit (cos theta, sin theta) pour un certain angle theta. Le couple (shipCos, shipSin) encode donc une direction sans qu'il soit jamais nécessaire de connaître theta lui-même. Comme le joystick fournit déjà, après normalisation, un tel vecteur unitaire pointant vers la cible (voir le chapitre sur le joystick), la direction de visée est disponible « gratuitement ». Pour les ailes, on écarte cette direction de plus ou moins 2,45 rad par rapport à l'axe de visée. Chaque aile fait ainsi un angle d'environ 140 degrés avec la pointe, ce qui revient à la placer à environ 40 degrés de l'axe arrière : les deux coins se retrouvent largement derrière le centre, de part et d'autre de l'arrière du vaisseau, et le triangle prend son allure de flèche. Un angle proche de 180 degrés ramènerait les ailes presque dans l'axe arrière et donnerait une flèche très effilée, tandis qu'un angle proche de 90 degrés produirait un triangle large et trapu. La valeur de 2,45 rad réalise un compromis visuel satisfaisant, tout en restant une constante que l'on peut précalculer, fidèle au fil rouge du projet : éviter le calcul flottant en temps réel pour ménager un processeur lent.

Les projectiles (tirs)

Les projectiles constituent le moyen d'action principal offert au joueur : depuis le centre de l'écran, le vaisseau triangulaire tire des disques orange qui filent en ligne droite vers les bords pour détruire les astéroïdes (chaque astéroïde détruit rapporte +10 au score). Ce chapitre détaille la manière dont les tirs sont représentés en mémoire, comment ils naissent (fonction tirer), comment ils évoluent à chaque tour de boucle (fonction majTirs) et comment la cadence de tir est limitée. Comme partout dans ce projet, la conception répond à une contrainte permanente : le PIC16F18877 est un microcontrôleur 8 bits dont le cycle instruction vaut FOSC/4 = 8 MHz (soit un temps de cycle Tcy de 125 ns), et qui est dépourvu d'unité de calcul flottant matérielle. Chaque appel à cos, sin, atan2 ou sqrtf se traduit donc par une routine logicielle lente. Chaque choix décrit ci-dessous vise à économiser le temps de calcul et la mémoire vive.

Représentation en mémoire : un tableau fixe de quatre tirs

Un tir est décrit par la structure suivante :

typedef struct { float x, y, vx, vy; uint8_t active, life; } Bullet;

Les champs x et y portent la position courante du projectile en pixels sur l'écran, et vx, vy sa vitesse, c'est-à-dire le déplacement appliqué à chaque tour de boucle. Le champ active indique si la case est occupée par un tir vivant (1) ou si elle est libre (0). Le champ life est un compteur de durée de vie : il décroît à chaque tour et provoque la disparition du tir lorsqu'il atteint zéro, ce qui évite que des projectiles n'errent indéfiniment à l'écran. Les deux derniers champs sont déclarés en uint8_t (entier non signé sur 8 bits, plage 0 à 255) : cela suffit largement pour un drapeau booléen et pour une durée de vie de 60, tout en occupant le minimum de RAM possible. La position et la vitesse, en revanche, restent en float parce que le déplacement de 9 pixels par tour combiné à une direction quelconque produit des incréments fractionnaires (par exemple 9 x cos(a)) : les arrondir à chaque tour déformerait la trajectoire. On accepte donc le coût des additions flottantes pour la position, mais on bannit les fonctions trigonométriques flottantes, bien plus coûteuses.

Les tirs sont stockés dans un tableau global de taille fixe :

Bullet bullets[MAX_BULLETS];   // MAX_BULLETS = 4

Le choix d'un tableau de taille fixe plutôt que d'une allocation dynamique (malloc/free) est un parti pris essentiel sur cible embarquée. L'allocation dynamique est lente, peut échouer si la mémoire est fragmentée, et expose à des fuites de mémoire si une libération est oubliée. Sur un microcontrôleur disposant de quelques kilo-octets de RAM seulement, ces risques sont inacceptables. La solution retenue consiste à réserver une fois pour toutes quatre emplacements et à les recycler en jouant simplement sur le drapeau active : un tir « disparaît » non pas en libérant de la mémoire, mais en repassant active à 0, ce qui rend la case immédiatement réutilisable pour un futur projectile. Le plafond MAX_BULLETS = 4 borne aussi le nombre de projectiles à dessiner par tour, donc le temps de calcul de la boucle de jeu reste prévisible et constant : on sait à l'avance qu'on ne tracera jamais plus de quatre disques de tir par image.

Projectile orange tiré par le vaisseau
Figure - Projectile orange tel qu'il apparaît à l'écran, un petit disque de rayon BULLET_RADIUS.

La fonction tirer() : création d'un projectile

Lorsque le joueur appuie sur le bouton de tir (BTN_B, sur RD6, patte 29), la fonction tirer est appelée. Son rôle est de trouver un emplacement libre dans le tableau et d'y inscrire un nouveau projectile, orienté comme le vaisseau.

void tirer(void){
    for(uint8_t i=0;i<MAX_BULLETS;i++){
        if(!bullets[i].active){
            bullets[i].x = CX + shipCos*SHIP_FRONT;
            bullets[i].y = CY + shipSin*SHIP_FRONT;
            bullets[i].vx = shipCos*BULLET_SPEED;
            bullets[i].vy = shipSin*BULLET_SPEED;
            bullets[i].life = BULLET_LIFE;
            bullets[i].active = 1;
            display_fillCircle((uint16_t)bullets[i].x,(uint16_t)bullets[i].y,BULLET_RADIUS,COL_ORANGE);
            son_tir();
            break;
        }
    }
}

La boucle for parcourt les quatre cases. Le test if(!bullets[i].active) sélectionne la première case libre rencontrée (drapeau à 0). Si les quatre tirs sont déjà actifs, la boucle se termine sans rien faire : il n'est tout simplement pas possible de tirer une cinquième fois tant qu'un projectile n'a pas disparu. C'est une limitation voulue, cohérente avec la taille fixe du tableau.

Une fois une case libre trouvée, le projectile est positionné à la pointe avant du vaisseau :

  • bullets[i].x = CX + shipCos*SHIP_FRONT;
  • bullets[i].y = CY + shipSin*SHIP_FRONT;

Ici CX et CY sont les coordonnées du centre de l'écran (CX = 240, CY = 172), point fixe où se trouve toujours le vaisseau. Le couple (shipCos, shipSin) est le vecteur unitaire d'orientation du vaisseau, déjà calculé à partir du joystick : shipCos est le cosinus de l'angle de visée, shipSin son sinus. Multiplier ce vecteur unitaire par la distance SHIP_FRONT = 18 (la longueur du nez du vaisseau) et l'ajouter au centre donne exactement la position de la pointe. Mathématiquement, on calcule le point situé à la distance SHIP_FRONT du centre dans la direction de visée :

pointe = (CX + cos(a) x SHIP_FRONT, CY + sin(a) x SHIP_FRONT)

Le tir part donc visuellement du canon du vaisseau, et non de son centre.

La vitesse reprend la même direction, mise à l'échelle par la cadence souhaitée :

  • bullets[i].vx = shipCos*BULLET_SPEED;
  • bullets[i].vy = shipSin*BULLET_SPEED;

avec BULLET_SPEED = 9.0. Comme (shipCos, shipSin) est un vecteur de norme 1 (cos(a)^2 + sin(a)^2 = 1), multiplier par 9 donne un vecteur vitesse de norme 9 pixels par tour, dirigé exactement comme le vaisseau. C'est l'avantage décisif de réutiliser l'orientation déjà connue : aucun appel à cos, sin ou atan2 n'est nécessaire au moment du tir, alors que ces fonctions flottantes seraient très coûteuses sur le PIC. On recycle un résultat déjà disponible, calculé une seule fois par tour lors de la lecture du joystick.

Le champ life est initialisé à BULLET_LIFE = 60 : le projectile vivra au plus 60 tours de boucle. Le drapeau active passe à 1 pour marquer la case occupée. La ligne display_fillCircle(...) dessine immédiatement le disque orange (COL_ORANGE = 0xFD20) de rayon BULLET_RADIUS = 2 à la position de départ. La conversion explicite (uint16_t)bullets[i].x tronque les coordonnées flottantes vers l'entier inférieur, car la primitive graphique (qui s'appuie sur display_fillCircle, lui-même bâti sur l'algorithme du point milieu de la bibliothèque GFX) attend des coordonnées de pixels entières. Enfin, son_tir() déclenche l'effet sonore via le périphérique NCO1 (Numerically Controlled Oscillator), qui génère une fréquence sur la patte RA6 sans solliciter le processeur pendant la lecture du son, et le break quitte la boucle : un seul tir est créé par appel, même si plusieurs cases étaient libres. Sans ce break, un seul appui remplirait d'un coup les quatre emplacements disponibles.

La fonction majTirs() : effacer, déplacer, redessiner

À chaque tour de boucle de jeu, la fonction majTirs (mise à jour des tirs) fait évoluer tous les projectiles actifs. Elle applique la technique fondamentale du rendu sur cet écran : effacer, déplacer, redessiner. Comme on ne dispose pas de double tampon (framebuffer) en RAM et que réafficher tout l'écran de 480 x 320 pixels via le bus SPI serait beaucoup trop lent, on ne touche qu'aux quelques pixels qui changent réellement.

Pour chaque tir actif, la séquence est la suivante :

  • Effacer : on redessine un disque de la couleur du fond (noir) à la position actuelle, ce qui « gomme » le projectile là où il était.
  • Déplacer : on met à jour la position par x += vx et y += vy. C'est une cinématique de mouvement rectiligne uniforme : à chaque tour, la position s'incrémente du vecteur vitesse. Sur un tour, le projectile avance donc de 9 pixels dans sa direction (norme de la vitesse). Aucune accélération n'intervient, la trajectoire est une droite parcourue à vitesse constante exprimée en pixels par tour de boucle.
  • Décrémenter la durée de vie : life diminue de 1. Le tir est désactivé (active = 0) si life atteint 0 ou si la nouvelle position sort de l'écran (coordonnées hors des bornes 0..SCREEN_W et 0..SCREEN_H, soit 0..480 en x et 0..320 en y). La case redevient alors libre pour un futur tir.
  • Redessiner : si le tir est toujours vivant, on retrace le disque orange à sa nouvelle position.
Pipeline de rendu effacer, déplacer, redessiner
Figure - Principe effacer puis déplacer puis redessiner appliqué à chaque élément mobile.

Ce cycle explique pourquoi chaque élément mobile (tirs, astéroïdes, vaisseau) mémorise sa position courante dans sa structure : il faut savoir où effacer avant de redessiner ailleurs. La double condition de désactivation (sortie d'écran ou durée de vie écoulée) garantit qu'aucun projectile ne reste actif inutilement. On peut d'ailleurs vérifier la cohérence des constantes : un tir vit au plus 60 tours et avance de 9 pixels par tour, soit une portée maximale théorique de 60 x 9 = 540 pixels. Comme la plus grande dimension de l'écran est 480 pixels, un tir parti du centre atteint toujours un bord avant l'épuisement de sa durée de vie ; c'est donc le plus souvent la sortie d'écran qui le désactive en premier, le compteur life ne servant que de garde-fou. Dans tous les cas, la case est libérée rapidement, ce qui borne la charge de calcul de la boucle.

L'anti-rafale : le compteur shootCooldown

Si la fonction tirer était appelée à chaque tour où le bouton est maintenu enfoncé, le joueur viderait instantanément les quatre cases et la cadence dépendrait de la vitesse de la boucle, ce qui serait peu maîtrisable. Pour éviter cette rafale incontrôlable, la boucle de jeu (boucleJeu) gère un compteur de temporisation, shootCooldown. Le principe est le suivant : un tir n'est autorisé que lorsque shootCooldown est revenu à zéro. Au moment où un projectile est créé, le compteur est rechargé à une valeur de délai, puis il décroît d'une unité à chaque tour de boucle. Tant qu'il n'est pas nul, les nouveaux appuis (ou le maintien du bouton, rappelons que BTN_B est actif à l'état bas grâce à une résistance de tirage) sont ignorés. On obtient ainsi une cadence de tir régulière et indépendante des micro-variations de durée de la boucle, et l'on protège en même temps le nombre limité d'emplacements disponibles. Cette temporisation par compteur logiciel est préférée à toute attente bloquante : la boucle de jeu ne doit jamais s'arrêter pour patienter, sous peine de figer le déplacement des astéroïdes et la réactivité du joystick.

Synthèse des tirs

La gestion des tirs illustre parfaitement le fil conducteur du projet. Le tableau fixe de quatre cases bannit l'allocation dynamique au profit d'un recyclage par drapeau active, sûr et prévisible. La direction du projectile réutilise le vecteur unitaire (shipCos, shipSin) déjà calculé pour le vaisseau, évitant tout appel trigonométrique flottant au moment du tir. Le déplacement se réduit à deux additions par tour, mouvement rectiligne uniforme à vitesse constante de 9 pixels par tour. Le rendu n'efface et ne retrace que les pixels concernés, et le compteur shootCooldown discipline la cadence sans jamais bloquer la boucle. Chaque décision ménage un microcontrôleur lent tout en offrant un comportement de jeu fluide et cohérent.

Les astéroïdes (météorites)

Les astéroïdes constituent la menace centrale du jeu : ce sont des octogones qui surgissent des bords de l'écran et foncent vers le vaisseau immobilisé au centre. Chaque astéroïde détruit rapporte 10 points au score, tandis qu'un contact avec le vaisseau provoque la fin de partie (GAME OVER). Ce chapitre détaille leur représentation en mémoire, leur apparition aléatoire, leur tracé graphique et leur mise à jour image par image. Comme partout dans le projet, l'objectif transversal est de ménager le PIC16F18877, un microcontrôleur 8 bits dépourvu d'unité de calcul flottant matérielle : on cherche donc à limiter le nombre d'opérations en virgule flottante et à ne redessiner que ce qui change.

Représentation en mémoire

Un astéroïde est décrit par une structure simple, déclarée dans main.c :

typedef struct {float x,y,vx,vy; uint8_t active;} Asteroid;

Les champs x et y représentent la position du centre de l'astéroïde à l'écran (en pixels, mais stockée en float car le calcul de trajectoire produit des valeurs non entières). Les champs vx et vy sont les deux composantes du vecteur vitesse : à chaque tour de boucle, on ajoute vx à x et vy à y pour déplacer l'astéroïde. Enfin, active est un drapeau (uint8_t, soit un seul octet) qui vaut 1 si l'astéroïde existe à l'écran et 0 si la case est libre. Le choix de uint8_t plutôt qu'un bool ou un int est délibérément économe : sur ce PIC, la RAM est limitée, et un octet suffit largement pour un indicateur logique. La structure comporte donc cinq champs : quatre flottants (position et vitesse) et un octet de statut.

Les astéroïdes sont rangés dans un tableau global de taille fixe :

Asteroid asteroids[MAX_ASTEROIDS];

avec MAX_ASTEROIDS=4. Ce dimensionnement fixe est un principe récurrent du système embarqué : on n'utilise pas d'allocation dynamique (malloc), coûteuse et risquée sur un petit microcontrôleur. Le tableau est réservé une fois pour toutes au démarrage, et le drapeau active sert de gestion d'occupation : une case « libre » est simplement une case dont active vaut 0. Limiter le nombre d'astéroïdes simultanés à quatre borne aussi la charge de calcul de la boucle de jeu, ce qui aide à conserver une cadence régulière. Le même procédé (tableau fixe + drapeau active) est utilisé pour les projectiles (bullets[MAX_BULLETS], avec MAX_BULLETS=4), ce qui confère au code une logique homogène et prévisible.

Visuellement, chaque astéroïde est un octogone (polygone à huit côtés), choisi comme compromis entre un rendu « rocheux » crédible et un coût de tracé faible (huit segments seulement).

Rendu d'un astéroïde à l'écran
Figure - Rendu d'un astéroïde à l'écran : un octogone tracé au trait.

Apparition : spawnAsteroide()

La fonction spawnAsteroide() fait naître un nouvel astéroïde sur un bord de l'écran, avec une trajectoire dirigée vers le centre. Voici le code complet :

void spawnAsteroide(void){
    uint8_t i;
    for(i=0;i<MAX_ASTEROIDS;i++){ if(!asteroids[i].active) break; }
    if(i==MAX_ASTEROIDS) return;
    uint8_t bord = rand()%4;
    if(bord==0){ asteroids[i].x = rand()%SCREEN_W; asteroids[i].y = HUD_H+1; }
    else if(bord==1){ asteroids[i].x = rand()%SCREEN_W; asteroids[i].y = SCREEN_H-1; }
    else if(bord==2){ asteroids[i].x = 0; asteroids[i].y = HUD_H + rand()%(SCREEN_H-HUD_H); }
    else { asteroids[i].x = SCREEN_W-1; asteroids[i].y = HUD_H + rand()%(SCREEN_H-HUD_H); }
    float dx = CX - asteroids[i].x;
    float dy = CY - asteroids[i].y;
    float d = sqrtf(dx*dx+dy*dy);
    if(d<1.0f) d=1.0f;
    asteroids[i].vx = (dx/d)*AST_SPEED;
    asteroids[i].vy = (dy/d)*AST_SPEED;
    asteroids[i].active = 1;
}

Recherche d'une case libre. La boucle for parcourt le tableau et s'arrête (break) sur la première case dont active vaut 0 (l'opérateur ! inverse la valeur logique). Si aucune case n'est libre, l'indice i atteint MAX_ASTEROIDS après la boucle : la condition if(i==MAX_ASTEROIDS) return; quitte alors la fonction sans rien créer. C'est ce mécanisme qui garantit qu'on ne dépasse jamais quatre astéroïdes et qu'on n'écrit jamais hors du tableau (pas de débordement mémoire).

Choix du bord. rand()%4 renvoie un entier dans {0,1,2,3}, ce qui désigne respectivement le bord haut, bas, gauche ou droite. La fonction rand(), issue de <stdlib.h>, fournit le hasard nécessaire pour que les vagues d'astéroïdes ne soient pas prévisibles. Selon le bord tiré :

  • bord 0 (haut) : abscisse aléatoire sur toute la largeur (rand()%SCREEN_W, soit 0 à 479), ordonnée juste sous le HUD (HUD_H+1) pour ne pas empiéter sur la bande de score ;
  • bord 1 (bas) : abscisse aléatoire, ordonnée SCREEN_H-1 (dernière ligne, 319) ;
  • bord 2 (gauche) : abscisse 0, ordonnée aléatoire dans la zone de jeu HUD_H + rand()%(SCREEN_H-HUD_H) ;
  • bord 3 (droite) : abscisse SCREEN_W-1 (479), ordonnée aléatoire dans la même zone.

On remarque que les positions verticales sont systématiquement comprises entre HUD_H (24) et SCREEN_H (320), ce qui interdit aux astéroïdes d'apparaître dans la bande de score HUD_H=24 pixels située tout en haut de l'écran. Cette précaution évite que des astéroïdes ne viennent recouvrir le compteur de points.

Calcul de la vitesse vers le centre. Une fois la position de départ fixée, on veut une vitesse qui pointe vers le centre du jeu (CX, CY), soit (240, 172). On forme d'abord le vecteur déplacement vers le centre :

dx = CX - x
dy = CY - y

Ce vecteur indique la bonne direction, mais sa longueur dépend de la distance au centre : un astéroïde né du coin irait beaucoup plus vite qu'un autre apparu près du centre. Pour que tous se déplacent à la même vitesse AST_SPEED (ici 4,0), on normalise le vecteur : on le divise par sa propre longueur d pour obtenir un vecteur unitaire (de longueur 1), puis on le multiplie par la vitesse voulue. La longueur se calcule par le théorème de Pythagore :

d = sqrt(dx^2 + dy^2)
vx = (dx/d) * AST_SPEED
vy = (dy/d) * AST_SPEED

La ligne if(d<1.0f) d=1.0f; est une garde de sécurité : si l'astéroïde naissait quasiment sur le centre, d serait proche de zéro et la division dx/d produirait une valeur énorme, voire une division par zéro. En forçant d à valoir au moins 1, on évite ce cas dégénéré et on garantit que vx et vy restent bornés.

C'est le seul appel à la racine carrée sqrtf (issue de <math.h>) du cycle de vie d'un astéroïde. Or spawnAsteroide() n'est appelée que rarement (au plus une fois tous les 19 tours de boucle, cadence réglée par SPAWN_DELAY=19), et au plus quatre astéroïdes coexistent. Le coût de ce flottant lent est donc parfaitement acceptable, parce qu'il est payé une seule fois à l'apparition et jamais pendant le déplacement. Cette répartition « calcul lourd à l'apparition, calcul léger en jeu » illustre la stratégie générale du projet face à un processeur sans virgule flottante matérielle : les seuls appels coûteux (racine carrée, division flottante) sont confinés aux événements rares, jamais à la boucle de rendu de chaque trame.

Tracé de l'octogone : dessinerAsteroide()

Le dessin s'appuie sur deux tableaux de constantes précalculées représentant un octogone de rayon 10, centré sur l'origine :

astX[8] = {10, 7, 0, -7, -10, -7,  0,  7}
astY[8] = { 0, 7,10,  7,   0, -7,-10, -7}

Ces huit couples décrivent les sommets d'un octogone parcouru dans le sens trigonométrique : chaque sommet est à une distance d'environ 10 de l'origine (par exemple sqrt(7^2 + 7^2) = sqrt(98) ~ 9,9, proche de 10). Le fait que ces valeurs soient figées une fois pour toutes évite tout appel à cos ou sin au moment du dessin. La fonction de tracé transforme ces huit sommets puis les relie :

void dessinerAsteroide(Asteroid *a, uint16_t c){
    int px[8]; int py[8];
    for(uint8_t k=0;k<8;k++){
        px[k] = (int)a->x + (AST_RADIUS*astX[k])/10;
        py[k] = (int)a->y + (AST_RADIUS*astY[k])/10;
    }
    for(uint8_t k=0;k<8;k++){
        uint8_t j=(k+1)&7;
        display_drawLine(px[k],py[k],px[j],py[j],c);
    }
}

Le paramètre Asteroid *a. La fonction reçoit un pointeur sur un astéroïde, et non une copie de la structure. La notation a->x est un raccourci pour (*a).x : la flèche signifie « le champ x de la structure pointée par a ». Passer un pointeur évite de recopier les cinq champs de la structure à chaque appel ; on transmet simplement une adresse, ce qui est plus économe en pile et en temps. Le second paramètre c est la couleur du trait, codée sur 16 bits (uint16_t, format RGB565) ; le pilote bas niveau de l'écran la convertira ensuite en trois octets 18 bits avant l'envoi sur le bus SPI.

Mise à l'échelle et translation. La première boucle calcule les huit sommets à l'écran. Le facteur (AST_RADIUS*astX[k])/10 met l'octogone à l'échelle : comme les constantes ont été définies pour un rayon 10, multiplier par AST_RADIUS (12) puis diviser par 10 redimensionne la forme au rayon réel souhaité. Le calcul reste entier (les astX/astY sont des entiers et AST_RADIUS aussi), donc rapide : aucune virgule flottante n'intervient ici. On ajoute ensuite la position du centre (int)a->x et (int)a->y : la conversion (int) tronque la partie décimale de la position flottante pour obtenir des coordonnées de pixels entières.

Tracé des côtés et l'astuce & 7. La seconde boucle relie chaque sommet k au suivant j. Le calcul j = (k+1)&7 est le point clé : &7 est un ET binaire avec le masque 7 (0b111), qui équivaut à un modulo 8. Lorsque k vaut 7, k+1 vaut 8, et 8 & 7 donne 0 : le dernier sommet se relie donc automatiquement au premier, fermant l'octogone. On évite ainsi un if particulier pour le dernier segment. Sur un PIC, un ET binaire est nettement moins coûteux qu'une opération de modulo % (qui implique une division), d'où ce choix. Chaque côté est tracé par display_drawLine, qui s'appuie sur l'algorithme de Bresenham fourni par la bibliothèque graphique.

Construction de l'octogone à partir des huit sommets précalculés
Figure - Construction de l'octogone à partir des huit sommets précalculés et fermeture par l'index (k+1) & 7.

Mise à jour : majAsteroides()

À chaque tour de boucle de jeu, la fonction majAsteroides() applique le schéma général du rendu adopté dans tout le projet : effacer / déplacer / désactiver si nécessaire / redessiner. Concrètement, pour chaque astéroïde actif :

  • Effacer l'octogone à sa position courante, en le redessinant avec la couleur du fond (typiquement noir). On ne rafraîchit ainsi qu'une petite zone, sans effacer tout l'écran, ce qui serait beaucoup trop lent sur la liaison SPI (chaque pixel coûte trois octets de couleur à transmettre).
  • Déplacer l'astéroïde : a->x += a->vx; et a->y += a->vy;. Comme vx et vy ont été calculés une fois pour toutes à l'apparition, le déplacement ne coûte que deux additions flottantes, sans aucune racine carrée ni trigonométrie.
  • Désactiver si trop loin : si l'astéroïde a dépassé le centre et continue hors de la zone de jeu, on remet active = 0 pour libérer la case. Celle-ci redevient alors disponible pour un futur spawnAsteroide().
  • Redessiner l'octogone à sa nouvelle position avec sa couleur normale (les teintes orangées COL_ORANGE (0xFD20) / COL_ORANGE_DARK (0xCB20) du jeu).

Ce cycle effacer/déplacer/redessiner n'agit que sur les pixels réellement concernés : il évite de reconstruire toute l'image à chaque trame, ce qui économise un nombre considérable d'octets envoyés sur le bus SPI vers l'écran ILI9488. C'est dans cette même fonction, ou juste après, que sont également testées les collisions (astéroïde contre projectile pour marquer +10, astéroïde contre vaisseau pour déclencher le GAME OVER), comparaisons effectuées sur les distances au carré afin d'éviter de nouvelles racines carrées.

Retour sur la normalisation vectorielle

Le point mathématique central de la trajectoire d'astéroïde est la normalisation d'un vecteur. Un vecteur (dx, dy) peut s'écrire comme une direction multipliée par une longueur. La direction seule s'obtient en divisant par la norme d = sqrt(dx^2 + dy^2) : le vecteur (dx/d, dy/d) a alors une longueur exactement égale à 1, c'est le vecteur unitaire. En le multipliant par AST_SPEED, on impose la vitesse de déplacement souhaitée tout en conservant la direction vers le centre. Sans cette normalisation, la vitesse dépendrait de la distance de naissance et les astéroïdes lointains traverseraient l'écran plus vite que les proches, ce qui serait visuellement incohérent et rendrait le jeu injouable. La même idée de vecteur unitaire se retrouve ailleurs dans le programme (orientation du vaisseau via shipCos/shipSin, qui forment eux aussi un vecteur de longueur 1), preuve de la cohérence de l'approche géométrique retenue tout au long du développement.

La détection des collisions

La détection des collisions constitue le cœur de l'interaction physique du jeu : c'est elle qui décide si un projectile détruit un astéroïde (et fait monter le score) et si un astéroïde percute le vaisseau (et provoque le GAME OVER). Sur un microcontrôleur PIC16F18877 cadencé à 32 MHz (FOSC = 32 MHz, soit un cycle instruction Tcy = 125 ns) et dépourvu d'unité de calcul flottant matérielle, le défi n'est pas seulement de détecter correctement les contacts, mais de le faire sans opération coûteuse. La stratégie retenue applique fidèlement le fil rouge du projet : modéliser chaque objet par un cercle et comparer des distances au carré pour éviter toute racine carrée. Ce chapitre détaille le test élémentaire collision(), la fonction d'orchestration collisions(), puis la justification mathématique du modèle en cercles.

Le test de base : la fonction collision()

Le principe géométrique est simple. Deux disques se touchent (ou se chevauchent) lorsque la distance qui sépare leurs centres est inférieure ou égale à la somme de leurs rayons. Si l'on note les deux centres (x1, y1) et (x2, y2), de rayons r1 et r2, la condition de contact s'écrit :

distance <= r1 + r2

avec distance = sqrt( (x1 - x2)^2 + (y1 - y2)^2 ).

Le calcul direct de cette condition demanderait une racine carrée (sqrtf), opération très lente sur un PIC8 bits sans FPU, et appelée potentiellement plusieurs fois par tour de boucle. Chaque appel à sqrtf se traduit en effet par une routine logicielle de la bibliothèque mathématique de XC8, soit des dizaines voire des centaines de cycles, là où une multiplication entière ou flottante simple reste bien plus économique. L'optimisation clé consiste donc à élever les deux membres de l'inégalité au carré. On compare alors des distances au carré, sans jamais extraire de racine. La condition devient :

(x1 - x2)^2 + (y1 - y2)^2 <= (r1 + r2)^2

Cette transformation est exacte : elle ne change ni le résultat logique, ni la précision, elle supprime simplement l'opération coûteuse. La fonction C s'écrit ainsi :

bool collision(float x1,float y1,float r1,float x2,float y2,float r2){
    float dx=x1-x2;
    float dy=y1-y2;
    float r=r1+r2;
    return (dx*dx+dy*dy) <= (r*r);
}

Détaillons ligne par ligne ce que fait ce bloc et pourquoi chaque choix a été fait.

  • bool collision(float x1,float y1,float r1,float x2,float y2,float r2) déclare une fonction qui renvoie un booléen (le contact a lieu ou non) et reçoit les centres et rayons des deux objets à tester. Le passage par valeur convient ici : les six paramètres sont de petits scalaires, et la fonction ne modifie rien à l'extérieur.
  • float dx=x1-x2; calcule l'écart horizontal entre les deux centres. On stocke ce résultat dans une variable locale plutôt que de le recalculer, ce qui évite de refaire deux soustractions identiques plus loin.
  • float dy=y1-y2; calcule de même l'écart vertical. À ce stade, le vecteur (dx, dy) joint le centre 2 au centre 1.
  • float r=r1+r2; précalcule la somme des rayons. C'est la distance de contact recherchée. La mettre dans une variable évite de l'additionner deux fois et rend la ligne de retour plus lisible.
  • return (dx*dx+dy*dy) <= (r*r); évalue d'un côté le carré de la distance réelle (dx*dx + dy*dy) et de l'autre le carré de la distance de contact (r*r), puis renvoie le booléen true si le premier est inférieur ou égal au second. Aucune racine carrée n'apparaît : c'est tout l'intérêt de comparer les carrés.

Les coordonnées sont des float car les positions des projectiles et des astéroïdes évoluent par petits incréments de vitesse (BULLET_SPEED = 9.0, AST_SPEED = 4.0) et seraient mal représentées par des entiers. En revanche, les seules opérations effectuées ici sont trois soustractions/additions, trois multiplications et une comparaison, toutes nettement moins pénalisantes qu'une racine carrée. La fonction reste donc parfaitement utilisable à chaque itération de la boucle de jeu.

La fonction collisions() : deux blocs de tests

La fonction collision() ne teste qu'une seule paire d'objets. C'est la fonction collisions() qui balaie toutes les paires utiles du jeu et déclenche les conséquences. Elle se compose de deux blocs distincts : d'abord les tirs contre les astéroïdes, ensuite le vaisseau contre les astéroïdes.

void collisions(void){
    for(uint8_t b=0;b<MAX_BULLETS;b++){
        if(!bullets[b].active) continue;
        for(uint8_t a=0;a<MAX_ASTEROIDS;a++){
            if(!asteroids[a].active) continue;
            if(collision(bullets[b].x,bullets[b].y,BULLET_RADIUS,
                         asteroids[a].x,asteroids[a].y,AST_RADIUS)){
                display_fillCircle((uint16_t)bullets[b].x,(uint16_t)bullets[b].y,BULLET_RADIUS,ILI9488_BLACK);
                dessinerAsteroide(&asteroids[a],ILI9488_BLACK);
                bullets[b].active=0;
                asteroids[a].active=0;
                score+=10;
                break;
            }
        }
    }
    for(uint8_t a=0;a<MAX_ASTEROIDS;a++){
        if(!asteroids[a].active) continue;
        if(collision(CX,CY,SHIP_RADIUS,
                     asteroids[a].x,asteroids[a].y,AST_RADIUS)){
            son_gameover();
            afficherGameOver();
            etat=ETAT_GAMEOVER;
            return;
        }
    }
}

Bloc 1 : tir contre astéroïde. Une double boucle imbriquée teste chaque tir contre chaque astéroïde. La boucle externe parcourt les MAX_BULLETS (4) projectiles possibles, la boucle interne les MAX_ASTEROIDS (4) astéroïdes possibles ; les compteurs b et a sont déclarés en uint8_t, le type le plus économe en RAM pour des valeurs aussi petites. Les deux lignes if(!bullets[b].active) continue; et if(!asteroids[a].active) continue; court-circuitent immédiatement le test lorsque l'objet n'existe pas à l'écran : il est inutile de calculer une collision pour un projectile éteint ou un astéroïde déjà détruit. Cette garde économise des calculs et constitue une bonne hygiène de boucle. Dans le pire des cas, le bloc effectue 4 x 4 = 16 appels à collision(), ce qui reste très faible.

Lorsque collision() renvoie true pour le couple (tir, astéroïde), quatre actions s'enchaînent. D'abord display_fillCircle(...) redessine le projectile en noir (ILI9488_BLACK) pour l'effacer de l'écran à sa dernière position connue ; la conversion (uint16_t) ramène les coordonnées flottantes à des entiers de pixels, car l'écran n'adresse que des positions entières. Ensuite dessinerAsteroide(&asteroids[a],ILI9488_BLACK) efface de même l'octogone de l'astéroïde en le redessinant en noir : on suit ici le principe effacer / déplacer / redessiner propre au moteur de rendu, qui ne repeint que ce qui change plutôt que tout l'écran. Puis bullets[b].active=0; et asteroids[a].active=0; désactivent logiquement les deux objets, qui ne seront plus ni affichés ni testés. Enfin score+=10; ajoute les 10 points associés à la destruction d'un astéroïde.

Schéma effacer déplacer redessiner
Figure - Principe effacer / déplacer / redessiner appliqué lors d'une destruction.

Le break; final est important : une fois qu'un projectile a touché un astéroïde, il est consommé. Inutile de continuer à le comparer aux autres astéroïdes puisqu'il vient d'être désactivé ; on quitte donc la boucle interne pour passer au projectile suivant. Cela évite qu'un seul tir ne détruise plusieurs astéroïdes dans la même passe et économise des itérations.

Bloc 2 : vaisseau contre astéroïde. Une boucle simple teste cette fois le vaisseau, modélisé par un cercle fixe centré en (CX, CY) = (240, 172) et de rayon SHIP_RADIUS = 18, contre chaque astéroïde actif. Le vaisseau restant au centre de l'écran, ses coordonnées sont des constantes, ce qui simplifie encore le calcul puisque (CX, CY) n'a jamais besoin d'être recalculé. Dès qu'un astéroïde entre en contact avec ce cercle, le jeu bascule : son_gameover() joue le signal sonore de fin via le NCO1 (sortie sur RA6, patte 14), afficherGameOver() affiche l'écran correspondant, et etat=ETAT_GAMEOVER; fait transiter la machine à états vers l'écran de fin de partie. Le return; interrompt immédiatement la fonction : la partie est terminée, il est superflu de tester les astéroïdes restants.

L'ordre des deux blocs n'est pas anodin. On traite d'abord les destructions par tir, puis seulement la collision fatale avec le vaisseau. Ainsi, un astéroïde détruit par un projectile dans le premier bloc est déjà désactivé (asteroids[a].active=0) et ne pourra pas, dans le second bloc, déclencher à tort un GAME OVER : la garde if(!asteroids[a].active) continue; l'écarte.

Pourquoi un modèle en cercles et la démonstration des carrés

Le vaisseau est en réalité un triangle et l'astéroïde un octogone (huit sommets précalculés dans les tableaux astX et astY, de rayon 10, mis à l'échelle x AST_RADIUS/10). On aurait pu calculer des intersections exactes entre ces polygones, mais ce serait inutilement complexe et coûteux. Le choix d'un cercle englobant (boundary circle) est ici parfaitement justifié : il est simple à coder, très rapide à évaluer, et suffisamment fidèle pour un jeu d'arcade où les objets sont petits et les contacts francs. Un cercle de rayon bien choisi (SHIP_RADIUS = 18, AST_RADIUS = 12) approxime convenablement l'encombrement réel de chaque forme, et l'imprécision résiduelle reste imperceptible en pratique. Ce compromis entre exactitude géométrique et coût de calcul est exactement ce qu'impose un microcontrôleur lent.

Forme réelle de l'astéroïde et son cercle englobant
Figure - Forme réelle de l'astéroïde (octogone) et son cercle englobant de rayon AST_RADIUS.

Reste à justifier rigoureusement que comparer les carrés équivaut à comparer les distances. Soit d la distance entre les centres et r la somme des rayons. Par construction, d est une distance donc d >= 0, et r est une somme de rayons donc r >= 0. On veut montrer que :

d <= r si et seulement si d^2 <= r^2

La fonction qui à un nombre positif associe son carré est strictement croissante sur l'ensemble des réels positifs ou nuls : si l'on augmente une grandeur positive, son carré augmente aussi. Plus formellement, on peut écrire la différence des carrés comme un produit :

r^2 - d^2 = (r - d)(r + d)

Le facteur (r + d) est positif ou nul puisque d et r le sont. Le signe de r^2 - d^2 est donc entièrement déterminé par le signe de (r - d). Si d <= r, alors (r - d) >= 0 et le produit (r - d)(r + d) >= 0, donc r^2 >= d^2, c'est-à-dire d^2 <= r^2. Réciproquement, si d^2 <= r^2, alors (r - d)(r + d) >= 0 ; comme (r + d) >= 0, le facteur (r - d) ne peut être que positif ou nul, donc d <= r. Les deux conditions sont bien équivalentes.

Rigueur et performance réconciliées

Cette équivalence n'est valable que parce que les deux membres sont positifs : élever au carré une inégalité n'est pas anodin si des nombres négatifs sont en jeu. Ici, distances et rayons étant par nature positifs, la condition (dx*dx + dy*dy) <= (r*r) donne exactement le même verdict que la condition géométrique d <= r, mais sans la racine carrée. Le test élimine ainsi l'unique opération flottante coûteuse tout en restant mathématiquement irréprochable : un compromis idéal entre rigueur et performance, parfaitement adapté aux contraintes du PIC16F18877.