Création d'un jeu de course pseudo-3D: mise en œuvre des collines et finition du jeu

Partie 3. Collines



Dans la partie précédente, nous avons créé un jeu de course pseudo-tridimensionnel simple , réalisant des routes droites et des courbes.

Cette fois, nous prendrons soin des collines; heureusement, ce sera beaucoup plus facile que de créer des routes courbes.

Dans la première partie, nous avons utilisé la loi des triangles similaires pour créer une projection en perspective tridimensionnelle:


... ce qui nous a amenés à obtenir les équations de projection des coordonnées du monde 3D dans les coordonnées de l'écran 2D.


... mais depuis lors, nous n'avons travaillé qu'avec des routes droites, les coordonnées mondiales n'avaient besoin que de la composante z , car x et y étaient égaux à zéro.

Cela nous convient bien, car pour ajouter des collines, il nous suffit de donner aux segments de route la coordonnée y non nulle correspondante , après quoi la fonction existante fonctionnera comme render()par magie.


Oui, c'est suffisant pour obtenir les collines. Ajoutez simplement la composante y aux coordonnées mondiales de chaque segment de route .

Changements dans la géométrie de la route


Nous allons modifier la méthode existante addSegmentafin que la fonction qui l'appelle puisse passer p2.world.y et p1.world.y correspondrait à p2.world.y du segment précédent:

function addSegment(curve, y) {
  var n = segments.length;
  segments.push({
     index: n,
        p1: { world: { y: lastY(), z:  n   *segmentLength }, camera: {}, screen: {} },
        p2: { world: { y: y,       z: (n+1)*segmentLength }, camera: {}, screen: {} },
     curve: curve,
     color: Math.floor(n/rumbleLength)%2 ? COLORS.DARK : COLORS.LIGHT
  });
}

function lastY() {
  return (segments.length == 0) ? 0 : segments[segments.length-1].p2.world.y;
}

Ajoutez des constantes pour indiquer les collines basses ( LOW), moyennes ( MEDIUM) et hautes ( HIGH):

var ROAD = {
  LENGTH: { NONE: 0, SHORT:  25, MEDIUM:  50, LONG:  100 },
  HILL:   { NONE: 0, LOW:    20, MEDIUM:  40, HIGH:   60 },
  CURVE:  { NONE: 0, EASY:    2, MEDIUM:   4, HARD:    6 }
};

Modifiez la méthode existante addRoad()afin qu'elle reçoive l'argument y , qui sera utilisé avec les fonctions de lissage pour la montée et la descente progressive de la colline:

function addRoad(enter, hold, leave, curve, y) {
  var startY   = lastY();
  var endY     = startY + (Util.toInt(y, 0) * segmentLength);
  var n, total = enter + hold + leave;
  for(n = 0 ; n < enter ; n++)
    addSegment(Util.easeIn(0, curve, n/enter), Util.easeInOut(startY, endY, n/total));
  for(n = 0 ; n < hold  ; n++)
    addSegment(curve, Util.easeInOut(startY, endY, (enter+n)/total));
  for(n = 0 ; n < leave ; n++)
    addSegment(Util.easeInOut(curve, 0, n/leave), Util.easeInOut(startY, endY, (enter+hold+n)/total));
}

De plus, à l'instar de ce que nous avons fait dans la partie 2 s addSCurves(), nous pouvons imposer toutes les méthodes dont nous avons besoin pour construire la géométrie, par exemple:

function addLowRollingHills(num, height) {
  num    = num    || ROAD.LENGTH.SHORT;
  height = height || ROAD.HILL.LOW;
  addRoad(num, num, num,  0,  height/2);
  addRoad(num, num, num,  0, -height);
  addRoad(num, num, num,  0,  height);
  addRoad(num, num, num,  0,  0);
  addRoad(num, num, num,  0,  height/2);
  addRoad(num, num, num,  0,  0);
}

Modifications de la méthode de mise à jour


Dans le jeu d'arcade que nous créons, nous ne chercherons pas à simuler la réalité, donc les collines n'affectent en rien le joueur ou le monde du jeu, ce qui signifie que des update()changements ne sont pas nécessaires dans la méthode .

Rendu des collines


render()Aucun changement n'est requis dans la méthode non plus, car les équations de projection ont été écrites à l'origine de manière à projeter correctement les segments de route avec des coordonnées y non nulles .

Fond de défilement de parallaxe


En plus d'ajouter des coordonnées y à tous les segments de route , le seul changement sera la mise en œuvre du déplacement vertical des couches d'arrière-plan avec les collines (tout comme elles se déplacent horizontalement avec les courbes). Nous implémentons cela avec un autre argument de la fonction d'assistance Render.background.

Le mécanisme le plus simple sera le déplacement de fond habituel par rapport à la position playerY(qui doit être interpolé à partir des positions mondiales y du segment de joueur actuel).

Ce n'est pas le comportement le plus réaliste, car cela vaut probablement la peine de considérer la pente du segment actuel de la route du joueur, mais cet effet est simple et fonctionne assez bien pour une démo simple.

Conclusion


C'est tout, maintenant nous pouvons compléter les fausses courbes avec de vraies collines:


Le travail que nous avons fait dans la première partie, y compris l'infrastructure pour ajouter de vraies collines 3D projetées, je ne vous en ai pas parlé avant.

Dans la dernière partie de l'article, nous ajouterons des sprites, ainsi que des arbres et des panneaux d'affichage le long des bords de la route. Nous ajouterons également d'autres voitures contre lesquelles il sera possible de concourir, la reconnaissance des collisions et la fixation du «record du cercle» du joueur.

Partie 4. Version prête



Dans cette partie, nous ajouterons:

  • Panneaux d'affichage et arbres
  • Autres voitures
  • Reconnaissance de collision
  • IA rudimentaire des voitures
  • Interface avec chronomètre et record de tour

... et cela nous fournira un niveau d'interactivité suffisant pour enfin appeler notre projet un "jeu".

Remarque sur la structure du code


, /, Javascript.

. () , ...

… , , , , .



Dans la partie 1, avant le début du cycle de jeu, nous avons téléchargé une feuille de sprite contenant toutes les voitures, les arbres et les panneaux d'affichage.

Vous pouvez créer manuellement une feuille de sprite dans n'importe quel éditeur d'images, mais il est préférable de confier le stockage des images et le calcul des coordonnées à un outil automatisé. Dans mon cas, la feuille de sprite a été générée par une petite tâche de râteau à l'aide de l' usine de sprites Ruby Gem .

Cette tâche génère des feuilles de sprites combinées à partir de fichiers images séparés et calcule également les coordonnées x, y, w, h, qui seront stockées dans une constante SPRITES:

var SPRITES = {
  PALM_TREE:   { x:    5, y:    5, w:  215, h:  540 },
  BILLBOARD08: { x:  230, y:    5, w:  385, h:  265 },

  // ... etc

  CAR04:       { x: 1383, y:  894, w:   80, h:   57 },
  CAR01:       { x: 1205, y: 1018, w:   80, h:   56 },
};

Ajout de panneaux d'affichage et d'arbres


Ajoutez à chaque segment de la route un tableau qui contiendra des sprites d'objets le long des bords de la route.

Chaque sprite est constitué de sourcela collection SPRITES, avec un décalage horizontal offset, qui est normalisé de sorte que -1 indique le bord gauche de la route et +1 signifie le bord droit, ce qui nous permet de ne pas dépendre de la valeur roadWidth.

Certains sprites sont placés intentionnellement, d'autres sont randomisés.

function addSegment() {
  segments.push({
    ...
    sprites: [],
    ...
  });
}

function addSprite(n, sprite, offset) {
  segments[n].sprites.push({ source: sprite, offset: offset });
}

function resetSprites() {

  addSprite(20,  SPRITES.BILLBOARD07, -1);
  addSprite(40,  SPRITES.BILLBOARD06, -1);
  addSprite(60,  SPRITES.BILLBOARD08, -1);
  addSprite(80,  SPRITES.BILLBOARD09, -1);
  addSprite(100, SPRITES.BILLBOARD01, -1);
  addSprite(120, SPRITES.BILLBOARD02, -1);
  addSprite(140, SPRITES.BILLBOARD03, -1);
  addSprite(160, SPRITES.BILLBOARD04, -1);
  addSprite(180, SPRITES.BILLBOARD05, -1);

  addSprite(240, SPRITES.BILLBOARD07, -1.2);
  addSprite(240, SPRITES.BILLBOARD06,  1.2);

  
  for(n = 250 ; n < 1000 ; n += 5) {
    addSprite(n, SPRITES.COLUMN, 1.1);
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE1, -1 - (Math.random() * 2));
    addSprite(n + Util.randomInt(0,5), SPRITES.TREE2, -1 - (Math.random() * 2));
  }

  ...
}

Remarque: si nous créions un vrai jeu, nous pourrions écrire un éditeur de route pour créer visuellement une carte avec des collines et des courbes, ainsi que d'ajouter un mécanisme pour organiser les sprites le long de la route ... mais pour nos tâches, nous pouvons simplement le faire par programme addSprite().

Ajout de machines


En plus des sprites d'objets au bord de la route, nous ajouterons une collection de voitures qui occupera chaque segment ainsi qu'une collection séparée de toutes les voitures sur l'autoroute.

var cars      = [];  // array of cars on the road
var totalCars = 200; // total number of cars on the road

function addSegment() {
  segments.push({
    ...
    cars: [], // array of cars within this segment
    ...
  });
}

Le stockage de deux structures de données automobiles nous permet de faire le tour itératif de toutes les voitures d'une méthode update(), en les déplaçant d'un segment à l'autre si nécessaire; en même temps, cela nous permet d'exécuter render()uniquement des machines sur des segments visibles.

Chaque machine reçoit un décalage horizontal aléatoire, une position z, une source de sprite et une vitesse:

function resetCars() {
  cars = [];
  var n, car, segment, offset, z, sprite, speed;
  for (var n = 0 ; n < totalCars ; n++) {
    offset = Math.random() * Util.randomChoice([-0.8, 0.8]);
    z      = Math.floor(Math.random() * segments.length) * segmentLength;
    sprite = Util.randomChoice(SPRITES.CARS);
    speed  = maxSpeed/4 + Math.random() * maxSpeed/(sprite == SPRITES.SEMI ? 4 : 2);
    car = { offset: offset, z: z, sprite: sprite, speed: speed };
    segment = findSegment(car.z);
    segment.cars.push(car);
    cars.push(car);
  }
}

Rendu des collines (retour)


Dans les parties précédentes, j'ai parlé du rendu des segments de la route, y compris les courbes et les collines, mais il y avait quelques lignes de code que je n'ai pas prises en compte. Ils concernaient une variable maxypartant du bas de l'écran, mais diminuant lors du rendu de chaque segment pour déterminer quelle partie de l'écran nous avions déjà rendue:

for(n = 0 ; n < drawDistance ; n++) {

  ...

  if ((segment.p1.camera.z <= cameraDepth) || // behind us
      (segment.p2.screen.y >= maxy))          // clip by (already rendered) segment
    continue;

  ...

  maxy = segment.p2.screen.y;
}

Cela nous permettra de recadrer des segments qui seront couverts par des collines déjà rendues.

Dans l'algorithme traditionnel de l'artiste, le rendu se produit généralement de l'arrière vers l'avant, tandis que les segments les plus proches chevauchent les plus éloignés. Cependant, nous ne pouvons pas passer du temps à rendre des polygones, qui seront éventuellement remplacés, il devient donc plus facile de rendre d'avant en arrière et de recadrer des segments distants qui sont fermés par des segments proches déjà rendus si leurs coordonnées projetées sont plus petites maxy.

Rendu des panneaux d'affichage, des arbres et des voitures


Cependant, la traversée itérative des segments de route d'avant en arrière ne fonctionnera pas lors du rendu des sprites, car ils se chevauchent souvent et doivent donc être rendus à l'aide de l'algorithme de l'artiste.

Cela complique notre méthode render()et nous oblige à contourner les segments de route en deux étapes:

  1. d'avant en arrière pour le rendu routier
  2. en arrière pour le rendu des sprites


En plus des sprites qui se chevauchent partiellement, nous devons traiter des sprites qui «dépassent légèrement» en raison de l'horizon au sommet de la colline. Si le sprite est suffisamment haut, alors nous devrions voir sa partie supérieure, même si le segment de la route sur lequel il se trouve est à l'arrière de la colline, et n'est donc pas rendu.

Nous pouvons résoudre le dernier problème en enregistrant la valeur de maxychaque segment sous forme de ligne clipà l'étape 1. Ensuite, nous pouvons recadrer les sprites de ce segment le long de la ligne clipà l'étape 2.

Le reste de la logique de rendu détermine comment mettre à l'échelle et positionner le sprite en fonction du coefficient scaleet des coordonnées screendes segments de route (calculé sur étape 1), en raison de laquelle, à la deuxième étape de la méthode, render()nous avons environ les éléments suivants:

// back to front painters algorithm
for(n = (drawDistance-1) ; n > 0 ; n--) {
  segment = segments[(baseSegment.index + n) % segments.length];

  // render roadside sprites
  for(i = 0 ; i < segment.sprites.length ; i++) {
    sprite      = segment.sprites[i];
    spriteScale = segment.p1.screen.scale;
    spriteX     = segment.p1.screen.x + (spriteScale * sprite.offset * roadWidth * width/2);
    spriteY     = segment.p1.screen.y;
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, sprite.source, spriteScale, spriteX, spriteY, (sprite.offset < 0 ? -1 : 0), -1, segment.clip);
  }

  // render other cars
  for(i = 0 ; i < segment.cars.length ; i++) {
    car         = segment.cars[i];
    sprite      = car.sprite;
    spriteScale = Util.interpolate(segment.p1.screen.scale, segment.p2.screen.scale, car.percent);
    spriteX     = Util.interpolate(segment.p1.screen.x,     segment.p2.screen.x,     car.percent) + (spriteScale * car.offset * roadWidth * width/2);
    spriteY     = Util.interpolate(segment.p1.screen.y,     segment.p2.screen.y,     car.percent);
    Render.sprite(ctx, width, height, resolution, roadWidth, sprites, car.sprite, spriteScale, spriteX, spriteY, -0.5, -1, segment.clip);
  }

}

Collisions avec des panneaux d'affichage et des arbres


Maintenant que nous pouvons ajouter et rendre des sprites d'objets le long des bords de la route, nous devons changer la méthode update()pour déterminer si le joueur a rencontré l'un de ces sprites dans son segment actuel:

nous utilisons une méthode auxiliaire Util.overlap()pour implémenter la reconnaissance généralisée de l'intersection des rectangles. Si une intersection est détectée, nous arrêtons la voiture:

if ((playerX < -1) || (playerX > 1)) {
  for(n = 0 ; n < playerSegment.sprites.length ; n++) {
    sprite  = playerSegment.sprites[n];
    spriteW = sprite.source.w * SPRITES.SCALE;
    if (Util.overlap(playerX, playerW, sprite.offset + spriteW/2 * (sprite.offset > 0 ? 1 : -1), spriteW)) {
      // stop the car
      break;
    }
  }
}

Remarque: si vous étudiez le vrai code, vous verrez qu'en fait, nous n'arrêtons pas la voiture, car elle ne pourra pas se déplacer latéralement pour éviter les obstacles; comme un simple hack, nous fixons leur position et permettons à la voiture de «glisser» sur les côtés autour du sprite.

Collisions avec des voitures


En plus des collisions avec des sprites le long des bords de la route, nous devons reconnaître les collisions avec d'autres voitures, et si une intersection est détectée, nous ralentissons le joueur en le «repoussant» derrière la machine avec laquelle il est entré en collision:

for(n = 0 ; n < playerSegment.cars.length ; n++) {
  car  = playerSegment.cars[n];
  carW = car.sprite.w * SPRITES.SCALE;
  if (speed > car.speed) {
    if (Util.overlap(playerX, playerW, car.offset, carW, 0.8)) {
      // slow the car
      break;
    }
  }
}

Mise à jour de la machine


Pour que d'autres voitures se déplacent le long de la route, nous leur donnerons l'IA la plus simple:

  • rouler à vitesse constante
  • contourner automatiquement le joueur en cas de dépassement
  • contourner automatiquement les autres voitures en cas de dépassement

Remarque: nous n'avons pas à nous soucier de faire tourner d'autres voitures le long d'une courbe sur la route, car les courbes ne sont pas réelles. Si nous faisons simplement rouler les voitures le long des segments de la route, elles passeront automatiquement le long des courbes.

Tout cela se produit pendant le cycle de jeu update()lors d'un appel updateCars()dans lequel nous avançons chaque voiture à une vitesse constante et passons d'un segment au suivant si elles se sont déplacées sur une distance suffisante pendant cette image.

function updateCars(dt, playerSegment, playerW) {
  var n, car, oldSegment, newSegment;
  for(n = 0 ; n < cars.length ; n++) {
    car         = cars[n];
    oldSegment  = findSegment(car.z);
    car.offset  = car.offset + updateCarOffset(car, oldSegment, playerSegment, playerW);
    car.z       = Util.increase(car.z, dt * car.speed, trackLength);
    car.percent = Util.percentRemaining(car.z, segmentLength); // useful for interpolation during rendering phase
    newSegment  = findSegment(car.z);
    if (oldSegment != newSegment) {
      index = oldSegment.cars.indexOf(car);
      oldSegment.cars.splice(index, 1);
      newSegment.cars.push(car);
    }
  }
}

Le procédé updateCarOffset()permet la mise en œuvre de "l'intelligence artificielle" , permettant à la machine de contourner le joueur ou d'autres machines. C'est l'une des méthodes les plus complexes de la base de code, et dans un vrai jeu, elle devrait être beaucoup plus complexe pour que les machines semblent beaucoup plus réalistes que dans une simple démo.

Dans notre projet, nous utilisons une force brute d'IA naïve, forçant chaque machine:

  • hâte de 20 segments
  • si elle trouve une voiture plus lente devant elle qui croise son chemin, alors contournez-la
  • tourner à droite des obstacles sur le côté gauche de la route
  • tourner à gauche des obstacles sur le côté droit de la route
  • tourner suffisamment pour éviter les obstacles à venir dans la distance restante

Nous pouvons également tricher avec ces voitures qui sont invisibles pour le joueur, leur permettant simplement de ne pas se contourner et de passer. Ils ne devraient paraître «intelligents» que dans la visibilité du joueur.

function updateCarOffset(car, carSegment, playerSegment, playerW) {

  var i, j, dir, segment, otherCar, otherCarW, lookahead = 20, carW = car.sprite.w * SPRITES.SCALE;

  // optimization, dont bother steering around other cars when 'out of sight' of the player
  if ((carSegment.index - playerSegment.index) > drawDistance)
    return 0;

  for(i = 1 ; i < lookahead ; i++) {
    segment = segments[(carSegment.index+i)%segments.length];

    if ((segment === playerSegment) && (car.speed > speed) && (Util.overlap(playerX, playerW, car.offset, carW, 1.2))) {
      if (playerX > 0.5)
        dir = -1;
      else if (playerX < -0.5)
        dir = 1;
      else
        dir = (car.offset > playerX) ? 1 : -1;
      return dir * 1/i * (car.speed-speed)/maxSpeed; // the closer the cars (smaller i) and the greater the speed ratio, the larger the offset
    }

    for(j = 0 ; j < segment.cars.length ; j++) {
      otherCar  = segment.cars[j];
      otherCarW = otherCar.sprite.w * SPRITES.SCALE;
      if ((car.speed > otherCar.speed) && Util.overlap(car.offset, carW, otherCar.offset, otherCarW, 1.2)) {
        if (otherCar.offset > 0.5)
          dir = -1;
        else if (otherCar.offset < -0.5)
          dir = 1;
        else
          dir = (car.offset > otherCar.offset) ? 1 : -1;
        return dir * 1/i * (car.speed-otherCar.speed)/maxSpeed;
      }
    }
  }
}

Dans la plupart des cas, cet algorithme fonctionne assez bien, mais avec une grande foule de voitures devant, nous pouvons remarquer que les voitures se déplacent de gauche à droite et en arrière, essayant de se faufiler dans l'écart entre les deux autres machines. Il existe de nombreuses façons d'améliorer la fiabilité de l'IA, par exemple, vous pouvez permettre aux voitures de ralentir si elles voient qu'il n'y a pas assez d'espace pour éviter les obstacles.

Interface


Enfin, nous allons créer une interface HTML rudimentaire:

<div id = "hud">
  <span id = "speed" class = "hud"> <span id = "speed_value" class = "value"> 0 </span> mph </span>
  <span id = "current_lap_time" class = "hud"> Heure: <span id = "current_lap_time_value" class = "value"> 0.0 </span> </span> 
  <span id = "last_lap_time" class = "hud"> Dernier tour: <span id = "last_lap_time_value" class = "value"> 0.0 </span> </span>
  <span id = "fast_lap_time" class = "hud"> Tour le plus rapide: <span id = "fast_lap_time_value" class = "value"> 0.0 </span> </span>
</div>

... et y ajouter du style CSS

#hud                   { position: absolute; z-index: 1; width: 640px; padding: 5px 0; font-family: Verdana, Geneva, sans-serif; font-size: 0.8em; background-color: rgba(255,0,0,0.4); color: black; border-bottom: 2px solid black; box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; }
#hud .hud              { background-color: rgba(255,255,255,0.6); padding: 5px; border: 1px solid black; margin: 0 5px; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud #speed            { float: right; }
#hud #current_lap_time { float: left;  }
#hud #last_lap_time    { float: left; display: none;  }
#hud #fast_lap_time    { display: block; width: 12em;  margin: 0 auto; text-align: center; transition-property: background-color; transition-duration: 2s; -webkit-transition-property: background-color; -webkit-transition-duration: 2s; }
#hud .value            { color: black; font-weight: bold; }
#hud .fastest          { background-color: rgba(255,215,0,0.5); }


... et nous exécuterons sa mise à jour () pendant le cycle de jeu:

if (position > playerZ) {
  if (currentLapTime && (startPosition < playerZ)) {
    lastLapTime    = currentLapTime;
    currentLapTime = 0;
    if (lastLapTime <= Util.toFloat(Dom.storage.fast_lap_time)) {
      Dom.storage.fast_lap_time = lastLapTime;
      updateHud('fast_lap_time', formatTime(lastLapTime));
      Dom.addClassName('fast_lap_time', 'fastest');
      Dom.addClassName('last_lap_time', 'fastest');
    }
    else {
      Dom.removeClassName('fast_lap_time', 'fastest');
      Dom.removeClassName('last_lap_time', 'fastest');
    }
    updateHud('last_lap_time', formatTime(lastLapTime));
    Dom.show('last_lap_time');
  }
  else {
    currentLapTime += dt;
  }
}

updateHud('speed',            5 * Math.round(speed/500));
updateHud('current_lap_time', formatTime(currentLapTime));

La méthode d'assistance updateHud()nous permet de mettre à jour les éléments DOM uniquement lorsque les valeurs changent, car une telle mise à jour peut être un processus lent et nous ne devrions pas le faire à 60 images par seconde si les valeurs elles-mêmes ne changent pas.

function updateHud(key, value) { // accessing DOM can be slow, so only do it if value has changed
  if (hud[key].value !== value) {
    hud[key].value = value;
    Dom.set(hud[key].dom, value);
  }
}

Conclusion



Fuh! La dernière partie était longue, mais nous avons quand même terminé, et la version finie a atteint le stade où elle peut être appelée un jeu. Elle est encore loin du match terminé , mais c'est toujours un match.

C'est incroyable que nous ayons vraiment réussi à créer un jeu, bien que si simple. Je ne prévois pas de mener à bien ce projet. Il doit être considéré simplement comme une introduction au sujet des jeux de course pseudo-tridimensionnels.

Le code est publié par github , et vous pouvez essayer de le transformer en un jeu de course plus avancé. Vous pouvez également essayer:

  • ajouter des effets sonores aux voitures
  • améliorer la synchronisation de la musique
  • implémenter le plein écran
  • ( , , , ..)
  • (, ..)
  • ,
  • , -
  • ,
  • ( , ..)
  • drawDistance
  • x,y
  • ( , )
  • connexions à la fourche et à la route
  • le changement de nuit et de jour
  • conditions météorologiques
  • tunnels, ponts, nuages, murs, bâtiments
  • ville, désert, océan
  • Ajoutez Seattle et Space Needle aux arrière-plans
  • «Méchants» - ajoutez des concurrents pour rivaliser avec
  • modes de jeu - le tour le plus rapide, une course contre une (ramasser des pièces?, tirer sur les méchants?)
  • des tonnes d'options de personnalisation du gameplay
  • etc.
  • ...

Nous avons donc terminé. Un autre «projet de week-end» qui a pris beaucoup plus de temps que prévu, mais au final le résultat a été plutôt bon.

Références



Liens vers des démos jouables:


All Articles