◆ Portail Antalaha

Annuaire › Code ouvert

Pirogue d'Antalaha - jeu de vanille (p5.js)

tutorial · Voir le site ↗
Connectez-vous pour copier ce site

Code publié par son auteur, en lecture seule. « Copier dans mon espace » crée un nouveau site à vous (non publié) que vous pouvez modifier librement.

js/jeu.js · 19.4 Ko

/* ============================================================
   jeu.js - la classe Jeu (le chef d'orchestre)
   ============================================================
   Cette classe :
     - possède tous les objets du jeu (pirogue, gousses, obstacles,
       particules, fond, sons) ;
     - gère la MACHINE À ÉTATS : menu -> jeu -> fin -> jeu -> ... ;
     - calcule la difficulté, les apparitions, les collisions, le score ;
     - dessine l'écran (décor, objets, interface).

   MACHINE À ÉTATS : le jeu est dans un état à la fois (this.etat).
   Selon l'état, on fait des choses différentes à chaque image :
     ETATS.MENU : le décor défile doucement, on attend un appui ;
     ETATS.JEU  : la partie se joue ;
     ETATS.FIN  : on affiche le score, on attend un appui pour rejouer.

   TEMPS ET dt : chaque méthode mettreAJour(dt) reçoit `dt`, le temps
   écoulé (en SECONDES) depuis la dernière image. On déplace chaque
   chose de  vitesse x dt : le jeu va à la même vitesse quel que soit
   le nombre d'images par seconde de l'appareil.
   ============================================================ */

class Jeu {

  /* constructor : crée tous les objets et charge le record. */
  constructor() {
    this.sons = new GestionnaireSons();
    this.fond = new Fond();
    this.pirogue = new Pirogue();

    this.gousses = [];       // gousses à l'écran
    this.obstacles = [];     // obstacles à l'écran
    this.particules = [];    // effets visuels

    this.etat = ETATS.MENU;
    this.tempsEtat = 0;      // secondes écoulées depuis le dernier changement d'état

    this.score = 0;
    this.vies = VIES_DEPART;
    this.temps = 0;          // durée de la partie en cours (secondes)
    this.vitesse = DIFFICULTE.vitesseMenu;   // vitesse de défilement actuelle
    this.chronoGousse = 0;   // compte à rebours avant la prochaine gousse
    this.chronoObstacle = 0; // idem pour les obstacles
    this.secousse = 0;       // durée restante de la secousse d'écran

    // Record : lu dans le stockage (texte) puis converti en nombre entier.
    this.record = parseInt(Stockage.lire(CLE_RECORD, '0'), 10) || 0;
    this.nouveauRecord = false;

    // Préférence de son mémorisée.
    this.sons.muet = (Stockage.lire(CLE_MUET, '0') === '1');
  }

  /* ==========================================================
     SECTION 1 : changements d'état et nouvelle partie
     ========================================================== */

  /* changerEtat(nouvelEtat) : passe à un autre état et remet le chrono d'état à 0. */
  changerEtat(nouvelEtat) {
    this.etat = nouvelEtat;
    this.tempsEtat = 0;
  }

  /* demarrerPartie() : réinitialise tout et lance une partie. */
  demarrerPartie() {
    this.gousses = [];
    this.obstacles = [];
    this.particules = [];
    this.score = 0;
    this.vies = VIES_DEPART;
    this.temps = 0;
    this.nouveauRecord = false;
    this.chronoGousse = 0.6;      // première gousse très vite
    this.chronoObstacle = 1.6;    // premier obstacle un peu plus tard : on laisse le temps de réagir
    this.pirogue.reinitialiser();
    this.sons.jouer('clic');
    this.changerEtat(ETATS.JEU);
  }

  /* finirPartie() : passe à l'état FIN et enregistre le record si battu. */
  finirPartie() {
    if (this.score > this.record) {
      this.record = this.score;
      this.nouveauRecord = true;
      Stockage.ecrire(CLE_RECORD, this.record);
      this.sons.jouer('victoire');
    }
    this.changerEtat(ETATS.FIN);
  }

  /* ==========================================================
     SECTION 2 : difficulté progressive
     ========================================================== */

  /* difficulte() : renvoie un nombre de 0 (début) à 1 (difficulté maximale). */
  difficulte() {
    return constrain(this.temps / DIFFICULTE.dureeMax, 0, 1);
  }

  /* niveau() : numéro de niveau affiché (1, 2, 3...). */
  niveau() {
    return 1 + Math.floor(this.temps / DIFFICULTE.secondesParNiveau);
  }

  /* ==========================================================
     SECTION 3 : mise à jour (appelée à chaque image)
     ========================================================== */

  /* ----------------------------------------------------------
     mettreAJour(dt, entrees)
     Paramètres :
       dt      : secondes écoulées depuis la dernière image
       entrees : {direction, cibleX} lus dans sketch.js
     ---------------------------------------------------------- */
  mettreAJour(dt, entrees) {
    this.tempsEtat += dt;
    this.secousse = Math.max(0, this.secousse - dt);

    if (this.etat === ETATS.JEU) {
      this.mettreAJourPartie(dt, entrees);
    } else {
      // Menu et fin : le décor défile doucement, la pirogue flotte.
      this.vitesse = DIFFICULTE.vitesseMenu;
      this.fond.mettreAJour(dt, this.vitesse);
      this.pirogue.mettreAJour(dt, 0, null);
    }

    // Particules : on met à jour, puis on GARDE seulement les vivantes (filter).
    for (const p of this.particules) p.mettreAJour(dt);
    this.particules = this.particules.filter(function (p) { return p.estVivante(); });
  }

  /* ----------------------------------------------------------
     mettreAJourPartie(dt, entrees)
     But : tout ce qui se passe pendant une partie.
     ---------------------------------------------------------- */
  mettreAJourPartie(dt, entrees) {
    this.temps += dt;
    const d = this.difficulte();

    // La vitesse de défilement augmente avec la difficulté :
    // lerp(a, b, t) = a + (b - a) * t
    this.vitesse = lerp(DIFFICULTE.vitesseMin, DIFFICULTE.vitesseMax, d);

    this.fond.mettreAJour(dt, this.vitesse);
    this.pirogue.mettreAJour(dt, entrees.direction, entrees.cibleX);

    // --- Apparitions : un compte à rebours par type d'objet ---
    this.chronoGousse -= dt;
    if (this.chronoGousse <= 0) {
      this.faireApparaitreGousses();
      this.chronoGousse = lerp(DIFFICULTE.intervalleGousseMin, DIFFICULTE.intervalleGousseMax, d);
    }
    this.chronoObstacle -= dt;
    if (this.chronoObstacle <= 0) {
      this.faireApparaitreObstacle(d);
      this.chronoObstacle = lerp(DIFFICULTE.intervalleObstacleMin, DIFFICULTE.intervalleObstacleMax, d);
    }

    // --- Mouvement de tous les objets ---
    for (const g of this.gousses) g.mettreAJour(dt, this.vitesse);
    for (const o of this.obstacles) o.mettreAJour(dt, this.vitesse);

    this.gererCollisions();

    // --- Nettoyage : on retire les objets ramassés, détruits ou sortis de l'écran ---
    this.gousses = this.gousses.filter(function (g) { return !g.mort && !g.estSortie(); });
    this.obstacles = this.obstacles.filter(function (o) { return !o.mort && !o.estSorti(); });
  }

  /* ----------------------------------------------------------
     positionLibre(demiLargeur, ySpawn)
     But : choisir une abscisse où l'on peut faire apparaître un objet
           sans le superposer à un autre objet proche de la zone d'apparition.
     Paramètres : demiLargeur = demi-largeur de l'objet ; ySpawn = son y d'apparition
     Retour : un nombre (x) ou null si aucune place n'a été trouvée.
     On tire jusqu'à 8 positions au hasard et on prend la première libre.
     ---------------------------------------------------------- */
  positionLibre(demiLargeur, ySpawn) {
    const xMin = RIVE + 10 + demiLargeur;
    const xMax = LARGEUR_JEU - RIVE - 10 - demiLargeur;
    const autres = this.gousses.concat(this.obstacles);   // concat colle deux tableaux

    for (let essai = 0; essai < 8; essai++) {
      const x = random(xMin, xMax);
      let libre = true;
      for (const o of autres) {
        const procheVerticalement = Math.abs(o.y - ySpawn) < 120;
        const procheHorizontalement = Math.abs(o.x - x) < (o.rx + demiLargeur + 22);
        if (procheVerticalement && procheHorizontalement) {
          libre = false;
          break;                                           // inutile de tester les autres
        }
      }
      if (libre) return x;
    }
    return null;
  }

  /* ----------------------------------------------------------
     faireApparaitreGousses()
     But : créer une file de 1 à 3 gousses alignées en haut de l'écran.
     ---------------------------------------------------------- */
  faireApparaitreGousses() {
    const x = this.positionLibre(16, -30);
    if (x === null) {
      this.chronoGousse = 0.2;       // pas de place : on réessaie dans un instant
      return;
    }
    const nombre = Math.floor(random(1, 4));        // 1, 2 ou 3
    for (let i = 0; i < nombre; i++) {
      const doree = random() < PROBA_DOREE;         // random() : nombre entre 0 et 1
      this.gousses.push(new Gousse(x, -30 - i * 55, doree));
    }
  }

  /* ----------------------------------------------------------
     faireApparaitreObstacle(d)
     But : créer un rocher ou une vague (la part de vagues augmente avec d).
     ---------------------------------------------------------- */
  faireApparaitreObstacle(d) {
    const probaVague = lerp(DIFFICULTE.probaVagueMin, DIFFICULTE.probaVagueMax, d);
    const type = (random() < probaVague) ? 'vague' : 'rocher';
    const obstacle = new Obstacle(type, 0, -40);
    const x = this.positionLibre(obstacle.rx, -40);
    if (x === null) {
      this.chronoObstacle = 0.2;
      return;
    }
    obstacle.x = x;
    this.obstacles.push(obstacle);
  }

  /* ----------------------------------------------------------
     gererCollisions()
     But : détecter les contacts pirogue/gousses et pirogue/obstacles.
     ---------------------------------------------------------- */
  gererCollisions() {
    const boitePirogue = this.pirogue.boite();

    // Gousses : on les ramasse.
    for (const g of this.gousses) {
      if (!g.mort && collisionRect(boitePirogue, g.boite())) {
        g.mort = true;
        const points = g.doree ? POINTS_GOUSSE_DOREE : POINTS_GOUSSE;
        this.score += points;
        this.sons.jouer(g.doree ? 'succes' : 'piece');
        Particule.gerbe(this.particules, g.x, g.y, g.doree ? 14 : 8,
                        g.doree ? [255, 215, 0] : [255, 255, 255], 160);
        Particule.texteFlottant(this.particules, g.x, g.y - 12, '+' + points,
                                g.doree ? [255, 215, 0] : [255, 255, 255]);
      }
    }

    // Obstacles : seulement si la pirogue n'est pas déjà invulnérable.
    if (this.pirogue.invulnerable <= 0) {
      for (const o of this.obstacles) {
        if (!o.mort && collisionRect(boitePirogue, o.boite())) {
          this.perdreVie(o);
          break;                       // un seul choc par image
        }
      }
    }
  }

  /* ----------------------------------------------------------
     perdreVie(obstacle)
     But : gérer un choc (vie en moins, effets, fin de partie éventuelle).
     ---------------------------------------------------------- */
  perdreVie(obstacle) {
    obstacle.mort = true;
    this.vies -= 1;
    this.pirogue.invulnerable = INVULNERABILITE;
    this.secousse = 0.35;
    this.sons.jouer('erreur');
    Particule.gerbe(this.particules, this.pirogue.x, this.pirogue.y - 20, 18, [190, 235, 255], 240);
    if (this.vies <= 0) {
      Particule.gerbe(this.particules, this.pirogue.x, this.pirogue.y, 30, [190, 235, 255], 300);
      this.finirPartie();
    }
  }

  /* ==========================================================
     SECTION 4 : entrées (appui souris/doigt, touche Entrée/Espace)
     ========================================================== */

  /* ----------------------------------------------------------
     appuyer(x, y)
     But : réagir à un clic ou un toucher.
     Paramètres : x, y = position dans le monde logique (360 x 640)
     ---------------------------------------------------------- */
  appuyer(x, y) {
    this.sons.activer();       // premier appui : on a le droit de préparer les sons

    // Appui sur l'icône du son ?
    if (dist(x, y, LARGEUR_JEU - 26, 66) < 24) {
      this.sons.basculerMuet();
      return;
    }
    this.actionPrincipale();
  }

  /* actionPrincipale() : lance ou relance la partie si l'état le permet. */
  actionPrincipale() {
    if (this.etat === ETATS.MENU) {
      this.demarrerPartie();
    } else if (this.etat === ETATS.FIN && this.tempsEtat > DELAI_REJOUER) {
      this.demarrerPartie();
    }
  }

  /* ==========================================================
     SECTION 5 : dessin
     ========================================================== */

  /* ----------------------------------------------------------
     dessiner()
     But : dessiner toute l'image, dans l'ordre (fond -> objets -> interface).
     ---------------------------------------------------------- */
  dessiner() {
    // Couleur de secours visible si la secousse décale l'image.
    background(COULEURS.eauBas);

    // Secousse d'écran : on décale TOUT le monde d'un petit montant aléatoire.
    // push/pop limitent ce décalage au monde (l'interface reste stable).
    push();
    if (this.secousse > 0) {
      const force = this.secousse * 14;
      translate(random(-force, force), random(-force, force));
    }

    this.fond.dessiner();
    for (const o of this.obstacles) o.dessiner();
    for (const g of this.gousses) g.dessiner();
    if (this.etat !== ETATS.FIN) this.pirogue.dessiner();
    for (const p of this.particules) p.dessiner();
    pop();

    // Interface selon l'état
    if (this.etat === ETATS.JEU) {
      this.dessinerHUD();
    } else if (this.etat === ETATS.MENU) {
      this.dessinerMenu();
    } else {
      this.dessinerFin();
    }
    this.dessinerIconeSon();
  }

  /* ----------------------------------------------------------
     dessinerHUD()
     But : afficher score, vies, record et niveau pendant la partie.
     HUD = "Head-Up Display" (l'affichage tête haute du jeu).
     ---------------------------------------------------------- */
  dessinerHUD() {
    noStroke();
    fill(0, 0, 0, 90);
    rect(0, 0, LARGEUR_JEU, 44);                     // bandeau sombre translucide

    fill(COULEURS.texte);
    textFont(POLICE);
    textStyle(BOLD);
    textSize(17);
    textAlign(LEFT, CENTER);
    text('Gousses : ' + this.score, 12, 22);

    // Vies : un cœur plein par vie restante, un cœur vide sinon.
    for (let i = 0; i < VIES_DEPART; i++) {
      this.dessinerCoeur(168 + i * 26, 22, 20, i < this.vies);
    }

    textAlign(RIGHT, CENTER);
    textSize(14);
    textStyle(NORMAL);
    fill(COULEURS.record);
    text('Record : ' + this.record, LARGEUR_JEU - 12, 22);

    // Niveau, sous le bandeau
    fill(255, 255, 255, 200);
    textAlign(LEFT, CENTER);
    textSize(13);
    text('Niveau ' + this.niveau(), 12, 60);
  }

  /* ----------------------------------------------------------
     dessinerCoeur(x, y, taille, plein)
     But : dessiner un cœur (2 cercles + 1 triangle).
     Paramètres : x, y = centre ; taille ; plein = true (rouge) ou false (vide)
     ---------------------------------------------------------- */
  dessinerCoeur(x, y, taille, plein) {
    push();
    translate(x, y);
    noStroke();
    fill(plein ? COULEURS.coeur : color(255, 255, 255, 70));
    const s = taille;
    circle(-s * 0.25, -s * 0.12, s * 0.55);
    circle(s * 0.25, -s * 0.12, s * 0.55);
    triangle(-s * 0.5, -s * 0.02, s * 0.5, -s * 0.02, 0, s * 0.5);
    pop();
  }

  /* ----------------------------------------------------------
     dessinerIconeSon()
     But : petit haut-parleur (barré si le son est coupé) ; on peut
           le toucher pour couper/remettre le son.
     ---------------------------------------------------------- */
  dessinerIconeSon() {
    push();
    translate(LARGEUR_JEU - 26, 66);
    noStroke();
    fill(0, 0, 0, 90);
    circle(0, 0, 36);

    fill(255);
    rect(-9, -4, 6, 8);                          // la base du haut-parleur
    triangle(-3, -4, 5, -10, 5, 10);             // le cône
    triangle(-3, 4, -3, -4, 5, 10);              // (complète la forme)
    noFill();
    stroke(255);
    strokeWeight(2);
    if (this.sons.muet) {
      stroke(255, 120, 120);
      line(-10, -10, 10, 10);                    // barre : son coupé
    } else {
      arc(5, 0, 10, 12, -HALF_PI, HALF_PI);      // ondes sonores
      arc(5, 0, 20, 22, -HALF_PI, HALF_PI);
    }
    pop();
  }

  /* ----------------------------------------------------------
     dessinerBouton(cx, cy, l, h, libelle, actif)
     But : un bouton rectangulaire arrondi (purement visuel : l'appui
           est détecté sur tout l'écran).
     Paramètres : cx, cy = centre ; l, h = taille ; libelle = texte ;
                  actif = false pour l'afficher grisé
     ---------------------------------------------------------- */
  dessinerBouton(cx, cy, l, h, libelle, actif) {
    // Léger effet de "respiration" pour attirer l'attention.
    const pulse = actif ? 1 + 0.03 * Math.sin(millis() / 250) : 1;
    push();
    translate(cx, cy);
    scale(pulse);                                // scale() agrandit / réduit le dessin
    noStroke();
    fill(actif ? COULEURS.bouton : color(160, 160, 160));
    rect(-l / 2, -h / 2, l, h, 14);
    fill(COULEURS.texteSombre);
    textFont(POLICE);
    textStyle(BOLD);
    textSize(22);
    textAlign(CENTER, CENTER);
    text(libelle, 0, 1);
    pop();
  }

  /* dessinerPanneau(x, y, l, h) : fond blanc translucide arrondi derrière les textes. */
  dessinerPanneau(x, y, l, h) {
    noStroke();
    fill(255, 255, 255, 225);
    rect(x, y, l, h, 18);
  }

  /* ----------------------------------------------------------
     dessinerMenu()
     But : écran d'accueil.
     ---------------------------------------------------------- */
  dessinerMenu() {
    this.dessinerPanneau(24, 90, LARGEUR_JEU - 48, 290);

    fill(COULEURS.texteSombre);
    textFont(POLICE);
    textAlign(CENTER, CENTER);
    textStyle(BOLD);
    textSize(34);
    text('Pirogue', LARGEUR_JEU / 2, 132);
    textSize(26);
    text("d'Antalaha", LARGEUR_JEU / 2, 168);

    textStyle(NORMAL);
    textSize(15);
    text('Ramasse les gousses de vanille', LARGEUR_JEU / 2, 215);
    text('et évite les rochers et les vagues.', LARGEUR_JEU / 2, 238);
    textSize(14);
    fill(60, 80, 100);
    text('Flèches ou Q / D pour piloter', LARGEUR_JEU / 2, 280);
    text('Sur mobile : glisse le doigt', LARGEUR_JEU / 2, 302);
    fill(COULEURS.texteSombre);
    textStyle(BOLD);
    textSize(16);
    text('Meilleur score : ' + this.record, LARGEUR_JEU / 2, 345);

    this.dessinerBouton(LARGEUR_JEU / 2, 430, 190, 56, 'JOUER', true);
    fill(255);
    textStyle(NORMAL);
    textSize(13);
    textAlign(CENTER, CENTER);
    text('Appuie, touche ou presse Entrée', LARGEUR_JEU / 2, 480);
  }

  /* ----------------------------------------------------------
     dessinerFin()
     But : écran de fin de partie.
     ---------------------------------------------------------- */
  dessinerFin() {
    fill(0, 0, 0, 80);
    noStroke();
    rect(0, 0, LARGEUR_JEU, HAUTEUR_JEU);            // assombrit tout le décor
    this.dessinerPanneau(24, 120, LARGEUR_JEU - 48, 280);

    textFont(POLICE);
    textAlign(CENTER, CENTER);
    fill(COULEURS.texteSombre);
    textStyle(BOLD);
    textSize(30);
    text('Fin de partie', LARGEUR_JEU / 2, 165);

    textSize(20);
    text(this.score + (this.score > 1 ? ' gousses ramassées' : ' gousse ramassée'), LARGEUR_JEU / 2, 220);

    textSize(17);
    if (this.nouveauRecord) {
      fill('#b36b00');
      text('Nouveau record !', LARGEUR_JEU / 2, 262);
    } else {
      fill(60, 80, 100);
      textStyle(NORMAL);
      text('Record : ' + this.record, LARGEUR_JEU / 2, 262);
    }

    const pret = this.tempsEtat > DELAI_REJOUER;
    this.dessinerBouton(LARGEUR_JEU / 2, 340, 190, 56, 'REJOUER', pret);
  }
}