Retourner au sommaire

Développer des jeux

Basé sur les articles publiés dans Quasar CPC numéro 18 et numéro 19, Dossier, par Hicks.

Bonbon au lait !? 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.

Longévité et Gameplay

Comme il faut bien commencer par quelque chose, voyons brièvement quelques points d'aspect généraux tout de même importants.

Le scénario

Rien ne vaut un bon petit scénario murement réfléchi et bien détaillé 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.

Retenir le joueur

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.

Aspect technique

L'algorithme général

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.

Furetons...

La sauvegarde du décor

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.

Listing : sauvegarde du décor

;
; 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 restitution du décor

Restitution du décor !

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.

L'autogénéré

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.

Listing : routine autogénérée

;
; 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

Le masquage pixel

Trouvons le bon compromis entre vitesse et élégance 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.

Listing : masquage pixel

;
; 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

Le bitplan

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.

Listing : bitplan

;
; 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

La restitution incrustée

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 :

Nous aussi on aime les bonbons au lait !

        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.

Structurer pour mieux régner

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 :

  • un tableau à deux dimensions pour chaque écran, et où chaque valeur (de 8 bits chacune, cela devrait suffir) représentera le numéro du rectangle à afficher, d'une taille de 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.).
  • les rectangles placés en mémoire les uns à la suite des autres.

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..

Collisions et coordonnées

Et paf ! 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.

Dans le décor

Stop ! Pas si vite ! 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.

1) non, je ne vise personne
2) n'est-ce pas OffseT ?
 
dossier/sprites.txt · Dernière modification: 2017/10/09 10:04 (édition externe)