BAZA WIEDZY
KURSY
Bazy danych w PHP
Kurs AdvancedAJAX
Kurs ASP
Kurs ASP.NET
Kurs C++
Kurs CSS
Kurs HTML
Kurs HTML drugi
Kurs JavaScript
Kurs MySQL
Kurs PHP
Kurs RSS
Kurs XHTML
Obiekty DOM
MANUALE
CSS1 - W3C
DOM - w budowie
PHP 2005
PHP 2006
Wyrażenia regularne
SHOUTBOX
STAT
Online: 34 | UU: 523

Większy projekt

Doszedłszy do tego miejsca w lekturze niniejszego kursu posiadłeś już dosyć dużą wiedzę programistyczną. Pora zatem na wykorzystanie jej w praktyce: czas stworzyć jakąś większą aplikację, a ponieważ docelowo mamy zajmować się programowaniem gier, więc będzie nią właśnie gra.

Nie możesz wprawdzie liczyć na oszałamiające efekty graficzne czy dźwiękowe, gdyż chwilowo potrafimy operować jedynie konsolą, lecz nie powinno cię to mimo wszystko zniechęcać. Środki tekstowe okażą się bowiem całkowicie wystarczające dla naszego skromnego projektu.

Projektowanie

Cóż więc chcemy napisać? Otóż będzie to produkcja oparta na wielce popularnej i lubianej grze w kółko i krzyżyk :) Zainteresujemy się jej najprostszym wariantem, w którym dwoje graczy stawia naprzemian kółka i krzyżyki na planszy o wymiarach 3 ? 3. Celem każdego z nich jest utworzenie linii z trzech własnych symboli - poziomej, pionowej lub ukośnej.


Rysunek 2. Rozgrywka w kółko i krzyżyk

Nasza gra powinna pokazywać rzeczoną planszę w czasie rozgrywki, umożliwiać wykonywanie graczom kolejnych ruchów oraz sprawdzać, czy któryś z nich przypadkiem nie wygrał :)

I taki właśnie efekt będziemy chcieli osiągnąć, tworząc ten program w C++. Najpierw jednak, skoro już wiemy, co będziemy pisać, zastanówmy się, jak to napiszemy.

Struktury danych w aplikacji

Pierwszym zadaniem jest określenie struktur danych , wykorzystywanych przez program. Oznacza to ustalenie zmiennych, które przewidujemy w naszej aplikacji oraz danych, jakie mają one przechowywać. Ponieważ wiemy już niemal wszystko na temat sposobów organizowania informacji w C++, nasze instrumentarium w tym zakresie będzie bardzo szerokie. Zatem do dzieła!

Chyba najbardziej oczywistą potrzebą jest konieczność stworzenia jakiejś programowej reprezentacji planszy, na której toczy się rozgrywka. Patrząc na nią, nietrudno jest znaleźć odpowiednią drogę do tego celu: wręcz idealna wydaje się bowiem tablica dwuwymiarowa o rozmiarze 3 x 3.

Sama wielkość to jednak nie wszystko - należy także określić, jakiego typu elementy ma zawierać ta tablica. Aby to uczynić, pomyślmy, co się dzieje z planszą podczas rozgrywki. Na początku zawiera ona wyłącznie puste pola; potem kolejno pojawiają się w nich kółka lub krzyżyki. Czy już wiesz, jaki typ będzie właściwy?. Naturalnie, chodzi tu o odpowiedni typ wyliczeniowy , dopuszczający jedynie trzy możliwe wartości: pole puste, kółko lub krzyżyk. To było od początku oczywiste, prawda? :)

Ostatecznie plansza będzie wyglądać w ten sposób:

enum FIELD { FLD_EMPTY, FLD_CIRCLE, FLD_CROSS };
FIELD g_aPlansza[ 3 ][ 3 ] = { { FLD_EMPTY, FLD_EMPTY, FLD_EMPTY },
{ FLD_EMPTY, FLD_EMPTY, FLD_EMPTY },
{ FLD_EMPTY, FLD_EMPTY, FLD_EMPTY } };

Inicjalizacja jest tu odzwierciedleniem faktu, iż na początku wszystkie jej pola są puste.

Plansza to jednakowoż nie wszystko. W naszej grze będzie się przecież coś dziać: gracze dokonywać będą swych kolejnych posunięć. Potrzebujemy więc zmiennych opisujących przebieg rozgrywki.

Ich wyodrębnienie nie jest już takie łatwe, aczkolwiek nie powinniśmy mieć z tym wielkich kłopotów. Musimy mianowicie pomyśleć o grze w kółko i krzyżyk jako o procesie przebiegającym etapami , według określonego schematu. To nas doprowadzi do pierwszej zmiennej, określającej aktualny stan gry :

enum GAMESTATE { GS_NOTSTARTED, // gra nie została rozpoczęta
GS_MOVE, // gra rozpoczęta, gracze wykonują ruchy
GS_WON, // gra skończona, wygrana któregoś gracza
GS_DRAW }; // gra skończona, remis
GAMESTATE g_StanGry = GS_NOTSTARTED;

Wyróżniliśmy tutaj cztery fazy:

  • początkowa - właściwa gra jeszcze się nie rozpoczęła, czynione są pewne przygotowania (o których wspomnimy nieco dalej)

  • rozgrywka - uczestniczący w niej gracze naprzemiennie wykonują ruchy. Jest to zasadnicza część całej gry i trwa najdłużej.

  • wygrana - jeden z graczy zdołał ułożyć linię ze swoich symboli i wygrał partię

  • remis - plansza została szczelnie zapełniona znakami zanim którykolwiek z graczy zdołał zwyciężyć

Czy to wystarczy? Nietrudno się domyślić, że nie. Nie przewidzieliśmy bowiem żadnego sposobu na przechowywanie informacji o tym, który z graczy ma w danej chwili wykonać swój ruch. Czym prędzej zatem naprawimy swój błąd:

enum SIGN { SGN_CIRCLE, SGN_CROSS };
SIGN g_AktualnyGracz;

Zauważmy, iż nie posiadamy o graczach żadnych dodatkowych wiadomości ponad fakt, jakie znaki (kółko czy krzyżyk) stawiają oni na planszy. Informacja ta jest zatem jedynym kryterium, pozwalającym na ich odróżnienie - toteż skrzętnie z niej korzystamy, deklarując zmienną odpowiedniego typu wyliczeniowego.

Zamodelowanie właściwych struktur danych kontrolujących przebieg gry to jedna z ważniejszych czynności przy jej projektowaniu. W naszym przypadku są one bardzo proste (jedynie dwie zmienne), jednak zazwyczaj przyjmują znacznie bardziej skomplikowaną formę. W swoim czasie zajmiemy się dokładniej tym zagadnieniem.

Zdaje się, że to już wszystkie zmienne, jakich będziemy potrzebować w naszym programie. Czas zatem zająć się jego drugą, równie ważną częścią, czyli kodem odpowiedzialnym za właściwe funkcjonowanie.

Działanie programu

Przed chwilą wprowadziliśmy sobie dwie zmienne, które będą nam pomocne w zaprogramowaniu przebiegu naszej gry od początku aż do końca. Teraz właśnie zajmiemy się tymże "szlakiem" programu, czyli sposobem, w jaki będzie on działał i prowadził rozgrywkę. Możemy go zilustrować na diagramie podobnym do poniższego:


Schemat 13. Przebieg gry w kółko i krzyżyk

Widzimy na nim, w jaki sposób następuje przejście pomiędzy poszczególnymi stanami gry, a więc kiedy i jak ma się zmieniać wartość zmiennej g_StanGry . Na tej podstawie moglibyśmy też określić funkcje, które są konieczne do napisania oraz ogólne czynności, jakie powinny one wykonywać.

Powyższy rysunek jest uproszczonym diagramem przejść stanów. To jeden z wielu rodzajów schematów, jakie można wykonać podczas projektowania programu.

Potrzebujemy jednak bardziej szczegółowego opisu. Lepiej jest też wykonać go teraz, podczas projektowania aplikacji, niż przekładać do czasu faktycznego programowania.

Przy okazji uściślania przebiegu programu postaramy się uwzględnić w nim także pominięte wcześniej, "drobne" szczegóły - jak choćby określenie aktualnego gracza i jego zmiana po każdym wykonanym ruchu.

Nasz nowy szkic może zatem wyglądać tak:


Schemat 14. Działanie programu do gry w kółko i krzyżyk

Można tutaj zauważyć czwórkę potencjalnych kandydatów na funkcje - są to sekwencje działań zawarte w zielonych polach. Faktycznie jednak dla dwóch ostatnich (wygranej oraz remisu) byłoby to pewnym nadużyciem, gdyż zawarte w nich operacje można z powodzeniem dołączyć do funkcji obsługującej rozgrywkę. Są to bowiem jedynie przypisania do zmiennej.

Ostatecznie mamy przewidziane dwie zasadnicze funkcje programu:

  • rozpoczęcie gry, realizowane na początku. Jej zadaniem jest przygotowanie rozgrywki, czyli przede wszystkim wylosowanie gracza zaczynającego

  • rozgrywka, a więc wykonywanie kolejnych ruchów przez graczy

Skoro wiemy już, jak nasza gra ma działać "od środka", nie od rzeczy będzie zajęcie się metodą jej komunikacji z żywymi użytkownikami-graczami.

Interfejs użytkownika

Hmm, jaki interfejs?.

Zazwyczaj pojęcie to utożsamiamy z okienkami, przyciskami, pola tekstowymi, paskami przewijania i innymi zdobyczami graficznych systemów operacyjnych. Tymczasem termin ten ma bardziej szersze znaczenie:

Interfejs użytkownika (ang. user interface ) to sposób, w jaki aplikacja prowadzi dialog z obsługującymi ją osobami. Obejmuje to zarówno pobieranie od nich danych wejściowych, jak i prezentację wyników pracy.

Niewątpliwie więc możemy czuć się uprawnieni, aby nazwać naszą skromną konsolę pełnowartościowym środkiem do realizacji interfejsu użytkownika! Pozwala ona przecież zarówno na uzyskiwanie informacji od osoby siedzącej za klawiaturą, jak i na wypisywanie przeznaczonych dla niej komunikatów programu.

Jak zatem mógłby wyglądać interfejs naszego programu?. Twoje dotychczasowe, bogate doświadczenie z aplikacjami konsolowymi powinny ułatwić ci odpowiedź na to pytanie.

Informacja, którą prezentujemy użytkownikowi, to oczywiście aktualny stan planszy. Nie będzie ona wprawdzie miała postaci rysunkowej, jednakże zwykły tekst całkiem dobrze sprawdzi się w roli "grafiki".

Po wyświetleniu bieżącego stanu rozgrywki można poprosić o gracza o wykonanie swojego ruchu. Gdybyśmy mogli obsłużyć myszkę, wtedy posunięcie byłoby po prostu kliknięciem, ale w tym wypadku musimy zadowolić się poleceniem wpisanym z klawiatury.

Ostatecznie wygląd naszego programu może być podobny do poniższego:


Screen 27. Interfejs użytkownika naszej gry

Przy okazji zauważyć można jedno z rozwiązań problemu pt. "Jak umożliwić wykonywanie ruchów, posługując się jedynie klawiaturą?" Jest nim tutaj ponumerowanie kolejnych elementów tablicy-planszy liczbami od 1 do 9, a następnie prośba do gracza o podanie jednej z nich. To chyba najwygodniejsza forma gry, jaką potrafimy osiągnąć w tych niesprzyjających, tekstowych warunkach.

Metodami na przeliczanie pomiędzy zwyczajnymi, dwoma współrzędnymi tablicy oraz tą jedną "nibywspółrzędną" zajmiemy się podczas właściwego programowania.

***

Na tym możemy już zakończyć wstępne projektowanie naszego projektu :) Ustaliliśmy sposób jego działania, używane przezeń struktury danych, a nawet interfejs użytkownika. Wszystko to ułatwi nam pisanie kodu całej aplikacji, które to rozpoczniemy już za chwilę.

To był tylko skromny i bardzo nieformalny wstęp do dziedziny informatyki zwanej inżynierią oprogramowania. Zajmuje się ona projektowaniem wszelkiego rodzaju programów, poczynając każdy od pomysłu i prowadząc poprzez model, kod, testowanie i wreszcie użytkowanie. Jeżeli chciałbyś się dowiedzieć więcej na ten interesujący i przydatny temat, zapraszam do Materiału Pomocniczego C, Podstawy inżynierii oprogramowania (aczkolwiek zalecam najpierw skończenie tej części kursu).

Kodowanie

Nareszcie możemy uruchomić swoje ulubione środowisko programistyczne, wspierające ulubiony język programowania C++ i zacząć właściwe programowanie zaprojektowanej już gry. Uczyń to więc, stwórz w nim nowy projekt, nazywając go dowolnie 1 , i czekaj na dalsze rozkazy ;D

Kilka modułów i własne nagłówki

Na początek utworzymy i dodamy do projektu wszystkie pliki, z jakich docelowo ma się składać. Zgadza się - pliki . Pisany przez nas program może okazać się całkiem duży, dlatego rozsądnie będzie podzielić jego kod pomiędzy kilka odrębnych modułów. Utrzymamy wtedy jego względny porządek oraz skrócimy czas kolejnych kompilacji.

Zwyczajowo zaczniemy od pliku main.cpp , w którym umieścimy główną funkcję programu, main() . Chwilowo jednak nie wypełnimy ją żadną treścią:

void main()
{
}

Zamiast tego wprowadzimy do projektu jeszcze jeden moduł, w którym wpiszemy właściwy kod naszej gry. Przy pomocy opcji menu Project|Add New Item dodaj więc do aplikacji drugi już plik typu C++ File (.cpp) i nazwij go game.cpp . W tym module znajdą się wszystkie zasadnicze funkcje programu.

To jednak nie wszystko! Na deser zostawiłem bowiem pewną nowość, z którą nie mieliśmy okazji się do tej pory zetknąć. Stworzymy mianowicie swój własny plik nagłówkowy , idący w parze ze świeżo dodanym modułem game.cpp . Uczynimy to podobny sposób, co dotychczas - z tą różnicą, iż tym razem zmienimy typ dodawanego pliku na Header File (.h) .


Screen 28. Dodawanie pliku nagłówkowego do projektu

Po co nam taki własny nagłówek? W jakim celu w ogóle tworzyć nagłówki we własnych projektach?.

Na powyższe pytania istnieje dosyć prosta odpowiedź. Aby ją poznać przypomnijmy sobie, dlaczego dołączamy do naszych programów nagłówki w rodzaju iostream czy conio.h . Hmm?.

Tak jest - dzięki nim jesteśmy w stanie korzystać z takich dobrodziejstw języka C++ jak strumienie wejścia i wyjścia czy łańcuchy znaków. Generalizując, można powiedzieć, że nagłówki udostępniają pewien kod wszystkim modułom, które dołączą je przy pomocy dyrektywy #include .

Dotychczas nie zastanawialiśmy się zbytnio nad miejscem, w którym egzystuje kod wykorzystywany przez nas za pośrednictwem nagłówków. Faktycznie może on znajdować się "tuż obok" - w innym module tego samego projektu (i tak będzie u nas), lecz równie dobrze istnieć jedynie w skompilowanej postaci, na przykład biblioteki DLL.

W przypadku dodanego właśnie nagłówka game.h mamy jednak niczym nieskrępowany dostęp do odpowiadającego mu modułu game.cpp . Zdawałoby się zatem, że plik nagłówkowy jest tu całkowicie zbędny, a z kodu zawartego we wspomnianym module moglibyśmy z powodzeniem korzystać bezpośrednio.

Nic bardziej błędnego! Za użyciem pliku nagłówkowego przemawia wiele argumentów, a jednym z najważniejszych jest zasada ograniczonego zaufania . Według niej każda cząstka programu powinna posiadać dostęp jedynie do tych jego fragmentów, które są niezbędne do jej prawidłowego funkcjonowania.

U nas tą cząstką będzie funkcja main() , zawarta w module main.cpp . Nie napisaliśmy jej jeszcze, ale potrafimy już określić, czego będzie potrzebowała do swego poprawnego działania. Bez wątpienia będą dlań konieczne funkcje odpowiedzialne za wykonywanie posunięć wskazanych przez graczy czy też procedury wyświetlające aktualny stan rozgrywki. Sposób, w jaki te zadania są realizowane, nie ma jednak żadnego znaczenia! Podobnie przecież nie jesteśmy zobligowani do wiedzy o szczegółach funkcjonowania strumieni konsoli, a mimo to stale z nich korzystamy.

Plik nagłówkowy pełni więc rolę swoistej zasłony, przykrywającej nieistotne detale implementacyjne, oraz klucza do tych zasobów programistycznych (typów, funkcji, zmiennych, itd.), którymi rzeczywiście chcemy się dzielić.

Dlaczego w zasadzie mamy się z podobną nieufnością odnosić do, bądź co bądź, samego siebie? Czy rzeczywiście w tym przypadku lepiej wiedzieć mniej niż więcej?.
Główną przyczyną, dla której zasadę ograniczonego zaufania uznaje się za powszechnie słuszną, jest fakt, iż wprowadza ona sporo porządku do każdego kodu. Chroni też przed wieloma błędami spowodowanymi np. nadaniem jakiejś zmiennej wartości spoza dopuszczalnego zakresu czy też wywołania funkcji w złym kontekście lub z nieprawidłowymi parametrami.

Nagłówki są też pewnego rodzaju "spisem treści" kodu źródłowego modułu czy biblioteki. Zawierają najczęściej deklaracje wszystkich typów oraz funkcji, więc mogą niekiedy służyć za prowizoryczną dokumentację 2 danego fragmentu programu, szczególnie przydatną w jego dalszym tworzeniu.

Z tego też powodu pliki nagłówkowe są najczęściej pierwszymi składnikami aplikacji, na których programista koncentruje swoją uwagę. Później stanowią one również podstawę do pisania właściwego kodu algorytmów.

My także zaczniemy kodowanie naszego programu od pliku game.h ; gotowy nagłówek będzie nam potem doskonałą pomocą naukową :)


1 Kompletny kod całej aplikacji jest zawarty w przykładach do tego rozdziału i opatrzony nazwą TicTacToe .

2 Nie chodzi tu o podręcznik użytkownika programu, ale raczej o jego dokumentację techniczną, czyli opis działania aplikacji od strony programisty.

Treść pliku nagłówkowego

W nagłówku game.h umieścimy przeróżne deklaracje większości tworów programistycznych, wchodzących w skład naszej aplikacji. Będą to chociażby zmienne oraz funkcje.

Rozpoczniemy jednak od wpisania doń definicji trzech typów wyliczeniowych, które ustaliliśmy podczas projektowania programu. Chodzi naturalnie o SIGN , FIELD i GAMESTATE :

enum SIGN { SGN_CIRCLE, SGN_CROSS };
enum FIELD { FLD_EMPTY, FLD_CIRCLE, FLD_CROSS };
enum GAMESTATE { GS_NOTSTARTED, GS_MOVE, GS_WON, GS_DRAW };

Jest to powszechny zwyczaj w C++. Powyższe linijki moglibyśmy wszakże z równym powodzeniem umieścić wewnątrz modułu game.cpp . Wyodrębnienie ich w pliku nagłówkowym ma jednak swoje uzasadnienie: własne typy zmiennych są bowiem takimi zasobami, z których najczęściej korzysta większa część danego programu. Jako kod współdzielony (ang. shared ) są więc idealnym kandydatem do umieszczenia w odpowiednim nagłówku.

W dalszej części pomyślimy już o konkretnych funkcjach, którym powierzymy zadanie kierowania naszą grą. Pamiętamy z fazy projektowania, iż przewidzieliśmy przynajmniej dwie takie funkcje: odpowiedzialną za rozpoczęcie gry oraz za przebieg rozgrywki, czyli wykonywanie ruchów i sprawdzanie ich skutku. Możemy jeszcze dołożyć do nich algorytm "rysujący" (jeśli można tak powiedzieć w odniesieniu do konsoli) aktualny stan planszy.

Teraz sprecyzujemy nieco nasze pojęcie o tych funkcjach. Do pliku nagłówkowego wpiszemy bowiem ich prototypy :

// prototypy funkcji
//------------------
// rozpoczęcie gry

bool StartGry();
// wykonanie ruchu
bool Ruch( unsigned );
// rysowanie planszy
bool RysujPlansze();

Cóż to takiego? Prototypy, zwane też deklaracjami funkcji, są jakby ich nagłówkami oddzielonymi od bloku zasadniczego kodu (ciała). Mając prototyp funkcji, posiadamy informacje o jej nazwie , typach parametrów oraz typie zwracanej wartości . Są one wystarczające do jej wywołania, aczkolwiek nic nie mówią o faktycznych czynnościach, jakie dana funkcja wykonuje.

Prototyp ( deklaracja ) funkcji to wstępne określenie jej nagłówka. Stanowi on informację dla kompilatora i programisty o sposobie, w jaki funkcja może być wywołana.

Z punktu widzenia kodera dołączającego pliki nagłówkowe prototyp jest furtką do skarbca, przez którą można przejść jedynie z zawiązanymi oczami. Niesie wiedzę o tym, co prototypowana funkcja robi, natomiast nie daje żadnych wskazówek o sposobie, w jaki to czyni. Niemniej jest on nieodzowny, aby rzeczoną funkcję móc wywołać.

Warto wiedzieć, że dotychczas znana nam forma funkcji jest zarówno jej prototypem (deklaracją), jak i definicją (implementacją). Prezentuje bowiem pełnię wiadomości potrzebnych do jej wywołania, a poza tym zawiera wykonywalny kod funkcji.

Dla nas, przyszłych autorów zadeklarowanych właśnie funkcji, prototyp jest kolejną okazją do zastanowienia się nad kodem poszczególnych procedur programu. Precyzując ich parametry i zwracane wartości, budujemy więc solidne fundamenty pod ich niedalekie zaprogramowanie.

Dla formalności zerknijmy jeszcze na składnię prototypu funkcji:

typ_zwracanej_wartości / void nazwa_funkcji ( [ typ_parametru [ nazwa ] , ... ] );

Oprócz uderzającego podobieństwa do jej nagłówka rzuca się w oczy również fakt, iż na etapie deklaracji nie jest konieczne podawanie nazw ewentualnych parametrów funkcji. Dla kompilatora w zupełności bowiem wystarczają ich typy.

Już któryś raz z kolei uczulam na kończący instrukcję średnik . Bez niego kompilator będzie oczekiwał bloku kodu funkcji, a przecież istotą prototypu jest jego niepodawanie.

Właściwy kod gry

Zastanowienie może budzić powód, dla którego żadna z trzech powyższych funkcji nie została zadeklarowana jako void . Przecież zgodnie z tym, co ustaliliśmy podczas projektowania wszystkie mają przede wszystkim wykonywać jakieś działania, a nie obliczać wartości.

To rzeczywiście prawda. Rezultat zwracany przez te funkcje ma jednak inną rolę - będzie informował o powodzeniu lub niepowodzeniu danej operacji. Typ bool zapewnia tutaj najprostszą możliwą obsługę ewentualnych błędów . Warto o niej pomyśleć nawet wtedy, gdy pozornie nic złego nie może się zdarzyć. Wyrabiamy sobie w ten sposób dobre nawyki programistyczne, które zaprocentują w przyszłych, znacznie większych aplikacjach.

A co z parametrami tych funkcji, a dokładniej z jedynym argumentem procedury Ruch() ? Wydaje mi się, iż łatwo jest dociec jego znaczenia: to bowiem elementarna wielkość, opisująca posunięcie zamierzone przez gracza. Jej sens został już zaprezentowany przy okazji projektu interfejsu użytkownika: chodzi po prostu o wprowadzony z klawiatury numer pola, na którym ma być postawione kółko lub krzyżyk.

Zaczynamy

Skoro wiemy już dokładnie, jak wyglądają wizytówki naszych funkcji oraz z grubsza znamy należyte algorytmy ich działania, napisanie odpowiedniego kodu powinno być po prostu dziecinną igraszką, prawda?. :) Dobre samopoczucie może się jednak okazać przedwczesne, gdyż na twoim obecnym poziomie zaawansowania zadanie to wcale nie należy do najłatwiejszych. Nie zostawię cię jednak bez pomocy!

Dla szczególnie ambitnych proponuję aczkolwiek samodzielne dokończenie całego programu, a następnie porównanie go z kodem dołączonym do kursu. Samodzielne rozwiązywanie problemów jest bowiem istotą i najlepszą drogą nauki programowania!
Podczas zmagania się z tym wyzwaniem możesz jednak (i zapewne będziesz musiał) korzystać z innych źródeł informacji na temat programowania w C++, na przykład MSDN. Wiadomościami, które niemal na pewno okażą ci się przydatne, są dokładne informacje o plikach nagłówkowych i związanej z nimi dyrektywie #include oraz słowie kluczowym extern . Poszukaj ich w razie napotkania nieprzewidzianych trudności.
Jeżeli poradzisz sobie z tym niezwykle trudnym zadaniem, będziesz mógł być z siebie niewypowiedzianie dumny :D Nagrodą będzie też cenne doświadczenie, którego nie zdobędziesz inną drogą!

Mamy więc zamiar pisać instrukcje stanowiące blok kodu funkcji, przeto powinniśmy umieścić je wewnątrz modułu, a nie pliku nagłówkowego. Dlatego też chwilowo porzucamy game.h i otwieramy nieskażony jeszcze żadnym znakiem plik game.cpp .

Nie znaczy to wszak, że nie będziemy naszego nagłówka w ogóle potrzebować. Przeciwnie, jest ona nam niezbędny - zawiera przecież definicje trzech typów wyliczeniowych, bez których nie zdołamy się obejść.

Powinniśmy zatem dołączyć go do naszego modułu przy pomocy poznanej jakiś czas temu i stosowanej nieustannie dyrektywy #include :

#include "game.h"

Zwróćmy uwagę, iż, inaczej niż to mamy w zwyczaju, ujęliśmy nazwę pliku nagłówkowego w cudzysłowy zamiast nawiasów ostrych. Jest to konieczne; w ten sposób należy zaznaczać nasze własne nagłówki, aby odróżnić je od "fabrycznych" ( iostream , cmath itp.)

Nazwę dołączanego pliku nagłówkowego należy umieszczać w cudzysłowach ( "" ), jeśli jest on w tym samym katalogu co moduł, do którego chcemy go dołączyć. Może być on także w jego pobliżu (nad- lub podkatalogu) - wtedy używa się względnej ścieżki do pliku (np. "..\plik.h" ).

Dołączenie własnego nagłówka nie zwalnia nas jednak od wykonania tej samej czynności na dwóch innych tego typu plikach:

#include <iostream>
#include <ctime>

Są one konieczne do prawidłowego funkcjonowania kodu, który napiszemy za chwilę.

Deklarujemy zmienne

Włączając plik nagłówkowy game.h mamy do dyspozycji zdefiniowane w nim typy SIGN , FIELD i GAMESTATE . Logiczne będzie więc zadeklarowanie należących doń zmiennych g_aPlansza , g_StanGry i g_AktualnyGracz :

FIELD g_aPlansza[ 3 ][ 3 ] = { { FLD_EMPTY, FLD_EMPTY, FLD_EMPTY },
{ FLD_EMPTY, FLD_EMPTY, FLD_EMPTY },
{ FLD_EMPTY, FLD_EMPTY, FLD_EMPTY } };
GAMESTATE g_StanGry = GS_NOTSTARTED;
SIGN g_AktualnyGracz;

Skorzystamy z nich niejednokrotnie w kodzie modułu game.cpp , zatem powyższe linijki należy umieścić poza wszelkimi funkcjami.

Funkcja StartGry()

Nie jest to trudne, skoro nie napisaliśmy jeszcze absolutnie żadnej funkcji :) Niezwłocznie więc zabieramy się do pracy. Rozpoczniemy od tej procedury, która najszybciej da o sobie znać w gotowym programie - czyli StartGry() .

Jak pamiętamy, jej rolą jest przede wszystkim wylosowanie gracza, który rozpocznie rozgrywkę. Wcześniej jednak przydałoby się, aby funkcja sprawdziła, czy jest wywoływana w odpowiednim momencie - gdy gra faktycznie się jeszcze nie zaczęła:

if (g_StanGry != GS_NOTSTARTED) return false ;

Jeżeli warunek ten nie zostanie spełniony, funkcja zwróci wartość wskazującą na niepowodzenie swych działań.

Jakich działań? Nietrudno zapisać je w postaci kodu C++:

// losujemy gracza, który będzie zaczynał
srand ( static_cast < unsigned >(time(NULL)));
g_AktualnyGracz = (rand() % 2 == 0 ? SGN_CIRCLE : SGN_CROSS);
// ustawiamy stan gry na ruch graczy
g_StanGry = GS_MOVE;

Losowanie liczby z przedziału < 0 ; 2 ) jest nam czynnością na wskroś znajomą. W połączeniu z operatorem warunkowym ?: pozwala na realizację pierwszego z celów funkcji. Drugi jest tak elementarny, że w ogóle nie wymaga komentarza. W końcu nie od dziś stykamy się z przypisaniem wartości do zmiennej :)

To już wszystko, co było przewidziane do zrobienia przez naszą funkcję StartGry() . W pełni usatysfakcjonowani możemy więc zakończyć ją zwróceniem informacji o pozytywnym rezultacie podjętych akcji:

return true ;

Wywołujący otrzyma więc wiadomość o tym, że czynności zlecone funkcji zostały zakończone z sukcesem.

Funkcja Ruch()

Kolejną funkcją, na której spocznie nasz wzrok, jest Ruch() . Ma ona za zadanie umieścić w podanym polu znak aktualnego gracza (kółko lub krzyżyk) oraz sprawdzić stan planszy pod kątem ewentualnej wygranej któregoś z graczy lub remisu. Całkiem sporo do zrobienia, zatem do pracy, rodacy! ;D

Pamiętamy oczywiście, że rzeczona funkcja ma przyjmować jeden parametr typu unsigned , więc jej szkielet wyglądać będzie następująco:

bool Ruch( unsigned uNumerPola)
{
// ...
}

Na początku dokonamy tutaj podobnej co poprzednio kontroli ewentualnego błędu w postaci złego stanu gry. Dodamy jeszcze warunek sprawdzający, czy zadany numer pola zawiera się w przedziale < 1 ; 9 > . Całość wygląda następująco:

if (g_StanGry != GS_MOVE) return false ;
if (!(uNumerPola >= 1 && uNumerPola <= 9 )) return false ;

Jeżeli punkt wykonania pokona obydwie te przeszkody, należałoby uczynić ruch, o który użytkownik (za pośrednictwem parametru uNumerPola ) prosi. W tym celu konieczne jest przeliczenie, zamieniające pojedynczy numer pola (z zakresu od 1 do 9) na dwa indeksy naszej tablicy g_aPlansza (każdy z przedziału od 0 do 2). Pomocy może nam tu udzielić wizualny diagram, na przykład taki:


Schemat 15. Numerowanie pól planszy do gry w kółko i krzyżyk

Odpowiednie formułki, wyliczające współrzędną pionową ( uY ) i poziomą ( uX ) można napisać, wykorzystując dzielenie całkowitoliczbowe oraz resztę z niego:

unsigned uY = (uNumerPola - 1 ) / 3 ;
unsigned uX = (uNumerPola - 1 ) % 3 ;

Odjęcie jedynki jest spowodowane faktem, iż w C++ tablice są indeksowane od zera (poza tym jest to dobra okazja do przypomnienia tej ważnej kwestii :D).

Mając już obliczone oba indeksy, możemy spróbować postawić symbol aktualnego gracza w podanym polu. Uda się to jednak wyłącznie wtedy, gdy nikt nas tutaj nie uprzedził - a więc kiedy wskazane pole jest puste, co kontrolujemy dodatkowym testem:

if (g_aPlansza[uY][uX] == FLD_EMPTY)
// wstaw znak aktualnego gracza w podanym polu
else
return false ;

Jeśli owa kontrola się powiedzie, musimy zrealizować zamierzenie i wstawić kółko lub krzyżyk - zależnie do tego, który gracz jest teraz uprawniony do ruchu - w żądanie miejsce. Informację o aktualnym graczu przechowuje rzecz jasna zmienna g_AktualnyGracz . Niemożliwe jest jednak jej zwykłe przypisanie w rodzaju:

g_aPlansza[uY][uX] = g_AktualnyGracz;

Wystąpiłby tu bowiem konflikt typów, gdyż FIELD i SIGN są typami wyliczeniowymi, nijak ze sobą niekompatybilnymi. Czyżbyśmy musieli zatem uciec się do topornej instrukcji switch ?

Odpowiedź na szczęście brzmi nie. Inne, lepsze rozwiązanie polega na "dopasowaniu" do siebie stałych obu typów, reprezentujących kółko i krzyżyk. Niech będą one sobie równe; w tym celu zmodyfikujemy definicję FIELD (w pliku game.h ):

enum FIELD { FLD_EMPTY,
FLD_CIRCLE = SGN_CIRCLE,
FLD_CROSS = SGN_CROSS };

Po tym zabiegu cała operacja sprowadza się do zwykłego rzutowania:

g_aPlansza[uY][uX] = static_cast <FIELD>(g_AktualnyGracz);

Liczbowe wartości obu zmiennych będą się zgadzać, ale interpretacja każdej z nich będzie odmienna. Tak czy owak, osiągnęliśmy obrany cel, więc wszystko jest w porządku :)

Niedługo zresztą ponownie skorzystamy z tej prostej i efektywnej sztuczki.

Nasza funkcja wykonuje już połowę zadań, do których ją przeznaczyliśmy. Niestety, mniejszą połowę :D Oto bowiem mamy przed sobą znacznie poważniejsze wyzwanie niż kilka if -ów, a mianowicie zaprogramowanie algorytmu lustrującego planszę i stwierdzającego na jej podstawie ewentualną wygraną któregoś z graczy lub remis. Trzeba więc zakasać rękawy i wytężyć intelekt.

Zajmijmy się na razie wykrywaniem zwycięstw. Doskonale chyba wiemy, że do wygranej w naszej grze potrzebne jest graczowi utworzenie z własnych znaków linii poziomej, pionowej lub ukośnej, obejmującej trzy pola. Łącznie mamy więc osiem możliwych linii, a dla każdej po trzy pola opisane dwiema współrzędnymi. Daje nam to, bagatelka, 48 warunków do zakodowania, czyli 8 makabrycznych instrukcji if z sześcioczłonowymi (!) wyrażeniami logicznymi w każdej! Brr, brzmi to wręcz okropnie.

Jak to jednak nierzadko bywa, istnieje rozwiązanie alternatywne, które jest z reguły lepsze :) Tym razem jest nim użycie tablicy przeglądowej, w którą wpiszemy wszystkie wygrywające zestawy pól: osiem linii po trzy pola po dwie współrzędne daje nam ostatecznie taką oto, nieco zakręconą, stałą 1 :

const LINIE[][ 3 ][ 2 ] = { { { 0 , 0 }, { 0 , 1 }, { 0 , 2 } }, // górna pozioma
{ { 1 , 0 }, { 1 , 1 }, { 1 , 2 } }, // środ. pozioma
{ { 2 , 0 }, { 2 , 1 }, { 2 , 2 } }, // dolna pozioma
{ { 0 , 0 }, { 1 , 0 }, { 2 , 0 } }, // lewa pionowa
{ { 0 , 1 }, { 1 , 1 }, { 2 , 1 } }, // środ. pionowa
{ { 0 , 2 }, { 1 , 2 }, { 2 , 2 } }, // prawa pionowa
{ { 0 , 0 }, { 1 , 1 }, { 2 , 2 } }, // p. backslashowa
{ { 2 , 0 }, { 1 , 1 }, { 0 , 2 } } }; // p. slashowa

Przy jej deklarowaniu korzystaliśmy z faktu, iż w takich wypadkach pierwszy wymiar tablicy można pominąć, lecz równie poprawne byłoby wpisanie tam 8 explicité .

A zatem mamy już tablicę przeglądową . Przydałoby się więc jakoś ją przeglądać :) Oprócz tego mamy jednak dodatkowy cel, czyli znalezienie linii wypełnionej tymi samymi znakami, nasze przeglądanie będzie wobec tego nieco skomplikowane i przedstawia się następująco:

FIELD Pole, ZgodnePole;
unsigned uLiczbaZgodnychPol;
for ( int i = 0 ; i < 8 ; ++i)
{
// i przebiega po kolejnych możliwych liniach (jest ich osiem)
// zerujemy zmienne pomocnicze

Pole = ZgodnePole = FLD_EMPTY; // obie zmienne == FLD_EMPTY
uLiczbaZgodnychPol = 0 ;
for ( int j = 0 ; j < 3 ; ++j)
{
// j przebiega po trzech polach w każdej linii
// pobieramy rzeczone pole
// to zdecydowanie najbardziej pogmatwane wyrażenie :)

Pole = g_aPlansza[LINIE[i][j][ 0 ]][LINIE[i][j][ 1 ]];
// jeśli sprawdzane pole różne od tego, które ma się zgadzać...
if (Pole != ZgodnePole)
{
// to zmieniamy zgadzane pole na to aktualne
ZgodnePole = Pole;
uLiczbaZgodnychPol = 1 ;
}
else
// jeśli natomiast oba pola się zgadzają, no to // inkrementujemy licznik takich zgodnych pól
++uLiczbaZgodnychPol;
}
// teraz sprawdzamy, czy udało nam się zgodzić linię
if (uLiczbaZgodnychPol == 3 && ZgodnePole != FLD_EMPTY)
{
// jeżeli tak, no to ustawiamy stan gry na wygraną
g_StanGry = GS_WON;
// przerywamy pętlę i funkcję
return true ;
}
}

" No nie" - powiesz pewnie - "Teraz to już przesadziłeś!" ;) Ja jednak upieram się, iż nie całkiem masz rację, a podany algorytm tylko wygląda strasznie, lecz w istocie jest bardzo prosty.

Na początek deklarujemy sobie trzy zmienne pomocnicze, które wydatnie przydadzą się w całej operacji. Szczególną rolę spełnia tu uLiczbaZgodnychPol ; jej nazwa mówi wiele. Zmienna ta będzie przechowywała liczbę identycznych pól w aktualnie badanej linii - wartość równa 3 stanie się więc podstawą do stwierdzenia obecności wygrywającej kombinacji znaków.

Dalej przystępujemy do sprawdzania wszystkich ośmiu interesujących sytuacji, determinujących ewentualne zwycięstwo. Na scenę wkracza więc pętla for ; na początku jej cyklu dokonujemy zerowania wartości zmiennych pomocniczych, aby potem. wpaść w kolejną pętlę :) Ta jednak będzie przeskakiwała po trzech polach każdej ze sprawdzanych linii:

for ( int j = 0 ; j < 3 ; ++j)
{
Pole = g_aPlansza[LINIE[i][j][ 0 ]][LINIE[i][j][ 1 ]];
if (Pole != ZgodnePole)
{
ZgodnePole = Pole;
uLiczbaZgodnychPol = 1 ;
}
else
++uLiczbaZgodnychPol;
}

Koszmarnie wyglądająca pierwsza linijka bloku powyższej pętli nie będzie wydawać się aż tak straszne, jeśli uświadomimy sobie, iż LINIE[i][j][ 0 ] oraz LINIE[i][j][ 1 ] to odpowiednio: współrzędna pionowa oraz pozioma j -tego pola i -tej potencjalnie wygrywającej linii. Słusznie więc używamy ich jako indeksów tablicy g_aPlansza , pobierając stan pola do sprawdzenia.

Następująca dalej instrukcja warunkowa rozstrzyga, czy owe pole zgadza się z ewentualnymi poprzednimi - tzn. jeżeli na przykład poprzednio sprawdzane pole zawierało kółko, to aktualne także powinno mieścić ten symbol. W przypadku gdy warunek ten nie jest spełniony, sekwencja zgodnych pól "urywa się", co oznacza w tym wypadku wyzerowanie licznika uLiczbaZgodnychPol . Sytuacja przeciwstawna - gdy badane pole jest już którymś z kolei kółkiem lub krzyżykiem - skutkuje naturalnie zwiększeniem tegoż licznika o jeden.

Po zakończeniu całej pętli (czyli wykonaniu trzech cykli, po jednym dla każdego pola) następuje kontrola otrzymanych rezultatów. Najważniejszym z nich jest wspomniany licznik uLiczbaZgodnychPol , którego wartość konfrontujemy z trójką. Jednocześnie sprawdzamy, czy "zgodzone" pole nie jest przypadkiem polem pustym, bo przecież z takiej zgodności nic nam nie wynika. Oba te testy wykonuje instrukcja:

if (uLiczbaZgodnychPol == 3 && ZgodnePole != FLD_EMPTY)

Spełnienie tego warunku daje pewność, iż mamy do czynienia z prawidłową sekwencją trzech kółek lub krzyżyków. Słusznie więc możemy wtedy przyznać palmę zwycięstwa aktualnemu graczowi i zakończyć całą funkcję:

g_StanGry = GS_WON;
return true ;

W przeciwnym wypadku nasza główna pętla się zapętla w swym kolejnym cyklu i bada w nim kolejną ustaloną linię symboli - i tak aż do znalezienia pasującej kolumny, rzędu lub przekątnej albo wyczerpania się tablicy przeglądowej LINIE .

Uff?. Nie, to jeszcze nie wszystko! Nie zapominajmy przecież, że zwycięstwo nie jest jedynym możliwych rozstrzygnięciem rozgrywki. Drugim jest remis - zapełnienie wszystkich pól planszy symbolami graczy bez utworzenia żadnej wygrywającej linii.

Jak obsłużyć taką sytuację? Wbrew pozorom nie jest to wcale trudne, gdyż możemy wykorzystać do tego fakt, iż przebycie przez program poprzedniej, wariackiej pętli oznacza nieobecność na planszy żadnych ułożeń zapewniających zwycięstwo. Niejako "z miejsca" mamy więc spełniony pierwszy warunek konieczny do remisu.

Drugi natomiast - szczelne wypełnienie całej planszy - jest bardzo łatwy do sprawdzenia i wymagania jedynie zliczenia wszystkich niepustych jej pól:

unsigned uLiczbaZapelnionychPol = 0 ;
for ( int i = 0 ; i < 3 ; ++i)
for ( int j = 0 ; j < 3 ; ++j)
if (g_aPlansza[i][j] != FLD_EMPTY)
++uLiczbaZapelnionychPol;

Jeżeli jakimś dziwnym sposobem ilość ta wyniesie 9, znaczyć to będzie, że gra musi się zakończyć z powodu braku wolnych miejsc :) W takich okolicznościach wynikiem rozgrywki będzie tylko mało satysfakcjonujący remis:

if (uLiczbaZapelnionychPol == 3 * 3 )
{
g_StanGry = GS_DRAW;
return true ;
}

W taki oto sposób wykryliśmy i obsłużyliśmy obydwie sytuacje "wyjątkowe", kończące grę - zwycięstwo jednego z graczy lub remis. Pozostało nam jeszcze zajęcie się bardziej zwyczajnym rezultatem wykonania ruchu, kiedy to nie powoduje on żadnych dodatkowych efektów. Należy wtedy przekazać prawo do posunięcia drugiemu graczowi, co też czynimy:

g_AktualnyGracz = (g_AktualnyGracz == SGN_CIRCLE ?
SGN_CROSS : SGN_CIRCLE);

Przy pomocy operatora warunkowego zmieniamy po prostu znak aktualnego gracza na przeciwny (z kółka na krzyżyk i odwrotnie), osiągając zamierzony skutek.

Jest to jednocześnie ostatnia czynność funkcji Ruch() ! Wreszcie, po długich bojach i bólach głowy ;) możemy ją zakończyć zwróceniem bezwarunkowo pozytywnego wyniku:

return true ;

a następnie udać się po coś do jedzenia ;-)

Funkcja RysujPlansze()

Jako ostatnią napiszemy funkcję, której zadaniem będzie wyświetlenie na ekranie (czyli w oknie konsoli) bieżącego stanu gry:


Screen 29. Ekran gry w kółko i krzyżyk

Najważniejszą jego składową będzie naturalnie osławiona plansza, o zajęcie której toczą boje nasi dwaj gracze. Oprócz niej można jednak wyróżnić także kilka innych elementów. Wszystkie one będą "rysowane" przez funkcję RysujPlansze() . Niezwłocznie więc rozpocznijmy jej implementację!

Tradycyjnie już pierwsze linijki są szukaniem dziury w całym, czyli potencjalnego błędu. Tym razem usterką będzie wywołanie kodowanej właśnie funkcji przez rozpoczęciem właściwego pojedynku, gdyż w tej sytuacji nie ma w zasadzie nic do pokazania. Logiczną konsekwencją jest wtedy przerwanie funkcji:

if (g_StanGry == GS_NOTSTARTED) return false ;

Jako że jednak wierzymy w rozsądek programisty wywołującego pisaną teraz funkcję (czyli nomen-omen w swój własny), przejdźmy raczej do kodowania jej właściwej części "rysującej".

Od czego zaczniemy? Odpowiedź nie jest szczególnie trudna; co ciekawe, w przypadku każdej innej gry i jej odpowiedniej funkcji byłaby taka sama. Rozpoczniemy bowiem od wyczyszczenia całego ekranu (czyli konsoli) - tak, aby mieć wolny obszar działania. Dokonamy tego poprzez polecenie systemowe CLS, które wywołamy funkcją C++ o nazwie system() :

system ( "cls" );

Mając oczyszczone przedpole przystępujemy do zasadniczego rysowania. Ze względu na specyfikę tekstowej konsoli zmuszeni jesteśmy do zapełniania jej wierszami, od góry do dołu. Nie powinno nam to jednak zbytnio przeszkadzać.

Na samej górze umieścimy tytuł naszej gry, stały i niezmienny. Kod odpowiedzialny za tę czynność przedstawia się więc raczej trywialnie:

std::cout << " KOLKO I KRZYZYK " << std::endl;
std::cout << "---------------------" << std::endl;
std::cout << std::endl;

Żądnych wrażeń pocieszam jednak, iż dalej będzie już ciekawiej :) Oto mianowicie przystępujemy do prezentacji planszy w postaci tekstowej - z zaznaczonymi kółkami i krzyżykami postawionymi przez graczy oraz numerami wolnych pól. Operację tą przeprowadzamy w sposób następujący:

std::cout << " -----" << std::endl;
for ( int i = 0 ; i < 3 ; ++i)
{
// lewa część ramki
std::cout << " |" ;
// wiersz
for ( int j = 0 ; j < 3 ; ++j)
{
if (g_aPlansza[i][j] == FLD_EMPTY)
// numer pola
std::cout << i * 3 + j + 1 ;
else
// tutaj wyświetlamy kółko lub krzyżyk... ale jak? :)
}
// prawa część ramki
std::cout << "|" << std::endl;
}
std::cout << " -----" << std::endl;
std::cout << std::endl;

Cały kod to oczywiście znowu dwie zagnieżdżone pętle for - stały element pracy z dwuwymiarową tablicą. Zewnętrzna przebiega po poszczególnych wierszach planszy, zaś wewnętrzna po jej pojedynczych polach.

Wyświetlenie takiego pola oznacza pokazanie albo jego numerku (jeżeli jest puste), albo dużej litery O lub X, symulującej wstawione weń kółko lub krzyżyk. Numerek wyliczamy poprzez prostą formułkę i * 3 + j + 1 (dodanie jedynki to znowuż kwestia indeksów liczonych od zera), w której i jest numerem wiersza, zaś j - kolumny. Cóż jednak zrobić z drugim przypadkiem - zajętym polem? Musimy przecież rozróżnić kółka i krzyżyki.

Można oczywiście skorzystać z instrukcji if lub operatora ?: , jednak już raz zastosowaliśmy lepsze rozwiązanie. Dopasujmy mianowicie stałe typu FIELD (każdy element tablicy g_aPlansza należy przecież do tego typu) do znaków 'O' i 'X' . Przypatrzmy się najpierw definicji rzeczonego typu:

enum FIELD { FLD_EMPTY,
FLD_CIRCLE = SGN_CIRCLE,
FLD_CROSS = SGN_CROSS };

Widać nim skutek pierwszego zastosowania sztuczki, z której chcemy znowu skorzystać. Dotyczy on zresztą interesujących nas stałych FLD_CIRCLE i FLD_CROSS , równych odpowiednio SGN_CIRCLE i SGN_CROSS . Czy to oznacza, iż z triku nici?

Bynajmniej nie. Nie możemy wprawdzie bezpośrednio zmienić wartości interesujących nas stałych, ale możliwe jest "sięgnięcie do źródeł" i zmodyfikowanie SGN_CIRCLE oraz SGN_CROSS , zadeklarowanych w typie SIGN :

enum SIGN { SGN_CIRCLE = 'O' , SGN_CROSS = 'X' };

Tą drogą, pośrednio, zmienimy też wartości stałych FLD_CIRCLE i FLD_CROSS , przypisując im kody ANSI wielkich liter "O" i "X". Teraz już możemy skorzystać z rzutowania na typ char , by wyświetlić niepuste pole planszy:

std::cout << static_cast < char >(g_aPlansza[i][j]);

Kod rysujący obszar rozgrywki jest tym samym skończony.

Pozostał nam jedynie komunikat o stanie gry, wyświetlany najniżej. Zależnie od bieżących warunków (wartości zmiennej g_StanGry ) może on przyjmować formę prośby o wpisanie kolejnego ruchu lub też zwyczajnej informacji o wygranej lub remisie:

switch (g_StanGry)
{
case GS_MOVE:
// prośba o następny ruch
std::cout << "Podaj numer pola, w ktorym" << std::endl;
std::cout << "chcesz postawic " ;
std::cout << (g_AktualnyGracz == SGN_CIRCLE ?
"kolko" : "krzyzyk" ) << ": " ;
break ;
case GS_WON:
// informacja o wygranej
std::cout << "Wygral gracz stawiajacy " ;
std::cout << (g_AktualnyGracz == SGN_CIRCLE ?
"kolka" : "krzyzyki" ) << "!" ;
break ;
case GS_DRAW:
// informacja o remisie
std::cout << "Remis!" ;
break ;
}

Analizy powyższego kodu możesz z łatwością dokonać samodzielnie 2 .

Na tymże elemencie "scenografii" kończymy naszą funkcję RysujPlansze() , wieńcząc ją oczywiście zwyczajowym oddaniem wartości true :

return true ;

Możemy na koniec zauważyć, iż pisząc tą funkcję uporaliśmy się jednocześnie z elementem programu o nazwie "interfejs użytkownika" :D

Funkcja main() , czyli składamy program

Być może trudno w to uwierzyć, ale mamy za sobą zaprogramowanie wszystkich funkcji sterujących przebiegiem gry! Zanim jednak będziemy mogli cieszyć się działającym programem musimy wypełnić kodem główną funkcję aplikacji, od której zacznie się jej wykonywanie - main() .

W tym celu zostawmy już wymęczony moduł game.cpp i wróćmy do main.cpp , w którym czeka nietknięty szkielet funkcji main() . Poprzedzimy go najpierw dyrektywami dołączenia niezbędnych nagłówków - także naszego własnego, game.h :

#include <iostream>
#include <conio.h>
#include "game.h"

 

Własne pliki nagłówkowe najlepiej umieszczać na końcu szeregu instrukcji #include , dołączając je po tych pochodzących od kompilatora.

Teraz już możemy zająć się treścią najważniejszej funkcji w naszym programie. Zaczniemy od następującego wywołania:

StartGry();

Spowoduje ono rozpoczęcie rozgrywki - jak pamiętamy, oznacza to między innymi wylosowanie gracza, któremu przypadnie pierwszy ruch, oraz ustawienie stanu gry na GS_MOVE .

Od tego momentu zaczyna się więc zabawa, a nam przypada obowiązek jej prawidłowego poprowadzenia. Wywiążemy się z niego w nieznany dotąd sposób - użyjemy pętli nieskończonej :

for (;;)
{
// ...
}

Konstrukcja ta wcale nie jest taka dziwna, a w grach spotyka się ją bardzo często. Istota pętli nieskończonej jest częściowo zawarta w jej nazwie, a po części można ją wydedukować ze składni. Mianowicie, nie posiada ona żadnego warunku zakończenia 3 , więc w zasadzie wykonywałaby się do końca świata i o jeden dzień dłużej ;) Aby tego uniknąć, należy gdzieś wewnątrz jej bloku umieścić instrukcję break ; , która spowoduje przerwanie tego zaklętego kręgu. Uczynimy to, kodując kolejne instrukcje w tejże pętli.

Najpierw funkcja RysujPlansze() wyświetli nam aktualny stan rozgrywki:

RysujPlansze();

Pokaże więc tytuł gry, planszę oraz dolny komunikat - komunikat, który przez większość czasu będzie prośbą o kolejny ruch. By sprawdzić, czy tak jest w istocie, porównamy zmienną opisującą stan gry z wartością GS_MOVE :

if (g_StanGry == GS_MOVE)
{
unsigned uNumerPola;
std::cin >> uNumerPola;
Ruch (uNumerPola);
}

Pozytywny wynik wspomnianego testu słusznie skłania nas do użycia strumienia wejścia i pobrania od użytkownika numeru pola, w które chce wstawić swoje kółko lub krzyżyk. Przekazujemy go potem do funkcji Ruch() , serca naszej gry.

Następujące po sobie posunięcia graczy, czyli kolejne cykle pętli, doprowadzą w końcu do rozstrzygnięcia rozgrywki - czyjejś wygranej albo obustronnego remisu. I to jest właśnie warunek, na który czekamy:

else if (g_StanGry == GS_WON || g_StanGry == GS_DRAW)
break ;

Przerywamy wtedy pętlę, zostawiając na ekranie końcowy stan planszy oraz odpowiedni komunikat. Aby użytkownicy mieli szansę go zobaczyć, stosujemy rzecz jasna funkcję getch() :

getch();

Po odebraniu wciśnięcia dowolnego klawisza program może się już ze spokojem zamknąć ;)

Uroki kompilacji

Fanfary! Zdaje się, że właśnie zakończyliśmy kodowanie naszego wielkiego projektu! Nareszcie zatem możemy przeprowadzić jego kompilację i uzyskać gotowy do uruchomienia plik wykonywalny.

Zróbmy więc to! Uruchom Visual Studio (jeżeli je przypadkiem zamknąłeś), otwórz swój projekt, zamknij drzwi i okna, wyprowadź zwierzęta domowe, włącz automatyczną sekretarkę i wciśnij klawisz F7 (lub wybierz pozycję menu Build|Build Solution ).

***

Co się stało? Wygląda na to, że nie wszystko udało się tak dobrze, jak tego oczekiwaliśmy. Zamiast działającej aplikacji kompilator uraczył nas czterema błędami:

c:\Programy\TicTacToe\main.cpp(20) : error C2065: 'g_StanGry' : undeclared identifier
c:\Programy\TicTacToe\main.cpp(20) : error C2677: binary '==' : no global operator found which takes type 'GAMESTATE' (or there is no acceptable conversion)
c:\Programy\TicTacToe\main.cpp(28) : error C2677: binary '==' : no global operator found which takes type 'GAMESTATE' (or there is no acceptable conversion)
c:\Programy\TicTacToe\main.cpp(28) : error C2677: binary '==' : no global operator found which takes type 'GAMESTATE' (or there is no acceptable conversion)

Wszystkie one dotyczą tego samego, ale najwięcej mówi nam pierwszy z nich. Dwukrotnie kliknięcie na dotyczący go komunikat przeniesie nas bowiem do linijki:

if (g_StanGry == GS_MOVE)

Występuje w niej nazwa zmiennej g_StanGry , która, sądząc po owym komunikacie, jest tutaj uznawana za niezadeklarowaną .

Ale dlaczego?! Przecież z pewnością umieściliśmy jej deklarację w kodzie programu. Co więcej, stale korzystaliśmy z tejże zmiennej w funkcjach StartGry() , Ruch() i RysujPlansze() , do których kompilator nie ma najmniejszych zastrzeżeń. Czyżby więc tutaj dopadła go nagła amnezja?

Wyjaśnienie tego, jak by się wydawało, dość dziwnego zjawiska jest jednak w miarę logiczne. Otóż g_StanGry została zadeklarowana wewnątrz modułu game.cpp , więc jej zasięg ogranicza się jedynie do tegoż modułu. Funkcja main() , znajdująca się w pliku main.cpp , jest poza tym zakresem, zatem dla niej rzeczona zmienna po prostu nie istnieje . Nic dziwnego, iż kompilator staje się wobec nieznanej nazwy g_StanGry zupełnie bezradny.

Nasuwa się oczywiście pytanie: jak zaradzić temu problemowi? Co zrobić, aby nasza zmienna była dostępna wewnątrz funkcji main() ?. Chyba najszybciej pomyśleć można o przeniesieniu jej deklaracji w obszar wspólny dla obu modułów game.cpp oraz main.cpp . Takim współdzielonym terenem jest naturalnie plik nagłówkowy game.h . Czy należy więc umieścić tam deklarację GAMESTATE g_StanGry = GS_NOTSTARTED; ?

Niestety, nie jest to poprawne. Musimy bowiem wiedzieć, że zmienna nie może rezydować wewnątrz nagłówka! Jej prawidłowe zdefiniowanie powinno być zawsze umieszczone w module kodu. W przeciwnym razie każdy moduł, który dołączy plik nagłówkowy z definicją zmiennej, stworzy swoją własną kopię tejże! U nas znaczyłoby to, że zarówno main.cpp , jak i game.cpp posiadają zmienne o nazwach g_StanGry , ale są one od siebie całkowicie niezależne i "nie wiedzą o sobie nawzajem"!

Definicja musi zatem pozostać na swoim miejscu, ale plik nagłówkowy niewątpliwie nam się przyda. Mianowicie, wpiszemy doń następującą linijkę:

extern GAMESTATE g_StanGry;

Jest to tak zwana deklaracja zapowiadająca zmiennej. Jej zadaniem jest poinformowanie kompilatora, że gdzieś w programie 4 istnieje zmienna o podanej nazwie i typie. Deklaracja ta nie tworzy żadnego nowego bytu ani nie rezerwuje dlań miejsca w pamięci operacyjnej, lecz jedynie zapowiada (stąd nazwa), iż czynność ta zostanie wykonana. Obietnica ta może być spełniona podczas kompilacji lub (tak jak u nas) dopiero w czasie linkowania.

Z praktycznego punktu widzenia deklaracja extern (ang. external - zewnętrzny) pełni bardzo podobną rolę, co prototyp funkcji. Podaje bowiem jedynie minimum informacji, potrzebnych do skorzystania z deklarowanego tworu bez marudzenia kompilatora, a jednocześnie odkłada jego właściwą definicję w inne miejsce i/lub czas.

Deklaracja zapowiadająca (ang. forward declaration ) to częściowe określenie jakiegoś programistycznego bytu. Nie definiuje dokładnie wszystkich jego aspektów, ale wystarcza do skorzystania z niego wewnątrz zakresu umieszczenia deklaracji.
Przykładem może być prototyp funkcji czy użycie słowa extern dla zmiennej.

Umieszczenie powyższej deklaracji w pliku nagłówkowym game.h udostępnia zatem zmienną g_StanGry wszystkim modułom, które dołączą wspomniany nagłówek. Tym samym jest już ona znana także funkcji main() , więc ponowna kompilacja powinna przebiec bez żadnych problemów.

Czujny czytelnik zauważył pewnie, że dość swobodnie operuję terminami "deklaracja" oraz "definicja", używając ich zamiennie. Niektórzy puryści każą jednak je rozróżniać. Według nich jedynie to, co nazwaliśmy przed momentem "deklaracja zapowiadającą", można nazwać krótko "deklaracją". "Definicją" ma być za to dokładne sprecyzowanie cech danego obiektu, oraz, przede wszystkim, przygotowanie dla niego miejsca w pamięci operacyjnej.
Zgodnie z taką terminologią instrukcje w rodzaju int nX; czy float fY; miałyby być "definicjami zmiennych", natomiast extern int nX; oraz extern float fY; - "deklaracjami". Osobiście twierdzę, że jest to jeden z najjaskrawszych przykładów szukania dziury w całym i prób niezmiernego gmatwania programistycznego słownika. Czy ktokolwiek przecież mówi o "definicjach zmiennych"? Pojęcie to brzmi tym bardziej sztucznie, że owe "definicje" nie przynoszą żadnych dodatkowych informacji w stosunku do "deklaracji", a składniowo są od nich nawet krótsze!
Jak więc w takiej sytuacji nie nazwać spierania się o nazewnictwo zwyczajnym malkontenctwem? :)

 

1 Brak nazwy typu w deklaracji zmiennej sprawia, iż będzie należeć ona do domyślnego typu int . Tutaj oznacza to, że elementy naszej tablicy będą liczbami całkowitymi.

2 A jakże! Już coraz rzadziej będę omawiał podobnie elementarne kody źródłowe, będące prostym wykorzystaniem doskonale ci znanych konstrukcji języka C++. Jeżeli solennie przykładałeś się do nauki, nie powinno być to dla ciebie żadną niedogodnością, zaś w zamian pozwoli na dogłębne zajęcie się nowymi zagadnieniami bez koncentrowania większej uwagi na banałach.

3 Zwanego też czasem warunkiem terminalnym.

4 Mówiąc ściśle: gdzieś poza bieżącym zakresem.

Uruchamiamy aplikację

To niemalże niewiarygodne, jednak stało się faktem! Zakończyliśmy w końcu programowanie naszej gry! Wreszcie możesz więc użyć klawisza F5, by cieszyć tym oto wspaniałym widokiem:



Screen 30. Gra w "Kółko i krzyżyk" w akcji

A po kilkunastominutowym, zasłużonym relaksie przy własnoręcznie napisanej grze przejdź do dalszej części tekstu :)

Wnioski

Stworzyłeś właśnie (przy drobnej pomocy :D) swój pierwszy w miarę poważny program, w dodatku to, co lubimy najbardziej - czyli grę. Zdobyte przy tej okazji doświadczenie jest znacznie cenniejsze od najlepszego nawet, lecz tylko teoretycznego wykładu.

Warto więc podsumować naszą pracę, a przy okazji odpowiedzieć na pewne ogólne pytania, które być może przyszły ci na myśl podczas realizacji tego projektu.

Dziwaczne projektowanie

Tworzenie naszej gry rozpoczęliśmy od jej dokładnego zaprojektowania. Miało ono na celu wykreowanie komputerowego modelu znanej od dziesięcioleci gry dwuosobowej i zaadaptowanie go do potrzeb kodowania w C++.

W tym celu podzieliliśmy sobie zadanie na trzy części:

  • określenie struktur danych wykorzystywanych przez aplikację

  • sprecyzowanie wykonywanych przez nią czynności

  • stworzenie interfejsu użytkownika

Aby zrealizować pierwsze dwie, musieliśmy przyjąć dość dziwną i raczej nienaturalną drogę rozumowania. Należało bowiem zapomnieć o takich "namacalnych" obiektach jak plansza, gracz czy rozgrywka. Zamiast tego mówiliśmy o pewnych danych , na których program miał wykonywać jakieś operacje .

Te dwa światy - statycznych informacji oraz dynamicznych działań - rozdzieliły nam owe "naturalne" obiekty związane z grą i kazały oddzielnie zajmować się ich cechami (jak np. symbole graczy) oraz realizowanymi przezeń czynnościami (np. wykonanie ruchu).

Podejście to, zwane programowaniem strukturalnym , mogło być dla ciebie trudne do zrozumienia i sztuczne. Nie martw się tym, gdyż podobnie uważa większość współczesnych koderów! Czy to znaczy, że programowanie jest udręką?

Domyślasz się pewnie, że wszystko co niedawno uczyniliśmy, dałoby się zrobić bardziej naturalnie i intuicyjne. Masz w tym całkowitą rację! Już w następnym rozdziale poznamy znacznie wygodniejszą i przyjaźniejszą technikę programowania, który zbliży kodowanie do ludzkiego sposobu myślenia.

Dość skomplikowane algorytmy

Kiedy już uporaliśmy się z projektowaniem, przyszedł czas na uruchomienie naszego ulubionego środowiska programistycznego i wpisanie kodu tworzonej aplikacji.

Jakkolwiek większość użytych przy tym konstrukcji języka C++ była ci znana od dawna, a duża część pozostałej mniejszości wprowadzona w tym rozdziale, sam kod nie należał z pewnością do elementarnych. Różnica między poprzednimi, przykładowymi programami była znacząca i widoczna niemal przez cały czas.

Na czym ona polegała? Po prostu język programowania przestał tu być celem , a stał się środkiem . Już nie tylko prężył swe "muskuły" i prezentował szeroki wachlarz możliwości. Stał się w pokornym sługą, który spełniał nasze wymagania w imię wyższego dążenia, którym było napisanie działającej i sensownej aplikacji.

Oczywiste jest więc, iż zaczęliśmy wymagać więcej także od siebie. Pisane algorytmy nie były już trywialnymi przepisami, wyważającymi otwarte drzwi. Wyżyny w tym względzie osiągnęliśmy chyba przy sprawdzaniu stanu planszy w poszukiwaniu ewentualnych sekwencji wygrywających. Zadanie to było swoiste i unikalne dla naszego kodu, dlatego też wymagało nieszablonowych rozwiązań. Takich, z jakimi będziesz się często spotykał.

Organizacja kodu

Ostatnia uwaga dotyczy porządku, jaki wprowadziliśmy w nasz kod źródłowy. Zamiast pojedynczego modułu zastosowaliśmy dwa i zintegrowaliśmy je przy pomocy własnego pliku nagłówkowego.

Nie obyło się rzecz jasna bez drobnych problemów, ale ogólnie zrobiliśmy to w całkowicie poprawny i efektywny sposób. Nie można też zapominać o tym, że jednocześnie poznaliśmy kolejny skrawek informacji na temat programowania w C++, tym razem dotyczący dyrektywy #include , prototypów funkcji oraz modyfikatora extern .

Drogi samodzielny programisto - ty, który dokończyłeś kod gry od momentu, w którym rozstaliśmy się nagłówkiem game.h , bez zaglądania do dalszej części tekstu!
Jeżeli udało ci się dokonać tego z zachowaniem założonej funkcjonalności programu oraz podziału kodu na trzy odrębne pliki, to naprawdę chylę czoła :) Znaczy to, że jesteś wręcz idealnym kandydatem na świetnego programistę, gdyż sam potrafiłeś rozwiązać postawiony przed tobą szereg problemów oraz znalazłeś brakujące ci informacje w odpowiednich źródłach. Gratulacje!
Aby jednak uniknąć ewentualnych kłopotów ze zrozumieniem dalszej części kursu, doradzam powrót do opuszczonego fragmentu tekstu i przeczytanie chociaż tych urywków, które dostarczają wspomnianych nowych informacji z zakresu języka C++.

| | | |
Copyright © 2006-2013 egrafik.pl | Kontakt | Reklama | Projekty domów
jocker