Asynchroniczna konsola Windows

Uwaga! Informacje na tej stronie mają ponad 6 lat. Nadal je udostępniam, ale prawdopodobnie nie odzwierciedlają one mojej aktualnej wiedzy ani przekonań.

Wstęp

Konsola jaka jest, każdy widzi. Ot, miejsce pełne tekstu, do którego możemy wprowadzać polecenia i oglądać ich wyniki. Nazywana bywa też terminalem. Czasami jest kolorowa, czasami posiada autouzupełnianie poleceń - mniejsza z tym.

Ważne, że bywa przydatna. Przydaje się do wielu rzeczy. Pierwszą i najważniejszą jest możliwość wydawania poleceń. Taki sposób sterowania z punktu widzenia użytkownika z pewnością nie należy do najprzyjemniejszych, ale ma swoje zalety (miłośnicy Linuksa doskonale o tym wiedzą :) Ponadto jest prostszy do zrealizowania, niż tworzenie specjalnego okienka dialogowego do każdego rodzaju opcji czy innych dostępnych w programie funkcji - nawet jeśli dysponujemy dobrą biblioteką do GUI. Czasami na konsolę wypisuje się też komunikaty nie będące bezpośrednim skutkiem wydania jakiegoś polecenia - wtedy służy jako swego rodzaju log.

Z tych właśnie powodów konsola bywa używana także w grach. Wiele gier posiada takową umożliwiając wydawanie różnego rodzaju poleceń - począwszy od służących twórcom do debugowania, poprzez sterujące w jakiś sposób grą czy administrujące serwerem gry aż po cheaty. Z pewnością pierwsze, co się nasuwa, to zaimplementowanie jej we własnym zakresie, tak jak to robi np. Quake:

Konsola Quake 3

Pomyślałem sobie, że być może istnieje prostsze wyjście. Windows posiada mechanizm konsoli używany choćby w "Wierszu poleceń" (dawniej "Tryb MS-DOS"), a dostępny także dla nas jako programistów. Z pewnością wiesz, jak pisać programy konsolowe - od nich przecież zaczyna się zawsze naukę programowania!

Konsola Windows

Wykorzystanie tego mechanizmu jako konsoli w grze okazuje się jednak nie być takie proste. Trzeba najpierw przezwyciężyć kilka trudności:

W tym artykule zajmiemy się właśnie przezwyciężeniem tych problemów i nauczymy się, jak napisać konsolę do gry z wykorzystaniem standardowego mechanizmu konsoli Windows, ale działającą w sposób asynchroniczny.

Jaka wiedza jest potrzebna do zrozumienia tego artykułu? Na pewno umiejętność programowania (najlepiej w C++). Poza tym pewnie pojęcie o programowaniu gier (przynajmniej frameworka). Resztę postaram się wyjaśnić.

Konsola Windows

Skąd się bierze konsola? Wszystko zależy od opcji kompilacji. Kiedy projekt w IDE jest stworzony jako aplikacja konsolowa i posiada funkcję main, otrzymuje od systemu konsolę "gratis". Jeśli jest aplikacją okienkową i posiada funkcję WinMain, konsoli nie ma. To nie znaczy jednak, że nie może jej sobie sam utworzyć.

Tworzenie konsoli

Żeby jednak to zrobić, musisz zapomnieć o wszelkich sposobach na organizowanie konsolowego wejścia-wyjścia z użyciem funkcji biblioteki standardowej C (printf, scanf) czy C++ (cin, cout). Będziemy używali Windows API. Tylko ono daje nam dostęp do pełnych możliwości konsoli systemowej. Włączamy więc odpowiedni nagłówek i zaczynamy!

#include <windows.h>

Jeśli mamy aplikację okienkową, możemy wciąż dostać do dyspozycji konsolę, tylko musimy tego jawnie zażądać. To proste - wszystko załatwi jedna, bezparametrowa funkcja:

AllocConsole();

Pamiętaj, że aplikacja może mieć tylko jedną konsolę. Zostanie automatycznie usunięta podczas jej zamykania, ale możemy to zrobić wcześniej wywołując:

FreeConsole();

Z tego typu prostych, podstawowych funkcji przydatna może jeszcze okazać się zmiana tytułu okienka konsoli:

SetConsoleTitle("Moja wypasiona gra i jej konsola");

Prawda, że to banalne? Aż trudno uwierzyć, że w Windows API są *też* tak proste i przyjmujące tak mało parametrów funkcje :) Żeby robić coś dalej, potrzebujemy uchwytów do wejścia i wyjścia konsoli. Otrzymujemy je w taki sposób:

HANDLE HandleIn = GetStdHandle(STD_INPUT_HANDLE);
HANDLE HandleOut = GetStdHandle(STD_OUTPUT_HANDLE);

Wejście-wyjście

Zanim zaczniemy kombinować, zobaczmy, jak realizuje się normalne, synchroniczne wejście-wyjście. Do wypisania tekstu posłużyć może takie wywołanie:

DWORD Foo;
std::string Text = "All systems ready.";
WriteConsole(HandleOut, Text.data(), (DWORD)Text.length(), &Foo, 0);

Tu robi się już troche trudniej, dlatego muszę chyba wyjaśnić poszczególne parametry pokazanej funkcji:

  1. Uchwyt do wyjścia konsoli, który pobraliśmy w poprzednim podrozdziale.
  2. Łańcuch znaków do zapisania, typu const char*.
  3. Liczba znaków, czyli długość tego łańcucha, typu DWORD.
  4. Parametr wyjściowy zwracający liczbę znaków, które udało się zapisać. My nie będziemy się tym przejmowali, ale że coś tam podać trzeba, podajemy nieużywaną dalej zmienną.
  5. Tutaj po prostu musi być 0.

Wejście implementuje się podobnie:

const DWORD BUFFER_SIZE = 1024;
char Buffer[BUFFER_SIZE];
DWORD Length;
ReadConsole(HandleIn, Buffer, BUFFER_SIZE, &Length, 0);

Oto znaczenie kolejnych parametrów przedstawionej funkcji:

  1. Uchwyt do wejścia konsoli, który pobraliśmy w poprzednim podrozdziale.
  2. Wskaźnik do bufora na wprowadzone polecenie, typu char*.
  3. Długość naszego bufora, a tym samym maksymalna liczba znaków, typu DWORD.
  4. Parametr wyjściowy zwracający liczbę znaków, jakie naprawdę zostały umieszczone w buforze (czyli długość wprowadzonego polecenia).
  5. Tutaj ma być 0.

Ta funkcja blokuje działanie programu i zwraca sterowanie dopiero, kiedy doczeka się, aż użytkownik wprowadzi jakieś polecenie i wciśnie ENTER. Co więcej, bez wywołania tej funkcji wprowadzanie poleceń czy jakichkolwiek znaków do konsoli nie jest możliwe - konsola jest tylko do odczytu. To z pewnością nie jest pożądane przez nas zachowanie. Zajmiemy się tym niebawem...

Kolory

Wcześniej jednak chciałbym przekonać Cię, że nie warto ignorować możliwości używania w konsoli kolorów. Skoro już przeszliśmy na obsługę konsoli z użyciem Windows API, otwierają się przed nami nowe, niedostępne w bibliotece C czy C++ możliwości. To nie tylko ładnie wyglądający "bajer", ale funkcja, która może znacznie poprawić czytelność. Możesz na przykład błędy wyświetlać na czerwono, mniej ważne informacje na szaro, komunikaty od programu na zielono itd.

Kolor bieżący, czyli ten, którym wypisywane będą wszystkie nowe znaki, które wyprowadzimy na wyjście lub które użytkownik wpisze na wejście, ustawiamy taką oto funkcją:

SetConsoleTextAttribute(
  HandleOut,
  FOREGROUND_GREEN | FOREGROUND_INTENSITY | BACKGROUND_BLUE);

Kolorów jest, podobnie jak za czasów DOS-a, 16 (programowałeś może w Turbo Pascalu?). Składamy je z następujących flag bitowych:

Mamy 4 flagi dla koloru tekstu i 4 podobne flagi dla koloru tła. To daje nam po 16 różnych kombinacji na każdy z nich. RED oznacza oczywiście kolor czerwony, GREEN zielony, a BLUE niebieski. Brak jakiejkolwiek flagi to kolor czarny. Połączenie jakiegoś koloru z flagą INTENSITY daje kolor jasny. Sama flaga INTENSITY oznacza szary. Na przykład pokazany wyżej fragment ustawia jasny zielony kolor tekstu na ciemnym niebieskim tle. Poeksperymentuj z tym, a zrozumiesz ogólną zasadę. Cóż, nie jest to najwygodniejsze... To jednak nie jest nasz największy problem, dlatego już zostawmy kolory w spokoju i przejdźmy wreszcie do sedna sprawy.

Asynchroniczna konsola

Chcemy osiągnąć dwie rzeczy:

  1. Aby konsola była stale gotowa do wprowadzania poleceń.
  2. Aby nie blokowała działania programu, czyli aby działała asynchronicznie.

Mój pomysł polega na wykorzystaniu do wczytywania poleceń osobnego wątku. Zastosowałem tutaj następującą sztuczkę: Wątek główny programu działa sobie nieprzerwanie w taki sposób w jaki chce i co jakiś czas wypisuje komunikat na wyjście konsoli, a tymczasem wątek poboczny zajmuje się nieustannie czekaniem na wprowadzenie nowego polecenia. Okazało się, że takie rozwiązanie jest możliwe i działa poprawnie, nawet kiedy wypisujemy tekst na wyjściu konsoli w czasie, kiedy drugi wątek jest w trakcie wywołania funkcji czekającej na wejście z tej samej konsoli.

Jeśli nie znasz programowania wielowątkowego, nie przejmuj się - postaram się wszystko wyjaśnić.

Uruchamianie wątku

Odpalenie nowego wątku jest proste. Definiujemy zmienną na jego uchwyt:

HANDLE m_ThreadHandle;

A następnie tworzymy wątek podając wskaźnik do funkcji, która ma zostać w jego ramach wywołana. Funkcja ta zacznie wykonywać się równolegle z naszym kodem, a funkcja tworząca wątek natychmiast zwróci sterowanie umożliwiając nam dalsze działanie niezależnie od niego.

m_ThreadHandle = CreateThread(0, 0, &ReadThreadProc_s, this, 0, 0);

Jeśli wątek (podobnie jak nasz) pętli się bez końca albo robi coś, co blokuje jego działanie i nie ma zamiaru zakończyć swojego działania "po dobroci", możemy go "zabić" z zewnątrz używając uchwytu do niego:

TerminateThread(m_ThreadHandle, 0);

Pojawia się tutaj jednak pewien problem. Otóż Windows API, podobnie jak większość używanych dziś interfejsów i bibliotek, jest strukturalny i nie można ot tak połączyć go z kodem obiektowym podając tutaj wskaźnik do metody jakiejś klasy. (Aż chciałoby się zapytać: Jak długo jeszcze???)

Rozwiązanie polega na napisaniu metody statycznej. Metoda taka, choć formalnie należy do klasy, jest tak naprawdę funkcją globalną, a więc zadziała jako procedura wątku w Windows API (a także jako procedura obsługi komunikatów okna Windows i w wielu innych miejscach). Dopiero ta metoda wywoła normalną metodę naszej klasy konsoli realizującą wszystko, co mamy do zrobienia.

static DWORD WINAPI ReadThreadProc_s(void *This);
DWORD ReadThreadProc();

Problem w tym, że metoda statyczna nie zna ot tak, sama z siebie, obiektu swojej klasy, bo wywołana zostaje na rzecz samej klasy, a nie konkretnego obiektu. Tutaj jednak przychodzi nam z pomocą możliwość przekazania do procedury wątku jednego, dowolnego parametru typu void*, który możemy wykorzystać w dowolny sposób. Tworząc wątek podaliśmy tam adres bieżącego obiektu - this - i wewnątrz naszej metody statycznej wykorzystamy go do wywołania właściwej metody w taki oto sposób:

DWORD WINAPI AsyncConsole::ReadThreadProc_s(void *This)
{
  return ((AsyncConsole*)This)->ReadThreadProc();
}

Mam nadzieję, że opisałem to wszystko dostatecznie jasno? Ta sztuczka nie jest może kluczowa dla zrozumienia przedstawianego tutaj tematu, ale warto ją znać, gdyż bywa przydatna w wielu podobnych sytuacjach.

Kolejka poleceń

Utworzony przed chwilą wątek poboczny będzie czekał na wprowadzenie polecenia i ilekróć doczeka się na nie, doda go do kolejki poleceń. Tymczasem wątek główny (czy jakikolwiek inny) będzie mógł co jakiś czas, w dowolnej chwili sprawdzić stan tej kolejki i wczytać z niej ostatnie polecenie jednocześnie go stamtąd usuwając (jak to zwykle z kolejkami bywa).

static const DWORD BUFFER_SIZE = 1024;
std::vector<char> m_Buffer;
std::queue<string> m_Queue;

Tutaj znowu pojawia się problem. Tym razem chodzi o synchronizację między wątkami. Wszelkie operacje na wspólnej strukturze danych, które mogą być wykonywane przez wiele wątków jednocześnie, muszą zostać zabezpieczone tak, aby tylko jeden z nich mógł taką operację wykonywać w danej chwili. W przeciwnym razie dwa wątki mogłyby zacząć "mieszać" coś przy naszej kolejce w tej samej chwili, a to doprowadziłoby do nieoczekiwanych błędów.

Na szczęście synchronizacja nie jest trudna. Użyjemy tutaj sekcji krytycznej, czyli najprostszego z obiektów synchronizujących dostępnego w Windows API (inne to m.in. muteks i semafor). Deklarujemy pole:

CRITICAL_SECTION m_CriticalSection;

Tworzymy sekcję krytyczną na początku:

InitializeCriticalSection(&m_CriticalSection);

A na końcu ją usuwamy:

DeleteCriticalSection(&m_CriticalSection);

Nareszcie nadszedł czas, aby przedstawić treść właściwej funkcji czekającej na wejście (to właśnie ta, która stanowi kod wykonywany w osobnym wątku).

DWORD AsyncConsole::ReadThreadProc()
{
  DWORD CharactersRead;
  string s;

  while (true)
  {
    ReadConsole(m_HandleIn, &m_Buffer[0], BUFFER_SIZE, &CharactersRead, 0);

    EnterCriticalSection(&m_CriticalSection);
    s.assign(&m_Buffer[0], CharactersRead-2); // -2, bo bez końca wiersza
    m_Queue.push(s);
    s.clear();
    LeaveCriticalSection(&m_CriticalSection);
  }

  return 0;
}

Co się tutaj dzieje? Nic trudnego. Nieskończona pętla zajmuje się jedynie czekaniem na nową linijkę tekstu wprowadzoną przez użytkownika do konsoli, następnie przenosi tą linijkę z bufora do łańcucha, a łańcuch ten dodaje do kolejki.

Operacja na kolejce zabezpieczona jest sekcją krytyczną. Działanie użytych w tym celu funkcji jest następujące: Na raz tylko jeden wątek może być "wewnątrz" sekcji krytycznej, czyli między wywołaniem EnterCriticalSection a LeaveCriticalSection. Jeśli jeden wątek właśnie to robi, a drugi chce w tym samym czasie dostać się do tego samego czy innego kodu zabezpieczonego tą samą sekcją krytyczną, jego wywołanie funkcji EnterCriticalSection zablokuje jego działanie aż do chwili, kiedy ten pierwszy wątek opuści sekcję krytyczną.

Słowa wyjaśnienia wymaga tutaj jeszcze użycie wektora jako bufora. Równie dobrze mogłem użyć zwyczajnej statycznej czy dynamicznej tablicy znaków, ale nie ma przeciwwskazań, aby zamiast nich wszędzie stosować wektor z STL-a. Wektor nie tylko doskonale sprawdza się w roli zwyczajnej tablicy (wskaźnik do jego pierwszego elementu otrzymujemy za pomocą &Wektor[0], ale też sam zwalnia swoją pamięć. Żeby jednak mógł taki wektor funkcjonować w pokazanym wyżej kodzie jako bufor na znaki z wejścia, musi najpierw zostać zaalokowany takim oto wywołaniem:

m_Buffer.resize(BUFFER_SIZE+1);

Oto metody dające dostęp do tej kolejki:

// Zwraca true jeśli kolejka poleceń jest pusta
bool InputQueueEmpty();
// Zwraca true i pierwsze polecenie z kolejki, jeśli nie jest pusta
// Usuwa to polecenie.
// Jesli kolejka jest pusta, zwraca false.
bool GetInput(string *s);

bool AsyncConsole::InputQueueEmpty()
{
  EnterCriticalSection(&m_CriticalSection);
  bool R = m_Queue.empty();
  LeaveCriticalSection(&m_CriticalSection);
  return R;
}

bool AsyncConsole::GetInput(string *s)
{
  EnterCriticalSection(&m_CriticalSection);
  bool R = m_Queue.empty();
  if (!R)
  {
    *s = m_Queue.front();
    m_Queue.pop();
    if (m_Queue.empty())
      ResetEvent(m_Event);
  }
  LeaveCriticalSection(&m_CriticalSection);
  return !R;
}

Czekanie na wejście

Mimo że nasza konsola działa asynchronicznie, możemy rozbudować ją o możliwość czekania na wprowadzenie polecenia. Pierwsze, co przychodzi tutaj do głowy, to napisanie pętli wywołującej w kółko InputQueueEmpty, aż zwróci true. Jednak drugą myślą, jaka powinna Ci przyjść natychmiast do głowy jest wniosek, że takie rozwiązanie jest złe, bo zajmuje cały wolny czas procesora zamiast wstrzymać dany wątek i pozwolić mu odpocząć, a procesorowi zająć się czymś ciekawszym niż wykonywaniem tak lamerskiej pętli.

Takiego czekania nie zrobimy bez pomocy ze strony systemu. Ten jednak przychodzi nam z pomocą udostępniając jeszcze inny, również prosty obiekt służący do synchronizacji między wątkami - zdarzenie (event). Zdarzenie jest w każdej chwili w stanie sygnalizowanym lub niesygnalizowanym. Wykonywać można na nim takie oto operacje:

Jeśli zdarzenie jest już w stanie sygnalizowanym, funkcja czkająca kończy się natychmiast. Dodatkowo zdarzenie może się automatycznie resetować, jeśli jakiś wątek czekał na jego sygnalizowanie i się doczekał. My jednak nie skorzystamy z tej opcji. Zadeklarujmy więc uchwyt do zdarzenia:

HANDLE m_Event;

Utwórzmy je:

m_Event = CreateEvent(0, TRUE, FALSE, 0);

A na końcu usuńmy:

CloseHandle(m_Event);

Umawiamy się, że zdarzenie jest w stanie niesygnalizowanym, kiedy kolejka poleceń z wejścia jest pusta, a stan sygnalizowany oznacza, że jakieś polecenia czekają w kolejce. Wyposażeni w to założenie możemy już rozszerzyć naszą funkcję realizującą wątek o zasygnalizowanie, że kolejka jest niepusta:

DWORD AsyncConsole::ReadThreadProc()
{
  DWORD CharactersRead;
  string s;

  while (true)
  {
    ReadConsole(m_HandleIn, &m_Buffer[0], BUFFER_SIZE, &CharactersRead, 0);

    EnterCriticalSection(&m_CriticalSection);
    s.assign(&m_Buffer[0], CharactersRead-2); // -2, bo bez końca wiersza
    m_Queue.push(s);
    s.clear();
    SetEvent(m_Event);
    LeaveCriticalSection(&m_CriticalSection);
  }

  return 0;
}

Następnie możemy napisać funkcje, które pomogą w czekaniu na polecenie (czyli aż zdarzenie przejdzie w stan sygnalizowany):

// Czeka na wejście zatrzumując wątek, który to wywoła
void WaitForInput();
// Zwraca uchwyt do eventa, żeby można sobie urządzić czekanie na niego we
// własnym zakresie
HANDLE GetWaitEvent() { return m_Event; }

void AsyncConsole::WaitForInput()
{
  WaitForSingleObject(m_Event, INFINITE);
}

Funkcja GetWaitEvent jest potrzebna, bo użytkownik biblioteki do konsoli, którą tak naprawdę tutaj piszemy może chcieć czekać na jedno z wielu zdarzeń na raz, na przykład na wejście z konsoli i jeszcze inne zdarzenia, muteksy, semafory itp., którekolwiek będzie pierwsze. To można zrealizować tylko z użyciem specjalnych funkcji takich jak WaitForMultipleObjects, które potrzebują uchwytów do wszystkich obiektów, na które mają czekać.

Kolory

Konsola pracująca w sposób asynchroniczny wymaga też specjalnego podejścia do kwestii kolorów. Oto, jak rozwiążemy ten problem: Będziemy pamiętali dwa kolory: bieżący kolor używany do wypisywania tekstu na wyjściu ("kolor wyjściowy") oraz kolor, w którym mają być wyświetlane znaki wprowadzane przez użytkownika na wejściu ("kolor wejściowy"). Jako że konsola stale czeka na wejście od użytkownika, jej kolor musi stale pozostawać ustawiony na ten "wejściowy", a przestawiany na ten "wyjściowy" będzie tylko na czas wyświetlenia komunikatu wyjściowego.

Jesteśmy teraz gotowi, aby zobaczyć resztę kodu naszej klasy do asynchronicznej konsoli:

WORD m_InputColor;
WORD m_OutputColor;

void AsyncConsole::SetOutputColor(WORD Color)
{
  m_OutputColor = Color;
}

void AsyncConsole::SetInputColor(WORD Color)
{
  m_InputColor = Color;
  SetConsoleTextAttribute(m_HandleOut, Color);
}

void AsyncConsole::Write(const string &s)
{
  DWORD Foo;

  SetConsoleTextAttribute(m_HandleOut, m_OutputColor);
  WriteConsole(m_HandleOut, s.data(), (DWORD)s.length(), &Foo, 0);
  SetConsoleTextAttribute(m_HandleOut, m_InputColor);
}

void AsyncConsole::Writeln(const string &s)
{
  Write(s);
  Write("\r\n");
}

Przykładowy program

Dołączam do artykułu krótki, przykładowy program. Znajdziesz w nim kompletną i nadającą się praktycznie natychmiast do użytku klasę asynchronicznej konsoli, której większość kawałek po kawałku przedstawiłem powyżej.

Pobierz: AsyncConsole.cpp (4.6 kB)

Razem z tą klasą program zawiera przykładowy kod, który umożliwia natychmiastowe uruchomienie i przetestowanie naszej konsoli. Jego obsługa sprowadza się do wprowadzania poleceń i oglądania efektów. Co 2 sekundy na wyjściu pojawia się napis "Sth...". Tymczasem użytkownik może w każdej chwili wprowadzać polecenia na wejście.

Polecenie "wait" spowoduje zatrzymanie programu do czasu wprowadzenia kolejnego polecenia. Polecenie "exit" kończy działanie aplikacji. Każde inne powoduje odpowiedź w postaci wypisania na wyjściu polecenia, które wprowadziłeś.

Czas reakcji na wprowadzane polecenia jest w przykładowym programie tak długi, ponieważ przetwarzający je wątek główny zatrzymuje się w pętli na 2 sekundy. Normalna gra będzie się pętliła dużo szybciej - nowy cykl nastąpi co najmniej kilkadziesiąt razy na sekudnę, a więc i czas odpowiedzi konsoli będzie dużo lepszy.

Nasza konsola

Rozważania

Czas na rozważenie kilku dodatkowych zagadnień związanych z przedstawionym kodem. Moje rozwiązanie, jak każde, nie jest niestety pozbawione wad.

Konsola a pełny ekran

Ponieważ konsola to osobne okno, oczywiste jest, że nie będzie można jej używać w grach, które działają na pełnym ekranie albo które zawłaszczają wyłączność na klawiaturę i myszkę, np. używając DirectInput. Dlatego pokazana tutaj konsola nadaje się raczej tylko do celów debugowania i ewentualnie administrowania, a nie do normalnego używania podczas gry.

Jednoczesne wejście i wyjście

Być może sam zadałeś sobie już wcześniej pytanie: Co się stanie, jeśli program postanowi wypisać coś na wyjściu konsoli w czasie, kiedy użytkownik wpisuje akurat polecenie na jej wejście? Odpowiedź jest następująca: Wszystko zadziała dobrze, ale wizualnie efekt będzie nienajlepszy. Otóż wypisywany komunikat przerwie wpisywanie polecenia i dalsze wprowadzane znaki trafią dopiero dalej po tym komunikacie. To nie przeszkadza jednak, by polecenie zostało dokończone i wprowadzone poprawnie do pamięci.

Jak rozwiązać ten problem? Do końca zrobić się tego nie da, bo to wymagałoby stworzenia własnej konsoli, w której osobne pole przeznaczone jest do wprowadzania poleceń, a osobne do wyprowadzania komunikatów (jak programy do czatowania typu mIRC czy Gadu-Gadu). Można jednak nie wypisywać na wyjście komunikatów inaczej, niż tylko bezpośrednio w reakcji na wprowadzone polecenie. To powinno wystarczyć, aby zapewnić dobry efekt.

Kodowanie znaków

Wielkim zaskoczeniem jest dla każdego, kto słyszy o tym po raz pierwszy, że konsola Windows stosuje inne kodowanie polskich znaków (jak "ą", "ó", "ł"), niż cała reszta systemu. Nie jest to ani strona kodowa Windows-1250, której używa cały okienkowy Windows, ani ISO-8859-2, która jest standardem zalecanym m.in. dla Internetu. Konsola używa kodowania IBM (CP852) - tego samego, które obowiązywało dla polskich znaków jeszcze pod DOS-em!

Czy to aż taki duży problem? Przedstawię poniżej tablicę z kodami wszystkich polskich znaków w obydwu kodowaniach i jestem przekonany, że bez problemu poradzisz sobie z samodzielnym napisaniem funkcji, która będzie zastępowała wszystkie te znaki konwertując łańcuchy z jednego kodowania na drugie w obydwie strony.

                 ą      ć      ę      ł      ń      ó      ś      ź      ż
---------------------------------------------------------------------------
Windows-1250    185    230    234    179    241    243    156    159    191
IBM (CP852)     165    134    169    136    228    162    152    171    190

                 Ą      Ć      Ę      Ł      Ń      Ó      Ś      Ź      Ż
---------------------------------------------------------------------------
Windows-1250    165    198    202    163    209    211    140    143    175
IBM (CP852)     164    143    168    157    227    224    151    141    189

(NOWOŚĆ) Jak słusznie zauważył czytelnik z Biura Obsługi Klienta Koko Software, w WinAPI są gotowe funkcje do konwersji łańcuchów między kodowaniem domyślnym aplikacji (dla języka polskiego: Windows-1250 lub UTF-16) a CP852 (nazywanym w dokumentacji "OEM"). Nazywają się OemToChar i CharToOem.

CTRL+C

Dodatkową funkcją konsoli jest możliwość wciśnięcia klawiszy CTRL+C lub CTRL+BREAK, które powodują natychmiastowe zakończenie aplikacji. Jeśli chciałbyś zmienić to zachowanie, zainteresuj się funkcją SetConsoleCtrlHandler. Myślę jednak, że czasami (zwłaszcza podczas programowania i testowania) taka dodatkowa możliwość awaryjnego wyłączenia programu może się przydać.

Co dalej?

Oto co możnaby zrobić dalej w dziedzinie implementowania porządnej konsoli do gry:

Przetwarzanie wprowadzanych poleceń

Trzeba przynajmniej rozkładać takie polecenie na poszczególne parametry, a potem jakoś wykonywać. Można to usprawnić łącząc konsolę z interpreterem jakiegoś języka skryptowego, np. LUA albo Python. Warto też zainteresować się wzorcem projektowym o nazwie dator, który, mówiąc w skrócie, polega na napisaniu klasy otoczki na pojedynczą wartość (zmienną) jakiegoś typu tak, by można było łatwo i wygodnie udostępniać odczyt i zapis tej wartości z poziomu konsoli.

Niskopoziomowy dostęp do konsoli

Windows API posiada także funkcje do manipulowania na konsoli na niskim poziomie, w tym zmianę już istniejących znaków w poszczególnych miejscach okna oraz odbieranie naciśnięcia pojedynczych klawiszy. Użycie tych funkcji otworzyłoby możliwość pełnej kontroli nad wyglądem i zachowaniem konsoli. Po szczegóły odsyłam do MSDN Library.

Inne metody synchronicznego wejścia-wyjścia

Metoda, której użyłem tutaj (osobny wątek czkający na wejście) nie jest jedyną możliwą. Nie wiem nawet, czy jest do końca poprawna. Możesz spróbować zrealizować to samo zadanie używając innych metod na asynchroniczne wejście-wyjście z Windows API - takich jak Overlapped I/O.

Adam Sawicki
8 sierpnia 2006
[Download] [Dropbox] [pub] [Mirror] [Privacy policy]
Copyright © 2004-2024