Pizza, Tanks and Code

par Yome NetSan @ 13 avril 2010

Pour la suite de mon apprentissage du développement de jeux vidéo, j'avais plusieurs idées en tête pour amélioré mon prototype commencé la semaine dernière :

J'ai du pain sur la planche !

 

Changement de thème

Les pizzas c'est marrant mais ça donne faim. Je ne sais pas encore vers quoi mon jeu tend au niveau gameplay ou visuel, mais j'avais envie de quelque chose de plus "futuriste". Pour autant, je voulais quelque chose avec des gros pixels qui tachent, c'est mon côté retro. J'ai donc réfléchi à ce que je pourrai trouver comme décor de jeu, genre survol de différents environnements et j'ai pensé à la grande map de Xevious. Je l'ai trouvé assez facilement sur le Net et j'en ai découpé un rectangle de 240 x 320 pixels (en verra plus tard pour faire défiler le fond).

Bon, tant que j'y suis, je vais rester avec le thème Xevious et prendre les sprites du jeu original. Je les trouve aussi et je choisi le Galaxian pour le joueur (normal) et le tank pour les éléments qui tombent.

Par contre, je ne fais pas gaffe à la taille dans un premier temps. Il se trouve que chaque pixel d'origine faisait ici 4 x 4 ! La taille à l'écran était donc beaucoup trop grosse à mon goût. Je réduit tout ça aux dimensions originales et là, les pauvres pixels sont perdus au milieu d'un écran bien plus grand qu'il ne l'était à l'époque ! J'ai donc fini par garder une proportion deux fois plus grande pour arriver à un résultat agréable.

J'ai importé ses nouvelles textures en les rangeant bien à leur place dans un dossier "Sprites". J'en ai profité aussi pour remplacer toutes les références à pizza et à playerdans le code par tank et galaxian.

Ca peut paraitre une bonne idée d'avoir des noms de variables ou de classes explicites. Mais c'est dans le cas normal où l'on sait où l'on va ! Ce n'est pas mon cas et, depuis, j'ai pensé à ce que serait au final ce jeu et ce ne sera pas un remake de Xevious. Je vais donc avoir à changer encore une fois tous les noms de variables...

En attendant, cette version se nomme XevYome !


Monter et descendre

Dans un premier temps, j'ai voulu pouvoir tester le jeu directement sur Windows (étant donné que le code est exactement le même, seul la cible change). J'ai donc réintégré les commandes au clavier que j'avais enlevées la dernière fois.

Pour faire monter et descendre le Galaxian, rien de plus simple : on prend les quelques lignes pour faire gauche/droite et on remplace les X par des Y.

  if (keyboard.IsKeyDown(Keys.Up) 
     || gamePad.DPad.Up == ButtonState.Pressed)
     galaxianPosition.Y -= galaxianMoveSpeed;
  if (keyboard.IsKeyDown(Keys.Down) 
     || gamePad.DPad.Down == ButtonState.Pressed)
     galaxianPosition.Y += galaxianMoveSpeed;

Par contre, il ne faut pas oublier d'interdir le vaisseau de sortir de l'écran. Là encore, on prend les X et on leur coupe une patte.

  // Prevent the galaxian from moving off of the screen
  galaxianPosition.X = MathHelper.Clamp(galaxianPosition.X, 
        m_safeBounds.Left, m_safeBounds.Right - galaxianTexture.Width);
  galaxianPosition.Y = MathHelper.Clamp(galaxianPosition.Y, 
        m_safeBounds.Top, m_safeBounds.Bottom - galaxianTexture.Height);

Et comme disent les anglo-saxons dans un français approximatif : "Et voilà".

 


Créer des classes

J'ai commencé simple en créant la classe du joueur, le Galaxian. Comme à mon habitude, j'ai bien rangé ça dans un dossier "App_Code".

Alors attention, il y a une astuce et c'est sur XNA-France que je l'ai trouvé. Il ne faut pas simplement créer une classe, mais une classe qui hérite de Microsoft.Xna.Framework.DrawableGameComponent. Le plus simple est de faire "Ajouter un élément" et de choisir "Game Component". Il faut ensuite remplacer l'héritage :

  public class galaxian 
       : Microsoft.Xna.Framework.GameComponent

par...

  public class galaxian 
       : Microsoft.Xna.Framework.DrawableGameComponent

Notre classe est donc à présent "dessinable".

J'ai récupérer dans le code d'origine toutes les lignes correspondant à mon Galaxian. La tâche est assez simple car mon élément dessinable doit suivre les mêmes étapes que la classe d'origine, c'est-à-dire qu'il doit être initialisé, chargé avec les contenus (ici la texture du sprite), mis à jour (pour le déplacement) et enfin dessiné.

Par contre, pour que la classe suive bien le reste du jeu, il faut lui passer dans le constructeur l'état du reste du jeu.

  #region Variables
        XevYome m_game; //servira a stocker la classe de départ
        ContentManager m_content; //servira a stocker le content principal
        GraphicsDeviceManager m_graphics; //servira a stocker le device manager principal
        Rectangle m_safeBounds; //servira a stocker le safeBounds principal
        public Rectangle galaxianRectangle;
	
        Texture2D galaxianTexture;
        SpriteBatch spriteBatch;
	
        // galaxian 
        Vector2 galaxianPosition;
        public int galaxianMoveSpeed = 4;
  #endregion

  public galaxian(XevYome game, ContentManager content, 
             GraphicsDeviceManager graphics, Rectangle safeBounds)
             : base(game)
  {
       this.m_game = game;
       this.m_content = content;
       this.m_graphics = graphics;
       this.m_safeBounds = safeBounds;
  }

Une fois la classe prête, il faut l'appeler dans le programme principale. L'avantage du framework est qu'il n'est pas nécessaire de dire explicitement à notre Glaxian de s'initialiser, de se mettre à jour ou de se dessiné. Comme la classe est un DrawableGameComponent, elle le fait toute seule.
Il suffit donc de créer une instance de notre vaisseau...

  galaxian galaxian1;

et de la construire à l'initialisation

  galaxian1 = new galaxian(this, this.Content, this.graphics, safeBounds);

 


Afficher le score

Alors pour l'instant, on n'a pas encore de score mais il faut d'abord s'avoir l'afficher avant de le compter, parce que compter sans l'afficher ça sert à rien.

Je me suis donc basé sur ce tutorial en anglais pour écrire un texte à l'écran. Le principe est d'utiliser le spriteBatch pour écrire une chaine de caractère au lieu d'un rectangle, comme on le faisait auparavant.

Pour cela, il nous faut une police de caractères. XNA utilise un fichier XML pour définir une police de caractère à utiliser dans le jeu. Dans le dossier "Content", il faut faire "Ajouter un élément" et choisir "Sprite Font". Le fichier XML est créé et on pourrait s'arrêter là, mais ouvrons-le pour voir ce qu'il a dans le ventre (cf. sources du projet pour une version complète et commentée du fichier).

<XnaContent xmlns:Graphics="Microsoft.Xna.Framework
                            .Content.Pipeline.Graphics">
  <Asset Type="Graphics:FontDescription">
    <FontName>Emulator</FontName>
    <Size>8</Size>
    <Spacing>0</Spacing>
    <UseKerning>true</UseKerning>
    <Style>Regular</Style>
    <!-- <DefaultCharacter>*</DefaultCharacter> -->
    <CharacterRegions>
      <CharacterRegion>
        <Start>&#32;</Start>
        <End>&#126;</End>
      </CharacterRegion>
    </CharacterRegions>
  </Asset>
</XnaContent>

Le plus important à mon sens est le tout début : FontName et Size

Il suffit d'écrire le nom d'une police True Type existant sur le poste de développement pour l'intégrer au jeu. Mais alors attention, parce que j'ai un peu galèré pour trouver le "vrai" nom de la police que je voulais (une version True Type des écritures en sprites de la NES trouvé ici).

Maintenant, il faut écrire. Voici la ligne de code pour écrire le fameux Hello World en haut à gauche de l'écran, en blanc, avec la police "Emulator10".

  spriteBatch.DrawString(Content.Load("Emulator10"), 
                  "Hello World", new Vector2(120, 5), Color.White);

Tout marche bien sauf que le texte s'affiche en dessous des tanks qui passent. Je regarderai ce bug un peu plus tard...

 

Compter les hits

C'est bien gentil tout ça, mais il manque du challenge ! On va donc compter des points et pour le coup on inverse la logique : il faut toucher les tanks pour marquer.

A priori, la tâche n'est pas très compliquée : on ajoute une variable globale (en entier initialisé à 0), on l'incrémente lorsqu'un tank touche le vaisseau (on est toujours sur un mode de collision par rectangle) et on l'affiche à l'écran.

  // For when a collision is detected
  bool galaxianHit = false; //Bon, ce booléen ne sert plus à rien...
  int nbHit = 0;

Dans Update...

  // Check collision with galaxian
  if (galaxian1.galaxianRectangle.Intersects(tankRectangle))
  {
        nbHit++;
  }

Et dans Draw...

  string sScore = string.Format("Score {0}", nbHit);
  spriteBatch.DrawString(Content.Load("Emulator10"), 
                    sScore, new Vector2(5, 5), Color.White);

Personnellement, je n'ai pas vu venir le loup...

Et bien oui, j'ai oublié que l'étape Update est exécutée 30 fois par seconde. Donc lorsque le Galaxian touche un tank, le score augmente tant que le tank reste en contact ! Les points montent donc beaucoup trop vite !

Il faut donc trouver une solution pour qu'une fois qu'un tank est touché, les points n'augmentent plus avec ce même tank.
Il y aurait une solution propre en créant une classe tank avec une propriété touché que l'on passe à true et que l'on test lors d'une collision. Mais visuellement, le tank continuerait de rouler tout droit après avoir été touché.

J'ai donc décidé de faire simple : le tank disparait quand on le touche !

Le code d'origine avait déjà la fonction de supprimer un tank lorsqu'il tombe hors de l'écran (il suffit de tester si la coordonnée Y du tank est supérieure à la hauteur de l'écran). La suppression du tank consiste à supprimer le tank correspondant de la liste des tanks à l'écran.

Je me suis aussi dit que ce serait marrant de marquer 2 points au lieu d'un seul si l'on touche un tank dans la partie supérieure de l'écran, là où l'on a moins de temps pour se déplacer.
Hop, un petit copier/coller et voici le bloc gérant les collisions :

  // Check collision with galaxian
  if (galaxian1.galaxianRectangle.Intersects(tankRectangle))
  {
       if ((_HEIGHT-galaxian1.galaxianRectangle.Top) >=_HEIGHT/2)
            nbHit++;

       nbHit++;
       tankPositions.RemoveAt(i);
  }

 

Compter les miss

Aucun rapport avec Madame de Fontenay, ici je veux décrémenter le score lorsque l'on rate un tank (lorsqu'il sort de l'écran donc).

  // Remove this tank if it have fallen off the screen
  if (tankPositions[i].Y > _HEIGHT)
  {
       tankPositions.RemoveAt(i);
       nbHit--;
       // When removing a tank, the next tank will have the same index
       // as the current tank. Decrement i to prevent skipping a tank.
       i--;
  }

On va garder maintenant le plus haut score atteint. Il suffit d'ajouter une autre variable global et de comparer à la fin de l'Update si le score courant est supérieur ou non au maximum. Bon, il se trouve que j'ai fait ce test dans le Draw, ce qui est une erreur de conception. Ca marche, mais il faut veiller à respecter les méthodes : Update pour les modifications de valeurs, Draw pour les afficher.

  if (nbHit > maxScore)
      maxScore = nbHit;

Il ne reste plus qu'à afficher tout ce beau monde.

  string sScore = string.Format("Score {0}", nbHit);
  spriteBatch.DrawString(Content.Load("Emulator10"), 
                      sScore, new Vector2(5, 5), Color.White);
  string sMaxScore = string.Format("Max {0}", maxScore);
  spriteBatch.DrawString(Content.Load("Emulator10"), 
                      sMaxScore, new Vector2(120, 5), Color.White);

 


Augmenter la difficulté

Dans mon cas, la seule difficulté vient de la vitesse de descente des tanks et de déplacement du vaisseau. Il faut donc que je l'augmente au fur et à mesure du temps de jeu.

Augmenter les vitesse c'est facile, elles sont réglées par des variables à l'initialisation.

  public int galaxianMoveSpeed = 4;

  ...

  int tankFallSpeed = 2;

Maintenant il faut savoir le temps passé depuis le début du jeu. Toujours sur XNA-France, j'ai trouvé mon bonheur et je l'ai facilement intégré.

  protected override void Update(GameTime gameTime)
  {
       tempsPasse = (float)gameTime.TotalGameTime.TotalSeconds;
       tankFallSpeed = 
                Convert.ToInt32(tempsPasse / 10) + 2;
       galaxian1.galaxianMoveSpeed = 
                Convert.ToInt32(tempsPasse / 20) + 4;
       ...

Pour pouvoir contrôler les choses, j'affiche le temps passé et les vitesses respectives.

  string sTempsPasse = string.Format("{0} s", tempsPasse);
  spriteBatch.DrawString(Content.Load("Emulator10"), 
                     sTempsPasse, new Vector2(5, 15), Color.White);

  string sFallSpeed = string.Format("Tank {0} Galaxian {1}", 
                               tankFallSpeed, galaxian1.galaxianMoveSpeed);
  spriteBatch.DrawString(Content.Load("Emulator10"), 
                     sFallSpeed, new Vector2(5, 30), Color.White);

Voilà, maintenant les choses commencent à devenir intéressantes ! Plus le temps passe, plus le jeu accélère. Mais la vitesse du Galaxian n'augmente pas autant que celle des tanks. ce qui fait qu'au bout d'un moment, une fois qu'un tank est passé en dessous du vaisseau, il n'est plus possible de le rattraper et le point est forcément perdu. Au bout de 2 minutes, il devient très dur de toucher plus de tank que l'on en rate. Le score descend donc et le maximum n'augmente plus.

C'est là qu'il faudra à l'avenir arrêter le jeu et afficher le score. Il y a plusieurs possibilités niveau gameplay :

  • soit on arrête quand le score est revenu à 0
  • soit on arrête quand le pourcentage de tank raté est trop élevé par rapport aux tank touchés
  • soit on arrête quand le score max n'a pas augmenter pendant 10 secondes (je pense que ça rajoute un petit niveau de stress)

 

Pad et manettes

Lorsque l'on développe quelque chose, que ce soit une application, un jeu ou un site web, le développeur est obligé de tenir compte de l'environnement d'exécution. Sauf lorsque le client demande un développement adapté à un environnement très précis et spécifié dans le cahier des charges (le navigateur Internet Explorer 6 dans le cas de mon boulot actuel), il faut composer avec toutes les possibilités ou du moins avec la majorité d'entres elles.

Pour un jeu fait avec le XNA, il faut prendre en compte de la machine cible : Windows, Xbox 360 et/ou Zune.
Dans mon cas, la cible principale est le Zune, mais pourquoi ne pas le diffuser sur Windows ? Je pense bien entendu aussi à la Xbox 360, là où les chances de le voir jouer par le plus grand nombre sont les plus élevées, mais le coût d'entrée est plus élevé (99$ pour 1 an). Il n'est même pas possible de tester et débugger sur sa propre console sans l'abonnement XNA premium, donc cela attendra que j'ai un jeu intéressant et qui tourne sur Windows.

Le framework XNA supporte nativement 2 types de contrôles : clavier et gamepad.
Le gros avantage est que derrière "gamepad", on entend une manette Xbox 360, une manette Windows et le pad tactile (ou non) du Zune. C'est donc très pratique et évite d'avoir à coder différemment les contrôles selon la cible. Mon jeu tel qu'il est marche donc sur Zune avec les boutons de direction, sur Windows avec le clavier et une manette et potentiellement sur Xbox 360 avec la croix de direction.

Sur Windows, j'ai pu tester le jeu au clavier mais pas à la manette. Pourtant, j'en ai 3 différentes, par coïncidence toutes de marque Thrustmaster, mais aucune ne marche. Après une petite recherche avec mon ami Google, j'apprend que le XNA ne prend en fait en charge que les manettes Microsoft de type Xbox 360 ! La raison officielle est qu'il faut que le XNA soit compatible entre les différentes plate-formes et manettes. En effet, aucun de mes pads n'a le même nombre de bouton ou de stick. Il est toutefois dommage de ne pas pouvoir utiliser la fonction standard de contrôleur de jeu de Windows.
J'ai pourtant trouvé un projet Open Source (bien que je n'ai pas trouvé les sources) qui permet d'interfacer justement ce système standard DirectX DirectInput avec XNA. Malheureusement, je n'ai pas réussi à le faire tourner (cela date de 2007 problème de version de XNA entre la 2.0 et la 3.1 ?). Je creuserai le sujet un peu plus tard.

Concernant le Zune, l'idéal est de prendre en compte les 3 versions existantes : Le Zune 30 n'a que desboutons de direction, les Zune 4/8/16/80/120 on un pad tactile par dessus des boutons de directions et le Zune HD a un écran tactile (comme l'iPod Touch ou l'iPhone).

N'aillant pas de Zune HD sous la main, je ne sais pas du tout comment réagit l'écran tactile. Je prends donc le parti de ne pas le supporter.
Les boutons de direction du Zune 30 et des Zune 4 à 120 marchent exactement pareil. Donc si cela marche sur mon Zune 120, cela marchera pour les autres.
La prise en compte du pad tactile des Zune 80 et 120 est donc un plus que je peux tester, donc GO.

Dans la pratique, les commandes des boutons de direction des Zune sont les mêmes que pour la croix de direction d'une manette Xbox 360 ou Windows. Il en est de même pour le pad tactile du Zune qui est considéré comme le stick analogique gauche d'une manette ! Mais il faut reconnaitre quelque chose, la maniabilité avec les boutons de direction du Zune n'est pas terrible...

Niveau code, j'ai donc voulu voir quelles étaient les proportions des valeurs données par le pad tactile.

  float stickX;
  float stickY;

  ...

  stickX = gamePad.ThumbSticks.Left.X;
  stickY = gamePad.ThumbSticks.Left.Y;

  ...

  string coordonnees = string.Format("X {0} : Y {1}", stickX, stickY);
  spriteBatch.DrawString(Content.Load("Emulator10"), coordonnees, new Vector2(5, 45), Color.White);

J'ai constaté que les valeurs allaient de -1 à +1, en passant par plein de virgules !

  • Gauche = X tend vers -1
  • Droite = X tend vers +1
  • Haut = Y tend vers +1
  • Bas= Y tend vers -1

Pour le déplacement du vaisseau, j'ai donc gardé la logique existante (car il faut que cela reste compatible avec le clavier et les boutons de direction) et j'ai ajouté les lignes suivantes :

  galaxianPosition.X += Convert.ToInt32(galaxianMoveSpeed * gamePad.ThumbSticks.Left.X);
  galaxianPosition.Y -= Convert.ToInt32(galaxianMoveSpeed * gamePad.ThumbSticks.Left.Y);

A chaque boucle (30x par secondes je le rappelle), les coordonnées du vaisseau sont donc modifié en fonction de la position du stick virtuel.
Je me suis rendu compte plus tard que cette méthode ne donne pas un vrai mouvement analogique. Il suffit de faire un calcul  d'exemple.

Si je me déplace vers la gauche, à un peu plus de la moitié du pad
     gamePad.ThumbSticks.Left.X = -0.65431354813 (à peu prêt)
Je multiplie ça par la vitesse du Galaxian (4 au début du jeu)
     galaxianMoveSpeed * gamePad.ThumbSticks.Left.X = -2,61725419252
Je converti en entier parce que l'on ne peut pas déplacer un sprite de 2,6 pixel :
     galaxianPosition.X += 2

Suivant la position, le déplacement sera de 0, 1, 2 ou 3 pixels (pour arriver à 4, il faut être à l'extrémité du pad et c'est quasiment impossible dans la pratique, peut être avec un stick analogique Xbox 360 par contre). Donc oui, il y a une différence de vitesse suivant la position du stick mais elle ne se voit vraiment que lorsque la vitesse de base est plus élevée. Au tout début, la valeur était de 2 au lieu de 4, les valeurs auraient donc été "à l'arrêt" (0) ou "en mouvement" (1), binaire donc.

Il est à noté que les boutons marchent toujours. Donc en appuyant sur une direction, la vitesse est maximal. Il est donc possible de combiner les deux maniabilités pour donner des petits "coups d'accélérateur" au vaisseau pour chopper un tank qui s'échappe.

 

Conclusion

Voilà un prototype qui est plus intéressant déjà. On marque des points, on en perd, le score est affiché, la maniabilité (bien que limitée par la machine elle-même) s'améliore. On est loin d'un jeu terminé mais c'est déjà ça.

Je dois maintenant étudier l'affichage de différents écran (un titre, le jeu, le game over), la possibilité de faire "pause", avoir des ennemis différents, sauvegarder le score et avoir des sons.

Je pense que je ferai ça en plusieurs fois ;)

 

Liens

Comments are closed

Recherche avancée

Now doing...

Now Reading...
Third Editions : Bioshock
de Rapture à Columbia

Now Playing...
Playdate
The Scrolling Enigma

Now Playing...
Playdate
What the Crow?!

Now Playing...
Super Famicom
パネルでポン (Panel De Pon)

Now Playing...
XBox One
Bioshock Remastered

Now Playing...
XBox One
Bioshock Remastered

Now Playing...
Super Famicom
パネルでポン (Panel De Pon)

Now Reading...
Third Editions : Bioshock
de Rapture à Columbia

Now Listening...
Roberto Fonseca
Temperamento

Now Playing...
Jeu de société
Planet Unknown

Now Playing...

Now Playing...
Detective Box
Le Tueur Au Tarot - Ep.3