Tutorial 7: Programmierung einer Konsole
Der Nutzen einer Konsole, liegt nicht nur bei dem Anwender, auch der Programmierer
kann somit mitten im Programm Variablen verändern, Levels laden oder einfach
Nebel der Level hinzufügen. Wenn einer nicht weiß was eine Konsole
sein soll, sollte er sich an Quake oder Half-life erinnern, bei denen man mit
Hilfe der Konsole Level laden konnte (speziell Q1) oder Tastaturkommandos einer
Taste zuweisen konnte (CS). Heute wollen wir aber selbst so eine Konsole erstellen.
Grundideen...
Wie schon gesagt nutzt die Konsole nicht nur dem Anwender. Sie ist einfach
ein nützliches Hilfsmittel, das einem das Leben erleichert. Hier jetzt
einmal die Ideen und Anwendungszwecke für den Programmierer und den Anwender.
...für den Programmierer
Für den Programmierer, der die Konsole selbst nützen möchte,
ist es wichtig, dass die Konsole sich leicht und ohne Probleme in ein Projekt
einfügen lässt. Weiters sollte es sehr einfach sein weitere Kommandos
hinzuzufügen. Das heißt mit ein paar wenigen Codezeilen, sollte er
in der Lage sein ein neues Kommando zu schreiben-
...für den Anwender
Der Anwender sollte mit der Konsole einfach Sachen tun können, die sonst
nur umständlich zu handhaben wären. Einsatzmöglichkeiten gibt
es genug: Levels laden, Bots hinzufügen, mit anderen Mitspielern kommunizieren,...
Dies alles sollte einfach sein und schnell zu handhaben sein. Weiters sollte
er einmal schon geschriebene Kommandos, die möglicherweise komplizierte
Parameter besitzen, nicht wieder eintippen müssen, sondern diese auswählen
können.
Konzept
So, unsere Konsole wurde jetzt so circa beschrieben. Hier jetzt eine Zusammenfassung
der nötigen Features, die die Konsole aufweisen sollte:
Darstellung einer Textur zur Verschönerung
Einfache Eingabe
schon einmal geschriebene Kommandos wiederverwenden
die Konsolen-History sollte durchforstbar sein
Kommando-Vervollständigung
Aufzeichnung der Eingaben und der History in eine Datei
einfache Registrierung von neuen Kommandos mit variablen Parametern
leichte Integration in ein begonnenes Projekt
Für die Implementierung verwenden wir zwei Klassen. Erstens die Hauptklasse
zConsole, die die Benutzereingaben verwaltet, Kommandos registriert und die
Konsole darstellt. Die zweite Klasse ist zConsoleCommand. Diese Klasse entspricht
kurz gesagt einem Kommando. Die Hauptklasser hat also ein Array dieser Klasse,
die alle Informationen besitzt, wie die Kommandos heißen und welche Parameter
sie besitzen.
Implementierung
Hier jetzt ein Listing des
gesamten Headers, den wir jetzt besprechen wollen. Es wird jetzt nicht besprochen
wie die Konsole dargestellt wird, sondern nur die Funktionsweise der Konsoleneingabe
und das Kommando-Handling.
Das Konsolen-Kommando
class zConsoleCommand
{
friend class zConsole;
private:
zConVars *mVars; // Werte für die Parameter
unsigned int miNumVars; // Anzahl Werte
zString mszCommandString; // zb. "fogcolor"
zString mszParamString; // zb. "III" für 3 ints (R,G,B)
zString mszHelpString; // zb. "<r> <g> <b>" wird zu "Usage: fogcolor <r> <g> <b>"
void (*mpFunc)(zConsoleCommand &command); // Funktion die das Commando ausführt
. . . . . . .
Hier einmal die Variablen eines Kommandos. mszCommandString speichert das Kommando,
dass der Anwender eingeben muss. Also zB. fogcolor um die Farbe des Nebels zu
verändern. Natürlich reicht das noch nicht um die Farbe zu verändern.
Normalerweise besteht eine Farbe aus drei Werten. In unserem Fall wäre
das also drei Werte für Rot, Grün und Blau im Bereich von 0-255. Darum
gibt es noch einen Parameter-String indem wir den Typ der Variablen speichern.
Wir benötigen also drei Integer und darum beinhaltet der mszParamString
folgendes: III. Insgesamt unterstützt ein Kommando 4 Typen von Variablen:
I -> Integer
B -> Bool
F -> Float
S -> String
Würden wir aber zum Beispiel ein Kommando schreiben, dass den Namen des
Spieler verändert, würde nur der Buchstabe S (für String) im
Parameter-String stehen. Alles bis jetzt verstanden? Na dann weiter...
Der Benutzer kann ja nicht wissen, wie viele Parameter ein Kommando besitzt,
er könnte es ja vergessen haben. Darum existiert noch ein Hilfe-String,
der bei falscher Eingabe von Parametern, die richtigen darstellt. Für das
fogcolor Beispiel würde <red> <green> <blue> in diesem
String stehen. Gibt der Benutzer jetzt nur fogcolor ohne Parameter ein, erscheint
folgende Hilfe:
error: 3 params expected, but 0 given!
Usage: fogcolor <red> <green> <blue>
Syntax: fogcolor <int> <int> <int>
Das sollte reichen, damit der Benutzer sich selbst helfen kann.
Wo speichern wir jetzt aber die Parameter ab? Hierfür bilden wir ein Array
aus dieser Struktur:
struct zConVars
{
float f;
bool b;
int i;
char *s;
zConVars() { f=0.0f; b=true; i=0; s=NULL; }
};
Die Größe dieser Array ist einfach die String-Länge des Parameter-Strings,
der ja nur Buchstaben besitzt für die Art von Variablen-Typ. Der Parameter-String
FFBIS würder also ein Array mit fünf Strukturen erstellen. Wobei in
den beiden Ersten floats gespeichert werden, im dritten ein Bool, im vierten
ein Integer und im fünften ein String.
Natürlich muss dieses Kommando ja auch etwas bewirken. Darum besitzt jedes
Kommando einen Zeiger auf eine Funktion, die bei Aufruf dieses Kommando ausgeführt
wird. Ihr wird dabei eine Referenz des eigenen Kommandos übergeben. Darum
muss jede Funktion eines Kommandos dieselbe Syntax besitzen:
void Console_FogColor (zConsoleCommand &command);
Dadurch, dass wir der Funktion die Kommando-Klasse übergeben, hat die
Funktion auch Zugriff auf die Variablen und kann dadurch diese auslesen.
Das wären jetzt alle Variablen, auf zum public Teil der Kommando-Klasse:
. . . . . . .
public:
// constructor und destructor
zConsoleCommand();
~zConsoleCommand();
zConsoleCommand(zString command, zString paramCodes, zString help);
// get
zString GetCommandString() const { return mszCommandString; }
zString GetParamString() const { return mszParamString; }
zString GetHelpString() const { return mszHelpString; }
int GetNumParams() const { return strlen(mszParamString); }
EConMsgParamID GetParamType(int i) const;
zConVars* GetVars() { return mVars; }
zConVars& GetVar(int i) { return mVars[i]; }
// set
void SetCommandString(zString str) { mszCommandString=str; }
void SetParamString(zString str) { mszParamString=str; }
void SetHelpString(zString str) { mszHelpString=str; }
void SetFunc(void (*func)(zConsoleCommand&)) { mpFunc = func; }
// misc
bool HasValidParams();
void CreateVarsFromStrings(zArray<zString>* params);
};
Die meisten Funktionen sollten klar sein. Zu ein paar jedoch eine Bemerkung:
EConMsgParamID GetParamType(int i) const;
Hier überprüfen wir einfach den i-ten Buchstaben des Parameter-Strings.
Je nach Buchstabe (I,S,B oder F) wissen wir, was für ein Typ von Variable
dieser ist. EConMsgParamID ist dabei nur ein Enum:
enum EConMsgParamID {
CON_PARAM_UNKNOWN, // darf nie sein
CON_PARAM_FLOAT, // 'F'
CON_PARAM_INT, // 'I'
CON_PARAM_STRING, // 'S'
CON_PARAM_BOOL // 'B'
};
bool HasValidParams();
Diese überprüft, ob jeder Buchstabe im Parameter-String entweder I,S,B
oder F ist. Sollte ein anderer dabei sein, gibt diese Funktion false zurück.
void CreateVarsFromStrings(zArray<zString>* params);
Bei dieser Funktion, bekommen wir die eingegebenen Parameter des Benutzers
als Strings. Je nach Typ von Variable (auslesen aus dem Parameter-String), konvertieren
wir diese mit einer Funktion. C++ selbst hilft uns hier mit den Funktionen atoi()
und atof() für die String zu Integer bzw. Float Konvertierung. Für
eine Bool-Variable gibt es eine eigene Funktion, die überprüft ob
entweder false/true oder 0/1 im String steht. Und für einen String, brauchen
wir diesen einfach nur kopieren. Damit hätten wir aus den Strings die Variablen
erstellt.
Die Hauptklasse
Die Konsole-Definition ist zu lang um diese hier darzustellen. Darum gehen
wir nur die wichtigsten Dinge durch, die auch wirklich etwas mir der Verarbeitung
der Benutzereingaben und des Kommando-Handling zu tun haben. Wir teilen diesen
Abschnitt aber in drei Teile auf. Der zweite beschreibt den Ablauf der Benutzereingaben
und was damit passiert, der erste beschreibt das Registrieren eines Kommandos
und der dritte, wie es von den Benutzereingaben zum Aufruf der jeweiligen Funktion
kommt. Hier kommt es jetzt auch zu den ersten Codedarstellungen, da dies der
schwere Teil des ganzen ist.
Registieren von neuen Kommandos
Der Programmierer kann mit nur einem Funktionsaufruf ein neues Kommando registrieren.
Von dieser Funktion gibt es zwei Versionen:
void RegisterCommand(zString commandstr, zString paramcode, zString helpstr="",
void (*func)(zConsoleCommand&)=NULL);
void RegisterCommand(zString totalstr,
void (*func)(zConsoleCommand&)=NULL);
Der Unterschied ist, dass in der zweiten Funktion alle drei benötigten
Strings in einem zu finden sind. Hier ein Beispiel für den Aufruf dieser
Funktionen:
RegisterCommand("fogcolor", "III", "<red> <green> <blue>", Console_FogColor);
RegisterCommand("fogcolor, III, <red> <green> <blue>", Console_FogColor);
Ein Kommando muss aber keine Parameter und keinen Hilfe-String besitzen. Folgende
Beispiele, sind also genauso gültig:
RegisterCommand("fogcolor", "", "", Console_FogColor);
RegisterCommand("fogcolor", Console_FogColor);
Beginnen wir jetzt damit, dass der Programmierer eben den zweiten Typ von Funktin
aufruft:
RegisterCommand("fogcolor, III, <red> <green> <blue>", Console_FogColor);
Was jetzt passiert ist folgendes. Die Funktion versucht die durch die Beistriche
getrennten Strings zu zerlegen. Gelingt dies ruft sie den ersten Typ von Funktion
mit den getrennten Strings auf. Was aber bei der Stringzerlegung zu beachten
ist: Man muss die Beistriche und Leerzeichen am Anfang und Ende des Strings
entfernen. Weiters darf ein Kommando-String kein Leerzeichen besitzen. Das heißt
fog color wäre kein gültiges Kommando, da es ein Leerzeichen besitzt.
So, jetzt der Aufruf der ersten Funktion:
RegisterCommand("fogcolor", "III", "<red> <green> <blue>", Console_FogColor);
Diese Funktion erzeugt nur eine Klasse zConsoleCommand und übergibt dieser
die 4 Parameter. Daraufhin ruft es noch einen dritten Typ der Funktion RegisterCommand()
auf, die aber nicht öffentlich in der Klasse definiert wurde:
void RegisterCommand(zString commandstr,
zString paramcode, zString helpstr,
void (*func)(zConsoleCommand&))
{
zConsoleCommand command;
command.SetCommandString(commandstr);
command.SetParamString(paramcode);
command.SetHelpString(helpstr);
command.SetFunc(func);
RegisterCommand(command);
}
Diese dritte Funktion überprüft, ob die Paramter alle gültig
sind. Wenn ja, wird es in das Kommando-Array hinzugefügt:
void RegisterCommand(zConsoleCommand command)
{
if (!command.HasValidParams())
{
EM.WriteFormat("zConsole.RegisterCommand: %s hat invalide Params '%s'\n",
command.GetCommandString(), command.GetParamString());
return;
}
mpCommands.Add(command);
}
Um das Kommando in das Array hinzuzufügen, existiert ein Array-Template,
das nur die wichtigsten Funktionen zum hinzufügen und wieder löschen
implementiert hat. Mehr dazu siehe im Quellcode.
Das war auch schon alles um ein Kommando hinzuzufügen. Hier aber noch
ein Beispiel für eine Kommando-Funktion:
void Console_FogColor(zConsoleCommand &command)
{
int r = _clamp(command.GetVar(0).i, 0, 255);
int g = _clamp(command.GetVar(1).i, 0, 255);
int b = _clamp(command.GetVar(2).i, 0, 255);
DWORD fogcolor = (r<<16) + (g<<8) + b;
zString str;
str.Format("Fog Color: red: %d, green: %d, blue: %d", r, g, b);
Con.SendMessage(str);
pDev->SetRenderState(D3DRS_FOGCOLOR, fogcolor);
}
Da die Farbwerte zwischen 0 und 255 liegen müssen, prüfen wir, dass
keine höheren bzw. niederen Werte vorliegen. Da wir wissen, dass der Anwender
ja drei Integer Werte eingegeben hat, lesen wir auch nur den Integer Wert aus.
Dafür gibt es eben die Funktion von zConsoleCommand GetVar() die uns eine
Referenz auf eine Werte-Struktur vom Typ zConVars zurückliefert. Durch
die Funktion SendMessage() können wir der Konsole einen String übergeben,
den diese anzeigt. Dadurch kann man dem Benutzer Informationen übergeben,
die er sonst nicht erfahren könnte.
Um ein Kommando wieder zu löschen, muss der Programmierer nur den Kommando-String
der Konsole übergeben. Diese sucht das registrierte Kommando heraus und
löscht es:
bool UnRegisterCommand(zString commandstr)
{
// Alle Commands durchlaufen
for (int i=0; i<GetNumCommands(); i++)
{
// Command gefunden -> unregister
if (!strcmp(mpCommands[i].GetCommandString(),commandstr))
{
mpCommands.Remove(i);
return true;
}
}
// Kein Command gefunden, unregister failed
return false;
}
Beispiel für Aufruf:
UnRegisterCommand("fogcolor");
Ablauf der Benutzereingaben
Da wir ja irgendwie an die Benutzereingaben kommen müssen, überprüft
die Fensterprozedur WindowProc(), ob unsere geöffnet ist. Wenn dies der
Fall ist, übergibt sie bei jeder WM_KEYDOWN Nachricht den wParam der Konsole.
case WM_KEYDOWN:
if (wParam == VK_F1)
{
Con.ChangeState();
return 0;
}
if (wParam == VK_ESCAPE)
{
PostMessage(hWnd,WM_CLOSE,0,0);
return 0;
}
if (Con.IsActive())
Con.Input(wParam);
break;
Diese Funktion Input() überprüft jetzt mehrere möglichen Benutzereingaben,
die die Konsole verarbeitet:
- Enter: Hinzufügen des eingegebenen Kommandos in ein Array, um dieses
später wieder aufrufen zu können. Eingabestring überprüfen,
ob ein Kommando vorliegt (Dazu mehr im nächsten Teil). Eingabestring
wieder löschen.
- Rückschritt: Löschen des letzten eingegebenen Buchstaben.
- Leertaste: Leerzeichen hinzufügen.
- A-Z oder 0-9: Gewünschten Buchstaben hinzufügen.
- Tabulator: Versuch das Kommando selbst zu vervollständigen. Zum Beispiel
sollte der Benutzer nur fogc eingeben müssen, Tabulator drücken
und die Konsole sollte selbst das Kommando vervollständigen. Daraus folgt
das fogcolor in der Konsole steht sollte.
- Pfeil rauf: Hinauf-Scrollen durch eingegebene Kommando-Strings. Dadurch
erspart sich der Benutzer schon einmal eingegebene Kommandos noch einmal einzugeben.
- Pfeil runter: Hinunter-Scrollen durch eingegebene Kommando-Strings.
- Pfeil links: Hinauf-Scrollen durch die History der Konsole.
- Pfeil rechts: Hinunter-Scrollen durch die History der Konsole.
Der Ablauf von der Eingabe zum Aufruf der Kommando-Funktion
Wenn der Benutzer nun Enter drückt, übergeben wir den eingegebenen
String der Funktion ProcessMessage(). Diese versucht das Kommando herauszufinden,
die dafür nötigen Parameter zu erstellen und das Kommando auszuführen:
Als erstes zerlegen wir den String in den Kommando-String und den Parameter-String.
void ProcessMessage(zString message)
{
zString info;
message.TrimAll(); // Leerzeichen links und rechts vom String entfernen
message.Reduce(" "); // Hintereinander folgende Leerzeichen auf eins reduzieren, "A B" -> "A B"
int spacepos = message.Scan(' '); // 1. Leerzeichenposition im String suchen "fogcolor 100 100 100"
zString commandstr, paramstr;
if (spacepos == -1)
{ // Kein Space, d.h. keine Params
commandstr = message;
paramstr = "";
}
else
{ // Es gibt ein Leerzeichen -> es gibt Parameter -> teilen des Strings
commandstr = message.SubL(spacepos);
paramstr = message.SubR(message.Length()-spacepos-1);
}
. . . . . .
Ich habe zur Hilfe eine String Klasse geschrieben, die uns eine ganze Menge
an Arbeit erspart und noch einige zusätzliche Funktionen beinhaltet. Sehr
hilfreich!!!
Jetzt da wir den Kommando-String besitzen, überprüfen wir einfach,
ob diese Kommando registriert ist:
. . . . . .
for (int i=0; i < mpCommands.GetNum(); i++)
{
zConsoleCommand& command = mpCommands[i];
// check command
if (!strcmp(commandstr,command.GetCommandString()))
{
Wir haben das registrierte Kommando gefunden. Überprüfen ob es das
Kommando eine Funktion besitzt:
if (command.mpFunc == NULL)
{
AddLineToHistory(zString("This command has no execution function!"));
}
Überprüfen ob die Anzahl der Parameter übereinstimmt mit der
benötigten Anzahl. GetNumParamsInString() zählt dabei nur die Anzahl
der Leerzeichen und gibt diese Anzahl + Eins zurück. Sollte eine falsche
Anzahl vorliegen, Hilfe-Strings ausgeben:
// check params
if (GetNumParamsInString(paramstr) != command.GetNumParams()) {
// falsch Anzahl von Params
info.Format("error: %d params expected, but %d given!",command.GetNumParams(),
GetNumParamsInString(paramstr));
AddLineToHistory(info);
// help String?
if (command.GetHelpString().Length()) {
AddLineToHistory(zString("Usage: ") + command.GetCommandString() + zString(" ") +
command.GetHelpString());
// display syntax
zString syntaxstr = zString("Syntax: ") + command.GetCommandString() + zString(" ");
for (int e=0; e < command.GetNumParams(); e++) {
switch (command.GetParamType(e)) {
case CON_PARAM_FLOAT: syntaxstr += "<float> "; break;
case CON_PARAM_INT: syntaxstr += "<int> "; break;
case CON_PARAM_STRING: syntaxstr += "<string> "; break;
case CON_PARAM_BOOL: syntaxstr += "<bool> "; break;
default: syntaxstr += "<unknowntype> ";
}
}
syntaxstr.TrimAll();
AddLineToHistory(syntaxstr);
}
return;
}
Wenn wir hier landen, ist die Anzahl der Parameter korrekt. Hier versuchen wir
den Parameter-String in einzelne Strings zu zerlegen und diese einzelnen Strings
wiederum in die benötigten Variablentypen konvertieren. Danach können
wir die Kommando-Funktion aufrufen.
// num of params is correct
zArray<zString>* paramArray = ConstructParams(paramstr); // Parameter-String zerlegen
command.CreateVarsFromStrings(paramArray); // Variablen aus den Strings erzeugen
ProcessCommand(command); // Kommandofunktion aufrufen
delete paramArray;
return;
}
}
Sollten wir hier landen, kennen wir das eingegebene Kommando nicht:
info.Format("Unknown Command '%s'...",commandstr);
AddLineToHistory(info);
// ENDE ProcessMessage
Verbesserungen und Ideen
Natürlich ist diese Art von Konsole nicht ganz perfekt. Es gibt einige
Verbesserungen und der gesamte Code wurde noch nicht wirklich auf Fehler überprüft.
Jedoch funktioniert er bis jetzt bei mir ohne weitere Probleme. Was man verbessern
könnte ist die Eingabe des Benutzers. Bis jetzt kann diese nur Buchstaben
von A-Z, Zahlen von 0-9 und die Leertaste eingeben. Es wäre aber besser,
könnte er auch folgende Zeichen eintippen: ()[]/,.:;*+-#'"%}{\?. Weiters
ist das History- und das Kommando-Array statisch. Das heißt es wird schon
von Programmstart eine maximale Anzahl von Einträgen im Speicher belegt.
Besser wäre dies dynamisch zu machen und diese Länge variabel zu halten.
Eine weitere Verbesserungsmöglichkeit wäre, es anzubieten eine ganze
Datei von der Konsole verarbeiten zu lassen. Man übergibt ihr nur den Dateinamen
und die Konsole verarbeitet die Datei Zeile für Zeile. Und so weiter und
so fort....
Wenn einer noch Fragen hat, einfach eine E-mail an mich. Der Quellcode ist
dokumentiert, sollte also nicht so schwer zu verstehen sein. Freue mich über
jede Post in meiner Mehl-Box. *g*
Download der Source Codes
Copyright (c) by Kongo
Bei Fragen, Beschwerden, Wünschen schreib an kongo@codeworx.org
Tutorial vom 2002-03-28