Pour ce premier article de 2019, je vous propose un voyage dans le temps, au contenu plus personnel. J’ai récemment dîné avec mon vieil ami Nicolas Lidzborski qui travaille chez Google aux US et que je n’avais pas revu depuis une dizaine d’années. Comme toute retrouvaille, nous nous sommes remémorés des souvenirs du Lycée, ses profs et son club IF. De sa chambre d’ado, Nicolas a retrouvé une illustration que nous avions réalisé pendant le concours Soft la Nuit de 1996, un marathon de 24h pendant lequel 20 équipes de 4 jeunes devaient développer un logiciel. Et oui, mon premier hackaton commence à dater. Afin de pouvoir se qualifier, nous avions du présenter des projets personnels. Mon jeu vidéo Black Hell en faisait partie. Par curiosité, et avec un brin de nostalgie, je suis parti à la recherche d’un backup de disquette. J’en ai ressorti code source et binaire. Et surprise : avec l’émulateur DOSBox, j’ai réussi à le faire tourner, à la fois sous Windows 10 et MacOS.
Présentation du jeu
Blach Hell est un shoot’em up, ou plutôt 2 shoot’em up : le mode solo et le mode 2 joueurs (sur le même écran) n’ont rien à voir.
Dans la lignée de Xenon 2, la version solo vous propose de prendre les commandes d’un avion de chasse nommé « Silicium ». Vous aurez à éliminer des vaisseaux et à éviter des mines avant de pouvoir détruire le Big Boss final. Un travelling vertical fait avancer le terrain en arrière-plan. 5 touches sont nécessaires : les flèches multidirectionnelles pour se déplacer et Ctrl pour tirer.
Dans le mode 2 joueurs, chaque joueur choisit un vaisseau parmi les 3 proposés. Ils rentrent ensuite dans une arène pour un combat à mort. Afin de se déplacer sur tout l’écran et viser leur adversaire, les vaisseaux peuvent tourner à 360 °. Des options à récupérer viennent pimenter les parties : vitesse doublée, soin, invincibilité emporaire, double canon, bombe …
Pour celles et ceux qui souhaitent voir à quoi ce mode ressemble sans avoir à installer DOSBox ni à dépoussiérer leur vieux 486, j’ai publié une vidéo d’une partie 2 joueurs sur Youtube.
Caractéristiques techniques
Ce jeu a été développé en Turbo Pascal 6. De nombreuses routines de manipulation d’interrupteurs et d’optimisation de code sont codées en Assembleur x86.
Le jeu pèse 2,7 Mo. Il peut être compressé en une archive ZIP de moins d’1 Mo, ce qui lui permettait d’être distribué sur une disque de 1,44 Mo.
A elle seule, la vidéo de décollage N1PLAIN.FLX pèse 1,2 Mo.
Pour faire fonctionner le jeu, j’utilise DOSBox.
Les instructions sont données dans le README.MD.
Univers graphique
Pour créer un jeu, il faut du code, mais également des ressources graphiques.
A l’époque, point d’Internet pour trouver une banque d’images prête à l’emploi.
Les vaisseaux ont été modélisés en 3D avec le logiciel 3D Studio. Les textures et certains sprites ont été dessinés pixels par pixels avec Deluxe Paint Animation. Les paysages ont quant à eux été créés à l’aide de VistaPro.
La résolution VGA de 320×200 pixels avec 256 couleurs imposait que tous les fonds d’écran et sprites partagent la même palette de couleurs. C’est pourquoi, dans les binaires du jeu, les fichiers d’images (.IMA) et de palettes (.PAL et .COD) sont séparés.
L’écran d’options est le seul écran du jeu supportant le SVGA (640×480). Pour les personnes n’ayant pas de carte vidéo compatible, une option en ligne de commande (BH.EXE -VGA) permettait de le rétrograder en VGA.
Chaque partie commence par une animation en image de synthèse montrant le Silicium décoller de sa base. Cette animation est encodée dans le fichier N1PLAIN.FLX.
Un peu de code
En parcourant les 4 319 lignes de code source de Black Hell, je suis tombé sur de bons souvenirs. En voici une sélection.
Basculer la fenêtre ASCII de Ms DOS vers le mode VGA 320×200 avec 256 couleurs demande à déclencher l’interruption 10h du BIOS avec 13h dans le registre AX.
Ces 2 lignes de code assembleur se retrouvent à plusieurs fois :
ASM
MOV AX,13h
INT 10h
END;
A noter au passage l’interopérabilité très simple du Pascal avec l’Assembleur à l’aide du bloc de code ASM.
Le mode VGA est relativement simple à programmer : on accède directement à la mémoire de la carte vidéo par un pointeur localisé à l’adresse $A000:0000h (segment:offset avec segment*16 + offset = adresse physique). Un pixel est représenté par un seul octet (256 couleurs). La taille mémoire est de 320 x 200 x 1 = 64 000 octets.
Dans le code Pascal, on retrouve ce tableau de bytes (type Virtual) ainsi qu’un pointeur vers l’adresse de la mémoire vidéo (variable Screen) :
TYPE VirtualPtr=^Virtual;
Virtual=ARRAY[0..63999] of BYTE;
VAR Player1,Player2 : VirtualPtr;
Spr,Decors : VirtualPtr;
Screen : Virtual ABSOLUTE $A000:0;
La boucle principale du jeu consiste à gérer les touches, calculer la position des vaisseaux, détecter les collisions, scroller le fond puis afficher le tout à l’écran. L’image affichée à l’écran est calculée dans l’espace mémoire référencé par le pointeur Spr.
Avant de recopier Spr vers la carte vidéo, on doit attendre que le moniteur ait fini d’afficher l’image précédente, ceci afin d’éviter de désagréables effets d’images coupées.
Les écrans CRT utilisent des canons à électron pour afficher les pixels. Le faisceaux d’électron se déplace de gauche à droite et de haut en bas.
Le signal VBL indique que le faisceau a atteint le bas de l’écran et qu’il retourne en haut. Le signal HBL indique que le faisceau a atteint la fin de la ligne et qu’il revient au début de la ligne suivante.
Le code du jeu s’aligne sur le balayage de l’écran entre 2 recopies d’image :
PROCEDURE Synchro;ASSEMBLER;
LABEL Attend_VBL;
LABEL Attend_fin_HBL;
ASM
mov DX,$3DA
Attend_VBL:
IN AL,DX
AND AL,8
jz Attend_VBL
Mov DX,$3DA
Attend_fin_HBL:
IN AL,DX
AND AL,1
jnz Attend_fin_HBL
END;
Synchro;
MOVE(Sp^,Screen,64000);
La palette de couleurs tient sur 768 octets (3 x 256). Une couleur est représenté par 3 octets correspondant aux primitives RGB).
Le changement de palette de la carte vidéo nécessite davantage d’instructions :
VAR Palette: ARRAY[0..767] of BYTE;
DecodPal(s,Palette);
ASM
MOV AX,1012h
XOR BX,BX
MOV CX,256
MOV DX,Seg Palette
MOV ES,DX
MOV DX,Offset Palette
INT 10h
END;
Pour afficher l’écran d’accueil, des menus ou de l’aide, le mode VGA convient. Par contre, pour créer des animations, de nombreux jeux vidéo utilisaient un mode non documenté du VGA et popularisé par Michael Abrash : le fameux Mode X. Ce mode permet de gérer le double-buffering. Black Hell ne déroge pas à la règle :
PROCEDURE INITMODX;ASSEMBLER;
ASM
MOV AX,13h
INT 10h
MOV DX,3C4h { TS }
MOV AL,4
OUT DX,AL
INC DX
IN AL,DX
AND AL,0F7h
OR AL,4h { Désensenclenche le mode Chain 4 }
OUT DX,AL
DEC DX { Efface les 4 pages graphiques }
MOV AX,0F02h
OUT DX,AX
MOV AX,0A000h
MOV ES,AX
XOR DI,DI
XOR AX,AX
MOV CX,0FFFFh
CLD
REP STOSW
MOV DX,3D4h { CRTC }
MOV AL,14h
OUT DX,AL
INC DX
IN AL,DX
AND AL,0BFh { bit 6 du doubleword à 0 }
OUT DX,AL
DEC DX
MOV AL,17h
OUT DX,AL
INC DX
IN AL,DX
OR AL,40h { bit 6 à 1 }
OUT DX,AL
MOV FPAGE,0;
END;
Le DOS n’a pas été conçu pour exploiter plus d’1 Mo de RAM. Et seuls les premiers 640 Ko de mémoire conventionnelle pouvaient servir aux applications.
Pour charger ses images, Black Hell requière 1 Mo mémoire. Il fait donc appel à la mémoire étendue connue sous le nom de XMS (Extended Memory Specification) :
VAR XMSDrv : POINTER;
XRegs : XREGISTERS;
PROCEDURE XMS_INIT;
{ Vérifie que l'XMS est installée }
BEGIN
XMSExists:=false;
WITH Regs DO
BEGIN
AX:=$4300;
Intr($2F,regs);
if Regs.al = $80 then
Begin
{ Copie l'adresse d'accès au pilote XMS }
AX:=$4310;
Intr($2F,regs);
XMSDrv:=Ptr(ES,BX);
XMSExists:=true;
{ Détermine la taille de mémoire étendue disponible }
Xregs.AX:=$0800;
XmsCall(XRegs);
XMF:=Xregs.AX;
XML:=Xregs.DX;
End;
END;
If (not XmsExists) or (XML<1024) then
BEGIN
ClrScr;
If not XmsExists then Write('Xms indisponible ...')
Else Write(XmF,' Ko d''XMS libre et il en faut au moins 1024 ...');
Halt;
END;
END;
La détection des frappes de touche passe par l’usage du PORT 60 et des codes du contrôleur clavier :
CASE PORT[$60] Of
72 : Haut:=True;
80 : Bas:=True;
75 : Gauche:=True;
77 : Droite:=True;
END
On retrouve même des cheat-code pour être invulnérable et debugger plus facilement le jeu (je vous laisse retrouver avec quelle touche) :
CONST TI = 23;
If Inv and alt then If not Invulnerable then Invulnerable:=True Else Invulnerable:=False;
L’effet de fade-out de fin de jeu est implémenté à l’aide de rechargements successifs de la palette graphiques. Entre chaque rechargement, les composantes RVB sont décrémentées de 1, ceci afin d’atteindre progressivement le 0 du noir :
PROCEDURE FADE_OUT;
Var CoulMax : Byte;
BEGIN
CoulMax:=63;
REPEAT
For i:=0 to 767 do
BEGIN
j:=Palette[i];
If j>0 then Dec(j);
Palette[i]:=j;
END;
Synchro;
SetPal('',3);
Dec(CoulMAx);
UNTIL CoulMax=0;
END;
Le Turbo Pascal vient sans API de manipulation ni de chargement d’images. Pour lire des fichiers au format GIF, JPG ou PCX, il était nécessaire d’implémenter à la main l’algorithme de décompression. Ne disposant pas de leurs spécifications, j’ai utilisé un algorithme de compression / décompression très naïf, peu efficient mais qui a du faire gagner au total quelques centaines de Ko. Je vous laisse en juger :
Decompact('N1Alien.ima',1);
PROCEDURE DECOMPACT(Ne:String;Num:Word);
VAR Nb : WORD;
Color : BYTE;
BEGIN
Total:=0;
Assign(f,ne);
Reset(f);
BEGIN
REPEAT
Read(f,Ch);
Read(f,ch2);
Nb:=Ord(ch);
Color:=Ord(ch2);
i:=0;
REPEAT
CASE Num OF
0 : Spr^[Total+i]:=Color;
1 : Player1^[Total+i]:=Color;
2 : Player2^[Total+i]:=Color;
3 : Decors^[Total+i]:=color;
END;
if nb>i then inc(i);
UNTIL (i=nb);
Inc(Total,nb);
UNTIL Total>=64000;
Close(f);
END;
end;
A l’époque, pas de Direct3D ni d’OpenGL. La génération de l’ombre portée sous les vaisseaux est réalisée à la main par la routine suivante :
PROCEDURE Shadow;
VAR Plus : WORD;
D0001 : BYTE;
OmbX,OmbY : INTEGER;
BEGIN
If (SprP1>=0) and (SprP1<=4) then BEGIN Plus:=0;D0001:=0; END
Else If (SprP1>=5) and (SprP1<=9) then BEGIN Plus:=11200;D0001:=5; END
Else BEGIN Plus:=22400;D0001:=10; END;
For u:=1 to 27 do For v:=1 to 17 do
If Decors^[320*(v shl 1)+(u shl 1)+((SprP1-D0001)*54)+Plus]<>0 then
BEGIN
If Yp1+17>=100 then OmbY:=((100-Yp1+48) shr 2);
If Yp1+17<100 then OmbY:=((200-Yp1-48) shr 2);
If Xp1+27>=160 then OmbX:=48-(Xp1 shr 2);
If Xp1+27<160 then OmbX:=((200-Xp1) shr 2);
READPIXEL(Xp1+OmbX+u,Yp1+OmbY+v);
PUTPIXEL(Xp1+OmbX+u,Yp1+OmbY+v,ShadNiv1[CoulRead]);
END;
END;
Au début du jeu, à des fins d’optimisation, sont pré-calculés les tables de sinus et de cosinus :
For i:=1 to 360 do Sinus[i]:=Trunc(Round(Sin(i*Pi/180)*1024));
For i:=1 to 360 do Cosinus[i]:=Trunc(Round(Cos(i*Pi/180)*1024));
Pour calculer les trajectoires des vaisseaux, j’avais dû demander à mon prof de Maths de Première S quelle était la formule de matrice de rotation en 2 dimensions, formule qu’on n’apprenait en principe qu’en terminale :
{ Rotation autour des Z }
RotX:=((Play1.Circle[1]*Cosinus[Play1.Circle[3]*23+1]) shr 10) - ((Play1.Circle[2]*Sinus[Play1.Circle[3]*23+1]) shr 10);
RotY:=((Play1.Circle[1]*Sinus[Play1.Circle[3]*23+1]) shr 10 ) + ((Play1.Circle[2]*Cosinus[Play1.Circle[3]*23+1]) shr 10);
ScrY:=(RotY shl 6) div 45;
ScrX:=(RotX shl 6) div 45;
ScrY:=Play1.Cy+ScrY+Play1.Circle[5];
ScrX:=Play1.Cx+ScrX+Play1.Circle[4];
If (ScrX>0) and (ScrX<314) and (ScrY>0) and (ScrY<149) then
PutSprAt(10,122,14,126,ScrX+3,ScrY+3,3);
Jouer à 2 sur le même écran, c’est sympa. Avoir les commandes sur le même clavier, un peu moins. Du coup, j’avais pris en charge la gestion du Joystick pour le second joueur :
VAR j1b1,j1b2 : BYTE;
joyX,JoyY : WORD;
PROCEDURE JOYBOUTCOOR;ASSEMBLER;
ASM
MOV AH,84h
MOV DX,1
INT 15h
MOV JoyX,AX
MOV JoyY,BX
END;
PROCEDURE JOYKEYB;
BEGIN
CASE JoyX OF
5,4 : Play2.Gauche:=True;
152,153 : Play2.Droite:=True;
78 : BEGIN Play2.Gauche:=False;Play2.Droite:=False; END;
END;
CASE JoyY OF
4 : Play2.Haut:=True;
166,168 : Play2.Bas:=True;
85 : BEGIN Play2.Haut:=False;Play2.Bas:=False; END;
END;
END;
Conclusion
Beaucoup de personnes découvrent la programmation au travers le développement de jeux vidéo. J’en fais partie. Et ce n’est pas sans fierté que j’ai redécouvert mon premier jeu grand public. A noter qu’à l’époque, je le distribuais déjà avec son code source.
En 1995, Internet était balbutiant. Pour se former, il y’avait bien quelques chats et forums accessibles via BBS et sites minitels. En 2nde, j’ai eu la chance d’avoir un prof d’informatique qui m’enseigna le Turbo Pascal (une pensée pour Monsieur Canal). J’ai appris l’assembleur dans un livre de poche et la programmation système dans la fameuse Bible PC de Michael Tischer. Mes amis codeurs Patrick et Nicolas m’ont également beaucoup apportés.
C’est quelque peu étrange de publier dans GitHub du code écrit il y’a 24 ans, mais pourquoi pas ? Je serai a peu près sûr de le retrouver dans 20 ans pour mes petits-enfants.
Pour conclure ce billet, ma plus grande fierté a été quand mon plus jeune fils de 5 ans me demanda d’échanger sa partie de Mario Kart contre une de Black Hell. Vive le rétrogaming !
Pour le plaisir, voici quelques souvenirs d’un autre siècle :
La bonne époque les années 90!! Et l’assembleur que de souvenirs