Réalisation d'un moteur 3D en C++, partie VII : la console

Laurent Gomila

Nous allons dans cette nouvelle partie nous éloigner un peu du coeur du moteur. Loin d'être une étape primordiale, la conception d'une console est toutefois souvent dans les préoccupations des codeurs, et peut se révèler bien pratique pour la suite du développement. Nous en profiterons également pour étudier quelques bons morceaux de C++.

- Titre : Réalisation d'un moteur 3D en C++, partie VII : la console

- Auteur : Laurent Gomila

- Parution : 8 septembre 2005

- Mise à jour : 14 septembre 2005

- Licence : Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright ® 2005 Laurent Gomila. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.

Sommaire

- 1. Introduction

- 2. Le coeur de la console

- 3. Encapsulation des fonctions

- 3-1. Les foncteurs

- 3-2. Implémentation des foncteurs "fourre-tout"

- 3-3. Fonctions de « binding »

- 4. Personnalisation de la console

- 5. Conclusion

- 6. Téléchargements

1 - Introduction

La conception d'une console est aujourd'hui presque un classique. Cependant la façon de la coder n'a elle pas beaucoup évolué. Il s'agit la plupart de temps d'associer des commandes (sous forme de chaînes) à des fonctions de type prédéfini. Au choix, on peut trouver : des fonctions prenant un void*, des fonctions prenant un tableau de chaînes, des fonctions prenant une chaîne et un void*, ... Tout ça pour dire qu'on ne sait pas comment passer correctement n'importe quel type de paramètre. Et c'est là le principal problème des consoles : elles sont incapables de s'adapter aux fonctions qu'on voudrait leur faire appeler. Au lieu de cela, c'est l'utilisateur qui doit adapter ses fonctions afin qu'elles soient compatibles avec le type attendu par la console. Vous l'aurez donc deviné, nous ne nous arrêterons pas là et essayerons d'avoir une gestion la plus transparente possible pour l'utilisateur. En gros, nous allons faire une console qui accepte de manière homogène à peu près tout et n'importe quoi à appeler.

Un autre aspect important dans le développement d'une console est la séparation des tâches. On serait vite tenté de créer une classe CConsole effectuant magiquement tout le sale boulot, mais ce serait une erreur. La gestion d'une console fait intervenir plusieurs tâches distinctes, qu'il vaudra mieux séparer pour une meilleure maintenance et une meilleure flexibilité. En outre, nous allons développer 3 "modules" bien séparés mais intéragissant entre eux : le coeur de la console (traitement de la saisie et des commandes), le mécanisme d'encapsulation des fonctions, et pour finir, l'aspect visuel.

2 - Le coeur de la console

Une console n'est en soi pas très difficile à implémenter. Comme expliqué en intro, il ne s'agit que d'un mécanisme d'association entre des identifiants (les noms des commandes) et des fonctions. Lorsqu'on saisit une certaine commande, hop on appelle la fonction qui lui est associée.

La première chose à faire est donc de fournir à notre console une table associant des commandes (ie. des chaînes) à des fonctions (std::map est toujours parfait pour ça), ainsi qu'une fonction d'ajout de commande.

Une console a également besoin de savoir ce qui est saisi au clavier. L'erreur serait d'intégrer à notre console directement la gestion du clavier, et ceci pour deux raisons : la saisie est typiquement un aspect lié à l'OS et donc non-portable, et de plus la console ne sera certainement pas la seule à en avoir besoin. Gardez toujours en tête qu'une bonne séparation des tâches est primordiale. Pour en revenir à notre console, nous allons donc simplement lui fournir un moyen de récupérer la saisie utilisateur, peut importe d'où elle vient.

Enfin, il faudra également penser à une pitite fonction pour activer ou désactiver notre console.

class YES_EXPORT CConsole : public CSingleton<CConsole>
{
friend class CSingleton<CConsole>;

public :

// Enregistre une nouvelle commande
void RegisterCommand(const std::string& Name, const Console::CFunctor& Function);

// Envoie un nouveau caractère à la console
void SendChar(char Character);

// Active ou désactive la console
void Enable(bool Enabled);

private :

// Traite la ligne courante et appelle la fonction correspondante
void ProcessCurrent();

// Types
typedef std::map<std::string, Console::CFunctor> TCommandTable;

// Données membres
TCommandTable m_Commands; ///< Table des commandes enregistrées
std::string   m_Current;  ///< Ligne courante
bool          m_Enabled;  ///< Indique si la console est active ou non
};

Pour l'instant nous ne savons pas encore comment nous allons stocker nos fonctions. Nous savons juste que ce sera dans la classe CFunctor, et que celle-ci sera capable de relayer correctement l'appel ainsi que les paramètres via son opérateur ().

La fonction d'enregistrement de commande se passe de commentaire :

void CConsole::RegisterCommand(const std::string& Name, const Console::CFunctor& Function)
{
m_Commands[Name] = Function;
}

Le traitement des caractères est lui aussi plutôt simple : on ajoute le caractère reçu à la chaîne courante. Il faut juste gérer 2 caractères spéciaux : le saut de ligne, qui marque la validation de la commande, et le retour arrière qui efface le dernier caractère.

void CConsole::SendChar(char Character)
{
// Si la console n'est pas active on ne traite pas le caractère
if (!m_Enabled)
return;

// Traitement du caractère
switch (Character)
{
// Saut de ligne : on traite la commande et on efface la ligne
case '\n' :
case '\r' :
if (!m_Current.empty())
{
ProcessCurrent();
m_Current.clear();
}
break;

// Backspace : on efface le dernier caractère
case '\b' :
if (!m_Current.empty())
m_Current.erase(m_Current.size() - 1);
break;

// Tout le reste : on ajoute le caractère à la ligne courante
default :
m_Current += Character;
break;
}
}

La fonction la plus importante est sans doute celle-ci : elle s'occupe de "parser" la ligne saisie, pour y extraire la commande et les paramètres. Une fois tout ceci récupéré, on peut appeler la fonction correspondant à la commande.

void CConsole::ProcessCurrent()
{
// On récupère la commande
std::string Command;
std::istringstream iss(m_Current);
iss >> Command;

// On recherche la commande dans la table des commandes
TCommandTable::iterator It = m_Commands.find(Command);

// Si elle s'y trouve on appelle la fonction correspondante, sinon on génère une erreur
if (It != m_Commands.end())
{
// Récupération des paramètres
std::string Params;
std::getline(iss, Params);

// Appel du foncteur correspondant à la commande
It->second(Params);
}
else
{
// Erreur, commande inconnue...
}
}

Il est important de remarquer que les paramètres sont toujours passés sous forme de chaînes de caractères. Ceci pour une raison bien simple : nous ne pouvons pas savoir quel est le prototype de la fonction sous-jacente stockée dans notre CFunctor. Elle peut très bien prendre en paramètre un entier, deux floats, rien du tout, ou n'importe quoi d'autre. Mais à ce niveau cela ne nous regarde pas encore ; souvenez-vous que les tâches sont clairement séparées et que celle-ci n'est pas du ressort de CConsole. Le mécanisme de correspondance et de conversion sera abordé dans le prochain chapitre, c'est-à-dire... maintenant.

3 - Encapsulation des fonctions

Quittons à présent le coeur de notre console, qui fonctionne maintenant parfaitement, et penchons nous sur la partie la plus importante de ce tutoriel : le mécanisme d'encapsulation des fonctions. Si nous avions implémenté notre console comme 95% des consoles, alors nos CFunctor seraient seulement des pointeurs vers fonction de type prédéfini, et tout serait terminé. Mais nous avons à notre disposition un langage puissant, l'envie d'apprendre des techniques sympas, et un peu de temps devant nous. Et, surtout, nous voulons fournir à l'utilisateur (et à nous même) un fonctionnement qui soit le plus sympathique possible ! Ainsi donc nous allons mettre sur pied un mécanisme permettant de faire non pas ceci :

struct Application
{
// Additionne deux nombres et affiche le résultat dans la console
static void Add(const string& Args)
{
// Extrait les paramètres dans des variables du bon type
int x, y;
istringstream iss(Args);
if (!(iss >> x >> y))
throw CBadConversion("Pas cool...");

// Effectue le calcul
int z = x + y;

// Convertit le résultat en chaîne et le renvoie à la console
ostringstream oss;
oss << z;
Console.Print(oss.str());
}
};

Console.RegisterFunction("add", &Application::Add);

... mais ceci :

struct Application
{
// Additionne deux nombres et affiche le résultat dans la console
static int Add(int x, int y)
{
return x + y;
}
};

Console.RegisterFunction("add", Bind(&Application::Add));

Ainsi c'est au mécanisme de la console qu'est relégué le boulot de conversion des paramètres, de vérification des erreurs et d'affichage du résultat ; tout ceci est invisible pour l'utilisateur ce qui est finalement le plus important.

3-1 - Les foncteurs

Avant de rentrer dans le détail du code et ses explications, il est important de s'arrêter sur les foncteurs. Il s'agit en effet d'un concept important du C++, souvent peu connu lorsqu'on débute. Mais qu'est-ce qu'on foncteur exactement ? A quoi ça ressemble ? Et à quoi cela peut-il bien servir ?

-> Ce que c'est
Un foncteur est tout simplement "quelque chose" qui peut servir de fonction, c'est-à-dire auquel on peut appliquer l'opérateur () d'appel de fonction. C'est bien sûr le cas pour les pointeurs de fonctions "classiques", mais aussi, et c'est là que c'est intéressant, pour les instances de classes surchargeant l'opérateur (). On appelle de telles instances des "objets-fonction". Cependant, on commet habituellement l'abus de langage de parler de foncteur pour désigner uniquement les objets-fonction. On pourra également noter que les foncteurs possèdent typiquement une sémantique de valeur (ce sont des objets copiables).
Les objets-fonction se révèlent bien plus puissants que les pointeurs de fonction classiques : autant au niveau performance (possibilité de tout inliner) que de la flexibilité (l'objet-fonction étant une classe, on peut lui passer des paramètres via un constructeur par exemple).

-> A quoi ça ressemble
Comme je vous l'ai dit, un foncteur peut être un pointeur sur fonction classique, mais ce qui nous intéresse ici sont les objets-fonction. Un tel objet est habituellement une structure / classe, possédant donc un opérateur (), éventuellement un constructeur prenant des paramètres, ainsi que des données membres pour stocker ceux-ci. Le tout en un seul bloc, afin de tirer partie de l'inlining. La signature de l'opérateur () peut varier, selon l'utilisation que l'on aura du foncteur. Par exemple :

struct Functor
{
Functor (int Param) : m_Param(Param) {}

int operator ()(int x1, int x2) const
{
return x1 + x2 + m_Param;
}

private :

int m_Param;
};

Functor F(10);
int z = F(5, 6);

-> A quoi ça sert
Les foncteurs et surtout les objets-fonction sont très utilisés, notamment dans la STL. On peut les utiliser pour spécifier un critère de tri, pour appliquer une action à tous les éléments d'un conteneur, ou encore filtrer certaines valeurs ne répondant pas à un critère donné, par exemple.
Voici un petit exemple de leur utilisation avec la STL :

struct TriDecroissant
{
bool operator ()(int x1, int x2) const
{
return x1 > x2;
}
};

struct Plus
{
Plus(int Inc) : m_Inc(Inc) {}

int operator ()(int x) const
{
return x + m_Inc;
}

private :

int m_Inc;
};

std::vector<int> v;

// Ajoute 5 à tous les éléments
std::transform(v.begin(), v.end(), v.begin(), Plus(5));

// Trie les éléments par ordre décroissant
std::sort(v.begin(), v.end(), TriDecroissant());

3-2 - Implémentation des foncteurs "fourre-tout"

Ici, nous allons nous servir de foncteurs (d'objets-fonctions pour être précis) afin d'encapsuler de manière homogène tout type de fonction ou de foncteur. Plus précisément, nous allons construire une classe CFunctor qui sera l'interface entre notre console et "tout et n'importe quoi". Le fait de pouvoir fourrer n'importe quel type de fonction / foncteur dans une seule classe apportera sans conteste une très grande flexibilité, mais afin d'y parvenir il faudra déployer un peu plus de neurones que d'habitude, et surtout du C++ plein de templates comme on les aime.

Commençons par la définition de cette fameuse classe CFunctor :

class YES_EXPORT CFunctor
{
public :

// Construit le foncteur à partir d'une fonction
CFunctor(IFunction* Func = NULL) : m_Function(Func) {}

// Effectue l'appel à la fonction
std::string operator ()(const std::string& Params = "") const
{
if (!m_Function)
throw CException("Tentative d'effectuer un appel à une fonction nulle via un foncteur");

return m_Function->Execute(Params);
}

private :

// Données membres
CSmartPtr<IFunction> m_Function; ///< Pointeur sur l'objet stockant la fonction
};

Rien de bien étrange pour un foncteur : un constructeur prenant un paramètre, une donnée membre pour le stocker, et un opérateur () pour faire le boulot. En fait, cette classe n'aura pour but que de relayer l'appel de fonction à une certaine classe abstraite IFunction. Ce n'est donc pas à ce niveau qu'interviennent les manipulations magiques ; regardons alors du côté de IFunction :

class IFunction
{
public :

// Destructeur virtuel
virtual ~IFunction() {}

// Effectue l'appel de fonction
virtual std::string Execute(const std::string& Params) = 0;
};

Il s'agit d'une classe abstraite ne définissant qu'une fonction virtuelle : celle qui va relayer (une fois de plus -- heureusement qu'on ne doit pas s'occuper des performances) l'appel de fonction. En fait, elle représente une "fonction abstraite", ce sont ses classes dérivées qui vont effectivement stocker nos pointeurs de fonctions / foncteurs. Il y aura en l'occurence une classe pour chaque "type" de fonction, et comme vous vous en doutez il y en aura un paquet (fonctions membres, libres, const, sans paramètre, à 1 paramètre, ...).

Voici par exemple la classe dérivée qui va s'occuper de stocker et executer les fonctions libres à 1 paramètre :

template <typename Ret, typename Arg1>
class CFunction1 : public IFunction
{
private :

virtual std::string Execute(const std::string& Params)
{
typename Base<Arg1>::Type a1;
CStringExtractor(Params.c_str())(a1).ThrowIfEOF();

return CallFun<Ret>::Do(m_Func, a1);
}

typedef Ret (*TFuncType)(Arg1);
TFuncType m_Func;

public :

CFunction1(TFuncType Func) : m_Func(Func) {}
};

Et la classe dérivée qui va elle s'occuper des fonctions membres (sans paramètre, par exemple) :

template <typename ParamType, typename FuncType, typename Ret>
class CFunctionMem0 : public IFunction
{
private :

virtual std::string Execute(const std::string& Params)
{
CStringExtractor(Params.c_str()).ThrowIfEOF();

return CallMemFun<Ret, ParamType>::Do(m_Obj, m_Func);
}

FuncType  m_Func;
ParamType m_Obj;

public :

CFunctionMem0(FuncType Func, ParamType Obj) : m_Func(Func), m_Obj(Obj) {}
};

Le contenu des fonctions Execute peut vous sembler bizarre, mais pas de panique nous allons l'étudier en détail sans plus tarder.

La déclaration des paramètres à extraire ne se fait pas par un simple "Arg1 a1", mais via un template nommé Base. Pourquoi cette complication ? Il s'agit en fait d'une astuce permettant de gérer les situations où Arg1 serait un type référence ou constant (ou les deux) ; en effet dans ce cas il sera soit impossible de déclarer notre variable, soit de s'en servir. Le template Base permet de régler ce problème en "extrayant" le type de base correspondant à un type donné, c'est-à-dire sans les const et/ou les références.

template <typename T> struct Base {typedef T Type;};
template <typename T> struct Base<T&> {typedef T Type;};
template <typename T> struct Base<const T> {typedef T Type;};
template <typename T> struct Base<const T&> {typedef T Type;};

// Base<int>::Type équivaut à int
// Base<const int&>::Type équivaut également à int
// etc...

L'extraction des paramètres (conversion depuis string vers le bon type) passe par une classe CStringExtractor. Il s'agit simplement d'une classe utilitaire (elle sera bien utile ailleurs également) qui extrait des variables à partir d'une chaîne, le tout avec une syntaxe simplifiée et en gérant les différentes erreurs d'extraction. Ce sont notamment ces erreurs que va récupérer et afficher la console, dans le cas où une commande aurait mal été saisie.

L'appel de fonction et le renvoi du résultat s'effectue finalement via un autre template, CallFun ou CallMemFun selon le type de la fonction. Encore une fois, à quoi cela sert-il ? Simplement à gérer le cas des fonctions ne renvoyant rien (void), sans avoir à spécialiser la totalité des classes foncteurs. Ainsi notre template ressemblera à ceci :

template <typename Ret>
struct CallFun
{
template <typename FuncType>
static std::string Do(FuncType Func)
{
return CStringBuilder(Func());
}

// ...
};

/// Spécialisation pour les fonctions membres ne renvoyant rien
template <>
struct CallFun<void>
{
template <typename FuncType>
static std::string Do(FuncType Func)
{
Func();
return "";
}

// ...
};

La conversion de la valeur de retour en string fait lui appel à une autre classe utilitaire : CStringBuilder. C'est en fait la copine de CStringExtractor, qui effectue le boulot inverse. Ces deux classes ne sont d'ailleurs que des petits wrappers pour les stringstream, avec une syntaxe plus légère et une gestion des erreurs.

3-3 - Fonctions de « binding »

Il nous faut maintenant un moyen de construire facilement des instances de ces structures. C'est là qu'est toute la beauté du C++ : grace à la généricité, à la détection automatique et à la surcharge de fonction, nous allons pouvoir le faire très simplement, et surtout pour n'importe quel type de fonction. Contrairement aux solutions classiques à base de "simple" pointeur sur fonction, nous allons pouvoir manipuler absolument tout et n'importe via une seule fonction.

Le principe est simple : nous allons mettre sur pied une fonction Bind prenant en paramètre ce qu'il faut (foncteur, fonction, ou fonction + instance) et renvoyant un CFunctor. En fait nous n'allons pas écrire une seule fonction, mais plusieurs : il faudra une surcharge pour chaque cas à distinguer.

Commençons par le cas de base : une fonction libre, ne prenant aucun paramètre.

template <typename Ret>
inline CFunctor Bind(Ret (*Func)())
{
return new CFunction0<Ret>(Func);
}

Le paramètre template est le type de retour de la fonction, étant donné que seul lui peut varier.

Un peu plus compliqué : une fonction libre, mais prenant cette fois un paramètre.

template <typename Ret, typename Arg1>
inline CFunctor Bind(Ret (*Func)(Arg1))
{
return new CFunction1<Ret, Arg1>(Func);
}

Et finalement, la même chose avec 2 paramètres :

template <typename Ret, typename Arg1, typename Arg2>
inline CFunctor Bind(Ret (*Func)(Arg1, Arg2))
{
return new CFunction2<Ret, Arg1, Arg2>(Func);
}

Attention maintenant, autre difficulté : les fonctions membres, sans paramètre pour débuter.

template <typename Class, typename ParamType, typename Ret>
inline CFunctor Bind(Ret (Class::*Func)(), ParamType& Obj)
{
return new CFunctionMem0<ParamType&, Ret (Class::*)(), Ret>(Func, Obj);
}

Ici nous prenons en paramètre non seulement la fonction à appeler, mais aussi l'instance de la classe qu'il faudra utiliser pour l'appel. N'oubliez pas de prendre le paramètre par référence, afin de ne pas effectuer de copie qui créerait d'éventuels bugs sournois. Vous remarquerez également que notre fonction traite deux types de classe : Class et ParamType ; alors qu'en théorie, ces deux types devraient être identiques. En pratique ce sera presque toujours le cas sauf dans le cas de l'héritage, par exemple pour une instance qui voudrait appeler une fonction d'une de ses classes de base. Voici une illustration de ce problème :

struct Base
{
void F() {}
};

struct Derivee : Base
{
};

Derivee d;
Bind(&Derivee::F, d); // erreur
Bind(&Base::F,    d); // erreur
Bind(&Base::F,    static_cast<Base&>(d)); // OK mais crade

Pour les fonctions à 1 ou 2 paramètres, c'est exactement la même chose que plus haut.

Seconde difficulté : les fonctions membres constantes. Et oui, le const d'une fonction fait partie de son type, il faut donc créer les surcharges correspondantes. Qui plus est, notre instance devra elle aussi être const pour pouvoir appeler la fonction, il faudra donc la prendre par référence constante.

template <typename Class, typename ParamType, typename Ret>
inline CFunctor Bind(Ret (Class::*Func)() const, const ParamType& Obj)
{
return new CFunctionMem0<const ParamType&, Ret (Class::*)() const, Ret>(Func, Obj);
}

Enfin dernière difficulté : les objets-fonction. Un tel objet étant presque toujours un temporaire (car construit à la volée -- cf. les divers exemples), il ne faut cette fois plus en stocker une référence comme nous le faisions, mais une copie. Diantre, nous venons de faire en sorte de stocker des références justement pour éviter les copies ! Et bien hop, il n'y a plus qu'à écrire de nouvelles surcharges :

template <typename Class, typename Ret>
inline CFunctor BindCopy(Ret (Class::*Func)(), Class Obj)
{
return new CFunctionMem0<Class, Ret (Class::*)(), Ret>(Func, Obj);
}

//... versions avec paramètres, non-const et const

Nous sommes ici obligés de changer le nom de la fonction, car des surcharges identiques existent déjà. Pas de souci cependant, l'utilisateur n'aura en théorie pas besoin de les appeler directement. C'est notre surcharge gérant les objets-fonction qui va le faire :

template <typename T>
inline CFunctor Bind(T Obj)
{
return BindCopy(&T::operator (), Obj);
}

Voilà, arrivé ici nous gérons a priori tous les cas possibles et imaginables pouvant se présenter. Un peu long à écrire (même si ce n'est que du copié collé), mais une fois terminé nous n'aurons plus besoin d'y toucher et nous aurons à notre disposition une flexibilité plus qu'appréciable. Voici ce que l'on peut à présent écrire :

// Une classe
class MaClasse
{
// Une fonction statique à 2 paramètres -- équivalent à une fonction libre
static std::string StaticFun(int x1, int x2);

// Une fonction membre sans paramètre
int MemFun();

// Une fonction membre const à 1 paramètre
void ConstMemFun(double d) const;
};

// Une fonction libre à 1 paramètre
void FreeFun(const MaClasse& x);

// Un foncteur
struct Functor
{
bool operator ()(const MaClasse& c1, const MaClasse& C2) const
{
// ...
}
};

// Instance de MaClasse
MaClasse C;

// On peut tout binder :
Bind(&MaClasse::StaticFun);
Bind(&MaClasse::MemFun, C);
Bind(&MaClasse::ConstMemFun, C);
Bind(&FreeFun);
Bind(Functor());

// Et même des foncteurs / fonctions du standard :
Bind(&std::min<int>);
Bind(std::plus<int>());
Bind(std::multiplies<float>());

// ...On peut aussi s'amuser bête avec nos foncteurs :
Bind(Bind(Bind(Bind(Bind(&FreeFun)))));

4 - Personnalisation de la console

Arrivé à ce point, nous avons le coeur de notre console ainsi qu'un mécanisme pour lui envoyer toute sorte de fonction à executer. Mais qu'en est-il de l'affichage ? A quel moment va-t-on choisir quoi faire lors d'une erreur, ou lors de l'execution d'une commande ? La réponse est simple : ça dépend. A ce niveau on ne peut pas décider à la place de l'utilisateur, car le style visuel de la console et son comportement ne sont pas uniques ; il doit pouvoir les spécifier. Bien sûr on ne va pas laisser notre pauvre utilisateur dans l'embarras, et nous allons lui offrir de quoi faire tout ça simplement.

En C++, le meilleur moyen de spécifier un comportement est de dériver une classe abstraite. Une fois de plus nous n'allons donc pas nous en priver, et construire la classe de base qui va bien pour ce que l'on veut faire : la classe ILook. Voyons donc ce que nous allons bien pouvoir fourrer dedans afin d'offrir à l'utilisateur un grand choix de personnalisation, tout en gardant quelque chose de simple et qui ne peut pas altérer le comportement interne de notre console.

class ILook
{
public :

// Destructeur virtuel
virtual ~ILook() {}

// Fonction appelée lors de la mise à jour de la console
virtual void Update() = 0;

// Fonction appelée lors de l'affichage de la console
virtual void Draw() const = 0;

// Fonction appelée lors de l'activation / désactivation de la console
virtual void Show(bool Visible) = 0;

// Fonction appelée après l'appel à une commande
virtual void CommandCalled(const std::string& Result) = 0;

// Fonction appelée à chaque changement de la ligne courante
virtual void TextChanged(const std::string& NewText) = 0;

// Fonction appelée en cas d'erreur
virtual void Error(const std::string& Message) = 0;
};

Comme vous le voyez, les fonctions que nous avons définies pour l'interface de notre classe restent élémentaires, et l'utilisateur pourra sans trop d'efforts définir sa console perso. Pour vous en convaincre, un exemple d'implémentation est livré dans le code source accompagnant ce tutoriel.

Il ne reste plus maintenant qu'à faire intéragir cette classe avec le coeur de notre console. La première chose à faire est d'y stocker un pointeur vers un ILook, qui correspondra au look perso défini par l'utilisateur, ainsi que de fournir de quoi changer celui-ci.

class YES_EXPORT CConsole : public CSingleton<CConsole>
{
public :

// Change l'apparence de la console
void ChangeLook(Console::ILook* NewLook);

// Met à jour la console
void Update();

// Affiche la console
void Draw() const;

// ...

private :

// Données membres
CSmartPtr<Console::ILook> m_Look; ///< Pointeur sur la classe gérant l'apparence de la console
// ....
};

... puis de placer judicieusement les appels aux fonctions virtuelles de ILook :

void CConsole::ChangeLook(Console::ILook* NewLook)
{
Assert(NewLook != NULL);

m_Look = NewLook;
}

void CConsole::SendChar(char Character)
{
// ...

// On notifie au "look" que le texte courant vient de changer
m_Look->TextChanged(m_Current);
}

void CConsole::Update()
{
m_Look->Update();
}

void CConsole::Draw() const
{
m_Look->Draw();
}

void CConsole::Enable(bool Enabled)
{
m_Enabled = Enabled;
m_Look->Show(Enabled);
}

void CConsole::ProcessCurrent()
{
// ...

if (It != m_Commands.end())
{
// ...

// Appel du foncteur correspondant à la commande -
// s'il y a une erreur on la rattrape et on l'affiche dans la console
try
{
m_Look->CommandCalled(It->second(Params));
}
catch (std::exception& E)
{
m_Look->Error(E.what());
}
}
else
{
m_Look->Error("Commande \"" + Command + "\" inconnue");
}
}

5 - Conclusion

Bien que loin d'être primordiale au sein du moteur, la conception d'une console nous a permis d'aborder certains points intéressants. Et puis, elle n'est certes pas essentielle mais elle reste bien pratique : maintenant que nous avons une console, nous n'allons pas nous en priver pour la suite du développement (affichage de stats, d'infos de debugging, interactions avec l'utilisateur, ...).

Nous avons vu qu'il était important de correctement séparer les tâches, notamment l'aspect fonctionnel de l'aspect visuel.

Au travers de cette console, nous avons également vu que le C++ était un langage puissant, et qu'il pouvait nous permettre de coder des choses étonnantes si l'on s'en donnait les moyens. Le code produit peut paraître lourd et compliqué, mais encore une fois il faut regarder non pas les moyens mais les résultats. Et de ce côté il n'y a pas photo : l'utilisateur dispose d'un mécanisme flexible et fiable ainsi que d'un typage fort, tout cela via une interface simplifiée à l'extrême.

Enfin, il ne faut pas oublier qu'il ne s'agissait ici que d'un détour dans notre moteur, et que beaucoup de concepts primordiaux sont encore à voir : shaders, graphes de scène, gestion et optimisation du rendu, partitionnement spatial, ... De quoi occuper encore pour longtemps nos journées !

6 - Téléchargements

Les sources fournies dans les précédents tutoriels sont entièrement intégrées à celles-ci, ainsi qu'une petite démo reprenant tous les concepts vus, de manière à ce que vous ayiez toujours un package complet et à jour.

Les codes sources livrés tout au long de cette série d'articles ont été réalisés sous Visual Studio.NET 2003 ; des test ont également été effectués avec succès sur DevC++ 4.9.9.1. Quant aux autres compilateurs vous ne devriez pas rencontrer de problème (pour peu qu'ils soient suffisamment récents), le code respectant autant que possible le standard.

Télécharger les sources de cet article (zip, 2.43 Mo)

Screenshot de l'application exemple

Pour compiler, vous aurez également besoin des bibliothèques externes (zip)

Une version PDF de cet article est disponible : Télécharger (376 Ko)

Si vous avez des suggestions, remarques, critiques, si vous avez remarqué une erreur, ou bien si vous souhaitez des informations complémentaires, n'hésitez pas à me contacter !