MMMmmmmmmmhhhmmmmmmhmm..... (le blog de skaven)

Rechercher

Archives

Nombre de visiteurs*

* Visiteurs uniques : une (1) visite par IP.

Netproto Itération 5 (Video inside)

Samedi 16 août 2008 à 18 h 52
Prochaine session de test bientôt en download.
En attendant:

Musique de __MAX__.

Non monté (Divide, à l'aide!!!).

Les travelings de caméra sont générés. C'est le type de caméra qu'on voit quand on attend les synchros serveur et/ou d'autres joueurs.
Vimeo, c'est vachement mieux que youtube.
10 commentaires, dernier de divide.

JO

Vendredi 15 août 2008 à 13 h 51
Des messages sur des forums s'indignent des trucages chinois
"Il y a eu la danseuse aujourd'hui paralysée des deux jambes après une chute de trois mètres au cours d'une répétition, douze jours avant la cérémonie."

Qu'elle se rassure, cette danseuse pourra participer aux jeux paralympiques.
12 commentaires, dernier de moSk.

Appel à contribution

Lundi 11 août 2008 à 18 h 21
Je continue de coder le jeu de course.
Et j'ai bientôt terminé les machines à états pour la gestion de course (startup, mode spectateur,...).
Jusqu'à présent, j'utilisais une vidéo pour donner le top départ.
Vu qu'elle est (un peu) moche et que nofrag fourmille de talent, je vous invite a fabriquer une vidéo de startup. Il n'y a rien à gagner à part la voire dans les prochaines releases.
J'ai une vidéo de startup par piste. Les meilleures seront donc toutes prises et uploadées ici avec youtube.
Cette vidéo est mappée sur des éléments du monde. Notamment un écran au dessus des points de démarrage.



Et voici les sources de mon machin. C'est le top de ce que je sais faire avec 3DSmax
Pour la description technique:
xvid sans piste audio 512x128
10 secondes maximum decomposées ainsi:
1 seconde sans chiffre
1 seconde de 3
1 seconde de 2
1 seconde de 1
6 secondes max de 'GO' et de ce que vous voulez.
Une fois l'anim finie, je reste sur la derniere image. Dans le cas de ma vidéo, je faisais 1 fondu vers le noir.

Faites preuves d'originialité et a vos souris !!!

Textures, DDS, ZLib & Threading

Dimanche 10 août 2008 à 20 h 01
Les textures sont ce qui prends le plus de place sur disque et en mémoire.
De plus, quand on developpe et debug, on doit relancer un paquet de fois l'application dans la journée.
Quand on a 3 textures, ca prends peu de temps. Mais quand on a des textures de 12mb plus la normal map et la specular, le temps de chargement devient long et fatiguant.
Je vais donc vous montrer ma petite recherche & developpement pour améliorer la facon de gerer les textures.
Ou comment diviser la place sur disque par 6 et le temps de chargement par 100.

ETAPE 1

Au début il y avait le TGA. Ce format d'image simple a charger et supporté par tous les soft d'edition/manipulation. Le fichier est composé d'un header puis de la charge utile (l'image) brute ou en RLE.
Personne n'utilise le RLE.

J'ai donc les images a charger. Pour mes tests, j'ai 34fois le même lot de 10 images de tailles différentes.
Au total, 408Mb de datas. ca fait beaucoup.

Pour le chargement, je fais ainsi:
[code]
void LoadTexture(const char *szName)
{
FILE *fp = fopen(szName,"rb");
if (fp)
{
TargaHeader header;
fread(&header, sizeof(TargaHeader), 1, fp);

int imgbytes = ((header.PixelDepth==32)?4:3) * header.ImageWidth * header.ImageHeight;
unsigned char*tmpbuf = (unsigned char*)MYALLOC(imgbytes);

fread(tmpbuf, imgbytes, 1, fp);

glBindTexture(GL_TEXTURE_2D, mTextureID);
glTexImage2D(GL_TEXTURE_2D, 0,
GL_RGBA, header.ImageWidth, header.ImageHeight,
0, ((header.PixelDepth==32)?GL_RGBA:GL_RGB) ,GL_UNSIGNED_BYTE, NULL);

glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0,
header.ImageWidth, header.ImageHeight,
((header.PixelDepth==32)?GL_RGBA:GL_RGB) ,GL_UNSIGNED_BYTE, tmpbuf);

glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
MYFREE(tmpbuf);

fclose(fp);
}
}
[/code]

Je charge et upload à la suite, dans la même thread. Ca prends entre 20secondes (cache windows vide) et 10secondes (cache windows powaaa). C'est long et il faut une carte graphique avec au moins 512Mb pour ne pas qu'il y ait de swap sur le bus pciexpress/AGP. Pour les néophytes, on peut voir plus de texture que d'espace en mémoire dédiée VRAM pour Video RAM). Le surplus est stocké en RAM classique et transférée par l'AGP/PCIe au fur et à mesure du rendu par le driver. La VRAM sert alors de buffer +/- temporaire. On a alors de grosses

baisses de performances.
En outre, je check l'utilisation mémoire temporaire pour le chargement (on vera l'utilité apres).
C'est géré par mon mini memory manager: MYALLOC/MYFREE.
Vu qu'il y a allocation/suppression par image. Le pic d'utilisation de la mémoire temporaire correspond à la taille de la plus grande texture (3Mb dans mon cas pour 1024x1024xRGB24bits).

ETAPE 2
On est en 2008, on va utilisé un format de notre temps, le DDS. Format fait par microsoft.
Ce format supporte les textures compressées en VRAM, les mipmaps,...tout.
Je converti mon paquet de 10textures en DDS. Certains seront en RGBA(celle qui ne supportent pas la compression à perte comme les textures de GUI), d'autres en DXT5 textures avec transparence), d'autres en

DXT1 (textures non transparentes).

Ca me donne un ratio de 4 pour 1. En gros, j'ai divisé la taille du lot par 4. C'est un ratio global que j'ai a peu pres eu dans d'autres projets (DXT1 = ratio 8 pour 1, DXT5 = 4 pour 1, RGBA = 1 pour 1)
Au final, je me retrouve avec 115Mb de texture qui auront la meme taille en VRAM. Ma config minimale passe d'une carte graphique 512Mb à 128Mb. Avec une meilleure qualité puisque j'ai les mipmaps intégrés dedans.

Vachement mieux non?

Comment charger le merdier?
par exemple ici en opengl : http://www.codesampler.com/oglsrc/oglsrc_4.htm#ogl_dds_texture_loader
et dans le DXSDK pour directX.
C'est mieux expliqué que je ne le ferais :)


Pourquoi ne pas avoir utilisé du png ou du jpeg?
parce que le jpeg compresse avec beaucoup de perte visible (par rapport a du DXT), ne supporte pas les mipmaps, a une taille non compressée en vram et est super lent a decoder.
Idem pour le png.
En outre la taille d'un DDS en DXT1 compressée en zip et inférieure a la meme texture en jpeg (qualité a 60%).

Avec ca, ca prends entre 1 et 5 secondes de chargement (1.5 secondes quand c'est dans le cache windows est bien rempli) et 1Mb de pic d'utilisation mémoire.

Que des avantages a utiliser ce format (supporté aussi sur PS3)

ETAPE 3
1 a 5 secondes, c'est bien mais on peut faire encore mieux.
Il faut savoir que quand on fait 1 lecture/ecriture vers un périphérique, il y a ce qu'on appelle un DMA (Direct Memory Access). En gros, ce composant électronique permet de transferer des blocs mémoire sans que le CPU ne travaille. Il n'y a qu'a faire une copie de fichier pour se rendre compte que le CPU ne fait rien.
Dans notre cas. Le CPU attends que le DMA remplisse la mémoire quand on lit un fichier. Et il attends aussi quand on écrit en VRAM la texture qu'on vient de lire. On perd du temps 2 fois.

On doit donc pouvoir transferer une texture en VRAM pendant qu'on en lit une autre depuis le disque. Pour ca, on va utiliser une thread. Opengl ne supporte des opérations sur son contexte (upload des textures) que dans sa thread. On va donc faire une thread qui lit depuis le disque et la thread principale uploadera les textures.
L'autre avantage est qu'on va pouvoir rendre de la 3D (anim d'attente ou jauge...) pendant qu'on charge.

Comment ca se passe tout ca?
[code]
mFileNameListCriticalSection = new CRITICAL_SECTION();
InitializeCriticalSection((CRITICAL_SECTION*)mFileNameListCriticalSection);
mTexToUploadListCriticalSection = new CRITICAL_SECTION();
InitializeCriticalSection((CRITICAL_SECTION*)mTexToUploadListCriticalSection);

DWORD thId;
mbThreadLoaderRunning = true;
mthHand=::CreateThread(NULL,NULL,ThreadLoader,NULL,NULL,&thId);
[/code]

Cet init crée la thread qui lit depuis le disque ainsi que 2 section critiques.
La 1ere section critque sert pour la manipulation de la liste des fichiers a charger.
La 2eme est pour locker la liste des textures a uploader


Quand on veut lire une texture on appelle la fonction qui va bien depuis n'importe quel thread:
static void LoadTexture(const char *szName)
{
LockListFileName();
mFileNamesToLoad.push_back(szName);
UnlockListFileName();
}

C'est tout simple. On lock la liste des 'taches' a faire, on ajoute la tache et on se casse.

Pendant ce temps la, on a notre thread, tapie dans la penombre....

static DWORD WINAPI ThreadLoader(LPVOID param)
{
static ZTexture* mTextureBunch[512];
static std::string mTextureNameBunch[512];

while(mbThreadLoaderRunning)
{
int mTextureBunchAv = 0;
int mTextureNameBunchAv = 0;

LockListFileName();
// on devrait checker que ca ne depasse pas de buffer
for (int i=0;i<mFileNamesToLoad.size();i++)
{
mTextureNameBunch[mTextureNameBunchAv++] = mFileNamesToLoad[i];
}
mFileNamesToLoad.clear();
UnlockListFileName();


for (int i=0;i<mTextureNameBunchAv;i++)
{
ZTexture *nTex = new ZTexture;
nTex->LoadFile(mTextureNameBunch[i].c_str());
mTextureBunch[mTextureBunchAv++] = nTex;
}

LockListTexToUpload();
for (int i=0;i<mTextureBunchAv;i++)
{
mToUploadList.push_back(mTextureBunch[i]);
}
UnlockListTexToUpload();
}

return 0;
}

Elle scrute la liste des fichiers a charger, la copie dans sa liste perso (pour unlocker rapidement les sections critiques), fait uniquement le chargement, et met sa texture prete à etre uploadée dans une autre liste (elle aussi lockée).

Et enfin, à l'autre bout du tuyaux, on a notre upload opengl qui se fait dans la thread principale , celle qui a initialisé le contexte opengl.

if ((active && !DrawGLScene()) || keys[VK_ESCAPE]) // Active? Was There A Quit

Received?
{
done=TRUE; // ESC or

DrawGLScene Signalled A Quit
}
else // Not Time To

Quit, Update Screen
{
SwapBuffers(hDC); // Swap Buffers

(Double Buffering)
ZTexture::DoAllUploads();

Je place l'appel la. Car la placer dans la boucle de rendu diminuerais fortement les performances. Locker une ressource de la VRAM pendant un rendu est interdit.

static void DoAllUploads()
{
static ZTexture* mTextureBunch[512];
int mTextureBunchAv = 0;

LockListTexToUpload();
for (int i=0;i<mToUploadList.size();i++)
{
mTextureBunch[mTextureBunchAv++] = mToUploadList[i];
}
mToUploadList.clear();
UnlockListTexToUpload();

for (int i=0;i<mTextureBunchAv;i++)
{
mTextureBunch[i]->UploadTexture();
}
}

meme principe que precedement.
La méthode UploadTexture est, en gros, simplement un appel a glTexImage2D.


Avec ca, cache windows vide, ca prends 1.5 secondes (contre pas loin de 5 avant) et cache windows rempli, ca prends 500ms. Le seul soucis est que le pic de mémoire atteind 146Mb. Il va falloir utiliser plus de CPU à certains endroit pour lisser l'utilisation des flux.

On est déjà passé de 20secondes (cache vide) a 5 secondes (cache vide) puis 1.5 secondes (cache vide).

Mais on va essayer de faire encore un peu mieux :)
Et pourquoi pas un petit coup de ZLib???

ETAPE 4
Tape zlib dans google, et télécharge le tar.gz pour avoir les sources.
L'intégration de ZLib dans le bouzin se fait en 3 phases.
1ere phase, compresser les dds en dds compressés.

void CompressTextures()
{
for (int i=0;i<340;i++)
{
char tmps1[512];
char tmps2[512];
sprintf(tmps1, "Data/Tex%03d.dds", i);
sprintf(tmps2, "Data/Tex%03d.ddsc", i);

FILE *fp = fopen(tmps1, "rb");
if (fp)
{
int fileno = _fileno(fp);
int filesize = _filelength(fileno);

unsigned char *bufr = new unsigned char [filesize];
fread(bufr, filesize, 1, fp);
unsigned char *bufw = new unsigned char [10*1024*1024];


int dataSizeToCompress = filesize;
uLongf dataCompressedSize;
dataCompressedSize = 10*1024*1024;

int res = compress2 OF((bufw, &dataCompressedSize,
bufr, dataSizeToCompress,
9));
if (res == Z_OK)
{

FILE *fp2 = fopen(tmps2, "wb");
if (fp2)
{
fwrite(&filesize, sizeof(int), 1, fp2);
fwrite(bufw, dataCompressedSize, 1, fp2);

fclose(fp2);
}

}

delete [] bufr;
delete [] bufw;

fclose(fp);
}
}
}

C'est ce que fait cette fonction. 1ere bonne nouvelle! On passe de 115Mb de DDS a 72Mb sur disque. Ca fait dans les 37% de gagnés. En utilisation réelle, j'obtenais entre 30 et 35%.

Phase 2:
On modifie la fonction de chargement des dds depuis le disque pour qu'elle charge depuis la mémoire.

Phase 3:
On crée une 3eme liste et une 3eme thread. Maintenant, le flux ressemble à ca:
Thread1
Lecture des données compressée

Thread2
Decompression des données et lecture du header DDS

Thread3 (la principale)
Upload des données decompressées vers la VRAM

Et la les résultats sont impressionnants. 160ms pour uploader tout ca quand les 72Mb sont dans le cache de windows. 1 seconde quand ca ne l'est pas. Toujours beaucoup de mémoire (115Mb en pic d'allocation).

ETAPE 5 et futur du truc
Test sans succes pour limiter le pic d'utilisation mémoire.
Il va falloir essayer de faire ca avec les vertex et index array.
Modifier aussi un peu l'interface pour avoir de suite le pointeur sur la ZTexture (au cas ou on voudrais l'associer directement avec un mesh par exemple).
J'ai aussi eu de bons resultats avec ZLib parce qu'en mode release, j'ai désactiver les exceptions.
A ce titre, les exceptions font baisser les perfs de 10% et rajoutent 10% à la taille de l'exe.
Sachant que CA NE SERT A RIEN. Je vous invite a les enlever aussi.
Quoi qu'il se passe il y a toujours un moyen distingué pour se passer des exceptions.
C'est une technique qui n'a pas sa place dans le JV.
Sinon, les multithread utilisé dans cet exemple marchera aussi très bien avec une machine monocore.
Je suis parti from scratch avec VC9 et la lesson 06 de nehe.gamedev.net
La balise [code ] a pas l'air de marché. Tantpis pour vos yeux.

Je post les sources si j'ai pas la flemme et si quelqu'un est pret a negocier sa mere contre le zip.
Questions/trucs pas compris en commentaire :)
12 commentaires, dernier de skaven.

Faux air de Justice

Samedi 9 août 2008 à 14 h 08

Vous ne trouvez pas?

DevDiary : Plein le cul [EDIT]

Vendredi 8 août 2008 à 19 h 06
Aujourdhui j'en ai plein le cul de mon programme.
Des merdes dans l'architecture, une grosse baisse de rythme, marre de tout.
Je ne sais plus par quel bout prendre le bordel.

Demain ca ira mieu....
...ou pas.

[EDIT]
Malgrès que beaucoup de choses soient générées dans le jeu, il y a trop de contenus a faire (explosions, briques de circuits,...). Même ce minimum est trop pour un gars tout seul.
Ensuite, j'ai merdé sur certains choix. Soit je faisait 1 jeu solo, soit 1 jeu multiplayer. Faire les 2, c'est stupide. J'aurais du orienter des le début vers du multiplayer et aller a fond dedans. A la limite, juste apres le proto de l'année derniere, et quelques modifs de stabilité, j'aurais du faire le proto reseau du mois dernier. Iterer dessus en continuant 1 release chaque semaine (voire toutes les 2 semaines).
J'ai l'impression d'avoir epuisé mes points de karma/modjo dessus. J'ai perdu la flamme.
Un an, c'est assez court en même temps. Des soucis, c'est très gérable quand ca arrive dans les tous 1ers mois. Quand c'est après un an, ca mine le moral. En meme temps, si j'avais bien fait mon boulot les 1ers mois, ce genre de probleme n'aurait jamais du arriver.
J'en tire les conséquences en cherchant un nouveau projet.
Si quelqu'un veut les sources pour continuer le bouzin (licence libre), qu'il me contacte.

Autre soucis aussi, c'est le gamedesign. Bien que ce point soit assez difficil a défendre aussi. Je n'ai pas fait de gamedesign (cf pb reseau/solo) en meme temps, j'adaptais le jeu suivant mes moyens.

Pour une fois, je vais faire un break en joignant une équipe existante. Au moins un gars motivé qui fait du contenu et du design....changer d'air en fait.
Si tu as un projet +/- solide et que tu as envi de m'adopter, contacte moi.

Dédicace à maitre Kanter

Jeudi 7 août 2008 à 11 h 27

DevDiary : La physique

Samedi 2 août 2008 à 20 h 35
J'avais 2 choix pour la physique:
- utiliser ODE (ou un autre moteur physique)
- coder mon intégrateur

Je n'ai pas pris ODE pour diverses raisons dont celles-ci:
- Il est plus difficil de tweaker (faire des hack/arriver exactement au modèle physique qu'on veut)
- Les performances peuvent rapidement tomber.
J'ai vu une charge CPU anormale dans certains cas. Ces cas sont difficils a éliminer et/ou demande plus de travail

J'ai fait mon "moteur" physique pour ces raisons:
- apprendre à coder un moteur minimaliste
- beaucoup de cas particuliers (intégrateur qui tourne a 1Khz)
- performances: la physique prends 1% du CPU avec les collisions sur 1 athlong 64 3700+
- Le code de la détection de collision est ce qui demande le plus de travail/code et devait de toute facon etre fait meme avec ODE

Alors, comment marche la physique dans le jeu?

Il y a 2 parties +/- distinctes:
- La détection de collisions avec la piste, et les collisions entre vaisseau/missiles/mines
- la physique du vaisseau

Les collisions entre les vaisseaux, et entre tout ce qui n'est pas piste en générale, est super simple.
C'est du sphere-sphere. On détermine la distance au carrée entre les 2 objets. Si elle est inférieure aux carrés des rayons respectifs, il y a collision. Simple.
Pour les balles du machine gun, et les missiles, je fais un raycasting avec les mesh de la piste.
Il y a en plus 1 time to live par entité (sauf les mines). Le raycasting se fait pour les mesh dont la bounding sphere à une collision avec le ray de la velocité de l'objet mobil.
C'est ce qui prends le plus de temps cpu. En utilisant Opcode ou une autre lib du genre, ca diminuerais l'utilisation CPU.
MAIS, vu le faible nombre de missiles/balles/... et leur distribution tout au long du jeu, il y a globalement peu de raycasts dans une session de jeu.

La collision entre les vaisseaux et la route est une grosse gruge.
Voici une image qui décrit super bien la facon dont je gere la chose:


J'une ligne composée d'une multitutide points (tous les 2m) composant le tracé du circuit. Pour chaque point, j'ai un vecteur UP. Le 1er point à (0,1,0) comme vecteur UP.
A cela, et toujours pour chaque point, j'ai un vecteur right. Ce vecteur right est le produit vectoriel entre le UP du point et le vecteur directeur (point courrant - point suivant).
Plus la position du point, j'ai donc un ensemble de matrices.

Sur cette ligne de matrices, j'empile un ensemble de mesh (qui seront deformés). Pour chaque mesh, le designer specifie par script une largeur.
Le vaisseau crée, je lui assigne la matrice dont la position est la plus proche comme matrice courrante.

Maintenant, pour synthétisé la détection de collision, il faut penser au passage sur l'étoile noire de Starwars. Dans la tranchée, le XWing peut se crasher sur le sol, et les cotés.
Il en est de même dans le jeu et dans le manège.
Au rythme de 1Khz, quelque soit le nombre de FPS, je projete la position du vaisseau avec la matrice courrante, ainsi que les 10suivantes et les 10précedentes.
Avec ca, j'ai un scalaire droite gauche, un scalaire haut/bas et 1 scalaire avant/arriere. respectivement sur les vecteur RIGHT, UP et DIRECTION de la matrice.
Rien de plus simple ensuite pour calculer la collision potentielle. Il y a collision droite/gauche si la valeur absolue du scalaire droite/gauche est supérieure a la moitiée de la largeur de la route.
Il y a collision en bas, si le scalaire UP est inférieur a 0. Il n'y a pas de limite en hauteur.

On court dans un couloir. La physique du vaisseau est super simple aussi. J'ai un effet de spring sur la hauteur du vaisseau par rapport a la route. Ce spring marche ainsi. Si le vaisseau est plus haut que

hauteur du sol + 5m, la gravité va vers le bas. Si le vaisseau est entre le sol et sol+5m, on a une gravité vers le haut. C'est ce qui permet d'avoir un effet a la wipeout et qui donne du rebond apres un

saut. Le reste de la physique doit tenir dans le programme de 1ere S (mobile en mouvement, moment d'inertie, toussa).

Tout ca marche plutot bien et est plutot fiable (bien plus que je ne l'aurais imaginé au début).
2 commentaires, dernier de ap0.

DevDiary : NetProto Iteration 4

Vendredi 1er août 2008 à 15 h 07
Lundi 28 Juillet
Un petit break pour coder le renderer opengl. J'y vois 2 avantages:
- pouvoir continuer les iterations 4 et 5 qui utilisent toujours le framework de nehe
- Faire un passage en douceur vers du linux/macos (déjà entamer grâce à wxWidgets)
A ce titre, est ce que quelqu'un connais une lib (autre que SDL) multiplateforme pour les inputs clavier/souris/pad?
Une soirée de taf et j'ai un renderer fonctionnel (avec VBO) en pipe fixe. Mon interface de rendu n'était donc pas trop
mal faite ;) Chargement Collada et tout le bouzin marche.
J'attaque donc l'itération 4 qui consistera a afficher une circuit (depourvu de textures) pour faire courrir dedans
toute personne qui se connectera au serveur.
J'ai surtout envie de voir si ma technique de detection de collisions tiendra la route(sic!)

Mardi 29 Juillet
Petites retouches sur le renderer opengl.
Et surtout refactoring de certains bout de code trop dependants du client.
Par exemple, pour le chargement des ressources (circuits, vaisseaux,...), je recupere une interface sur la GUI
et je modifie la progression (logique). Sauf que sur le serveur en dédié, je n'ai pas de barre de progression
vu que je n'ai meme pas de GUI du tout. Et ce genre de problèmes intervient un peu partout.
J'avais prévu le coup il y a moment en faisant un Renderer NULL (renderer qui ne fait rien). Notement pour les outils en ligne de commande
qui utilisent le meme .exe que le jeu.

Mercredi 30 Juillet
Recodage de la detection des bonus de la piste. Un peu de rendu. Je vais pas tarder a commencer les 1ers tests de validation
de cette iteration. Il me reste essentiellement un probleme de recuperation de bonus, et 1 concernant la pertinence de la camera.
Quand ca tourne trop, on ne voit plus rien...


Jeudi 31 Juillet
Interpolation des angles en utilisant des quaternions. Je vois encore quelques problemes au niveau des positions. Sans doute à cause
des collisions avec le bord de la piste. Du raycasting pour les missiles et le machine gun. Il faut que je rajoute
une hierarchisation des meshs. Je fais trop de tests ray/bounding sphere.

Vendredi 1 Aout
Encore un paquet tout chaud. Ce soir 21h pendant une petite demi heure.
Meme punition que d'habitude. Si vous pouvez faire une capture des soucis avec fraps :)
Et je serai sur l'irc...

L'exe du netproto4. Lance client.bat
Les sources du net proto4
La redist des DLL pour VC8 (chez krosoft)

Qu'y a t'il de neuf dans ce proto4? Un circuit! même style graphique (que j'aime bien), le bonus instant speed en plus des mêmes bonus qu'auparavant. Une meilleure interpolation. Et le jeu solo.
5 commentaires, dernier de skaven.

L'humain

Mercredi 30 juillet 2008 à 12 h 07