• Nasza pierwsza gra - kurs programowania AmigaOS i C - część 3

26.12.2012 10:46, autor artykułu: Asman
odsłon: 4053, powiększ obrazki, wersja do wydruku,

Trzeci odcinek zaczniemy od drobnej erraty odcinka poprzedniego. Nie zdawałem sobie sprawy, że wkradł się błąd w pliku timer.c w funkcji sendTimerReq. Program działa, ale za szybko. Oto poprawna wersja:

static void sendTimerReq(void)
{
	m_tv.tv_secs = 0;
	m_tv.tv_micro = 20000;
	m_pTimerIO->tr_time = m_tv;

	SendIO((struct IORequest*)m_pTimerIO);
}

Tak jak zapowiedziałem w poprzednim odcinku, zajmiemy się mapami kafelkowymi. Zanim dojdziemy do map, to najpierw ustalimy co to jest ten kafelek. Ja w ten sposób rozumiem element graficzny w kształcie prostokąta, o z góry ustalonej szerokości i wysokości. Sama nazwa kafelek jest tłumaczeniem z języka angielskiego słowa "tile". Spotyka się też nazwy: blok, kostka, klocek. Warto dodać, że przeważnie spotyka się kafle kwadratowe, czyli o jednakowych wymiarach. Przykładem niech będzie klocek 8x8, oznaczający blok szeroki na 8 pikseli i wysoki na 8 pikseli. Pierwsze pytanie nasuwa się samo - po co nam te całe kafelki? Przecież można załadować rysunek i plansza z głowy? Pewnie, że można i nikt nam nie zakazuje robić w ten sposób gry. Przede wszystkim dzięki kafelkom oszczędzamy pamięć, co nie jest bez znaczenia na maszynach z niewielką ilością pamięci (bo przecież obrazek graficzny zajmuje więcej pamięci). Kafelków przeważnie mamy mniej niż wszystkich obrazków składających się na plansze. Aby stworzyć planszę z kafelków, musimy w jakiś sposób przechować informacje, który kafel gdzie ma się znajdować. Tu do akcji wkracza poczciwa tablica, która posłuży nam jako mapa odwzorowująca kafle na ekranie. Myślę, że prosty przykład jest warty więcej niż tysiąc słów. Załóżmy, że mamy tylko dwa klocki: pusty i pełny. Niech 0 (bajt o wartości 0) oznacza pusty kafel, a 1 to będzie pełny klocek. Jeśli mamy malutką planszę o wymiarach 4 klocki na 3 klocki, to taka tablica będzie wyglądać tak:

UBYTE tab[4*3] =
{
	1,1,1,1
	1,0,0,1
	1,1,1,1
};

Nic nie stoi na przeszkodzie, aby używać tablicy dwuwymiarowej - ja użyłem jednowymiarowej, aby nie komplikować sprawy. Zatem do dzieła! Dodajmy nowe pliki do naszego projektu - będą to: game.c, game.h, level.c, level.h, tile.c i tile.h. Przyjdzie nam też zaktualizować plik makefile. Dodajemy nowe wartości do zmiennych OBJ i LINKOBJ.

OBJ = boxo.o window.o timer.o input.o game.o tile.o level.o
LINKOBJ = boxo.o window.o timer.o input.o game.o tile.o level.o

i informujemy kompilator jak ma stworzyć obiekty: game.o, level.o i tile.o:

game.o: game.c
	$(CC) $(FLAGS) game.c -o game.o
level.o: level.c
	$(CC) $(FLAGS) level.c -o level.o
tile.o: tile.c
	$(CC) $(CFLAGS) tile.c -o tile.o

Pliki game.c i game.h będą odpowiadały za logikę gry, czyli będzie to serce naszej gry. A level.c i level.h będą odpowiadały za plansze. Zajmijmy się teraz tile.c i tile.h. W naszej grze użyjemy kafli 16x16. Informacje o rozmiarze kafla będziemy przechowywali w zmiennych g_nTileWidth i g_nTileHeight, które oznaczają odpowiednio wysokość i szerokość w pikselach. Umieszczając je w zmiennych, uczynimy program bardziej elastycznym, gdyż zmiana rozmiaru kafla może być dokonana bez powtórnej kompilacji. Na razie, aby nie utrudniać sprawy przyjmijmy, że kafle zawsze są długie i szerokie na 16 pikseli. Rozmiar kafli został wybrany rozmyślnie, bo jak łatwo zauważyć, szerokość okna dzieli się bez reszty przez szerokość kostki. Równo 20 kostek się zmieści w jednej linii, o ile nasze okno gry ma szerokość 320 pikseli. Podobnie jest z wysokością - tutaj mamy 12 kafli w pionie. W sumie na cały ekran gry wejdzie 240 takich elementów (20*12). Oznacza to, że potrzebujemy 240 elementową tablicę, aby pokryć cały ekran naszej gry. Gdy zmienimy rozmiar kafli na 32x32, to wtedy będziemy potrzebować tablicy o 80 elementach. Skoro rozmiar kafli mamy za sobą, to teraz zajmijmy się nimi samymi. W pliku tile.h zdefiniujemy typ enumeracyjny Tiles:

enum Tiles
{
	TILE_EMPTY = 0,	/* przestrzeń kosmiczna */
	TILE_WALL = 1,	/* niezniszczalna asteroida */
	TILE_ROCK = 2,	/* skała, która rozpada się pod wpływem uderzenia statku 
			 * (zamiana na TILE_EMPTY)
			*/
	TILE_SHIP = 3,	/* nasz kosmiczny pojazd */
	TILE_EXIT = 4,	/* wyjście z planszy */
};

W tym pliku zadeklarujemy funkcje widoczne na zewnątrz, czyli takie, które mogą być używane wszędzie. Najważniejsza z nich to initTiles, która wczytuje nasze klocki do bitmapy. Aby nie komplikować sprawy, nasze kafle umieściłem w zwyczajnym pliku IFF. Sama funkcja korzysta z datatypów. Przyjrzymy jej się bliżej (po przeczytaniu komentarzy, wszystko powinno być jasne - aby nie było za łatwo, komentarze są tylko w artykule).

int initTiles(void)
{
	/* zakładamy, że nie uda nam się załadować obrazka*/
	int iResult = RT_FAILED_LOAD_TILES_PIC;	

	g_nTileWidth = 16;	/* ustalamy rozmiar kafla, tu szerokość */
	g_nTileHeight = 16;	/* wysokość kafla */

	/* zakładamy kłódkę na ekran Workbencha, tak by nie mógł być zamknięty, kiedy my chcemy użyć
	 * jego właściwości, które przydadzą się w trakcie remapowania obrazka
	*/
	struct Screen* pWB = LockPubScreen(NULL);

	/* tworzymy obiekt, który zgodnie z DTA_GroupID ma być obrazkiem, i będzie
	 * przetworzony (zremapowany) na podstawie ekranu Workbencha w tym przypadku.
	 * Dalsze objaśnienia argumentów:
	 * "tile.pic" - nazwa pliku, który ma się znajdować w tej samej lokalizacji co plik wykonywalny
	 * DTA_GroupID, GID_PICTURE - ten tag, określa typ obiektu
	 * PDTA_Remap, TRUE - tag specyficzny dla picture.datatype, mówiący że obrazek ma być remapowany
	 * PDTA_Screen, pWb - tag zawierający wskaźnik do ekranu do którego będziemy prztwarzać obrazek
	*/
	Object* o = NewDTObject("tile.pic",
		DTA_GroupID, GID_PICTURE,
		PDTA_Remap, TRUE,
		PDTA_Screen, pWB,
		TAG_END);


	if (o)
	{
		struct BitMap* bm;
		
		/* przeprowadzamy działanie na obiekcie, w tym przypadku wykonujemy
		 * metodę DTM_PROCLAYOUT, która właśnie remapuje obrazek
		*/
		DoDTMethod(o, NULL, NULL, DTM_PROCLAYOUT,NULL,TRUE);

		/* dobieramy się do zremapowanej bitmapy poprzez pobieranie 
		 * atrybutu obiektu
		*/
		GetDTAttrs(o,
			PDTA_DestBitMap, &bm,
			TAG_END);

		/* bierzemy szerokość, wysokość i głębokość bitmapy tejże bitmapy.
		 * Dane te posłużą nam do stworzenia kopii bitmapy, bo trzeba 
		 * pamiętać, że bitmapa pobrana za pomocą GetDTAttrs zostanie
		 * automatycznie zwolniona po skasowaniu obiektu.
		*/

		ULONG nWidth = GetBitMapAttr(bm, BMA_WIDTH);
		ULONG nHeight = GetBitMapAttr(bm, BMA_HEIGHT);
		ULONG nDepth = GetBitMapAttr(bm, BMA_DEPTH);

		g_pTileBMap = AllocBitMap(nWidth, nHeight, nDepth, BMF_DISPLAYABLE|BMF_CLEAR, 0);

		if (NULL != g_pTileBMap)
		{
			/* robimy kopię naszej bitmapy */
			BltBitMap(bm, 0, 0, g_pTileBMap, 0, 0, nWidth, nHeight, 0xC0, 0xFF, NULL);

			/* i tworzymy rastport, który ją zawiera */
			g_pTileRPort = &m_RPortTile;
			InitRastPort(g_pTileRPort);
			g_pTileRPort->BitMap = g_pTileBMap;

			/* a jednak nam się udało pomyślnie załadować obrazek */
			iResult = RT_OK;
		}

		/* kasujemy obiekt*/
		DisposeDTObject(o);
	}

	/* zdejmujemy naszą kłódkę z ekranu Workbencha */
	UnlockPubScreen(NULL, pWB);

	return RT_OK;
}

Funkcja killTiles, jak łatwo się domyślić, zwalnia bitmapę, którą otrzymaliśmy w funkcji initTiles. Trzy kolejne funkcje służą do kopiowania kafli na ekran i nie wymagają większego komentarza - jest to zwyczajowe wykorzystanie funkcji BltBitMapRastPort.

void EraseShip(int x, int y)
{
	BltBitMapRastPort(g_pTileBMap, TILE_EMPTY*16, 0, g_pRpMain, x,y, 16, 16, 0xc0);
}
//============================================================================
void PasteShip(int x, int y)
{
	BltBitMapRastPort(g_pTileBMap, TILE_SHIP*16, 0, g_pRpMain, x,y, 16, 16, 0xc0);
}
//============================================================================
void PasteTile(int x, int y, UBYTE tile)
{
	BltBitMapRastPort(g_pTileBMap, tile*16, 0, g_pRpMain, x*16,y*16, 16, 16, 0xc0);
}

Należy wspomnieć, że w obrazek sam upichciłem i należy go traktować jako dzieło eklektyczne i pole do popisu dla grafików. Kafle znajdują się w górnym lewym rogu i tak blok od (0,0) do (15,15) to TILE_EMPTY, kostka od (15,0) do (31,0) to TILE_WALL i tak dalej. Oprócz kafli znajdują się czcionki (fonty) 8x8, które także stworzyłem w całe 10 minut. Jako miły dodatek zrobiłem też cyfry, a także sam stworzyłem dwie plansze. W każdym razie zachęcam do eksperymentów nad obrazkiem, planszami i grą. Dodałem też funkcję, która wyświetla napis na ekranie gry - napis ograniczony jest do liter łacińskich i spacji.

void PrintTxt(char* string, int x, int y)
{
	/* pustego napisu nie pokazuj */
	if(NULL == string)
	{
		return;
	}
	while(TRUE)
	{
		/* weź znak i sprawdz czy to koniec napisu */
		char c = *string;
		if (0 == c)
		{
			return;
		}
		/* zakładamy, że litera jest spacją */
		LONG xSrc = ('z'-'a' + 1)*8;
		if (' ' != c)
		{	/* litera jednak nie jest spacją */
			xSrc = (c - 'a')*8;
		}
		string++;
		/* kopiowanie znaku na ekran */
		BltBitMapRastPort(g_pTileBMap, xSrc, 16, g_pRpMain, x, y, 8, 8, 0xC0);
		x += 8;
	}
}

Przejdźmy teraz do budowania planszy, czyli naszej mapy kafelków. Wiemy już w jaki sposób mamy budować, znamy cegły (TILE_EMPTY, TILE_WALL, ...), to teraz pora przekuć to na kod (pliki level.c i level.h). Dla uproszczenia plansze będziemy przechowywali w pliku level.c. Konsekwencją (smutną) tego jest to, że po każdej zmianie elementu planszy, aby była zauważalna, musimy przeprowadzić kompilację naszej gry. Zatem tablica UBYTE g_tabLevels[] zawiera nasze plansze. Ponieważ występują w naszej grze elementy, które mogą ulec zmianie, na przykład TILE_ROCK zamienia się w TILE_EMPTY po uderzeniu pojazdu, to musimy mieć osobną tablicę, która będzie największą możliwą planszą obsługiwaną przez naszą grę. A to dlatego, że w przypadku straty "życia" musimy przywrócić planszę do postaci początkowej. Tę osobną planszę otrzymamy obliczając największą możliwą tablicę i alokując ją w funkcji initLevels:

int initLevels(void)
{
	/* rozmiary w kaflach */
	g_nLvlMaxTWidth = 320/g_nTileWidth;
	g_nLvlMaxTHeight = 192/g_nTileHeight;

	g_nLvlTWidth = g_nLvlMaxTWidth;
	g_nLvlTHeight = g_nLvlMaxTHeight;

	/* alokujemy tablicę */
	g_pLevel = (UBYTE*)malloc(g_nLvlMaxTWidth*g_nLvlMaxTHeight);

	if (NULL == g_pLevel)
	{
		return RT_FAILED_MEM_LEVEL;
	}
	return RT_OK;
}

Tablicę uwalniamy w funkcji KillLevels, którą to (tak na marginesie) wołamy w boxo.c. Najważniejsze funkcje w level.c to CopyLvl i LvlToWin. Pierwsza z nich odpowiada za kopiowanie planszy z tablicy (w której trzymamy plansze) do tablicy i nie wymaga ona większego komentarza. Oczywiście dysponujemy zmienną, dzięki której możemy wybrać numer planszy (g_nLvLNumber). Druga zaś tworzy planszę na ekranie gry i nie jest ona także skomplikowana. Dodatkowo umieściłem trzy funkcję (dwie z nich są widoczne na zewnątrz), które wypełniają planszę kaflami - odpowiednio TILE_EMPTY i TILE_ROCK. Dzięki temu można wyczyścić ekran gry bądź też pokazać cały ekran w skałach, co też wykorzystamy w logice naszej gry.

Zostało nam do omówienia serce naszej gry, czyli pliki game.c i game.h. To właśnie tutaj umieściłem cały kod, który odpowiada za pętlę gry, czyli jest to cały mechanizm tworzący naszą rozrywkę - począwszy od obrazka tytułowego, mówiącego że gra się nazywa "boxo", przez pętlę główną, a skończywszy na poczciwym "game over". Zanim przedstawię w szczegółach pętlę, należy się kilka słów wyjaśnienia w jaki sposób wywołujemy poszczególne funkcje pętli. Robimy to za pomocą wskaźnika na funkcję (dla przypomnienia - deklaracja PVF g_pFnc). Ten mechanizm wywoływania funkcji pozwala nam w elastyczny sposób manipulować procedurami pętli gry i pozwala zapomnieć o zbieraniu zdarzeń z okna czy też o odliczaniu czasu. Poza tym możemy bez problemu zakończyć naszą aplikację bez zbędnego czekania. Niedogodnością może być zmiana podejścia do troszkę innego kodowania funkcji i pojawiają się trochę inne problemy, gdy chcemy zaprogramować funkcje czekające na jakieś zdarzenie. Zamiast klasycznego wywoływania funkcji, musimy zapisać wskaźnik na funkcję, która to będzie uruchomiona w kolejnej iteracji pętli loop znajdującej się w boxo.c (o ile nadszedł sygnał z timer.device). Przyjrzyjmy się bliżej funkcji Title (a w zasadzie dwóm funkcjom), która pokazuje obrazek tytułowy i czeka na wciśnięcie klawisza A.

void Title(void)
{
	/*wyczyszczenie planszy kaflami TILE_EMPTY */
	CleanLevel();

	/* pokazanie nazwy gry na ekranie gry*/
	PrintTxt("boxo", 128,64);
	/* warto poinformować użytkownika, że czekamy na wciśnięcie klawisza A */
	PrintTxt("press key a to start", 64, 96);
	/* zapisujemy wskaźnik na funkcję, od teraz funkcja loop z boxo.c będzie wywoływać titleLoop() */
	g_pFnc = &titleLoop;
}
//============================================================================
static void titleLoop(void)
{
	/* czy został wciśnięty klawisz A */
	if (g_bFire)
	{
		g_bFire = FALSE;
		/* zmieniamy funkcję na Intro */
		g_pFnc = &Intro;
	}
}

Wygląda to może trochę dziwacznie, że titleLoop() czeka na klawisz i zmienia wskaźnik, ale dokładnie tego oczekujemy od tej funkcji. Jest krótka i szybka. Można ją przenieść w miejsce zapisania wskaźnika w funkcji Title, ale wtedy za każdym razem będzie wywoływana funkcja CleanLevel i za każdym razem będzie rysowany tekst "boxo" - byłoby to marnotrawstwo czasu i nie wyglądałoby atrakcyjnie na ekranie gry. Jak widać wywołujemy funkcję Intro, która podobnie jak funkcja Outro jest prawie pusta i w przyszłości (bądź jako ćwiczenie) zostanie wypełniona. W każdym razie Intro wywołuje NewGame(), która ustawia zmienne potrzebne dla całej gry, czyli ilość żyć, numer planszy. Następnie jest wykonywana funkcja NextLevel, której głównym zadaniem jest rysowanie planszy i logika z tym związana. Przechodzimy do GameLoop, która steruje właściwą grą. I tu mamy sprawdzenie czy pojazd kosmiczny nie wyszedł za granicę, bo jeśli tak się stało, to zmniejszamy ilość żyć i sprawdzamy czy mamy przejść do GameOver. Najważniejszą rzeczą w GameLoop jest normalne wywołanie moveShip(). Jej zadaniem jest poruszanie statkiem i logika z tym związana.

static void moveShip(void)
{
	UBYTE tile;
	/* ruch jest możliwy tylko gdy statek stoi */
	if (m_bShipStand)
	{
		if (g_bLeft)	/* ruch w lewo*/
		{
			m_bShipStand = FALSE;	/* ruszamy pojazdem */
			g_bLeft = FALSE;
			m_nHotX = 0;			/* ustalenie hot spota dla pozycji x */
			m_nVelocityX = -4;		/* prędkość pojazdu */
			m_nVelocityY = 0;
		}
		else if (g_bRight) /* ruch w prawo */
		{
			m_nHotX = 15;
			m_bShipStand = FALSE;
			g_bRight = FALSE;
			m_nVelocityX = 4;
			m_nVelocityY = 0;
		}

		if (g_bUp)
		{
			m_nHotY = 0;
			m_bShipStand = FALSE;
			g_bUp = FALSE;
			m_nVelocityX = 0;
			m_nVelocityY = -4;
		}
		else if (g_bDown)
		{
			m_nHotY = 15;
			m_bShipStand = FALSE;
			g_bDown = FALSE;
			m_nVelocityX = 0;
			m_nVelocityY = 4;
		}
	}
	/* obliczenie następnej pozcji pojazdu */
	int nPosX = (m_nPosX + m_nHotX + m_nVelocityX )/16;
	int nPosY = (m_nPosY + m_nHotY + m_nVelocityY)/16;

	/* pobranie z planszy kafla, na tej podstawie określimy typ zdarzenia */
	int nTabPos = nPosX + nPosY*20;
	tile = g_pLevel[nTabPos];

	if (TILE_WALL == tile)	/* pojazd będzie uderzał w ściane */
	{
		m_nVelocityX = 0;
		m_nVelocityY = 0;
		m_bShipStand = TRUE;
		g_bLeft = FALSE;
		g_bRight = FALSE;
		g_bUp = FALSE;
		g_bDown = FALSE;
	}
	else if (TILE_ROCK == tile) /* pojazd będzie uderzał w skałę */
	{
		m_nVelocityX = 0;
		m_nVelocityY = 0;
		m_bShipStand = TRUE;
		g_bLeft = FALSE;
		g_bRight = FALSE;
		g_bUp = FALSE;
		g_bDown = FALSE;
		g_pLevel[nTabPos] = 0;	/* skałę zamieniamy w element pusty */
		PasteTile(nPosX, nPosY, TILE_EMPTY);
	}
	else if (TILE_EXIT == tile)	/* dotrzemy do wyjścia */
	{
		m_bShipStand = TRUE;
		g_bLeft = FALSE;
		g_bRight = FALSE;
		g_bUp = FALSE;
		g_bDown = FALSE;
		g_nLvlNumber++;			/* zwiększamy numer planszy */
		g_pFnc = &NextLevel;	/* zmieniamy funkcję na NextLevel */
		return;
	}
	/* usuwamy statek ze starej pozycji */
	EraseShip(m_nPosX, m_nPosY);

	/* zmiana pozycji */
	m_nPosX += m_nVelocityX;
	m_nPosY += m_nVelocityY;

	/*narysowanie statku */
	PasteShip(m_nPosX, m_nPosY);
}

Kilka słów komentarza wymaga tajemniczy komentarz o hot spocie. Gdy rysujemy statek, który jest kaflem o wymiarach 16x16, to określając jego pozycję na ekranie to tak, jakbyśmy trzymali ten statek w lewym górnym rogu. Powoduje to problemy przy określaniu gdzie statek będzie się znajdował, gdy ruch ma być wykonany. Rozwiązuje się ten problem za pomocą gorących punktów obiektu dla kierunków, które dodane do pozycji poprawiają obliczenia, gdzie statek się znajduje na planszy. Warto poeksperymentować i pozmieniać wartości "hot spot" dla poszczególnych kierunków, aby przekonać się jak ważne jest dobranie właściwych wartości.

Jako ćwiczenie warto pozmieniać plansze, dodać jeszcze jedną bądź dwie - nie zapominając o korekcji zmiennej g_nAmountOfLvl. W następnym odcinku między innymi przeniesiemy plansze na zewnątrz kodu (jako osobny plik).

Paczka ze źródłami
Paczka z kodem wykonywalnym

Artykuł oryginalnie pojawił się w ósmym numerze Polskiego Pisma Amigowego.

    tagi: C, AmigaOS, programowanie
komentarzy: 11ostatni: 01.12.2018 22:09
Na stronie www.PPA.pl, podobnie jak na wielu innych stronach internetowych, wykorzystywane są tzw. cookies (ciasteczka). Służą ona m.in. do tego, aby zalogować się na swoje konto, czy brać udział w ankietach. Ze względu na nowe regulacje prawne jesteśmy zobowiązani do poinformowania Cię o tym w wyraźniejszy niż dotychczas sposób. Dalsze korzystanie z naszej strony bez zmiany ustawień przeglądarki internetowej będzie oznaczać, że zgadzasz się na ich wykorzystywanie.
OK, rozumiem