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)
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 !