Pravimo game engine od nule, deo 2: komunikacija sa spoljašnjim svetom

U drugom delu serijala u kome pravimo sopstveni game engine od nule zato što možemo i jer je poučno, Miroslav Gavrilov nas provodi kroz način na koji će igrači zapravo igrati — vezu sa ulaznim uređajima.

Miroslav Gavrilov - 23. Jul, 2015.

Dobrodošli u drugi deo serijala o pravljenju game engine-a od nule u C++-u! Hvala što pokazujete zainteresovanost za ovakve teme! Reakcije na prvi tekst su bile divne i dale nekoliko pravaca u kojim možemo da idemo dalje sa ovim serijalom, uzimajući u obzir želju da se prilagodi što više publici. Više o tome pri kraju gde dajem nekoliko odgovora na pitanja od prošle nedelje!

Da rezimiram, za one koji kasne: bavimo se pravljenjem game engine-a, što uključuje razne delove (renderer, menadžer memorije, kontrolera, zvuka, itd.) ali to nikako ne znači da ćemo ovde ulaziti u detalje implementacije svakog od tih od nule. Ni u čijem interesu nije niti će ikad biti izmišljanje tople vode. Sa tim na umu, neće se pisati novi renderer, ni slučajno. Što se ostalih delova tiče, pisaću ono što ocenim kao vredno pomena, opet, u pedagoškom smislu. Na kraju krajeva, cilj mi je da napravim tutorijal vredan čitanja zbog fora i fazona, a ne zato što je druga dokumentacija za DirectX. Pišemo C++ bez mnogo objašnjavanja, podrazumevajući da ili znate ili pitate ako ne znate.

Danas se bavimo ulaznim uređajima i kanalima: piše se menadžer za ulazne uređaje (tastatura, miš, itd), i jedan sporedni kanal komunikacije u obliku TCP socketa na kom će igra (isključivo u debug modu) slušati za poruke koje joj šaljemo. Ove dve stvari spajamo zato što ustvari jesu jedna: način da engine priča sa svetom. Brži protokol, sasvim logično, može se koristiti češće, pa će njega biti u samoj igri, za kontrolu; a sporiji kako bi se komuniciralo sa editorima. Naravno, ni ovo neće biti naših ruku delo – koristićemo FOSS editore gde god da možemo. U istom tom pravcu leži i pisanje ili dodavanje nekog skripting jezika, no to je tema nekog budućeg dela. Za danas, nekoliko stvari: zajednički delilac svih sledećih delova ovog engine-a biće event sistem, koji će prenositi poruke kroz ceo sistem. Njega neću pisati, uzeću implementaciju Paul Cook-a, koja je i više nego odlična, no nažalost, previše opširna za ovaj serijal. Ovako će izgledati sadržaj projekta jednom kad uključimo i ovu implementaciju.

Rasčlanio sam današnji tekst  tri dela – na jedan opšti, za eventove, a onda još dva, pod naslovom “Brzo i sporo ogledalo”, iz Pavićevog dela “Hazarski rečnik”. Ko nije, neka pročita, da bi sebi približio misteriju iza ovog naslova. Pređimo na stvar.

Jeste li i Vi pozvani na event?

Dakle, event sistem. Događaji su pokretačka snaga iza svakog operativnog sistema, i to se može videti i iz parčeta koda iz prvog dela serijala, gde stoji sledeće:

if (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE))
{
	TranslateMessage(&msg);
	DispatchMessage(&msg);
}

if (msg.message == WM_QUIT)
{
	this->closed = true;
}

Ovde možemo videti kako Windows izlazi na kraj sa događajima, tako što sluša za poruke i onda na licu mesta obrađuje. Pošto je sistem koji pišemo (nisam izdržao, učtiva akademska množina koju mnogi nalaze prepotentnom izleće iz mene na sve strane) mnogo veći od bilo koje početne ideje o istom, naš event sistem mora biti versatilniji: moraćemo da šaljemo i primamo poruke bez obzira na to gde se nalazimo, te s toga implementiramo nešto slično onome što imamo u C#-u: umesto da konstantno pitamo ima li poruka za nas, mi ćemo na početku da kažemo da nas neka određena poruka interesuje i da prosledimo mesto gde će se sve takve poruke obrađivati, a one će, kako koja stigne, da se skupljaju baš tamo.

U ovom momentu uzmite onaj gamedev.net članak gore i prođite ga, ili sadržaj njegov dodajte u svoj projekat. Neki bi mogli reći da je ovo jeftin potez, pošto uzimamo gotov kod, no ono je savršena implementacija, a usput u obliku tutorijala. Grehota ne uzeti! Ukratko, šta to tačno uzimamo? Uzimamo jednu klasu koja se zove Event, koja predstavlja događaj o čijem dešavanju ćemo obavestiti objekte klase Delegate, koji će, u slučaju da su pozvani na event, nešto da urade. Dodaćemo, zato, delegate svuda gde se očekuje reakcija na neki događaj, i — čekati!

Svaki menadžer resursa će imati po jedan delegat. IResources.h sada izgleda ovako:

// IResources.h
#pragma once

#include "Events.h"

struct IResourceManager {
	virtual void Initialize() = 0;
	virtual void Update() = 0;
	virtual void Shutdown() = 0;

	template<typename T>
	inline T* As()
	{
		return dynamic_cast<T*&gt;(this);
	}

	Delegate delegate;
};

template<typename... ResourceManagers>
struct IResources : public ResourceManagers... 
{
};

#define DATA_ONLY_RESOURCE void Initialize() {}; void Update() {}; void Shutdown() {}

Ovo možda deluje kao mala stvar, no to je obično dobro. Promene ulaze na mala vrata. Vreme je da omogućimo igrače da koriste tastaturu, te ćemo odmah da se bacimo i da napravimo nekoliko eventova za miša i tastaturu:


// GlobalEvents.h
#pragma once

#include "Events.h"

template<typename Type>
struct InputState
{
	Event<Type> up;
	Event<Type> down;
};

struct GlobalEvents
{
Event<bool> shutdown;

	InputState<unsigned int> keyboard;
	
	struct
	{
		Event<bool> left;
		Event<bool> middle;
		Event<bool> right;
		Event<int, int> moved;
	} mouse;
};

Ovu GlobalEvents strukturu ćemo postaviti kao statičku promenjivu unutar ISoosEngine-a, odmah ispod GetAspect() metode.


	static GlobalEvents events;

Ispod toga, van definicije klase, dajemo mu i vrednost:


template<typename T, typename Resources>
GlobalEvents ISoosEngine<T, Resources>::events = GlobalEvents();

Kako se ovo uklapa sve tačno zajedno?

playersanddevs

Ilustracija bi mogla pomoći.

Brzo ogledalo

Brzo ogledalo, ono koje ćemo koristiti u igri za uzimanje otisaka igrača u svakoj sekundi igre, ovog puta se zove InputManagerDirectX.h. Istini za volju, ovaj menadžer će verovatno biti isti i u OpenGL verziji, no krenuli smo ovim putem sad, lako ćemo ga preimenovati kasnije.


// InputManagerDirectX.h
#pragma once

#include "IResources.h"

struct InputManagerDirectX : public IResourceManager
{
	void Initialize();
	void Update();
	void Shutdown();

	bool IsKeyDown(unsigned int key);
	
	void ToggleKeyDown(unsigned int key);
	void ToggleKeyUp(unsigned int key);
private:
	bool keys[256];
};

Kao što se može očekivati, menadžer za unos pamti koje su tipke pritisnute. Sad možemo da ga lako implementiramo:


// InputManagerDirectX.cpp
#pragma once

#include "InputManagerDirectX.h"
#include "SoosEngineDirectX.h"

void InputManagerDirectX::Initialize() {
	for (int i = 0; i < 256; i++)
	{
		this->keys[i] = false;
	}

	this->delegate.connect(this, &InputManagerDirectX::ToggleKeyDown, SoosEngineDirectX::events.keyboard.down);
	this->delegate.connect(this, &InputManagerDirectX::ToggleKeyUp, SoosEngineDirectX::events.keyboard.up);
}

void InputManagerDirectX::Update() {}

void InputManagerDirectX::Shutdown() {}

bool InputManagerDirectX::IsKeyDown(unsigned int key)
{
	return this->keys[key];
}

void InputManagerDirectX::ToggleKeyDown(unsigned int key)
{
	this->keys[key] = true;
}

void InputManagerDirectX::ToggleKeyUp(unsigned int key)
{
	this->keys[key] = false;
}

Naglašeni su redovi gde konektujemo metode ToggleKeyDown i ToggleKeyUp sa eventom keyboard.down tj. keyboard.up. Vreme je da dodamo InputManager na listu Managera, u SoosEngineDirectX.h:


// SoosEngineDirectX.h, početak fajla sad izgleda ovako

#pragma once

#include "ISoosEngine.h"
#include "IResources.h"

#include "ConfigurationManager.h"
#include "WindowsManagerDirectX.h"
#include “InputManagerDirectX.h”

typedef ConfigurationManager Config;
typedef WindowsManagerDirectX Windows;
typedef InputManagerDirectX Input;

class ResourcesDirectX : public IResources<Config, Windows, Input> {};

Sada treba da uvežemo sistemske događaje sa našim, zbog čega se vraćamo u WindowsManagerDirectX.cpp.


LRESULT CALLBACK WindowsManagerDirectX::MessageHandler(HWND hWnd, UINT umsg, WPARAM wparam, LPARAM lparam)
{
	switch (umsg)
	{
	case WM_KEYDOWN:		
		SoosEngineDirectX::events.keyboard.down((unsigned int)wparam);
		return 0;
	case WM_KEYUP:
		SoosEngineDirectX::events.keyboard.up((unsigned int)wparam);
		return 0;	
	case WM_LBUTTONDOWN:
		SoosEngineDirectX::events.mouse.left(true);
		return 0;
	case WM_LBUTTONUP:
		SoosEngineDirectX::events.mouse.left(false);
		return 0;
	case WM_MBUTTONDOWN:
		SoosEngineDirectX::events.mouse.middle(true);
		return 0;
	case WM_MBUTTONUP:
		SoosEngineDirectX::events.mouse.middle(false);
		return 0;
	case WM_RBUTTONDOWN:
		SoosEngineDirectX::events.mouse.right(true);
		return 0;
	case WM_RBUTTONUP:
		SoosEngineDirectX::events.mouse.right(false);
		return 0;
	case WM_MOUSEMOVE:
		SoosEngineDirectX::events.mouse.moved(GET_X_LPARAM(lparam), GET_Y_LPARAM(lparam));
		return 0;

	default:
		return DefWindowProc(hWnd, umsg, wparam, lparam);
	}
}

O ovim WM_* sistemskim eventovima možete čitati više ovde, no sve što je nama bitno jeste da znamo da kad se desi sistemski event, mi pozovemo event našeg engine-a i gledamo magiju. Sada ćemo da ugradimo diskutabilno najbitniju funkciju svake igre: izlazak na ESCAPE! Idemo zato u Run metodu SoosEngineDirectX.cpp fajla, gde je dosad stajalo sledeće:


	Windows* const windows = this->GetAspect<Windows>();
	Input* const input = this->GetAspect<Input>();
	while (!this->stopped)
	{
		windows->Update();
		input->Update();
        this->stopped = windows->closed || input->IsKeyDown(VK_ESCAPE);
	}

Naravno, kao i sa svakim menadžerom, moramo dodati njegovu inicijalizaciju i gašenje u inicijalizaciju i gašenje engine-a:


void SoosEngineDirectX::Initialize()
{	
	this->GetAspect<Windows>()->Initialize();
	this->GetAspect<Input>()->Initialize();
}

void SoosEngineDirectX::Shutdown()
{	
	this->GetAspect<Input>()->Shutdown();
	this->GetAspect<Windows>()->Shutdown();
}

Imamo osnovnu podršku za tastaturu i miša, sada. Trebaće nam još toga kada dođe do crtanja i interakcije, ali sada nije vreme za to. Sada nastupa teži i zanimljiviji deo priče:

Sporo ogledalo

Za spor kanal komunikacije, koristićemo neku od mnogih message queueing (MQ) tehnologija. U najjednostavnijem mogućem izdanju, MQ primi neke poruke i onda ih isporuči nekim primaocima. U zavisnosti od implementacije, možemo videti razne pristupe, no u samoj suštini je baš ovaj opis. Postoji neka pošta, i postoje poštari koji nose pisma primaocima istih. Primaoci pisma su zauzeti i samo jednom na dan proveravaju poštu, ili ih ona iznenadi nekad u toku dana, pa je odlože dok ne bude vreme za nju.

U našem slučaju, recimo da tekst editor (van igre) pošalje poruku igri i kaže “dijalog u sceni 3 se updateovao, treba ga učitati opet”. Poruka je prosleđena MessageQueueManager-u koji dalje treba da vidi šta da uradi sa njom. On će, koristeći eventove, da te poruke isporučuje raznim delovima engine-a, kako to već bude potrebno.

U nekim ranijim projektima, koristio sam ZeroMQ, pa sam ostao privržen. Ono, pročitajte mu opis, dopašće vam se. ZMQ je veoma jednostavan: sa jedne strane neko pošalje nešto, sa druge neko primi. Čak i da primalac nije aktivan kada pošiljalac pošalje poruku, ona će ga sačekati.

Jednom kad uvedemo potrebnu biblioteku i linkujemo je sa projektom, pišemo novog menadžera:


// MessageQueueManagerDirectX.h
#pragma once

#include "IResources.h"
#include <string>

struct MessageQueueManagerDirectX : public IResourceManager
{
	MessageQueueManagerDirectX();
	~MessageQueueManagerDirectX();

	void Initialize();
	void Update();
	void Shutdown();

	void Return(std::wstring message);
	
private:
	void* queue_context;
	void *queue_socket;

	void ParseIncomingMessage(std::wstring message);
};

Ovde imamo samo nekoliko čudnih metoda: Return i ParseIncomingMessage. ParseIncomingMessage obavlja funkciju razumevanja poruke koja nam stiže i slanja te poruke dalje. Return, sa druge strane, nas obaveštava o tome da li je poruka primljena i kako. Ovde možemo videti i dve deklaracije ZMQ varijabli koje će nam biti potrebne: context i socket. Možda je čudno što su obe promenjive void* tipa, no kao univerzalni zajednički delilac za pokazivačke tipove, ovo je jedna od najkorišćenijih stvari u ANSI-C programiranju. “Ali mi radimo C++, nema li boljeg načina?”, čujem vas kako kažete i ostavljam bolji način za domaći.

Dakle, da vidimo implementaciju:


#include "MessageQueueManagerDirectX.h"

#include <zmq.h>
#include <assert.h>

#include <regex>

#include <sstream>
#include "SoosEngineDirectX.h"
#include "GlobalEvents.h"
#include "HelpersDirectX.h"

MessageQueueManagerDirectX::MessageQueueManagerDirectX()
	:
	queue_context(zmq_ctx_new()),
	queue_socket(zmq_socket(queue_context, ZMQ_REP)) {}

MessageQueueManagerDirectX::~MessageQueueManagerDirectX() {}

Ovde možemo videti zasnivanje konteksta: to je pošta kroz koju sve naše poruke idu. Sa druge strane, tu je i zmq_socket, koji je naš poštar. Idemo dalje:


void MessageQueueManagerDirectX::Initialize()
{
#ifdef _DEBUG
	int rc = zmq_bind(this->queue_socket, "tcp://*:5555");
	assert(rc == 0);

	this->delegate.connect(this, 
		&MessageQueueManagerDirectX::ParseIncomingMessage, 
		SoosEngineDirectX::events.messaging.incoming);
#endif
}

Evo nas gde vezujemo ZMQ za neki proizvoljan port, recimo 5555, preko kog ćemo dobijati poruke. Takođe, možete primetiti da već ovde povezujemo svog delegata (malo kasnije o tome), koji će da poruke primi i obradi ParseIncomingMessage metodom.

Takođe, primetimo da se sve ovo dešava samo ako smo u debug modu. Inače, ovo je sasvim prazna metoda (lepotom #ifdef blokova). Slično, Shutdown:


void MessageQueueManagerDirectX::Shutdown()
{
#ifdef _DEBUG
	zmq_close(this->queue_socket);
	zmq_ctx_destroy(this->queue_context);
#endif
}

Update metoda je ovog puta malo duža:


void MessageQueueManagerDirectX::Update()
{
#ifdef _DEBUG
	zmq_msg_t msg;	
	int rc = zmq_msg_init(&msg);
	assert(rc == 0);
	...

Inicijaliziramo poruku koja će možda biti primljena. Sada proveravamo da li smo primili poruku ili nismo:


	...
	rc = zmq_msg_recv(&msg, this->queue_socket, ZMQ_NOBLOCK);
	...

Pitamo sada da li imamo novih poruka ili ne, a specijalna pažnja ide na ZMQ_NOBLOCK: da njega nema, program bi se, do sledeće poruke, zamrzao. Ovako, ako nema poruke, odgovor će prosto biti negativan. U slučaju da je ima, dobićemo je, no postoji i treća varijanta: u slučaju da je došlo do greške, moramo da obradimo komplikovane slučajeve:


	...
	if (rc >= 0)
	{		
		std::string local(reinterpret_cast<const char*>(zmq_msg_data(&msg)));
		local = local.substr(0, rc);

		SoosEngineDirectX::events.messaging.incoming(std::wstring(local.begin(), local.end()));
	}
	else
	{
		int e = errno;
		bool catchError = true;
		std::wstringstream wss;
		wss << L"[MessageQueueManager.cpp] Error:";

		switch (e)
		{
		case 0:
		case EAGAIN:
			catchError = false;
			break;
		case ENOTSUP:
			wss << "ENOTSUP";
			break;
		case EFSM:
			wss << "EFSM";
			break;
		case ETERM:
			wss << "ETERM";
			break;
		case ENOTSOCK:
			wss << "ENOTSOCK";
			break;
		case EINTR:
			wss << "EINTR";
			break;
		case EFAULT:
			wss << "EFAULT";
			break;
		default:			
			break;
		}

		if (catchError)
		{
			auto err = wss.str().c_str();
			MSGBOX(err, L"ERROR!");
		}
	}
...

Kako sve ove greške dolaze pravo iz ZMQ dokumentacije, neću opširno ulaziti u to šta je, no svako ko vidi ovaj kod za hendlanje razume da nije pisan tako da bude prioritet: ovo je sve debug mod. Za kraj, brisanje poruke:


...
	zmq_msg_close(&msg);
#endif
}

Dobro, sada one dve specijalne metode:


void MessageQueueManagerDirectX::Return(std::wstring message)
{
	std::string s(message.begin(), message.end());
	zmq_send(this->queue_socket, s.c_str(), sizeof(char) * s.length(), 0);
}

Kao što se da primetiti, i nije toliko specijalna: samo spremi odgovor i pošalje ga poštaru. Ovo nam je potrebno kako komunikacija ne bi visila u etru. Opet, zapamtimo da sve ovo sa ZMQom radimo u debug modu, tako da nije potrebno raditi multipleksnu komunikaciju sa više niti i ko zna kakvim čudima – to ili nećemo raditi ili je za domaći, evo i uputstva. Parsiranje poruka, sa druge strane, je malo teže pitanje: zasad, imajmo samo jednu poruku koju umemo da parsiramo: kraj.


void MessageQueueManagerDirectX::ParseIncomingMessage(std::wstring message)
{
	bool parsed = false;

	std::wregex exitRegex(L"exit.*”);
	std::wsmatch sm;
	
	if (std::regex_match(message, sm, exitRegex))
	{
		parsed = true;
		SoosEngineDirectX::events.shutdown(true);
		this->Return(L"Shutting down.");
	}

	if (!parsed)
		this->Return(L"Unknown command.");
}

Kao i uvek, menadžer ničemu ne služi dok se ne uveže u engine, tako da se vraćamo u SoosEngineDirectX.h, koji sad izgleda ovako:


#pragma once

#include "ISoosEngine.h"
#include "IResources.h"

#include "ConfigurationManager.h"
#include "WindowsManagerDirectX.h"
#include "InputManagerDirectX.h"
#include "MessageQueueManagerDirectX.h"

#include <iostream>
#include <windows.h>

typedef ConfigurationManager		Config;
typedef WindowsManagerDirectX		Windows;
typedef InputManagerDirectX		Input;
typedef MessageQueueManagerDirectX	Queue;

class ResourcesDirectX : public IResources <Config, Windows, Input, Queue> {};

SOOS_ENGINE_DEFINITION(SoosEngineDirectX) USING(ResourcesDirectX)
{
	bool paused, stopped;
public:
	SoosEngineDirectX();
	~SoosEngineDirectX();

	void Initialize();
	void Run();
	void Shutdown();

	void DelegateShutdown(bool really);
	Delegate delegate;
};

Takođe, menjamo implementaciju tako što dodamo Initialize, Update i Shutdown metode svih menadžera u odgovarajuće metode engine-a:


void SoosEngineDirectX::Initialize()
{
	this->delegate.connect(this, &SoosEngineDirectX::DelegateShutdown, SoosEngineDirectX::events.shutdown);

	this->GetAspect<Queue>()->Initialize();
	this->GetAspect<Windows>()->Initialize();
	this->GetAspect<Input>()->Initialize();
}

void SoosEngineDirectX::DelegateShutdown(bool really)
{
	this->stopped = really;
}

void SoosEngineDirectX::Run()
{
	this->stopped = false;

	Queue* const queue = this->GetAspect<Queue>();
	Input* const input = this->GetAspect<Input>();
	Windows* const windows = this->GetAspect<Windows>();

	while (!this->stopped)
	{
		queue->Update();
		windows->Update();
		input->Update();

		this->stopped |= input->IsKeyDown(VK_ESCAPE) || windows->IsWindowClosed();
	}

	this->stopped = true;
}

void SoosEngineDirectX::Shutdown()
{	
	this->GetAspect<Input>()->Shutdown();
	this->GetAspect<Windows>()->Shutdown();
	this->GetAspect<Queue>()->Shutdown();
}

Time smo završili i ovo druženje. Naš engine je sada moguće ugasiti i ESCAPE dugmetom na tastaturi i komandom preko TCP-a. Na ovoj osnovi gradićemo komunikaciju između engine-a i igrača, sa jedne, i engine-a i developera, sa druge strane. Sledeći put predstavljamo onaj prvi korak koji ste očekivali još pre dve nedelje: GraphicsManager, koji u sebi krije jedno lepo iznenađenje, pored, naravno, dugo očekivane priče o renderovanju. Nadam se dobroj diskusiji na mrežama i ove nedelje! Javite se sa komentarima i, u odgovoru na par: “da, naravno da ćemo praviti mini-igre da demonstriramo kako ovo sve radi”; “ne, ovo neće biti i 3D engine, baziramo se isključivo na 2D igre” i, “da, možda ću uzeti taj predlog u obzir, hvala, @bkaradzic!”

Do sledeće nedelje, evo i gifa pokretanja SoosEngine-a i gašenja istog putem tastature.

soos2

Tastaturu ne vidite u gifu, nažalost. Takođe, ako ste se pitali, moja pozadina je trenutno The Legend of Chickensword fanart. Ako imam priliku da promovišem indie gamedev, valja je i iskoristiti! Imate želju da se vaša igra pojavi sledeće nedelje? Totalno mi pišite sa tim u vezi, moj desktop jedva čeka. :)

*Obnovite prvi ili pređite na treći deo.