Warsztat.GDCompo!ProjektyMediaArtykułyQ&AForumOferty pracyPobieranie

Opisz napotkaną sytuację, a redakcja niezwłocznie znajdzie rozwiązanie!

wyślij anuluj

Konsola a\la Quake

Uwaga! Tekst posiada 1 niepotwierdzonych zmian!

Tekst został importowany z Warsztatowych artykułów. Jego oryginalnym autorem jest novo. Jeżeli został importowany poprawnie, usuń ten szablon!

Wstęp

Przychodzi taki moment podczas pisania naszych wymarzonych gierek, ze ich debugowanie trwa dłużej niż samo pisanie kodu. Dobrze byłoby ten czas skrócić, pytanie tylko jak? Każda kolejna kompilacja trwa coraz dłużej a w końcu po paru godzinach męki okazuje sie, że nie zainicjowaliśmy jakiejś zmiennej, lub nadaliśmy jej złą wartość. W tym momencie przydaje sie dobry logger(polecam artykuł Loggery Temporala, pożyczyłem kilka rozwiązań z tego artykułu). Co nam daje logger? Daje nam możliwość m. in. wypisania wartości co ważniejszych zmiennych i sprawdzania jak sie one zmieniają w czasie działania gry. Można pójść dalej i dodać możliwość modyfikowania tych zmiennych oraz wywoływania funkcji natywnych. W tym momencie z pomocą przychodzi konsola. Wielu z was zapewne grało w któraś z części gry Quake, czy Half-Life. Tam po naciśnięciu ~(tyldy) pojawiała sie konsola w której mogliśmy podglądać i zmieniać wartości różnych zmiennych oraz wywoływać różne funkcje.

Dokładnie to do czego dążymy :)

Artykuł ten ma za zadanie pokazać, jak taka konsole napisać. Aby zrozumieć dołączony kod przydatna będzie znajomość C++, a w szczególności:

  • Funkcji wirtualnych.
  • Dziedziczenia.
  • Szablonów.
  • Inteligentnych wskaźników.

Jeżeli którekolwiek z powyższych pojęć jest dla ciebie obce, radziłbym je sobie powtórzyć przed przystąpieniem do dalszej części artykułu.

Ale co my właściwie chcemy napisać?

Pisząc konsole starałem sie aby dla gracza jej działanie możliwie jak najmniej różniło sie od tego zastosowanego w grze Half-Life 2. Oto co nasza konsola powinna umożliwiać:

  • Podgląd/modyfikowanie zmiennych używanych w kodzie.
  • Wywoływanie funkcji.
  • Wczytywanie ustawień z pliku.
  • Logowanie.
  • Definiowanie przez użytkownika 'aliasów', czyli grup zmiennych/komend.

Dobrze by było gdyby nasza konsola zapewniała rozszerzalność i żeby można ją było używać w kilku projektach bez potrzeby zmiany choćby linijki kodu. Pisząc konsole korzystałem z pomocniczych klas własnej produkcji :) a dokładniej: nstd::SmartPointer(inteligentne wskaźniki) i nstd::String(napisy). Oraz klasy ISingleton z książki Perełki Programowania Gier Vol.1.

Kolejne pytanie brzmi: jak?

Skoro juz wiemy co nasza konsola ma robić, pora zastanowić sie jak ma to robić. Potrzebujemy kilku klas. Przede wszystkim nasza główna klasa czyli Console. Dzięki niej będziemy udostępniać wybrane zmienne graczowi i pozwalać mu na ich zmianę/podgląd. Do tego potrzebujemy klasy reprezentujące zmienne, komendy i aliasy. Będą to kolejno CVar(Console Variable), CCmd(Console Command) i CAlias(Console Alias). Aby moc przechowywać wszystkie obiekty w jednym kontenerze, klasy te wyprowadzimy z interface'u IConsoleObject. Tak będzie sie przedstawiać nasza hierarchia klas:

                   IConsoleObject
                          |
             +------------+--------------+
             |            |              |
           CVar          CCmd         CAlias

Pozostała nam jeszcze klasa Console. W tym momencie zachęcam do przeczytania artykułu Temporala o loggerach, gdyż będziemy stosować podobne podejście. Oddzielimy implementację konsoli od wyjść, to nam da możliwość posiadania kilku wyjść(konsola w grze, plik z logami, konsola systemowa) bez modyfikowania kodu klasy Console. W tym celu stworzymy sobie interface IConsoleOutput. Dodanie nowego wyjścia będzie polegało na odziedziczeniu po klasie IConsoleOutput i dodaniu go do listy wyjść konsoli. Mając już jako takie pojęcie o tym co chcemy napisać i jak chcemy to zrobić, możemy przejść do implementacji...

Czas na kod

Z góry zaznaczę, że nie omówimy tutaj całego dołączonego kodu źródłowego a jedynie jego część niezbędną do zrozumienia artykułu. W katalogu src/nstd znajdują się nagłówki i część implementacji małej biblioteczki mojego autorstwa której używam przy większości projektów (interesuje nas część odpowiedzialna za napisy i inteligentne wskaźniki).

Interface obiektów konsoli.

Zacznijmy może od omówienia klasy odpowiedzialnej za wszystkie obiekty konsoli, czyli interface'u IConsoleObject. Każdy obiekt powinien mieć swoją nazwę i opis(pomoże graczom zrozumieć za co dany obiekt jest odpowiedzialny). Dodamy także zmienna informująca nas o typie danego obiektu. Nie możemy pozwolić na dodawanie do konsoli obiektów bez nazwy(jakbyśmy je potem odróżnili?), wiec ukryjemy konstruktor domyślny.

class IConsoleObject{
protected:
  IConsoleObject(){}
public:
  IConsoleObject(const nstd::String &name, const nstd::String &desc)
    :m_name(name), m_description(desc){}
  virtual ~IConsoleObject(){}

  const nstd::String& getDesc(){
    return m_description;
  }
  const nstd::String& getName(){
    return m_name;
  }
  uint32 getType(){
    return m_type;
  }
  virtual void printInfo()=0;
  virtual void update(const nstd::String &params)=0;
protected:
  uint32        m_type; // uint32 jest zdefiniowane w src/nstd/ntypes.h
private:
  nstd::String  m_name;
  nstd::String  m_description;
};

Dodałem jeszcze dwie funkcje czysto wirtualne. Pierwsza z nich, printInfo(), będzie miała za zadanie wyświetlić informację o obiekcie(np. kiedy zażyczy sobie tego gracz). Z kolei druga odpowiedzialna jest za uaktualnienie obiektu. Jako parametr przyjmuje string podany przez użytkownika. Może łatwiej będzie zrozumieć to na przykładzie. Mamy zmienną(CVar) o nazwie liczba. Gracz wpisuje w konsoli: liczba 5, wiec chcielibyśmy, aby konsola zmieniła nam wartość zmiennej o nazwie liczba na 5. Do tego wlanie służy nam funkcja update(). Wracając do przykładu po wpisaniu przez gracza liczba 5, konsola znajdzie obiekt o nazwie liczba i wywoła dla niego metodę update() z parametrem "5". Proste, prawda?

Konsola

Mając już nasz wspólny dla wszystkich obiektów interface możemy przejść do implementacji najważniejszej klasy. Klasy Console. Przedstawię od razu całą definicję, później zajmiemy sie objaśnieniem co do czego służy :)

class Console: public nstd::ISingleton<Console>{
public:
  Console();
  virtual ~Console();

  void registerObject(const nstd::SmartPtr<IConsoleObject> &pNewObj);
  const nstd::SmartPtr<IConsoleObject>& findConsoleObject(const nstd::String &name);

  void addOutput(const nstd::SmartPtr<IConsoleOutput> &pOutput);
  void removeOutput(const nstd::SmartPtr<IConsoleOutput> &pOutput);

  void print(const nstd::String &string, ConMsgType msgType=ConMsgNormal);
  void printLine(const nstd::String &string, ConMsgType msgType=ConMsgNormal);

  void init(const nstd::String &cmdLine);
  void parseInput(const nstd::String &input);

  nstd::String::Tokens getHint(const nstd::String &input);
  void truncateWS(nstd::String &string);
public:
  nboolean    m_developer;
protected:
  ConObjMap    m_conObjs;
  ConOutVec    m_outputs;
};

Zaczniemy od metod najważniejszych, czyli registerObject(), parseInput(), init(). Omówimy je dokładnie, jako ułatwienie przytoczę ich kod źródłowy. Pozostałe metody tylko opiszę, nie są zbyt skomplikowane i nie ma sensu marnować dla nich miejsca. Polecam zajrzeć do dołączonego kodu i przeanalizować je samodzielnie.

Na pierwszy ogień idzie metoda rejestrująca wszystkie obiekty w konsoli. Jej zadanie polega na dodaniu nowego obiektu do mapy już zarejestrowanych.

void Console::registerObject(const nstd::SmartPtr<IConsoleObject> &pNewObj){
  nstd::String str=pNewObj->getName();
  str.toLower();
  uint32 id=str.hash();
  ConObjMapItor it=m_conObjs.find(id);
  if(it==m_conObjs.end()){
    m_conObjs.insert( std::make_pair(id, pNewObj) );
  }else{
    if(it->second->getType()==CON_ALIAS){
      m_conObjs.erase(it);
      m_conObjs.insert( std::make_pair(id, pNewObj) );
    }else{
      printLine(
                 nstd::String::sprintf("'%s' already registered.", 
                                       pNewObj->getName().cStr()
                                       ), 
                 ConMsgError 
               );
    }
  }
}

Jako klucz w std::map użyjemy hash'u z nazwy. Przed hashowaniem konwertujemy nazwę obiektu do małych liter, dzięki temu jest ona niewrażliwa na wielkość liter. Gracz może wpisać CvarList, lub cvarlist i wynik będzie ten sam. Następnie sprawdzamy czy obiekt nie został już wcześniej zarejestrowany, jeżeli nie to go po prostu dodajemy. Jeżeli obiekt o takiej nazwie już istnieje musimy sprawdzić jego typ. Jeżeli to alias, to kasujemy poprzedni obiekt i tworzymy nowy. Dzięki temu gracz może zmieniać aliasy, np. jeżeli sie pomylił za pierwszym razem. Aliasy są także jedynymi obiektami, które mogą być dodawane w czasie działania gry(w zasadzie mogą być dodawane tylko wtedy, nie tworzymy aliasów bezpośrednio w kodzie). Jeżeli obiekt został wcześniej zarejestrowany i nie jest aliasem, wyświetlamy odpowiednią informację. Funkcja nstd::String::sprintf() działa podobnie jak jej odpowiednik z biblioteki standardowej, tylko zamiast przyjmować bufor wyjściowy jako parametr, zwraca nowo utworzony obiekt nstd::String.

Czas na najważniejszą metodę, czyli parseInput(). Zaczniemy jak zwykle od kodu:

void Console::parseInput(const nstd::String &input){
  using namespace nstd;

  String in=input;     // (1)
  convertTabs(in, 1);
  truncateWS(in);

  String::Tokens tokens( in.tokenize(' ', '\"') );

  tokens[0].toLower();
  if( tokens[0]==String("alias") && tokens.size() > 2 ){
    registerObject(
            new CAlias( tokens[1],in.substring(in.findFirstOf(tokens[2])) )
                       );
    return;
  }
  nstd::SmartPtr<IConsoleObject> pObj=findConsoleObject(tokens[0]); // (3)
  if(!pObj){
    printLine(String::sprintf("'s' undefined.", tokens[0].cStr()), ConMsgError);
  }else{
    if(tokens.size() > 1){
      if(tokens[1]==String("?"){
        pObj->printInfo();
      }else{
        pObj->update( in.substring(tokens[0].length()+1) );
      }
     }else{
        pObj->update("");
     }
   }
}

Zacznijmy od początku. (1) i trzy kolejne linijki wstępnie obrabiają to co nam gracz wklepał :). convertTabs() zamienia wszystkie tabulatory na spacje. Jako pierwszy argument podajemy napis do zmiany, jako drugi ile spacji funkcja ma wstawić zamiast tabulatora. W naszym przypadku po prostu zamieniamy każdego tabulatora na jedną spację. truncateWS() usuwa spacje z początku i końca napisu. Dochodzimy do najważniejszej linijki, najważniejszej metody:). Metoda String::tokenize(), przyjmuje dwa argumenty i zwraca bardzo prosty kontener(String::Tokens) zawierający wynik rozbicia string'a na części. Pierwszy argument, typu char, informuje na podstawie jakiego znaku rozbić string, drugi parametr(także typu char) jest opcjonalny i definiuje znak grupujący. Wszystko pomiędzy znakami grupującymi traktowane jest jako jeden wyraz. Prosty przykład:

nstd::String str="token1 token2 |token3 token4|";
// Można wywolac tokenize bez drugiego argumentu(nie ma znaku grupujacego).
nstd::String::Tokens tokens(str.tokenize(' '));
/*
 Powyzsze wywolanie zwroci:
   tokens[0]=="token1"
   tokens[1]=="token2"
   tokens[3]=="|token3"
   tokens[4]=="token4|"
*/
// Mozna takze podac znak grupujacy.
Tokens=str.tokenize(' ', '|');
/*
  tokens[0]=="token1"
  tokens[1]=="token2"
  tokens[2]=="token3 token4"
*/

Jak widać metoda nstd::String::tokenize() usuwa znaki grupujące i znaki pomiędzy dzielonymi wyrazami. Zanim opiszę kolejna linijkę, zastanówmy sie co będzie sie znajdowało w tokens[0]. Może to być nazwa zmiennej/komendy, albo słowo alias sygnalizujące nam, że gracz chce stworzyć nowy alias. W pierwszym przypadku wielkość liter nie ma znaczenia(id obiektu to hash z jego nazwy zapisanej małymi literami). Jeżeli gracz chce zdefiniować nowy alias mógł równie dobrze napisać Alias, aLias itd. Aby było łatwiej sprawdzić o co mu naprawdę chodziło, konwertujemy tokens[0] na małe litery i porównujemy tylko z wyrazem alias. Dochodzimy do momentu, gdy musze opisać kod którego jeszcze nie ma, czyli konstruktor klasy CAlias. Przyjmuje on dwa argumenty. Nazwę i treść aliasu. Alias można zdefiniować w następujący sposób:

alias [nazwa] [treść]

Widzimy więc, że tokens[1] zawiera nazwę naszego aliasu, a reszta to jego treść. Jeżeli ilość wyrazów po rozbiciu string'a jest równa 3 to jako nazwę podajemy tokens[1] a treść tokens[2]. Jeżeli jest jednak większa niż 3, musimy treść wyciągnąć. Zajmuje się tym następujący kod:

in.substring(in.findFirstOf(tokens[2]))

nstd::String::substring(int first, int last) zwraca nam podciąg składający sie z elementów o indeksach od first, do last(włącznie). Jeżeli last nie zostanie podane, nstd::String::substring() zwróci podciąg od elementu o indeksie first do końca napisu. nstd::String::findFirstOf() zwraca indeks pierwszego wystąpienia podanego słowa. W naszym wypadku findFirstOf() zwróci indeks początku treści, czyli to o co nam chodzi. Po utworzeniu aliasu nasza metoda może zakończyć prace. Gdy tokens[0]!="alias" mamy dwie możliwości.

  • Gracz chce wyświetlić informacje o obiekcie(podał jako parametr "?").
  • Gracz chce zmienić wartość zmiennej/wywołać komendę.

Najpierw sprawdzamy, czy podany obiekt w ogóle został w konsoli zar ejestrowany, jeżeli nie wyświetlamy odpowiednią informację. Sprawdzamy czy gracz podał argumenty, jeżeli tak sprawdzamy czy może chce ktoś tylko wyświetlić informacje o obiekcie. W przeciwnym wypadku wywołujemy metodę update() z IConsoleObject co powinno uaktualnić zmienną/wywołać komendę.

Na koniec została nam najprostsza metoda, czyli init(). Oto jej implementacja:

void Console::init(const nstd::String &cmdLine){
  using namespace nstd;
  conCmd_Exec("config.cfg");
  String::Tokens cmdLineArgs=cmdLine.tokenize('-');
  for(uint32 i; i<cmdLineArgs.size(); ++i){
    parseInput(cmdLineArgs[i]);
  }
}

Jak widać metoda króciutka. Teraz przejdźmy do opisu zadania które wykonuje. Uruchamiając naszą grę, gracz może w linii komend przekazać parametry. Nadpisują one wartości wprowadzone w pliku konfiguracyjnym. Aby przekazać argument, wystarczy wpisać go tak jak w konsoli, tylko poprzedzając znakiem '-'. Nawiązując do przykładu z liczbą, aby podać jej wartość w linii komend wystarczy uruchomić grę poleceniem: gra.exe -liczba 5. Można podać kilka parametrów, każdy poprzedzony znakiem '-'. Funkcja ta wymaga aby linia komend nie zawierała na początku ścieżki do programu. Należy jej przekazać same argumenty.

Pozostałe metody są zbyt proste aby analizować je krok po kroku i niepotrzebnie zwiększać rozmiar arta, wiec opisze je pokrótce i zostawię Ci przyjemność z ich rozpracowania :). Metoda findConsoleObject() jak sama nazwa wskazuje szuka obiektu według nazwy. Jeżeli obiekt nie został znaleziony zwraca 0(NULL). Metody addOutput() i removeOutput() zajmują się dodawaniem i usuwaniem wyjść naszej konsoli. Zagadnienie to wyjaśnione jest w artykule Temporala o którym wspomniałem na początku. Zachęcam do zapoznania się z nim. Kolejne metody, czyli print() i printLine() nie różnią sie prawie wcale. printLine() po prostu dodaje do końca tekstu znak końca linii(\n) i wywołuje metodę print().

Zmienne

Doszliśmy wreszcie do zmiennych używanych przez konsolę. W konsoli nie rozróżniamy typów, a C++ już od nas tego wymaga, trzeba więc to jakoś ominąć :). I tu z pomocą przychodzi nam klasa bazowa IConsoleObject. Ponieważ cala manipulacja zmiennymi konsolowymi z poziomu kodu odbywa się poprzez ten interface, nigdy nie używamy klasy CVar oprócz momentu jej stworzenia. Zrobimy zatem z niej szablon. Dzięki temu będzie można zdefiniować zmienne dowolnego typu. Teraz tylko pozostaje problem konwertowania tego co wpisał gracz(string), do naszej zmiennej. Inna zmienna będzie odpowiedzialna za stringi, inna za liczby całkowite itd. Wprowadzimy więc jeszcze możliwość zdefiniowania operatora konwersji do naszej zmiennej. Wszystko co potrzebne juz mamy. Panie i panowie, przedstawiam klasę CVar:

template<typename T, 
         const T (*pStringToCVar)(const nstd::String &)=defStringToCVar<T> >,
         const nstd::String (*pCVarToString)(const T&)=defCVarToString<T>
        >
class CVar: public IConsoleObject{
public:
  CVar(const nstd::String &name, T &val, T defVal, const nstd::String &desc)
    :IconsoleObject(name, desc), m_val(val), m_defaultValue(defVal){
      m_val    =m_defVal;
      m_type    =CON_VAR;
  }
  CVar(const nstd::String &name, T &val, const nstd::String &desc)
    :IconsoleObject(name, desc), m_val(val), m_defaultValue(val){
      m_val    =val;
      m_type    =CON_VAR;
  }
  virtual ~CVar(){}

  virtual void printInfo(){
    nstd::String output;
    output.format(" Name: %s\n Value: %s\n Default: %s\n Description: %s\n",
                  getName().cStr(),
                  pCVarToString(m_val).cStr(),
                  nstd::String(m_defaultValue).cStr(),
                  getDesc.cStr(),
                  );
     getConsole.print(output);
  }
  virtual void update(const nstd::String &value){
  if(value.empty()){
    printInfo();
  }else{
    nstd::String::Tokens tokens=value.tokenize(' ');

    if(tokens[0]==nstd::String("default"))
      m_val=m_defaultValue;
    else
      m_val=pStringToCVar(value);
   }
  }
protected:
  T      &m_val;
private:
  T      m_defaultValue;
};

Zacznijmy od początku. Nasza klasa będzie szablonem z 3 parametrami. Wymagany jest tylko 1, pozostałe dwa dodają nam funkcjonalności. Pierwszy parametr, to typ naszej zmiennej, może być float, int, nstd::String, czy nawet inna klasa zdefiniowana gdzieś w kodzie. Drugi parametr, to wskaźnik na funkcje konwertującą string na naszą zmienną. Trzeci parametr jest wskaźnikiem na funkcje wykonującą konwersje w przeciwną stronę, tzn. ze zmiennej tworzy string. Oto definicje domyślnych funkcji konwertujących(działają dla int, float, double, char, nstd::String i bool):

template<typename T>
const T defStringToCVar(const nstd::String &str){
  return (T)str;
}
template<typename T>
const nstd::String defCVarToString(const T &cvar){
  return nstd::String(cvar);
}

Przejdźmy do analizy samej klasy. Aby zmiana zmiennej oznaczała zmianę także zmiennej używanej w kodzie, klasa CVar przechowuje referencję do odpowiedniej zmiennej. Tworząc zmienną CVar podajemy jako argument konstruktora zmienną z która CVar ma być powiązana. Konstruktory są dwa, różnice między nimi są można rzec kosmetyczne. Używając pierwszego, jako argument podajemy wartość domyślną zmiennej. Drugi konstruktor jako wartość domyślną przyjmuje aktualną wartość zmiennej. Pierwszy i ostatni argument to odpowiednio nazwa i opis zmiennej.

Metoda wirtualna printInfo() odpowiedzialna jest za wyświetlenie informacji o zmiennej. Ponieważ zawsze konsola jest tworzona zanim dodawane są do niej zmienne, wiemy więc, że zanim została utworzona jakakolwiek zmienna konsola już istniała. Możemy więc skorzystać z niej do wypisania informacji na wszystkie jej wyjścia.

Na koniec została nam metoda update(). Jak wiemy, zajmuje się ona skonwertowaniem tego co wpisał gracz, na naszą zmienna. Wiemy już też, że mamy od tego funkcję pStringToCVar(), będącą parametrem naszego szablonu. Na początku sprawdzamy, czy gracz nie wpisał przypadkiem samej nazwy zmiennej(brak parametrów). Jeżeli tak, to wyświetlamy informacje o zmiennej. W przeciwnym wypadku mamy dwie możliwości, albo gracz podał jako argument nową wartość zmiennej, albo chce jej przywrócić wartość domyślną(jako nową wartość wpisał "default").Na tym kończymy opis klasy szablonowej CVar. Mam nadzieje, że wszystko już jasne. Przejdźmy teraz do komend.

Komendy

Czyli klasa odpowiedzialna za komendy w konsoli. Gdy gracz chce wywołać jakąś komendę, klasa CCmd jest odpowiedzialna za wywołanie odpowiedniej funkcji/metody ukrytej dla użytkownika pod nazwą komendy. Funkcja/metoda odpowiedzialna za daną komendę musi mieć prototyp: void [nazwa](const nstd::String &). Jako argument przyjmuje parametry przekazane przez gracza. Teraz tylko jak sprawić, żeby nasza klasa przyjmowała metody dowolnej klasy, a także zwykłe funkcje. Stworzymy sobie kilka klas pomocniczych w których będziemy przechowywać wskaźniki na odpowiednie metody/funkcje. Tak przedstawia się hierarchia tych klas:

                    ICCmdHandler
                          |
             +------------+--------------+
             |                           |
     CCmdHandlerMethod            CCmdHandlerFunction

ICCmdHandler to wspólny interface poprzez który będziemy używać odpowiednich klas przechowujących wskaźniki. Przyjrzyjmy się klasie ICCmdHandler:

class ICCmdHandler{
public:
  ICCmdHandler(){}
  virtual ~ICCmdHandler()=0{}

  virtual void operator()(const nstd::String &params)=0;
};

Jak widać jedyną metodą jest przeciążony operator(). Właśnie poprzez niego będziemy wywoływać nasze komendy. Każda klasa dziedzicząca musi implementować ten operator. Przeanalizujmy wiec CCmdHandlerMethod(przechowuje wskaźniki na metody klas):

template<typename T>
class CCmdHandlerMethod: public ICCmdHandler{
  typedef void (T::*FUNC)(const nstd::String amp;params);

  CCmdHandlerMethod(){}
public:
  CCmdHandlerMethod(T *pObj, FUNC pHandler)
    :m_pHandler(pHandler), m_pObj(pObj){}

  virtual ~CCmdHandlerMethod(){
    m_pObj=0;
    m_pHandler=0;
  }
  virtual void operator()(const nstd::String amp;params){
    (m_pObj->*m_pHandler)(params);
  }
protected:
  T     *m_pObj;
  FUNC  m_pHandler;
};

I jeszcze klasa CCmdHandlerFunction. Jak się zapewne domyślacie odpowiedzialna jest ona za przechowywanie wskaźników na zwykłe funkcje. Jej implementacja niewiele się różni od CCmdHandlerMethod.

class CCmdHandlerFunction: public ICCmdHandler{
  typedef void (*FUNC)(const nstd::String amp;params);
  CCmdHandlerFunction(){}
public:
  CCmdHandlerFunction(FUNC pHandler)
    :m_pHandler(pHandler){}

  virtual ~CCmdHandlerFunction(){
    m_pObj=0;
    m_pHandler=0;
  }
  virtual void operator()(const nstd::String amp;params){
    m_pHandler(params);
  }
protected:
  FUNC  m_pHandler;
};

CCmdHandlerFunction po prostu przechowuje wskaźnik na funkcję i wywołuje ją w operatorze(). Mając już pewne podstawy przejdźmy do klasy bezpośrednio odpowiedzialnej za komendy, czyli CCmd.

class CCmd: public IConsoleObject{
public:
  CCmd(const nstd::String amp;name, const nstd::SmartPtr<ICCmdHandler> amp;pHandler, 
       const nstd::String amp;desc)
    :IconsoleObject(name, desc, m_pHandler(pHandler){
      m_type=CON_CMD;
  }
  virtual ~CCmd(){}

  virtual void printInfo(){
    nstd::String output;
    output.format(" Name: %s\n Description: %s\n",
                  getName().cStr(),
                  getDesc().cStr()
                 );
    getConsole.print(output);
  }
  virtual void update(const nstd::String amp;params){
    (*m_pHandler)(params);
  }
protected:

nstd::SmartPtr<ICCmdHandler> m_pHandler
};

Konstruktor klasy CCmd jako argument przyjmuje nazwę i opis komendy a także wskaźnik na nasz interface do przechowywania wskaźników na metody/funkcje. Ponieważ posługujemy się interfacem nie interesuje nas to, czy jest to metoda czy funkcja. Czyż to nie jest piękne? Myślę, że nie ma sensu objaśniać pozostałych metod. printInfo() wyświetla informacje o komendzie, a update po prostu wywołuje odpowiednią metodę/funkcję przekazując jej parametry podane przez użytkownika. Zostały nam już tylko aliasy i nimi się właśnie zajmiemy.

Aliasy

Na początek zdefiniujmy sobie pojęcie aliasu. Aliasy to inaczej grupy komend/zmiennych. Jest to jedyny typ używany przez konsolę którego nie definiujemy w kodzie. Aliasy stworzone są tylko dla gracza. Umożliwiają np. przypisanie kilku komend do jednego klawisza oraz pisanie prostych skryptów. Nasze aliasy, będą po prostu przechowywały tablice obiektów alias_t. Każdy z nich zawiera wskaźnik na obiekt konsoli i argumenty wpisane przez użytkownika. Może mały przykładzik. Załóżmy, że mamy zmienne: cl_crosshairscale(typu int) i sv_gravity(typ int) i komendy: wait i echo. Teraz gracz zdefiniował sobie następujący alias:

alias test "cl_crosshairscale 1000; wait; sv_gravity 500; echo Napis do wyswietlenia"

Nasz alias będzie w tym wypadku przechowywał cztery obiekty alias_t. Każdy z nich będzie zawierał wskaźnik na obiekt konsoli o danej nazwie i jego parametry, czyli pierwszy element będzie zawierał wskaźnik na obiekt o nazwie cl_crosshairscale i argument "1000", drugi to odpowiednio wait i ""(brak argumentów) itd. Wykonanie aliasa to po prostu wywołanie metody update() na każdym obiekcie po kolei podając zapisane argumenty. Skoro już wiemy jak to działa, czas przedstawić implementację:

class CAlias: public IConsoleObject{
  struct alias_t{
    IConsoleObject    *pConObj;
    nstd::String    args;
  }
  CAlias(){}
public:
  CAlias(const nstd::String amp;name, const nstd::String amp;data){
    :IconsoleObject(name, ""), m_pAlias(0){
      m_type=CON_ALIAS;
      parse(data);
  }
  virtual ~CAlias(){}

  virtual void printInfo(){
    // Wycieto zeby nie marnowac miejsca. Po prostu wypisuje nazwe aliasu
    // i jego tresc.
  }
  virtual void update(const nstd::String amp;str){
    parse(str);
  }
protected:
  void parse(const nstd::String amp;str);
  alias_t     *m_pAlias;
  uint32      m_numCmds;
};
void CAlias::parse(const nstd::String amp;str){
  using namespace nstd;
  if(str.empty() amp;amp; m_pAlias){
    for(uint32 i=0; i<m_numCmds; ++i){
      if(m_pAlias[i].pConObj)
        m_pAlias[i].pConObj->update(m_pAlias[i].args);
    }
  }else{
     String::Tokens alias(str.tokenize(';', '\"');
     
     if(m_pAlias){
       delete[] m_pAlias;
     }
     m_numCmds=alias.size();
     m_pAlias=new alias_t[m_numCmds];
     String::Tokens cmd;
     for(uint32 i=0; i<m_numCmds; ++i){
       getConsole.truncateWS(alias[i]);
       cmd=alias[i].tokenize(' ');
       
       if( alias[i]==String("alias") ){
         getConsole.parseIntput( alias[i] );
       }else{
         m_pAlias[i].pConObj=getConsole.findConsoleObject(cmd[0]);
         m_pAlias[i].args=(cmd.size()>1 ? alias[i].substring(cmd[0].length()+1): "");
       }
    }
  }

Jedyną interesującą nas metodą jest parse(). Jeżeli została wywołana bez argumentów wykonujemy alias, jeżeli zaś argumenty zostały podane, oznacza to, że gracz chce zmienić treść aliasu. Kasujemy poprzednią(jeżeli istniała) i tworzymy alias od nowa. Nic trudnego :).

W tym momencie mamy już w pełni funkcjonalną konsolę. Pokażę teraz jak jej używać.

Sposób użycia

Tworzenie konsoli

Przede wszystkim, konsola powinna być pierwszym utworzonym w grze modułem. Dlaczego? Ponieważ jest odpowiedzialna za logowanie i wczytywanie ustawień. Pozostałe klasy mogą dzięki temu tworzyć w swoich konstruktorach komendy/zmienne. Zaraz po utworzeniu konsoli powinno zostać dodane przynajmniej jedno wyjście, w większości przypadków będzie to wyjście na plik z logiem. Gdy już wszystkie komendy i zmienne zostały dodane, należy wywołać metodę init() podając jej linię komend. Wczyta ona domyślny plik ustawień(config.cfg). W przykładowym programie dołączonym do tego artykułu inicjacja konsoli wygląda tak:

new ConsoleTutorial::Console();
getConsole.addOutput(new ConsoleTutorial::StdOut);

nstd::String cmdLine;
for(int i=1; i<argc; ++i){
  cmdLine+=argv[i];
  cmdLine+=" ";
}
getConsole.init(cmdLine);

Klasa StdOut to przykładowe wyjście na konsole DOS(używa std::cout do wypisywania tekstu). Gdy już stworzyliśmy i zainicjowaliśmy konsolę, przestajemy się nią przejmować :). Możemy jej używać do wypisywania informacji graczowi, ale sama kontrola zmiennych i komend nas nie interesuje. Zajmuje się tym konsola.

Tworzenie zmiennych i komend

Teraz zapewne zapytacie jak utworzyć zmienne czy komendy konsoli. Nic prostszego. Oto krótki przykład który powinien wszystko wyjaśnić.

// Tak tworzymy zmienne.
int gravity;
getConsole.registerObject( new CVar<int>("sv_gravity", gravity, 800,
                                         "Sila grawitacji")
                         );
// A teraz przykład komendy.
void conCmd_test(const nstd::String amp;params){
  getConsole.printLine(params);
}
getConsole.registerObject( new CCmd("Test",
                                    new CCmdHandlerFunction(amp;conCmd_Test),
                                    "Wypisuje podane argumenty do konsoli"
                                   )
                         );

Prawda, że proste?

Dodawanie nowych typów obiektów.

Z początku chciałem połączyć to z ogólnym opisem, ale stwierdziłem, że jest to idealny przykład jak łatwo rozszerzyć naszą konsolę o nowe możliwości. Dodamy sobie nowy typ zmiennych. Gdy użytkownik będzie zmieniał ich wartość, wywołają odpowiednią funkcję/metodę aby nas o tym powiadomić. Ponieważ będzie to nowy typ zmiennej, naszą klasę wyprowadzimy z CVar.

template<typename T,
         const T (*pStringToCVar)(const nstd::String amp;)=defStringToCVar,
         const nstd::String (*pCVarToString)(const Tamp;)=defCVarToString
        >
class NotifyCVar: public CVar<T, pStringToCVar, pCVarToString>{
public:
  NotifyCVar(const nstd::String amp;name, T amp;val, T defVal, const nstd::String amp;desc,
             const nstd::SmartPtr<ICCmdHandler> amp;pHandler)
    :CVar<T, pStringToCVar, pCVarToString>(name, val, defVal, desc){
  }
  NotifyCVar(const nstd::String amp;name, T amp;val, const nstd::String amp;desc,
             const nstd::SmartPtr<ICCmdHandler> amp;pHandler)
    :CVar<T, pStringToCVar, pCVarToString>(name, val, desc){
  }
  virtual ~NotifyCVar(){}

  virtual void update(const nstd::String amp;params){
    (*m_pHandler)(params);
    CVar<T, pStringToCVar, pCVarToString>::update(params);
  }
protected:
  nstd::SmartPtr<ICCmdHandler>    m_pHandler;
};

Myślę, że nie ma sensu objaśniać działania tej klasy. Jedyna różnica miedzy NotifyCVar a CVar, to wywołanie funkcji powiadamiającej o zmianie zmiennej.

Podsumowanie

Tak oto doszliśmy już do końca. Mam nadzieję, że lektura była ciekawa i omówiona tutaj konsola przyda się wam w waszych projektach. Jeżeli macie jakiekolwiek pytania, czy sugestie dotyczące tego artykułu, piszcie na [email protected].

Załącznik - kod źródłowy oraz artykuł w formacie PDF: ConsoleTutorial.rar (219.5 kB)

Tekst dodał:
Adam Sawicki
22.12.2006 23:24

Ostatnia edycja:
Tomasz Dąbrowski
27.06.2012 17:28
(+ 1 niepotwierdzonych zmian)

Kategorie:

Aby edytować tekst, musisz się zalogować.

# Edytuj Porównaj Czas Autor Rozmiar
#3 edytuj (poprz.) 27.06.2012 17:28 Tomasz Dąbrowski 32.24 KB (+151)
#2 edytuj (poprz.) (bież.) 15.06.2012 23:51 Piotr Matyja 32.09 KB (+266)
#1 edytuj (bież.) 22.12.2006 23:24 Adam Sawicki 31.83 KB
Zwykły
Do sprawdzenia
Do akceptacji
  • M2cl3k (@M2cl3k) 13 sierpnia 2010 00:55
    przez bibliotekę nstd się trochę nabawiłem kłopotów(użyłem jej w swoim programie i duużo godzin i to nie raz szukałem błędów, które okazywały się znajdować właśnie w nstd).... ale tak to spoks
  • novo (@novo) 23 sierpnia 2011 13:46
    przez bibliotekę nstd się trochę nabawiłem kłopotów ja tez :)
  • rychu_elektryk (@rychuelektryk) 21 czerwca 2012 17:39
    Nie mogę rozpakować załączonego archiwum. Wam działa?
  • Napisz komentarz:
    Aby dodać swój komentarz, musisz się zalogować.
Licencja Creative Commons

Warsztat używa plików cookies. | Copyright © 2006-2017 Warsztat · Kontakt · Regulamin i polityka prywatności
build #ff080b4740 (Tue Mar 25 11:39:28 CET 2014)