Faire un moteur
de jeu, c'est un
peu comme faire sa chaîne hi-fi de zéro. C'est fun
mais en général pas très
utile. En fait, si vous voulez faire un moteur de jeu 3D, je
suggèrerai
d'utiliser un moteur existant, comme Crystal Space, ou de le faire
simplement
en combinant certains éléments, comme Ogre3D,
PhysX, OpenAL et Lua, par
exemple.
En fait, faire un
moteur de jeu
3D est bien pour son CV, surtout si vous voulez travailler en amont
de
la création de jeu. Si vous voulez faire ça, je
vous recommande le livre 3D
Game Engine Architecture, de David H. Eberly, en anglais. Il est
également très
utile de travailler auparavant avec des moteurs existant, tels ceux
cités
auparavant, pour apprendre. Et c'est bien d'être conscient de
la difficulté de
la tâche aussi 
Mais bon, je vais
parler de
quelque chose de plus simple. Je fais des jeux en ligne en Java (j'ai
fait
auparavant plusieurs prototypes en 3D en C/C++ aussi, et un jeu 2D en
ASM avec
un ami), en 2D. J'ai commencé en fait en 3D, avec Java 3D
pour ma balle géante.
Curieusement, vous ne pouvez pas jouer à cette version.
Pourquoi ? Parce que
Java 3D n'est pas génial génial. Impossible
d'avoir une installation simple du
plugin (comme flash ou autre), plantages sur certains PC, exemples
écrits avec
les pieds par des trolls en hypothermie, et architecture pas toujours
bien
logique. Comme j'ai quand même lancé le "jeu" avec
cette version, ça
faisait beaucoup, le concept très bizarre (pas
d'interactions à l'époque :),
une connexion donnait un "hit"), et une technique pourrave. J'ai donc
rajouté une version 2D, avec un algorithme de
détection. Du fait la classe de
jeu GiantBall s'est séparée en deux: GiantBallEmb
(3D) et GiantBall2DEmb et
l'entrée de l'applet déplacée dans
StartGBApplet, avec le code suivant :
public void init()
{
boolean java3davailable=true;
java3davailable=false;
try
{
Class.forName( "javax.media.j3d.VirtualUniverse" ).newInstance();
}
catch (Exception e)
{
// Ok, let
switch to the 2D version !
java3davailable=false;
}
catch (Error e)
{
// Ok, let
switch to the 2D version !
java3davailable=false;
}
setLayout(new BorderLayout());
// TODO Auto-generated constructor
stub
if (java3davailable)
{
GB = new
GiantBallEmb();
GB.startGB(this);
}
else
{
// 2D
version !!!
GB = new
GiantBall2DEmb();
GB.startGB(this);
}
}
Simple mais
efficace. Après un
moment, en réalisant que la version 3D a trop
d'inconvénient, j'ai retiré le
choix (même si en fait la version 3D est encore dans le JAR!
Je dois régler ca
un jour ;( ).
Bref, revenons
à nos moutons! Ou
plutôt à mes applets. Depuis, j'ai
utilisé la base 2D de la balle géante pour
faire mon autre jeu, Similar. J'ai additionné du son, de la
musique en
streaming (avec un système pas super au point d'ailleurs).
Par contre la mise à
jour des graphismes est plus statique: elle ne change que quand
nécessaire
(avec la balle, la mise à jour est
régulière, assurée par un petit
thread).
Depuis, j'ai commencé plusieurs prototypes. Mais
réutiliser la même base est
bien, ça me simplifie la vie, mais je dois manuellement tout
gérer. Cependant
mes jeux ne seraient pas supportés correctement par les
moteurs de jeux que je
connais en Java. Comme j'ai une base assez conséquente, des
besoins bien
définis, pourquoi pas faire mon propre moteur de jeu. Assez
simple, mais me
permettant éventuellement pas mal d'économie de
code.
Quelle
architecture pour ce
moteur ?
Ce qui
m'intéresse, c'est de diminuer au maximum le travail de
gestion d'entités, de
jeux et graphiques. Cependant, je veux séparer la
représentation. Je vais donc
baser mon moteur sur des gestionnaires d'entités, en ayant
un gestionnaire de
rendu fixe (valable pour tout type de jeux pour ce moteur), et un
gestionnaire
d'entité (de jeu) qui pourra être
adapté selon le jeu, éventuellement. Mais je
reviendrais sur l'architecture dans un prochain billet (quand je serais
sûr que
je ne fais pas quelque chose d'idiot
).
La partie sûre, pour le moment, concerne l'aspect graphique.
Je veux pouvoir
avoir des éléments graphiques, relativement
quelconques, qui seront dessinés
par couches (pour simuler la profondeur ou simplement parce que). J'ai
d'abord
défini une classe Drawable2DEntity, qui est abstraite,
c'est-à-dire quelle ne
peut pas être utilisée elle-même pour
instancier un objet: une ou plusieurs de
ses méthodes ne sont pas implémentées.
Pourquoi ? Parce que je ne sais pas
quelle représentation graphique je vais donner, donc la
méthode qui dessine
l'objet drawMe, est abstraite. Cette classe définie les
éléments communs que je
pense retrouver pour la plupart de mes objets graphiques. Donc cette
classe
doit être héritée, et les classes
dérivées sont de bas-niveaux, car elle
s'occupe de se dessiner. C'est pour ça, entre autre, que je
voulais séparer les
entités de jeux et les entités graphiques.
Plusieurs types d'entités de jeux
vont se partager le même type d'entité graphique:
un bateau et un avion sont
deux entités de jeux différentes, mais seront
représentés par un sprite!
Voilà la classe:
package
subengine;
import java.awt.Graphics2D;
/**
* Superclass for the drawable 2D entity. Must be inherited
(the drawMe
method is virtual! )
* Then I recommend that the game entities are not the
sub-class, but have
a drawable facet,
* So create and update one or several drawable entities.
* A particule is not necessary a DrawableEntity (for
performance purpose)
* @author Alain Becam
*
*/
public abstract class Drawable2DEntity
{
private double x,y,z; // x,y the
position on the plan, z the
altitude
private double rotation; // Is also yaw,
but is called
rotation as the pitch,roll and yaw will be only useful for
3D-like motion
private double pitch; // Noze up and down
private double roll; // Wings up and down
private double size; // Global size. The
detail is up to the
subclass, as we don't know the shape here
private double sizeRelative;
private long deepOnScreen; // basically
z, but for the on
screen rendering. By default the round of z
private int alpha; // transparency of
the element
boolean valide=true; // will it be drawn
or not
long idEntity; // Given by the
RenderingManager
/**
* @return the idEntity
*/
public long getIdEntity()
{
return
idEntity;
}
/**
* @param idEntity the idEntity
to set
*/
public void setIdEntity(long idEntity)
{
this.idEntity = idEntity;
}
/**
* draw the enity. You must
implement this method!
* @param graphicsToUse
* @return
*/
public abstract boolean
drawMe(Graphics2D graphicsToUse);
/**
* Rotate the entity
* @param rot in radians
*/
public void rotate(double rot)
{
rotation+=rot;
// Stay
between 0 and 2*PI
while
(rotation > 2*Math.PI)
{
rotation-=2*Math.PI;
}
while
(rotation < 0)
{
rotation+=2*Math.PI;
}
}
/**
* Pitch the entity
* @param rot in radians
*/
public void pitch(double rot)
{
pitch+=rot;
// Stay
between 0 and 2*PI
while
(pitch > 2*Math.PI)
{
pitch-=2*Math.PI;
}
while
(pitch < 0)
{
pitch+=2*Math.PI;
}
}
/**
* Roll the entity
* @param rot in radians
*/
public void roll(double rot)
{
roll+=rot;
// Stay
between 0 and 2*PI
while
(roll > 2*Math.PI)
{
roll-=2*Math.PI;
}
while
(roll < 0)
{
roll+=2*Math.PI;
}
}
/**
* Move the entity, relative to
its current position
* @param dx
* @param dy
*/
public void moveRelative(double
dx,double dy)
{
x+=dx;
y+=dy;
}
/**
* Move the entity, relative to
its current position
* @param dx
* @param dy
* @param dz
*/
public void moveRelative(double
dx,double dy,double dz)
{
x+=dx;
y+=dy;
z+=dz;
deepOnScreen=Math.round(z);
}
/**
* Move the entity to the
desired position
* @param xDest
* @param yDest
*/
public void moveTo(double xDest,double
yDest)
{
x=xDest;
y=yDest;
}
/**
* Move the entity to the
desired position
* @param xDest
* @param yDest
* @param zDest
*/
public void moveTo(double xDest,double
yDest,double zDest)
{
x=xDest;
y=yDest;
z=zDest;
deepOnScreen=Math.round(z);
}
public void resize(double percent)
{
size=sizeRelative*percent/100;
}
/**
* @param x
* @param y
* @param size
*/
public Drawable2DEntity(double x, double
y, double size)
{
super();
this.x =
x;
this.y =
y;
this.z =
0;
this.rotation = 0;
this.pitch = 0;
this.roll
= 0;
this.size
= size;
this.sizeRelative = size;
}
/**
* @param x
* @param y
* @param z
* @param size
*/
public Drawable2DEntity(double x, double
y, double z, double
size)
{
super();
this.x =
x;
this.y =
y;
this.z =
z;
this.rotation = 0;
this.pitch = 0;
this.roll
= 0;
this.size
= size;
this.sizeRelative = size;
}
/**
* @param x
* @param y
* @param z
* @param rotation
* @param pitch
* @param roll
* @param size
*/
public Drawable2DEntity(double x, double
y, double z, double
rotation, double pitch, double roll, double size)
{
super();
this.x =
x;
this.y =
y;
this.z =
z;
this.rotation = rotation;
this.pitch = pitch;
this.roll
= roll;
this.size
= size;
this.sizeRelative = size;
}
/**
* @return the deepOnScreen
*/
public long getDeepOnScreen()
{
return
deepOnScreen;
}
/**
* @param deepOnScreen the
deepOnScreen to set
*/
public void setDeepOnScreen(long
deepOnScreen)
{
this.deepOnScreen = deepOnScreen;
}
/**
* @return the pitch
*/
public double getPitch()
{
return
pitch;
}
/**
* @param pitch the pitch to set
*/
public void setPitch(double pitch)
{
this.pitch = pitch;
}
/**
* @return the roll
*/
public double getRoll()
{
return
roll;
}
/**
* @param roll the roll to set
*/
public void setRoll(double roll)
{
this.roll
= roll;
}
/**
* @return the rotation
*/
public double getRotation()
{
return
rotation;
}
/**
* @param rotation the rotation
to set
*/
public void setRotation(double rotation)
{
this.rotation = rotation;
}
/**
* @return the size
*/
public double getSize()
{
return
size;
}
/**
* @param size the size to set
*/
public void setSize(double size)
{
this.size
= size;
this.sizeRelative = size;
}
/**
* @return the x
*/
public double getX()
{
return x;
}
/**
* @param x the x to set
*/
public void setX(double x)
{
this.x =
x;
}
/**
* @return the y
*/
public double getY()
{
return y;
}
/**
* @param y the y to set
*/
public void setY(double y)
{
this.y =
y;
}
/**
* @return the z
*/
public double getZ()
{
return z;
}
/**
* @param z the z to set
*/
public void setZ(double z)
{
this.z =
z;
}
/**
* @return the alpha
*/
public int getAlpha()
{
return
alpha;
}
/**
* @param alpha the alpha to set
*/
public void setAlpha(int alpha)
{
this.alpha = alpha;
}
/**
* @return the valide
*/
public boolean isValide()
{
return
valide;
}
/**
* validate the entity
*/
public void validate()
{
this.valide = true;
}
/**
* invalidate the entity
*/
public void invalidate()
{
this.valide = false;
}
}
Et voilà un exemple idiot de classe
dérivée:
package
subengine;
import java.awt.Graphics2D;
/**
* @author Alain Becam
*
*/
public class SimpleObject extends Drawable2DEntity
{
public SimpleObject()
{
super(0,0,10);
}
/* (non-Javadoc)
* @see
subengine.Drawable2DEntity#drawMe(java.awt.Graphics2D)
*/
@Override
public boolean drawMe(Graphics2D
graphicsToUse)
{
// TODO
Auto-generated method stub
graphicsToUse.draw3DRect((int
)this.getX(), (int )this.getY(), 10, 10, false);
return
false;
}
/* (non-Javadoc)
* @see
java.lang.Comparable#compareTo(java.lang.Object)
*/
public int compareTo(Object o)
{
// TODO
Auto-generated method stub
return 0;
}
}
Nous voyons donc
que cette classe
prend elle-même en charge son dessin. Mais ce drawMe ne sert
à rien tout seul.
Il est utile au sein du RenderingManager. On gèrera quand
même les entités
graphiques en dehors du manager, pour gagner du temps (ce serait peu
efficace
de faire findEntity(idEntity), manageEntity(idEntity), update(Entity)).
Mais le
renderingManager dessinera les entités sans actions
extérieures nécessaires,
les mises à jour seront prise en compte automatiquement
(parce qu'il garde la
référence de vos entités, ne les copie
pas).
package
subengine;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.util.HashMap;
import java.util.Collection;
import java.util.Iterator;
import java.util.ArrayList;
/**
* The rendering manager keep the list of drawable entities
and render
them as needed
* @author Alain Becam
*
*/
public class RenderingManager
{
...
}
Les
entités sont conservées dans
une ArrayList de HashMap, ouch !!! La HashMap associe l'identifiant de
l'entité
avec l'entité (clé<-id,
value<-entity), ce qui permet de retrouver rapidement
une entité dans la HashMap (une table de hachage permet de
réduire la
complexité pour rechercher un élément
à une place quelconque). Par contre, le
parcours de la HashMap pourrait être moins performant que si
j'utilisais un
Vector (mais je ne suis pas sûr de ça).
ArrayList<HashMap<Long,Drawable2DEntity>>
entitiesToDrawLayers;
Maintenant,
l'ArrayList...
Celle-ci remplace en quelque sorte le tableau de
référence (HasMap[40] bidule;
est remplacé par ArrayList<HashMap> bidule=
new
ArrayList<HashMap>(40); ). Mais pourquoi ??? Oui,
pourquoi ??? Et bien
parce que !!!!!
Bon, j'ai
galéré honteusement sur
ça pendant un bon moment. Honteusement parce que j'ai
étudié là-dessus et
déjà
pas mal bossé avec à une époque. Je
voulais avoir de la profondeur !!! Au alentour
de 2003-2004, je travaillais avec DirectX sur un moteur pseudo 2D,
très simple,
et j'avais la solution: utiliser la 3D
C'est une bonne solution,
parce que
la plupart de nos cartes graphiques sont conçues pour
ça. Elles arrivent à être
plus rapide en 3D qu'en 2D. Mais Java, seul et clean, est un
handicapé en 3D,
et pas forcément super aidé en 2D aussi
(même si ca s'est beaucoup améliorer).
Je ne veux pas d'extension OpenGL, même si elle sont
sûr mieux foutues que
Java3D, parce que d'expérience ça fout la merde.
Je ne veux pas d'un truc comme
JMonkey, parce que c'est trop lourd et ça démarre
en général avec Java Web
Start (une belle #$€$££$, qui fait fuire
les non-geeks comme le sucre attire
les mouches) ou un warning pas beaucoup mieux ("attention ce logiciel a
sûrement été conçu pour
détruire votre PC, exterminer vos animaux domestiques,
vous refiler la rage et vous maudire sur 34
génération. Voulez vous exécuter ce
logiciel ?"). Donc je me tiens à la toute puissance de
java.awt.Graphics,
ou plus exactement Graphics2D. En fait c'est suffisant pour mes besoins.
Malheureusement,
la façon de
dessiner avec profondeur avec Graphics2D est grosso-modo de dessiner
dans
l'ordre, du plus profond au moins profond. Donc si on laisse toute
liberté de
profondeur, il faut ordonner les éléments.
Seulement un tri ca prend du temps.
Si vous regarder le screenshot suivant, vous voyez un exemple de
problème
lié.

Avec ce moteur
maison, fait par
un collègue de la fac et des étudiants, le tri
des billboards (ces éléments plats
qui font toujours face au joueur) n'est pas effectué assez
vite et donc
certains éléments supposés au fond
apparaissent devant certains devant... Bad
... La plupart des moteurs 3D règlent ce
problème avec un "pixel
discard": si vous utilisez la transparence de l'objet (alpha channel),
vous devez ordonner, parce que vous ne pouvez pas utiliser le z-buffer
(qui
considèrerait tous les pixels, même transparent).
Pixel discard précise
simplement que ce pixel "n'existe pas" et donc on peut utiliser le
z-buffer.
Qu'est ce que le
z-buffer? C'est
en quelque sorte une image, de la même résolution
que celle dessinée qui
enregistre la profondeur. Quand vous dessinez un
élément, vous dessinez aussi
dans le z-buffer, pour chaque pixel, à quelle profondeur ce
trouve le pixel. Si
le pixel présent est déjà plus
prêt, alors on ne dessine rien, car quelque
chose est devant ce pixel ! C'est simple et rapide, mais demande plein
de
mémoire et comporte de sérieuses limitations.
Entre autre, cela limite
sérieusement la précision en profondeur. C'est
pourquoi le z-buffer n'est pas
toujours utilisé.
Mais
dans mon cas, pas de z-buffer, car
j'utilise Graphics2D, qui n'en comporte pas. Ma solution consiste
à définir
plusieurs couches, 40 pour être exact. Voilà donc
le pourquoi de mon ArrayList:
une HashMap par profondeur.
Le RenderingManager est unique. J'utilise donc un pattern singleton
pour le
définir:
Graphics ourGraphics;
private static RenderingManager
ourInstance = null;
public synchronized static
RenderingManager
getInstance(Graphics graphicsToUse)
{
if
(ourInstance == null)
{
ourInstance = new
RenderingManager(graphicsToUse);
}
return
(ourInstance);
}
public synchronized static
RenderingManager getInstance()
{
if
(ourInstance == null)
{
return null;
}
return
(ourInstance);
}
private RenderingManager(Graphics
graphicsToUse)
{
ourGraphics = graphicsToUse;
entitiesToDrawLayers = new
ArrayList<HashMap<Long,Drawable2DEntity>>(40);
for (int
iLayer=0;iLayer < 40 ;
iLayer++)
{
HashMap<Long,Drawable2DEntity> newLayer = new
HashMap<Long,Drawable2DEntity>();
entitiesToDrawLayers.add(newLayer);
}
}
Au moins le premier appel doit être fait avec le Graphics
venant de la classe
appelante. Il faut bien quelque chose sur quoi dessiner ! Utiliser un
singleton
permet d'être sûr qu'il n'y a qu'un seul
gestionnaire. De même, il permet une gestion
plus cohérente des entités. Comme il est le seul,
il peut donner un id unique à
chaque entité, on sait qu'une autre instance ne viendra pas
mettre le bordel:
private static long idCurrentEntity=0; // At the beginning of the class
public long
addDrawableEntity(Drawable2DEntity newEntity)
{
newEntity.setIdEntity(idCurrentEntity);
// By
default, in the middle!
entitiesToDrawLayers.get(20).put(idCurrentEntity,newEntity);
return
(idCurrentEntity++);
}
public long
addDrawableEntity(Drawable2DEntity newEntity,
int layer)
{
newEntity.setIdEntity(idCurrentEntity);
entitiesToDrawLayers.get(layer).put(idCurrentEntity,newEntity);
return
(idCurrentEntity++);
}
L'idéal pour une entité est de rester au
même niveau. Et ce sera sans doute le
plus souvent le cas. Malgré tout, l'utilisation de HashMap
permet ce changement
sans trop de difficultés :
/**
* Find the layer of the entity
and then change it.
* Shouldn't be use
(slow), so please keep the layer of
your entity
* @param idEntity the id of
the entity, returned at
the addition
* @param newLayer the new
layer where to push the
entity
*/
public void changeLayerOf(long
idEntity,int newLayer)
{
Drawable2DEntity theEntity;
// First
find the entity
for (int
iLayer=0;iLayer<40;iLayer++)
{
if
(entitiesToDrawLayers.get(iLayer).containsKey(idEntity))
{
theEntity=entitiesToDrawLayers.get(iLayer).get(idEntity);
entitiesToDrawLayers.get(iLayer).remove(idEntity);
entitiesToDrawLayers.get(newLayer).put(idEntity, theEntity);
break;
}
}
}
/**
* Change the layer of the
entity, knowing the original
layer
* @param idEntity the id of
the entity, returned at
the addition
* @param layer the current
layer of the entity
* @param newLayer the new
layer where to push the
entity
*/
public void changeLayerOf(long
idEntity,int layer,int
newLayer)
{
// If the
entity exists, remove it then
add it back in the right layer
if
(entitiesToDrawLayers.get(layer).containsKey(idEntity))
{
Drawable2DEntity
theEntity=entitiesToDrawLayers.get(layer).get(idEntity);
entitiesToDrawLayers.get(layer).remove(idEntity);
entitiesToDrawLayers.get(newLayer).put(idEntity, theEntity);
}
//
Otherwise do nothing
}
On rajoute aussi quelques méthodes pour effacer les
entités:
/**
* Find the layer of the entity
and then remove it.
* Shouldn't be use (slow), so
please keep the layer of
your entity
* @param idEntity the id of
the entity, return at the
addition
*/
public void removeEntity(long idEntity)
{
// First
find the entity
for (int
iLayer=0;iLayer<40;iLayer++)
{
if
(entitiesToDrawLayers.get(iLayer).containsKey(idEntity))
{
entitiesToDrawLayers.get(iLayer).remove(idEntity);
break;
}
}
}
/**
* Remove the entity from its
layer
* @param idEntity the id of
the entity, returned at
the addition
* @param layer the current
layer of the entity
*/
public void removeEntity(long
idEntity,int layer)
{
if
(entitiesToDrawLayers.get(layer).containsKey(idEntity))
{
entitiesToDrawLayers.get(layer).remove(idEntity);
}
}
/**
* Remove all entities.
*/
public void washAll()
{
for (int
iLayer=0;iLayer<40;iLayer++)
{
entitiesToDrawLayers.get(iLayer).clear();
}
}
Ces méthodes doivent être utilisées
avec parcimonie. Le bon moyen d'enlever une
entité consiste à la dévalider. Elle
ne sera pas dessinée alors, et pourra être
réutilisée plus tard (utile pour les niveaux
consécutifs où les entités se
retrouvent).
Finalement, la méthode importante, celle qui dessine tout.
Elle dessine d'abord
les couches inférieures, et sera utilisées, sans
doute, aussi pour les effets
spéciaux et les particules, plus tard:
/**
* Paint the different
elements, in the right order of
layers, using the given Graphics
*
*/
public void paint()
{
Drawable2DEntity currentEntity;
// In
order, paint all the entities!
for (int
iLayer=0;iLayer<40;iLayer++)
{
HashMap
currentLayer=entitiesToDrawLayers.get(iLayer);
if (!currentLayer.isEmpty())
{
Collection entitiesInLayer=currentLayer.values();
Iterator iEntities = entitiesInLayer.iterator();
while (iEntities.hasNext())
{
currentEntity=(Drawable2DEntity
)(iEntities.next());
if (currentEntity.isValide())
currentEntity.drawMe((Graphics2D)
ourGraphics);
}
}
// Here should be
painted the FX and particles for each level!
}
Commentaires