— Basé sur les articles publiés dans Quasar CPC numéro 18 et numéro 19, Dossier, par Hicks.
Alors que de plus en plus de monde souhaite voir des jeux sur CPC, de moins en moins de jeux sortent. Situation assez paradoxale qui peut ma foi s'expliquer. En effet, après avoir demandé à plusieurs codeurs pourquoi ils ne se tournaient pas plus vers les jeux, la même réponse ressortait systématiquement : c'est trop long et trop compliqué. C'est pourquoi nous allons aborder ici les différents points qui vous permettront de faire vos propres jeux.
Comme il faut bien commencer par quelque chose, voyons brièvement quelques points d'aspect généraux tout de même importants.
Trop souvent négligé, il m'est plutôt d'avis que le scénario fait partie des éléments essentiels. Donc, par pitiée, je ne veux plus voir de fichier ASCII expliquant une histoire sans intérêt1). Prenez plutôt exemple sur les productions allemandes (Megablasters, Black Land, etc.) où les scénarios tiennent toujours debout et sont très bien mis en scène. Car actuellement, sur les machines soit disant “plus puissantes” combien de fois a-t-on pu voir des jeux techniquement correct mais manquant cruellement d'intérêt ? C'est d'autant plus vrai avec les jeux en 3D, soit dit en passant. Donc, pensez à faire un scénario original, évolutif et non linéaire. Je vous conseille par exemple de commencer avec un scénario anodin et de dévoiler la trame de l'histoire peu à peu sans pour autant embrouiller le joueur. Pensez également aux rebondissements qui sont essentiels pour la durée de vie. Maintenant, à vous de faire quelque chose d'intéressant, et n'oubliez pas que la puissance de la machine n'a ici aucune importance, c'est pourquoi vous êtes parfaitement en mesure d'égaler les scénarios des jeux dernier cri.
Si vous ne voyez pas d'autres moyens pour retenir le joueur que de l'attacher à sa chaise avec des menottes, lisez ce qui suit. Eh oui, car pour intéresser le jeu, un bon scénario n'est pas tout. Une fois le jeu fini, le joueur risque de s'ennuyer ferme en recommençant une énième partie. Ça y est, à peine arrivé j'ai déjà cette godiche de petit blond à lunettes sur le dos pour raconter une fois de plus n'importe quoi en disant qu'il suffit de faire le jeu très dur pour retenir le joueur. Tiens, tiens, j'ai une piste : le petit blond à lunettes aurait des racines espagnoles. Quoi de plus énervant qu'un jeu où on n'avance pas ? Le petit blond à lunettes ? Ok, je vous l'accorde ! Mais trève de galéjades, pour retenir le joueur, il va falloir nous intéresser aux options.
Tout d'abord, pour que personne ne reproche à votre jeu d'être trop simple, il est impératif d'intégrer des niveaux de difficulté. De cette façon, tous les types de joueurs y trouveront leur compte.
Dans un autre domaine, prévoyez également un mode customisation (pour les jeux de voitures, combat, sport, etc.) avec possibilité de sauvegarder la configuration. Par exemple, construire ses circuits, créer son personnage, son équipe, bref une option qui permettra à votre jeu de se renouveller sans cesse.
Et puis, le must, je veux bien entendu parler de mode multijoueur. Une telle option peut conférer un intérêt illimité à tout type de jeu, c'est pourquoi il faut tout faire pour tenter d'intégrer cette possibilité. De plus, si votre jeu nécessite des écrans indépendants, n'oubliez pas le réseau ! Je vous encourage d'ailleurs à lire le dossier sur le réseau de Zik. Malgré tout, ce n'est pas exploitable pour tous les types de jeux et c'est bien dommage.
Après avoir abordé ces quelques points d'aspect “philosophique”2), intéressons-nous à la programmation de notre futur jeu. Prenons le cas le plus général, comprenant presque tous les types de jeux, nous aurons alors un algorithme de la forme suivante :
Frame |
Sauvegarde décor |
Restitution décor |
Affichage sprite |
Jouer musique/bruitages |
Gestion diverses (touches, collisions, compteurs, etc.) |
Ceci résume en gros ce qu'il va nous falloir gérer à chaque VBL, et dans cet ordre. En effet, tout ce qui concerne l'affichage doit être fait en priorité pour éviter de se manger le balayage. Imaginez que vous êtes en train d'afficher un sprite, et que le canon à électron passe par là avant même que vous ayez fini. Le résultat à l'écran sera un sprite affiché pas en entier mais à une vitesse de 50 images par secondes, cela sera traduit par un clignotement ou une sacade. Pour ce qui est de l'ordre “Sauvegarde-Restitution-Affichage”, je pense que vous avez tous compris qu'il vaut mieux avoir le moins de temps possible entre l'effaçage et l'affichage du sprite. La musique, elle, peut être jouée n'importe quand du moment que c'est à chaque VBL. La meilleure solution pour éviter à notre musique de ralentir consiste à la mettre sous interruption. Nous verrons ça une autre fois. Les diverses gestions peuvent également être mises sous interruption, mais à condition que votre jeu tourne à 50Hz.
Avant d'afficher notre sprite, il faut sauvegarder son futur emplacement afin de pouvoir le restituer par la suite. La première méthode qui est de loin la plus simple, consiste à sauvegarder la matrice rectangulaire correspondant à la taille de notre sprite, et cela aux nouvelles coordonnées. Il me semble que LDI
est l'instruction la plus adaptgée à ce genre de transferts (voir le listing). Le seul inconvénient vient du fait qu'il va nous falloir une routine différente pour chaque taille de sprite. C'est pas très grave car si on veut éviter le LDIR
, c'est la seule solution.
Oui mais voilà, j'entends déjà les perfectionnistes critiquer. Ils ont raison. Avec la méthode expliquée plus haut, nous allons sauvegarder du décor qui était déjà en mémoire. Un exemple : prenez un sprite de 8 octets de large se déplaçant vers la gauche (octet par octet) et ayant le décor qu'il écrase déjà stocké en mémoire. Au prochain déplacement, seul le contenu des octets de la colonne à gauche de notre sprite diffèreront de ceux déjà stockés en mémoire. C'est pourquoi le mieux serait de ne sauvegarder que ceux-ci. Ça nous ferait gagner le temps d'un transfert de 7 octets fois la hauteur dans notre exemple ce qui est tout de même pas mal. Mais, dans le buffer, on ne peut pas mettre la nouvelle colonne de décor sauvegardée toujours à la suite du décor déjà stocké en mémoire car notre buffer aurait alors une taille indéterminée. Il va donc falloir nous servir de tests pour savoir où on est dans le buffer lors de la sauvegarde et de la restitution. C'est relativement exploitable, pas très long et peu contraignant niveau mémoire.
Mais je gardais la meilleure solution pour la fin. En fait, la technique est de carrément éliminer l'étape de sauvegarde. En stockant une deuxième fois votre décor en mémoire, un simple pointeur suivant les déplacements de votre sprite indiquera à la routine l'adresse où est le décor à restituer. Ça prend évidemment beaucoup de place, mais le résultat en vaut la peine, surtout si vous animez beaucoup de sprites. De plus, selon le jeu, vous n'êtes pas obligés de mettre tout le décor une deuxième fois en mémoire, mais seulement là où les sprites peuvent passer. Cette technique n'est malheureusement utilisable que dans les jeux sans scrolling constant (pour le style Prehistorik 2 ça pourrait éventuellement fonctionner, ou dans un jeu écran par écran). En effet, pour les autres types de jeux ça prendrait sûrement plus de temps de sauvegarder la nouvelle colonne de décor apparue à l'écran grâce au scrolling que de passer par la méthode classique.
; ; Routine d'affichage et restitution de sprites ; Par Hicks pour Quasar CPC numéro 18 (04/10/99) ; ; Restitution classique ld hl,decor ; HL=decor à restituer ld de,ecran ; à l'adresse écran ld a,haut Rest1 ldi ; autant de ldi que le ldi ; sprite est large en ldi ; octets, exemple pour ldi ; 5 octets ldi ex hl,de ; car DE=écran et le BC26 call bc26 ; est fait pour HL=écran ex hl,de ; hop DE=écran à nouveau dec a jp nz,rest1 ret ; Call &bc26 BC26 ld bc,&800-larg ; on descend de ligne en add hl,bc ; ligne sur le même bloc ret nc ; de caractère ld bc,&c000+80 ; si débordement alors add hl,bc ; on passe au bloc suivant ret
La technique précédemment évoquée étant impossible pour l'étape de restitution, il va falloir recourir aux LDI
. La routine sera d'ailleurs presque similaire au premier listing à la différence que le transfert s'opère de la mémoire à la mémoire écran, ce qui va occasionner quelques modifications.
Nous allons maintenant voir ensemble 3 routines d'affichage différentes. Expliquons-les une par une.
Oui, ça prend beaucoup de mémoire. On peut dire qu'en gros, pour une routine autogénérée masquant à l'octet, elle va prendre trois fois plus de mémoire que le sprite que l'on veut afficher (sans tenir compte des tests d'octet vides, voir source). L'autogénéré, comme son nom l'indique, consiste à construire une routine d'affichage en incrustant les octets du sprite dans la routine. Cela nous évitera d'avoir un registre pointant sur le sprite, et du même coup, supprimera toutes les boucles.
Remarquez que j'ai choisi délibérément de faire le test de la hauteur avant le BC26
, ceci nous évitera de descendre d'une ligne inutilement après avoir affiché le dernier octet du sprite. Comme vous pourrez le voir, cette routine fait un masquage à l'octet, qui, je le rappelle n'est utilisable qu'en mode 0 car dans les autres modes graphiques on risquerait parfois de se retrouver avec plus de pixels éteints que d'allumés dans un octet : attroce.
Notez également qu'il vaut mieux ne pas générer une routine pour chaque sprite mais seulement pour ceux se déplaçant. On peut alors travailler avec deux types de routines.
; ; Routine d'affichage et restitution de sprites ; Par Hicks pour Quasar CPC numéro 18 (04/10/99) ; ; L'auto-généré ; ; Appeler la routine générée avec ; HL=mémoire écran ; DE=&800-larg-1 ; BC=&C050 ld hl,&2000 ; adr de la future routine ld de,sprite ; DE pointe sur le sprite ld c,haut Auto1 ld b,larg Auto2 ld a,(de) ; on prend l'octet sprite or a ; si nul on ne l'affiche jr z,noaff ; pas en sautant à NoAff ld (hl),&36 ; code de "ld hl,(nn)" inc hl ; ceci est donc le bout ld (hl),a ; de prog qui code inc hl ; l'affichage d'un octet NoAff inc de ; octet suivant du sprite ld (hl),&23 ; code de "inc hl" inc hl djnz auto2 ; larg=larg-1 dec c ; on teste si on passe à la jr z,fin ; dernière ligne ; BC26 dec hl ; si non, on dec HL pour ; effacer le "inc hl" inutile ld (hl),&19 ; code de "add hl,de" inc hl ; puis celui de ld (hl),&30 ; "jr nc,$+1" pour coder le inc hl ; BC26 dans le buffer ld (hl),1 ; $+1; saute d'un octet si inc hl ; la carry n'est pas mise ld (hl),&09 ; code de "add hl,bc" inc hl jp auto1 ; ligne suivante Fin ld (hl),&c9 ; fin ! code du "ret" ret
Tout d'abord, sachez que ce type de routine prend pas mal de temps machine, c'est pourquoi je ne la vous conseille seulement si vous travaillez en mode 1 ou 2 (!). Le résultat en mode 0 ne valant pas vraiment le coup au niveau résultat/temps machine par rapport au masquage à l'octet.
Le principe n'est pas très compliqué. On prend les octets du masque et on fait un ET
(je parle ici du AND
logique et non d'un quelconque film) avec l'octet de la mémoire écran afin que seuls les pixels différents de ceux du sprite (donc définis par le masque) soient retenus dans l'accumulateur. Puis grâce au OU
, on affiche les données du sprite là où il n'y a pas le masque (dans pas le décor) et on affiche l'octet. Enfin, on incrémente les trois pointeurs respectifs.
Le masquage pixel prend pas mal de mémoire du fait qu'un plus de votre sprite, vous devrez aussi stocker le masque qui prend autant de mémoire.
Bien que cette technique prenne du temps (16 nops pour un octet, les boucles pouvant être recopiées), je vous conseille de l'adapter à l'autogénéré et on peut ainsi obtenir un résultat de 10 nops pour un octet, mais la mémoire consommée est encore plus importante que pour un masquage à l'octet. À vous de voir.
; ; Routine d'affichage et restitution de sprites ; Par Hicks pour Quasar CPC numéro 18 (04/10/99) ; ; Masquage au pixel ld hl,ecran ld de,masque di ; on coupe les interruptions exx ; car on utilise les ld hl,sprite ; registres secondaires ld e,haut Pixl2 exx ; 1ère paire de registres ld b,larg Pixl1 ld a,(de) ; on prend l'octet du masque and (hl) ; masque AND ecran inc de exx ; 2ème paire de registres or (hl) ; OR sprite inc hl exx ; 1ère paire de registres ld (hl),a ; on affiche le résultat inc hl djnz pixl1 ; octet suivant call bc26 exx ; 2ème paire de registres dec e jp nz,pixl2 ei ; on autorise les interruptions ret BC26 ld bc,&800-larg ; on descend de ligne en add hl,bc ; ligne sur le même bloc ret nc ; de caractère ld bc,&c000+80 ; si débordement alors add hl,bc ; on passe au bloc suivant ret
Cette fois-ci c'est un masquage au pixel grâce à un simple OU
dont nous allons parler. La contrainte réside dans le fait que la moitié des encres devront être réservées pour le décor et l'autre moitié pour les sprites. Ceci rend cette technique quasiment inutilisable en mode 2 et très difficilement applicable au mode 1. Le mode 0 reste donc la meilleure alternative.
Pour ce qui est du principe, c'est très simple. On récupère chaque octet du sprite, on fait un OU
exclusif avec le décor, et on envoie le résultat de cette opération à l'écran. Grâce au jeu d'encres, pas d'équivoque possible, le sprite est masqué au pixel.
; ; Routine d'affichage et restitution de sprites ; Par Hicks pour Quasar CPC numéro 18 (04/10/99) ; ; Le bitplan ld hl,ecran ld de,sprite ld a,haut Bitp1 ld c,a ; on sauvegarde la hauteur ld b,larg Bitp2 ld a,(de) ; on effectue un OR entre or (hl) ; le sprite et l'écran et ld (hl),a ; grâce au jeu d'encre on inc hl ; a une précision au pixel inc de djnz bipt2 ld a,c ; on restitue la hauteur call bc26 ; dans A car C est modifié dec a ; par le BC26 jp nz,bitp1 ret BC26 ld bc,&800-larg ; on descend de ligne en add hl,bc ; ligne sur le même bloc ret nc ; de caractère ld bc,&c000+80 ; si débordement alors add hl,bc ; on passe au bloc suivant ret
Pour finir, voyons une méthode permettant de sauvegarder et d'afficher un sprite avec la même routine. Si nous faisions ces deux étapes séparément, il faudrait incrémenter deux fois nos pointeurs, faire deux BC26
… bref, perte de temps. Je ne vais donc pas vous fournir le source de cette routine, d'une part car j'occupe déjà pas mal de place, et d'autre part pour vous faire chercher un peu, vous ne comprendrez que mieux. Je vous donne juste le morceau de programme d'afficher un octet :
ld a,(de) ; HL=sprite ld (bc),a ; DE=écran ldi ; BC=mémoire
Compris ? Ok, quelques explications s'imposent. Les deux premières instructions effectuent la sauvegarde. Le LDI
s'occupe de l'affichage, va incrémenter HL
et DE
… et va décrémenter BC
! Et c'est là que réside l'astuce. oui, en faisant pointer BC
à la fin de l'emplacement mémoire réservé à la sauvegarde, le décor sera stocké à l'en vers. Il nous suffira alors de le réafficher dans le bon sens lors de la restitution. Utilisez les registres secondaires pour les boucles si vous ne voulez pas les recopier. Si vous avez choisi l'éliminer l'étape de sauvegarde, notez que l'on peut effectuer également restitution et affichage grâce à cette méthode. Voir même les trois étapes avec la même routine en rusant un peu… à vous de creuser.
Intéressons-nous à présent à la structure de nos niveaux et à plusieurs méthodes qui vont nous permettre de tester les collisions des sprites avec le décor puis avec d'autres sprites qui se déplacent. Afin de simplifier le travail du graphiste et de limiter la mémoire occupée par le niveau, nous découperons les graphismes des éléments de décor en rectangles identiques que nous rassemblerons ensuite pour créer nos décors. De cette façon, à partir d'un minimum de graphismes, nous pourrons créer un nombre presque infini de décors ce qui est quand même bien plus efficace que de dessiner chaque décors comme un écran à part entière. Bien, nous aurons besoin de deux choses pour pouvoir afficher nos décors :
lon_erc=(larg_e/larg_r)*(haut_e/haut_r) octets
; larg_e
est la largeur de l'écran, larg_r
est la largeur des rectangles, haut_e
est la hauteur de l'écran, haut_r
est la hauteur des rectangles. Chaque couple (larg_e
,larg_r
) et (haut_r
,haut_r
) doit être exprimé dans la même unité (word, octet, ligne, etc.).
La correspondance entre les valeurs figurant dans le tableau et l'adresse du rectangle à afficher se calculera simplement : adr_r
=adr_dr+(valeur*larg_r*haut_r)
; adr_dr
est l'adresse de début des rectangles, larg_r
et haut_r
doivent cette fois-ci exprimés en octets.
Selon la catégorie de jeu que vous souhaitez réaliser, votre niveau se présentera sous la forme d'un seul tableau ou bien d'un tableau par écran (ou par zone), qui sera lui-même rempli de valeurs permettant d'afficher le décor ligne à ligne, colonne par colonne, etc..
Maintenant que nous savons créer nos niveaux, testons les collisions entre les sprites qui peuvent se déplacer. On dit qu'il y a collision lorsque deux sprites, définis par les paramètres x1,y1,larg1,haut1
pour le premier et x2,y2,larg2,haut2
pour le second, se chevauchent. Pour cela, ces inégalités doivent toujours être vérifiées :
x1 + larg1 > x2 x2 + larg2 > x1 y1 + haut1 > y2 y2 + haut2 > y1
Cette méthode est toutefois assez rudimentaire et ne permet d'avoir un rendu parfait que si les sprites sont rectangulaires : ce n'est pas parce que les matrices rectangulaires qui définissent les sprites se chevauchent que les sprites se chevauchent eux aussi !
Dans l'absolu, il faudrait définir un couple (xi,largi) pour chaque ligne i de nos sprites et un couple (yj,hautj) pour chaque colonne de pixels j. Pour obtenir un résultat parfait, il suffirait ensuite d'appliquer les quatre tests précédents pour chaque ligne et chaque colonne de pixels. Mais comme cela serait bien trop long, nous allons ruser un peu en effectuant la moyenne de tous les xi, yj, largi, hautj. Grâce à cette approximation, nous gagnons énormément de temps machine et le résultat sera tout à fait satisfaisant. On pourra même améliorer cette technique si plusieurs zones xi, yj, largi, hautj se distinguent sur les sprites (fusil du personnage qui dépasse, coup de pied dans un jeu de combat…). Dans ce cas, il faudra faire la moyenne des xi, yj, largi, hautj pour chaque zone et appliquer les quatre sempiternels tests donnés dans le paragraphe précédent pour chaque zone.
Pour tester les collisions avec le décor, un premier technique consisterait à définir avec des segments l'aire non accessible et d'appliquer des tests similaires (dans le principe) à ceux déjà évoqués plus tôt. Mais cela monopoliserait encore de la mémoire supplémentaire (4 octets par segment !) et nous obligerait à tester à tout moment s'il y a collision avec chaque segment, ce qui ne nous assurerait pas d'avoir un temps machine constant selon qu'il y ait 2 ou 20 segments à tester. Oublions cette mauvaise méthode.
Seconde technique : des tests de couleurs. En réservant une ou plusieurs couleurs encres au décor inaccessible, la simple lecture d'un ou plusieurs (ce nombre sera logiquement inversement proportionnel au nombre d'encres réservées !) octets composant le décor nous dira immédiatement si oui ou non il y a collision. Comme toutes les techniques utilisant stratégiquement les encre elle n'est correctement exploitable qu'en mode 0.
Attardons-nous plus en détail sur la troisième méthode qui est à mon avis la plus performante tant au niveau mémoire que temps machime. Cette fois, nous raisonnerons sur le tableau qui nous a permis de créer le niveau. En stockant en mémoire tous les rectangles accessibles, puis à partir du rang n, tous les rectangles inaccessibles, nous saurons que si le personnage chevauche un rectangle possédant un numéro supérieur ou égal à n, alors il y aura collision. La conversion des coordonnées (x,y) de notre personnage en adresse du tableau où se trouve la valeur à lire se fait de cette façon :
adr_tab = (y / haut_c) * (larg_e / larg_c) + (x / larg_c)
Si le déplacement s'effectue horizontalement, il faudra tester (haut_p
/haut_c
) valeurs des rectangles en colonne dans le tableau. Si le déplacement est vertical, il faudra tester (larg_p
/larg_c
) valeurs en ligne (le couple (larg_r,haut_p) représente la largeur et la hauteur du personnage).
À moins d'être dans le cas avantageux où haut_p
modulo haut_c
est égal à 1, le nombre de valeurs à tester en colonne pour un déplacement horizontal pourra varier puisque la dernière ligne du personnage pourra dépasser sur un nouveau rectangle alors que la première ligne restera sur le même (faites un petit dessin pour mieux comprendre). Plutôt que de longues explications, voici une formule qui calcule si on doit tester une valeur supplémentaire :
si y modulo haut_c > haut_c - (haut_p modulo haut_c) alors nbr_r = nbr_r + 1
On procède exactement de la même manière pour tester les collisions d'un déplacement vertical (si larg_p modulo larg_c est différent de 1 !) :
si x modulo larg_c > larg_c - (larg_r modulo larg_c) alors nbr_r = nbr_r + 1
Voilà, vous pourrez maintenant améliorer cette méthode en associant à chaque rectangle un tableau où chaque bit permettrait de savoir si oui ou non le pixel est accessible, ce qui aurait pour conséquence d'affiner la méthode, ne donnant ainsi plus l'impression de travailler avec de gros blocs.