La physique et la partie

Étape 5

Nous allons voir dans cette cinquième et dernière étape la gestion de la physique et la gestion de la partie, ainsi à la fin de cette étape nous serons en mesure de faire déplacer une caisse par le personnage, de s'arrêter face à un mur et de déterminer si la partie est terminée.

Introduction

Les pré-requis de cette étape sont :

  • Avoir réaliser l'étape 1, l'étape 2, l'étape 3 et l'étape 4 de ce workshop.

Je vous invite à télécharger le code qui est le résultat de la quatrième étape, ceci pour partir sur des bases communes.

La physique : collision avec les murs

Il faut pouvoir rester bloqué si l'on cherche à franchir un mur, sachez qu'il s'agit de l'affaire de quelques lignes et que tout ce passe dans CharacterController.

D'abord, nous allons écrire la méthode pour détecter que le sprites (que nous nommerons aReplacedSprites) est un mur, il s'agit de la méthode isWall, voici le pseudo code :

SI aReplacedSprites = mur ALORS
  Retourner vrai
SINON
  Retourner faux
FIN SI

Ceci dit ce pseudo code peut-être optimisé, en effet la condition renvoye vrai ou faux, le pseudo code est ainsi :

Retourner (aReplacedSprites = mur)

Voici le code de la méthode :

const bool CharacterController::isWall(const char aReplacedSprites) const {
  return (aReplacedSprites == TypeOfSprites::WALL_TYPE);
}

Enfin nous avons plus qu'à interdire le déplacement si le sprite est un mur. Rappelez-vous du pseudo code du déplacement vers le haut par exemple, qui était le suivant :

Calculer la position fictive relative à un déplacement vers le haut
Récupérer le sprites relatif à la position fictive
SI nous sommes toujours sur la carte avec la position fictive ALORS
  Ecraser la position fictive avec la tuile correspondant au joueur
  Remplacer l'ancienne position du joueur par le sprites qui s'y trouvait avant
  Stocker le sprites relatif à la position fictive
  Mettre à jour la position du personnage
SINON
  Réintialiser la position fictive
FIN SI

Voici le pseudo code d'un déplacement vers le haut qui interdit de franchir un mur :

Calculer la position fictive relative à un déplacement vers le haut
Récupérer le sprites relatif à la position fictive
SI nous sommes toujours sur la carte avec la position fictive ET le sprites où nous voulons aller n'est pas un mur ALORS
  Ecraser la position fictive avec la tuile correspondant au joueur
  Remplacer l'ancienne position du joueur par le sprites qui s'y trouvait avant
  Stocker le sprites relatif à la position fictive
  Mettre à jour la position du personnage
SINON
  Réintialiser la position fictive
FIN SI

Voici le code du déplacement vers le haut avec la contrainte relative au franchissement de mur :

void CharacterController::goUp() {
  // calcul de la nouvelle position
  character->goUp();
  // récupérer la tuile de la nouvelle position
  char newTypeOfSprites = mapModel->getTypeOfSprites(character->getNextPos()[0], character->getNextPos()[1]);
  // si la nouvelle position du personnage est sur la carte et que ce n'est pas un mur
  if(character->getNextPos()[1] >= 0 && !isWall(newTypeOfSprites)) {
    // écraser nouvelle position par la tuile du joueur
    mapModel->setTypeOfSprites(character->getNextPos()[0], character->getNextPos()[1], getPlayerSprites(newTypeOfSprites));
    // remplacer ancienne position par la tuile qui était à cette position précédement
    mapModel->setTypeOfSprites(character->getX(), character->getY(), character->getOldTypeOfSprites());
    // stocker la tuile de la nouvelle position
    character->setOldTypeOfSprites(newTypeOfSprites);
    // mettre à jour la position
    character->updatePositions();
  } else {
    // remettre à zéro la position suivante
    character->resetNextPositions();
  }
}

Ajouter la contrainte aux autres déplacements et le tour sera joué.

La physique : déplacer une caisse

Nous allons maintenant voir le déplacement d'une caisse, sachez que c'est plus complexe que la contrainte précédente, mais je suis là pour vous guider. Et comme la contrainte précédente tout ce joue dans CharacterController.

Commençons par écrire le pseudo code de détection d'une caisse, pensez à la simplification utilisé lors de la détection de mur :

Retourner (aReplacedSprites = caisse) || (aReplacedSprites = caisse sur zone de 'chargement')

Voici le code de la méthode isBox :

const bool CharacterController::isBox(const char aReplacedSprites) const {
  return (aReplacedSprites == TypeOfSprites::BOX_TYPE) || 
    (aReplacedSprites == TypeOfSprites::BOX_ON_ZONE_TYPE);
}

Ecrivons un gros morceau, soit le pseudo code pour déplacer une caisse, il s'agit de la méthode moveBox. Les paramètres de cette méthode sont le sprites à remplacer (aReplacedSprites), les coordonnées X1, Y1 ainsi que les coordonnées X2, Y2 ces dernières représentent le sprite après la caisse. Voici le pseudo code :

Récupérer le sprites ayant pour coordonnées X2, Y2
Affecter à stopMove la valeur du test suivant : est différent du sol ou différent d'une zone de chargement
SI stopMove est faux ALORS
  SI le sprites de coordonnées X2, Y2 est une zone de chargement ALORS
    Affecter à replacedSprites2 un sprite de type caisse sur zone de chargement
  SINON
    Affecter à replacedSprites2 un sprite de type caisse
  FIN SI
  Ecraser la position X2, Y2 par replacedSprites2
  SI aReplacedSprites = caisse sur zone de chargement ALORS
    Retourner sprites de type zone de chargement
  SINON
    Retourner sprites de type sol
  FIN SI
FIN SI
Retourner aReplacedSprites

Nous pouvons simplifier le code à l'aide d'une condition particulière que l'on appelle condition ternaire. Voyons comment faire sur un exemple de pseudo code :

SI nb > 0 ALORS
  Affecter à signe la valeur '+'
SINON
  Affecter à signe la valeur '-'
FIN SI

Voici le code relatif à cette exemple, utilisant une condition ternaire :

char signe = (nb > 0) ? '+' : '-';

Je vous ai donner cette simplification car nous pouvons l'utiliser à deux reprises dans le code de moveBox que voici :

const char CharacterController::moveBox(const char aReplacedSprites, const int aX1, const int aY1, const int aX2, const int aY2) {
  // on récupère le sprites en X2, Y2
  const char sprites = mapModel->getTypeOfSprites(aX2, aY2);
  // si c'est le sol ou une zone de chargement alors on déplace la caisse
  stopMove = !((sprites == TypeOfSprites::FLOOR_TYPE) || (sprites == TypeOfSprites::DESTINATION_TYPE));
  if(!stopMove) {
    // on calcul le sprites X2, Y2
    const char replacedSprites2 = (sprites == TypeOfSprites::DESTINATION_TYPE) ? TypeOfSprites::BOX_ON_ZONE_TYPE : TypeOfSprites::BOX_TYPE ;
    // on affecte le sprites X2, Y2
    mapModel->setTypeOfSprites(aX2, aY2, replacedSprites2);
    return (aReplacedSprites == TypeOfSprites::BOX_ON_ZONE_TYPE) ? TypeOfSprites::DESTINATION_TYPE : TypeOfSprites::FLOOR_TYPE;
  }
  return aReplacedSprites;
}

Remarque : une optimisation est facultative mais possible, en effet nous n'avons pas besoin du couple de coordonnées X1, Y1, il peut donc être supprimer. Il s'agit d'une erreur de ma part, j'ai oublié de le supprimer dès l'étape 1 de ce workshop.

Il nous reste plus qu'à connecter les deux méthodes que l'on vient d'écrire au déplacement. Nous allons nous servir du déplacement vers le haut pour notre exemple, voici le pseudo code avec la contrainte de déplacement d'une caisse :

Calculer la position fictive relative à un déplacement vers le haut
Récupérer le sprites relatif à la position fictive
SI nous sommes toujours sur la carte avec la position fictive ET le sprites où nous voulons aller n'est pas un mur ALORS
  SI le sprites relatif à la position fictive est une caisse ALORS
    Faire un appel à la méthode de déplacement d'une caisse et affecter la valeur de retour au sprites relatif à la position fictive
  SINON
    Affecter à stopMove la valeur false
  FIN SI
      
  SI stopMove est faux ALORS
    Ecraser la position fictive avec la tuile correspondant au joueur
    Remplacer l'ancienne position du joueur par le sprites qui s'y trouvait avant
    Stocker le sprites relatif à la position fictive
    Mettre à jour la position du personnage
  SINON
    Réintialiser la position fictive
  FIN SI
SINON
  Réintialiser la position fictive
FIN SI

Voici le code de la méthode goUp modifier avec la contrainte de déplacement d'une caisse :

void CharacterController::goUp() {
  // calcul de la nouvelle position
  character->goUp();
  // récupérer la tuile de la nouvelle position
  char newTypeOfSprites = mapModel->getTypeOfSprites(character->getNextPos()[0], character->getNextPos()[1]);
  // si la nouvelle position du personnage est sur la carte et que ce n'est pas un mur
  if(character->getNextPos()[1] >= 0 && !isWall(newTypeOfSprites)) {
    // déplacer une caisse
    if(isBox(newTypeOfSprites)) {
      newTypeOfSprites = moveBox(newTypeOfSprites, character->getNextPos()[0], character->getNextPos()[1], character->getNextPos()[0], character->getNextPos()[1] - 1);
    } else {
      stopMove = false;
    }

    if(!stopMove) {
      // écraser nouvelle position par la tuile du joueur
      mapModel->setTypeOfSprites(character->getNextPos()[0], character->getNextPos()[1], getPlayerSprites(newTypeOfSprites));
      // remplacer ancienne position par la tuile qui était à cette position précédement
      mapModel->setTypeOfSprites(character->getX(), character->getY(), character->getOldTypeOfSprites());
      // stocker la tuile de la nouvelle position
      character->setOldTypeOfSprites(newTypeOfSprites);
      // mettre à jour la position
      character->updatePositions();
     } else {
        character->resetNextPositions();
     }
    } else {
      // remettre à zéro la position suivante
      character->resetNextPositions();
    }
}

Le code pour les autres directions est similaire, à l'exception des coordonnées X2, Y2 passé à moveBox qu'il faut adapter, voyons cela.

Aller vers la droite

X2 = character->getNextPos()[0] + 1
Y2 = character->getNextPos()[1]

Aller vers le bas

X2 = character->getNextPos()[0]
Y2 = character->getNextPos()[1] + 1

Aller vers la gauche

X2 = character->getNextPos()[0] - 1
Y2 = character->getNextPos()[1]

Une fois les méthodes complétées, amusez vous avec le jeu, en effet vous devriez être en mesure de déplacer les caisses sur les zones de chargement.

GESTION DE LA PARTIE

Dans ce dernier chapitre du workshop nous allons aborder la gestion de la fin de partie. Effectivement il serait bien de féliciter le joueur lorsque celui-ci à déplacer l'ensemble des caisses sur les zones de chargement.

Pour cela il faut parcourir la carte, la méthode isFinish de MapModel nous indique si le jeu est terminée ou non (via isEnd), voici le pseudo code :

Affecter à isEnd la valeur true
PARCOURIR la carte sur l'axe Y tant que le jeu est terminé
  PARCOURIR la carte sur l'axe X tant que le jeu est terminé
    Affecter à isEnd la valeur de isEnd ET (sprites X, Y différent de zone de chargement ET sprites X, Y différent de caisse)
  FIN PARCOURIR
FIN PARCOURIR
Retourner isEnd

Voici le code de la méthode :

bool MapModel::isFinish() const {
  bool isEnd = true;
  for(int y=0 ; y < HEIGHT_MAP && isEnd ; y++) {
    for(int x=0 ; x < WIDTH_MAP && isEnd ; x++) {
      isEnd = isEnd && !(mapOfGame[y][x] == TypeOfSprites::DESTINATION_TYPE || mapOfGame[y][x] == TypeOfSprites::BOX_TYPE);
    }
  }
  return isEnd;
}

Il faut maintenant que le contrôleur ai accès à isFinish, on rend cela possible via la méthode isEndOfGame de MapController que voici:

bool MapController::isEndOfGame() const {
  return model->isFinish();
}

Modifions la méthode run de MainController pour que lorsque la partie est finie on affiche un écran "Gagné", soit le code suivant :

void MainController::run() {
  if(! mapController->isEndOfGame()) {
    characterController->run();
    const int* cameraPos = cameraModel->getCameraPositions(characterController->getX(), characterController->getY());
    mapController->paint(cameraPos);
  } else {
    gb.display.setFontSize(2);
    gb.display.setColor(BROWN);
    gb.display.println("");
    gb.display.println("");
    gb.display.println("  Gagne");
  }
}

Votre jeu est maintenant complétement jouable, amusez vous bien !

CONCLUSION

Cette étape était la dernière du workshop. Vous pouvez télécharger le code source final. Le jeu est jouable mais également améliorable. En effet il pourrait être intéressant de pouvoir recommencer la partie (sans avoir à quitter le jeu), ou bien avoir plusieurs cartes, etc. Mais le but de ce workshop était de proposer une initiation à la programmation orientée objet et au modèle d'architecture Modèle Vue Contrôleur (MVC), j'espère que ce workshop vous servira dans vos prochaine créations. Le découpage de ce workshop n'est pas anodin, effectivement nous avons développer chacune des briques de notre jeu pas à pas, bloc de fonctionnalités après bloc, et c'est comme cela que vous devez concevoir vos jeux. Enfin comme les autres étapes n'hésitez pas à me donner votre avis.

Aller plus loin

Avant de partir, et si vous voulez voir une autre approche du MVC, je vous conseille fortement de lire l'excellent workshop de steph sur le jeu de la vie disponible ici. Le workshop présente le jeu selon différentes approches : une approche fonctionnelle jusqu'au MVC en passant par une version purement objet. De plus, il a ajouté, toujours dans ce workshop, des interactions avec les LEDs ainsi que des effets sonores.

Workshop suivant