www.codeworx.org/c++ tuts /Kongos rand() Tutorials, 7: Programmierung einer Konsole

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