Jeu des cinq LED

Le 6 novembre, j′ai participé à la journée de lancement de la manivelle 2, deuxième édition du concours d′inventions organisé par la MJC Massinon. Pour l′occasion, j′ai construit un petit jeu qui me servait de support ludique pour présenter mon activité Arduino. Dans les semaines qui ont suivi, j′ai refait ce projet avec les participants de mon atelier, et il s′avère particulièrement adapté pour des débutants. Voici une documentation détaillée du projet, étape par étape.

Description

Le jeu est composé de 5 LED formant une ligne, avec un bouton poussoir devant chaque LED. Quand on appuie sur un bouton, la LED correspondante change d′état (allumé vers éteint ou inversement), ainsi que les deux LED voisines.

Les LED formant une ligne, la première et la dernière n′ont qu′une voisine. On considère donc que la voisine de gauche de la première est la dernière, et la voisine de droite de la dernière est la première, comme si la ligne était en réalité une boucle.

Au démarrage, les LED sont allumées aléatoirement. Le but du jeu est de toutes les éteindre en minimisant le nombre de coups.

Le montage

J′ajouterai peut être un schéma si j′arrive à faire marcher fritzing sur mon ordi … pour l′instant c′est pas gagné.

La partie électronique du projet est très simple, même si le nombre de fils peut rendre la chose un peu brouillon.

  • les 5 LED sont alimentées par les entrées/sorties numériques 2 à 6. Le montage est très classique, l′anode sur le + (le port), la cathode sur une résistance qui mène à la masse.
  • les boutons poussoirs sont branchés d′un coté sur la masse et de l′autre coté sur les entrées/sorties numériques 8 à 12.
  • pour la forme, on peut ajouter un piezzo sur l′entrée sortie numérique 13.

Le code

L′intérêt de ce projet est qu′il peut facilement se subdiviser en plusieurs étapes consécutives, avec pour chaque étape du code fonctionnel, qu′on peut tester sur la plateforme.

Étape 1 : première analyse du problème

Ce projet nécessite de pouvoir faire deux actions :

  • allumer ou éteindre une LED ;
  • détecter quand on appuie sur un bouton.

On remarque que le montage électronique est composé de 5 «tranches» composées à chaque fois d′une LED et d′un bouton poussoir. Si on arrive à faire les deux actions précédentes sur une tranche, on peut aussi le faire sur les autres. Concentrons nous donc sur une tranche (premier bouton, première LED) et on généralisera après.

Étape 2 : lire un bouton et allumer une LED

int led_pin = 2;
int btn_pin = 8;

void setup()
{
  pinMode(led_pin,OUTPUT);
  pinMode(btn_pin,INPUT_PULLUP);
}

On initialise les deux entrées/sorties. Comme le bouton est connecté à la masse, on peut utiliser le mode INPUT_PULLUP. Ainsi, l′entrée 8 lit HIGH quand le bouton est relâché et LOW quand le bouton est pressé. Ca nous économise une résistance dans le montage (donc 5).

void loop()
{
  int etat = digitalRead(btn_pin);
  digitalWrite(led_pin,etat);
}

Rien de sorcier : on lit l′état du bouton, et on l′écrit sur la LED. Quand le bouton est pressé, la LED est éteinte. Mais ce n′est pas tout à fait ce qu′on veut …

Étape 3 : changement d′état

Notre but est d′avoir une LED qui change d′état lorsqu′on appuie sur le bouton. Le programme précédent nous en donnait l′illusion, mais ca n′était pas vraiment le cas : il se contentait de copier l′état du bouton sur la LED à chaque appel de la fonction loop(). Pour se rendre compte du problème, il suffit d′envoyer un message sur le port série quand le programme détecte que le bouton est pressé.

int led_pin = 2;
int btn_pin = 8;

void setup() {
  pinMode(led_pin,OUTPUT);
  pinMode(btn_pin,INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  int etat = digitalRead(btn_pin);
  if (etat == LOW) {
    Serial.println("PRESS!");
  }
}

Résultat : dès qu′on appuie sur le bouton, on se fait bombarder de messages ! A la place, on voudrait seulement réagir au changement d′état du bouton. Pour ça, on va mémoriser l′état précédent du bouton, et agir uniquement si on constate une différence entre l′état mémorisé et l′état courant.

int led_pin = 2;
int btn_pin = 8;
int btn_etat = HIGH;

void setup() {
  pinMode(led_pin,OUTPUT);
  pinMode(btn_pin,INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  int etat = digitalRead(btn_pin);
  if (etat != btn_etat) {
    btn_etat = etat;
    if (etat == LOW) {
      Serial.println("PRESS!");
    }
  }
}

Pour éviter que le programme ne réagisse lorsqu′on relâche le bouton, on ajoute la condition if (etat == LOW).

Étape 4 : état de la LED

L′objectif est que la LED change d′état quand on presse le bouton. Elle doit donc balancer entre deux états : allumé et éteint. Pour inverser l′état de la LED, on peut utiliser l′astuce suivante :

void loop() {
  int etat = digitalRead(btn_pin);
  if (etat != btn_etat) {
    btn_etat = etat;
    if (etat == LOW) {
      digitalWrite(led_pin, !digitalRead(led_pin));
    }
  }
}

Quand on appelle digitalRead sur un port configuré en sortie, la fonction retourne la valeur actuelle du port. LOW et HIGH correspondant respectivement aux valeurs 0 et 1, on peut utiliser l′opérateur de négation ! pour transformer l′un en l′autre.

Étape 5 : généralisation

Ca y est ! La LED s′allume ou s′éteint quand on appuie sur le bouton. Que devrait-on changer pour que le programme fasse la même chose avec la deuxième LED et le deuxième bouton ? Il suffit de changer les numéros des broches.

int led_pin = 3;
int btn_pin = 9;

Et ainsi de suite pour la 3ème 4ème et 5ème tranche. Pour généraliser tout ça, on va utiliser des tableaux. Ils permettent de stocker plusieurs valeurs du même type ensemble.

int led_pin[] = {2,3,4,5,6};
int btn_pin[] = {8,9,10,11,12};
int btn_etat[] = {HIGH,HIGH,HIGH,HIGH,HIGH};

Désormais, si on veut accéder au pin de la première LED, on écrit led_pin[0]. Il suffit alors de modifier le code précédent pour exploiter les tableaux et traiter les 5 tranches consécutivement. Bien entendu, il faut aussi modifier la fonction setup() pour initialiser les 5 tranches.

int led_pin[] = {
  2,3,4,5,6};
int btn_pin[] = {
  8,9,10,11,12};
int btn_etat[] = {
  HIGH,HIGH,HIGH,HIGH,HIGH};

void setup() {
  for (int i=0; i<5; i++) {
    pinMode(led_pin[i],OUTPUT);
    pinMode(btn_pin[i],INPUT_PULLUP);
  }
}

void loop() {
  for (int i=0; i<5; i++) {
    int etat = digitalRead(btn_pin[i]);
    if (etat != btn_etat[i]) {
      btn_etat[i] = etat;
      if (etat == LOW) {
        digitalWrite(led_pin[i],!digitalRead(led_pin[i]));
      }
    }
  }
}

Étape 6 : voisinage

On y est presque ! Il ne nous reste plus qu′à faire en sorte que lorsqu′on presse un bouton, les deux LED voisines changent aussi. Les LED étant dans l′ordre, c′est très simple : les voisines sont les LED aux indices i-1 et i+1.

      if (etat == LOW) {
        digitalWrite(led_pin[i-1],!digitalRead(led_pin[i-1]));
        digitalWrite(led_pin[i],!digitalRead(led_pin[i]));
        digitalWrite(led_pin[i+1],!digitalRead(led_pin[i+1]));
      }

Au premier abord, ça semble fonctionner. Mais si on regarde de plus près, on se rend compte que dans certains cas, le programme fait n′importe quoi. En particulier quand on appuie sur le premier et le dernier bouton.

Le soucis, c′est qu′on essaie d′accéder à des valeurs en dehors des limites des tableaux. Quand i==0, accéder à l′indice i-1 nous amène en dehors de l′espace mémoire alloué au tableau. Pareil pour i==4 et i+1. Il faut donc prendre en compte ces deux cas particuliers.

      if (etat == LOW) {
        int precedent = i-1;
        if (precedent == -1) precedent = 4;
        int suivant = i+1;
        if (suivant == 5) suivant = 0;
        digitalWrite(led_pin[precedent],!digitalRead(led_pin[precedent]));
        digitalWrite(led_pin[i],!digitalRead(led_pin[i]));
        digitalWrite(led_pin[suivant],!digitalRead(led_pin[suivant]));
      }

Et voilà ! Le jeu fonctionne !

Étape 7 : initialisation

Pour rendre le jeu plus intéressant, nous allons générer une solution aléatoire au démarrage du programme. Pour cela, nous allons utiliser la fonction random.

void setup() {
  for (int i=0; i<5; i++) {
    pinMode(led_pin[i],OUTPUT);
    pinMode(btn_pin[i],INPUT_PULLUP);
    digitalWrite(led_pin[i],random(2));
  }
}

random(2) retourne un entier tiré aléatoirement dans l′intervalle [0,2[. Donc, 0 ou 1. Mais quand on lance le programme, il démarre toujours sur la même combinaison … pas très aléatoire !

La raison, c′est que la fonction random() appelle un générateur de nombres pseudo-aléatoires. Le générateur produit une séquence de nombres qui semble aléatoire, mais qui sera toujours la même si on part du même point. Mais si on change la première valeur de la séquence, on obtient une séquence totalement différente. Il suffit donc d′initialiser le générateur avec une graine de hasard, et le tour est joué.

Une technique toute simple pour récupérer du hasard est de lire une entrée analogique sur laquelle rien n′est connecté. La valeur lue est dite flottante, c′est à dire chaotique (c′est pour cette raison qu′on ajoute habituellement des résistances pull-up ou pull-down pour se débarasser de ces flottements).

void setup() {
  randomSeed(analogRead(A0));
  for (int i=0; i<5; i++) {
    pinMode(led_pin[i],OUTPUT);
    pinMode(btn_pin[i],INPUT_PULLUP);
    digitalWrite(led_pin[i],random(2));
  }
}

Désormais, le jeu démarre dans un nouvel état aléatoire à chaque fois qu′on appuie sur le bouton reset.

Étape 8 : le piezzo

Pour améliorer l′interface du jeu, on ajoute un piezzo sur le pin 13. Chaque fois qu′un coup est joué, le piezzo fait un bip. Quand le joueur gagne (toutes les LED sont éteintes), le piezzo fait un bip différent.

Pour obtenir ce résultat, ajoutons une fonction testant la condition de victoire :

int victoire() {
  for (int i=0; i<5; i++) {
    if (digitalRead(led_pin[i]) == HIGH) return 0;
  }
  return 1;
}

Il ne nous reste plus qu′à appeler cette fonction après chaque coup pour vérifier si le joueur à gagné, et jouer un son avec la fonction tone.

        if (victoire()) {
          tone(13,880,250);
        } else {
          tone(13,440,100);
        }

Étape 9 : code complet

Et voilà, tout fonctionne ! Voici tout le code en un seul morceau.

int led_pin[] = {
  2,3,4,5,6};
int btn_pin[] = {
  8,9,10,11,12};
int btn_etat[] = {
  HIGH,HIGH,HIGH,HIGH,HIGH};

void setup() {
  randomSeed(analogRead(A0));
  for (int i=0; i<5; i++) {
    pinMode(led_pin[i],OUTPUT);
    pinMode(btn_pin[i],INPUT_PULLUP);
    digitalWrite(led_pin[i],random(2));
  }
}

void loop() {
  for (int i=0; i<5; i++) {
    int etat = digitalRead(btn_pin[i]);
    if (etat != btn_etat[i]) {
      btn_etat[i] = etat;
      if (etat == LOW) {
        int precedent = i-1;
        if (precedent == -1) precedent = 4;
        int suivant = i+1;
        if (suivant == 5) suivant = 0;
        digitalWrite(led_pin[precedent],!digitalRead(led_pin[precedent]));
        digitalWrite(led_pin[i],!digitalRead(led_pin[i]));
        digitalWrite(led_pin[suivant],!digitalRead(led_pin[suivant]));
        if (victoire()) {
          tone(13,880,250);
        } else {
          tone(13,440,100);
        }
        delay(200); // ajout pour les boutons trop sensibles
      }
    }
  }
}

int victoire() {
  for (int i=0; i<5; i++) {
    if (digitalRead(led_pin[i]) == HIGH) return 0;
  }
  return 1;
}

Étape 10 : conclusion

Concernant le code, il pourrait bien entendu être amélioré. Certains passages sont volontairement verbeux pour améliorer la compréhension. Selon les goûts des uns et des autres, certaines parties du code mériteraient d′être mises dans des fonctions séparées, mais pour le coup ça ne m′a pas parru indispensable.

Il pourrait aussi être intéressant de reprendre entièrement en AVR C, en exploitant les registres pour lire/écrire les 5 LED en même temps. Un bon exercice pour apprendre …

Concernant le jeu en lui-même, il est assez simple dès qu′on a pigé l′astuce. Il possède en tout 32 états, mais faire boucler les deux extrémités génère de nombreuses symétries, rendant ainsi de nombreux états strictement équivalents.

Pour la petite histoire, dans la première version de ce jeu, les deux extrémités ne bouclaient pas, ce qui le rendait plus dur. Mais à force de jouer, je me suis rendu compte que je n′arrivais pas toujours à trouver une solution. J′ai donc généré le graphe d′états du jeu, pour constater que le graphe était partitionné en deux. La moitié des états ne permettent donc pas d′atteindre la solution.

Sans bouclage des extrémités :

Avec bouclage des extrémités :

Comme ca, le graphe du jeu bouclé est assez impressionant. Mais dans la réalité c′est plutôt simple. Si je trouve le temps, je génèrerai une version en retirant toutes les symétries …

Post-scriptum : une réécriture en C

Janvier 2014 : J´ai réécrit ce programme en C, sans passer par les bibliothèques arduino.

#define F_CPU 16000000

#include <avr/io.h>
#include <util/delay.h>
#include <stdlib.h>

#define MASKD 0b01111100
#define MASKB 0b00011111

//helper macros to mbnipulate registers
#define SET(R, B)   (R |= (1 << B)) // set to 1
#define CLEAR(R, B) (R &= ~(1 << B)) // set to 0
#define FLIP(R, B)  (R ^= (1 << B)) // flip
#define GET(R, B)   (R & (1 << B)) // get

#define MOVE 1136
#define WIN 568

void beep() {
    double dur;
    if (PORTD & MASKD) {
        dur = 100000;
        while (dur > 0) {
            FLIP(PORTB, 5);
            _delay_us(MOVE);
            dur -= MOVE;
        }
    } else {
        dur = 200000;
        while (dur > 0) {
            FLIP(PORTB, 5);
            _delay_us(WIN);
            dur -= WIN;
        }
    }
}


uint8_t toggle_mask(uint8_t pos){
    switch(pos) {
        case 1  : return 0b10011;
        case 2  : return 0b00111;
        case 4  : return 0b01110;
        case 8  : return 0b11100;
        case 16 : return 0b11001;
        default : return 0; // any other case is due to multiple keypress - ignore
    }
}

uint8_t readADC() {
    ADCSRA |= (1 << ADPS2)|(1 << ADPS1)|(1 << ADPS0); // prescalar
    SET(ADMUX, REFS0);                                // voltage reference
    SET(ADMUX, ADLAR);                                // A0 + left align for easy 8bit reading
    CLEAR(PRR, PRADC);                                // turn off power reduction on ADC
    SET(ADCSRA, ADEN);                                // enable ADC
    SET(ADCSRA, ADSC);                                // start conversion
    while(!GET(ADCSRA,ADIF)){}                        // wait for the conversion to end
    CLEAR(ADCSRA, ADEN);                              // disable ADC
    SET(PRR, PRADC);                                  // turn on power reduction on ADC
    return ADCH;
}

int main (void) {
    uint8_t save, read;
    srandom(readADC());  // initalize RNG
    DDRD |= MASKD;       // set pins 2-6 for output
    DDRB &= ~MASKB;      // set pins 8-12 for input
    PORTB |= MASKB;      // activate pull-ups on pins 8-12
    DDRB |= (1 << 5);    // set pin 13 for output
    save = PINB & MASKB; // save buttons state
    PORTD |= readADC() << 2;
    // game logic
    for (;;) {
        read = PINB & MASKB;
        // something has changed
        if (read != save) {
            // toggle the leds corresponding to the bits that have changed from 1 to 0
            PORTD ^= toggle_mask((read ^ save) & ~read) << 2;
            if ((read ^ save) & ~read) beep();
            save = read;
            _delay_ms(100);
        }
    }
    return 0;
}