|

Φτιάχνοντας έναν Game Server σε C++ #6: Ένας Στοιχειώδης Client

Series Navigation<< Φτιάχνοντας έναν Game Server σε C++ #5: Συμμετοχή σε ΠαιχνίδιαΦτιάχνοντας έναν Game Server σε C++ #7: Σύνδεση με MySQL >>

Σε αυτό το άρθρο θα φτιάξω ένα στοιχειώδη client για τον server μου, για να κάνω πιο εύκολα δοκιμές. Ο πραγματικός μου στόχος όμως είναι να δημιουργήσω μια κλάση Client που θα χρησιμοποιείται από κάθε εφαρμογή. Θα πρέπει να περιλαμβάνει όλο το μηχανισμό για την επικοινωνία με τον server, ανεξάρτητα από τα παιχνίδια που τρέχουν.

Ξέρουμε σίγουρα ότι θα χρειαστούμε ένα TCP socket και ένα UDP. Τα πακέτα UDP θα έρχονται ασύγχρονα, οπότε θα χρειαστούμε ένα ξεχωριστό thread για να τα λαμβάνει και να τα προωθεί. Έτσι, η εφαρμογή/διεπαφή με τα παιχνίδια θα μπορεί να τα ανακτά και να τα διαχειρίζεται.

Από την άλλη, η επικοινωνία πάνω στο TCP θα είναι διαλεκτική. Με άλλα λόγια, για κάθε εντολή που στέλνουμε στον server θα πρέπει να περιμένουμε για μια απάντηση. Ειδική περίπτωση αποτελεί η αρχική σύνδεση, όπου ο server θα απαντήσει με το session id και θα πρέπει να το λάβουμε.

Με αυτά κατά νου, ας κάνουμε λίγη προεργασία:

typedef function<void(DGram)> dgramEvent;

class Client {

	private:

		sockaddr_in serverAddr{};
		int tcp,udp;

		Parser readTCP();
		Parser sendTCP(string);
		dgramEvent onDGram;
		void receiveUDP();

  	public:

		void sendUDP(DGram);
		
};

Ξεκινώντας με το TCP, η readTCP() θα πρειμένει για δεδομένα από τον server, και θα αναγνωρίζει τις αποσυνδέσεις. Όπως θα φανεί αργότερα, οι χαρακτήρες αλλαγής γραμμής δεν έχουν νόημα για τον client, και γι αυτό θα τους αντικαθιστούμε με κενά. Εξ άλλου, η sendTCP() στέλνει μια εντολή στον server και λαμβάνει την απάντηση καλώντας την readTCP(). Και οι δύο επιστρέφουν ένα αντικείμενο Parser, που μπορεί να είναι γνώριμο. Η υλοποίησή τους είναι η εξής:

Parser Client::readTCP() {

	char buffer[1024];
	
	ssize_t bytes=read(tcp,buffer,sizeof(buffer)-1);
	if(!bytes) throw runtime_error("Disconnected");

	buffer[bytes]='\0';
	string response(buffer);
	replace(response.begin(),response.end(),'\n',' ');
	
	return Parser(response);
	
}

Parser Client::sendTCP(string s) { 
	s+="\n";
	send(tcp,s.c_str(),s.size(),0);
	return readTCP();
}

Προψωρώντας τώρα στο UDP, έχουμε δύο μεθόδους:

  • Η sendUDP() χρησιμοποιείται από την εφαρμογή για να στείλει ένα datagram στον server
  • Η receiveUTP() χρησιμοποιείται από το thread που προαναφέραμε, για να προωθήσει τα εισερχόμενα datagrams στην εφαρμογή

Υπάρχει επίσης ένα πεδίο onDGram το οποίο είναι μια callback που χρησιμοποιείται από την receiveUDP() για να ειδοποιήσει την εφαρμογή ότι μόλις ήρθε ένα datagram. Ας δούμε πως υλοποιούνται όλα αυτά:

void Client::receiveUDP() {

	void *buffer=(void*)malloc(1024);
	sockaddr_in src{};
	socklen_t len=sizeof(src);

	ssize_t bytes=recvfrom(udp,buffer,1024,0,(sockaddr*)&src,&len);

	if(bytes) onDGram({buffer,bytes}); else free(buffer);
	
}

void Client::sendUDP(DGram d) { sendto(udp,d.getData(),d.getSize(),0,(sockaddr*)&serverAddr,sizeof(serverAddr)); }

Αυτά μας καλύπτουν για το επίπεδο επικοινωνίας. Πριν προχωρήσουμε στον constructor, ας δούμε λίγο τ διαδικασία του handshake:

class Client {

	private:

		/* ... */

  		bool handshake();

  		/* ... */

};

bool Client::handshake() {

	Parser p=readTCP();
	
	if(p.getToken()!="id") return false;

	int sessionId=atoi(p.getToken().c_str());
	if(!sessionId) return false;

	sendUDP({to_string(sessionId)});
	
	return true;

}

Είναι ξεκάθαρο τι προσπαθούμε να κάνουμε εδώ: να λάβουμε ένα id και, αν όλα πάνε καλά, να το στείλουμε πίσω μέσω UDP. Η μέθοδος επιστρέφει true αν επιτύχει, αλλιώς false.

Οκ, ας ασχοληθούμε τώρα με τον constructor, που θα λάβει ως παραμέτρους τον host και το port της σύνδεσης, μαζί με μια callback για το UDP thread. Προφανώς, το πρώτο πράγμα που έχουμε να κάνουμε είναι να ρυθμίσουμε τα sockets και να κάνουμε μια σύνδεση TCP με τον server. Μετά από αυτό, εφόσον αναμένουμε ο server να απαντήσει με το id, κάνουμε το handshake. Τέλος δημιουργούμε το UDP thread, το οποίο δεν είναι τίποτε άλλο από ένα ατέρμονο loop που καλεί την receiveUDP():

class Client {

	private:

  		/* ... */
  
  		string lastError;
  
  		/* ... */

	public:

		/* ... */
  
  		Client(string,int,dgramEvent);
  		string getLastError();

		/* ... */

};

Client::Client(string host,int port,dgramEvent onDGram): onDGram(onDGram) { 
	
	addrinfo hints{};
	hints.ai_family=AF_INET;
	hints.ai_socktype=SOCK_STREAM;

	addrinfo *res=nullptr;
	int rc=getaddrinfo(host.c_str(),nullptr,&hints,&res); 
	if(rc!=0) {
		lastError="Failed to resolve "+host+": "+gai_strerror(rc)+"\n";
		return;
	}
	
	sockaddr_in *resolved=(sockaddr_in*)res->ai_addr;
	serverAddr.sin_family=AF_INET;
	serverAddr.sin_port=htons(port);
	serverAddr.sin_addr=resolved->sin_addr;
	freeaddrinfo(res);

	tcp=socket(AF_INET,SOCK_STREAM,0);
	if(connect(tcp,(sockaddr*)&serverAddr,sizeof(serverAddr))<0) {
		lastError="Failed to connect to "+host+":"+to_string(port)+"\n";
		return;
	}

	udp=socket(AF_INET,SOCK_DGRAM,0);
	sockaddr_in localAddr{};
	localAddr.sin_family=AF_INET;
	localAddr.sin_addr.s_addr=INADDR_ANY;
	localAddr.sin_port=0;
	bind(udp,(sockaddr*)&localAddr,sizeof(localAddr));

	if(!handshake()) {
		lastError="Handshake failed\n";
		return;
	}
	
	thread([this](){ while(true) receiveUDP(); }).detach();
	
}

Τα λάθη αποθηκεύονται στη lastError, ενώ η getLastError() δίνει στις εφαρμογές προσβαση σε αυτό το πεδίο πριν το διαγράψει:

string Client::getLastError() { 
	string s=lastError;
	lastError="";
	return s; 
}

Το μόνο που μένει είναι να φτιάξουμε μεθόδους για κάθε διαθέσιμη εντολή του server. Σε αυτό το άρθρο θα καλύψω τέσσερις από αυτές: auth, join, games και worlds. Οι δύο πρώτες δουλεύουν με πανομοιότυπο τρόπο, εννοώντας ότι η απάντηση θα είναι είτε ok είτε κάποιο μήνυμα λάθους:

class Client {

	private:

  		/* ... */
  
		bool simpleCommand(string);
  
  		/* ... */

	public:

		/* ... */
  
		bool login(string,string);
		bool join(int);

		/* ... */

};

bool Client::simpleCommand(string prompt) {

	Parser p=sendTCP(prompt);;
	
	if("ok"==p.getToken()) return true;
	
	lastError=p.getToken();
	return false;
		
}

bool Client::join(int world) { return simpleCommand("join "+to_string(world)); }
bool Client::login(string username,string password) { return simpleCommand("auth "+username+" "+password); }

Οι άλλες δύο είναι αρκετά όμοιες, αλλά όχι ακριβώς ίδιες. Η καθεμιά πρέπει να επιστρέψει μια λίστα αντικειμένων διαφορετικού είδους:

struct clientGameEntry {
	string name;
	string description;
};

struct clientWorldEntry {
	string gameId;
};

class Client {

	public:

		/* ... */
  
		map<string,clientGameEntry> getGames();
		map<int,clientWorldEntry> getWorlds();

		/* ... */

};

map<string,clientGameEntry> Client::getGames() {

	Parser p=sendTCP("games");
	map<string,clientGameEntry> r;
	
	if("err"==p.getToken()) return r;
	
	do {
		string id=p.getToken();
		r[id]={p.getToken(),p.getToken()};
	} while(p.getToken().length());
	
	return r;
	
}

map<int,clientWorldEntry> Client::getWorlds() {

	Parser p=sendTCP("worlds");
	map<int,clientWorldEntry> r;

	if("err"==p.getToken()) return r;
	
	do {
		int id=atoi(p.getToken().c_str());
		r[id]={p.getToken()};
	} while(p.getToken().length());
	
	return r;

}

Και στις δυο περιπτώσεις, το πρώτο token δηλώνει αν έχουμε μήνυμα λάθους ή όχι. Αν όχι, κατασκευάζουμε την αντίστοιχη λίστα καταναλώνοντας τα υπόλοιπα tokens, αγνοώντας στην πορεία το πρώτο κάθε γραμμής (game ή world αντίστοιχα). Εδώ φαίνεται ξεκάθαρα γιατί οι χαρακτήρες αλλαγής γραμμής είναι άχρηστοι για τον client, αφού η υλοποίησή μας δεν τους χρησιμοποιεί.

Πιθανότατα θα προσθέσω πολύ περισσότερες εντολές, αλλά για το σκοπό αυτού του άρθρουμ, αυτές αρκούν. Τώρα ας προχωρήσουμε σε μια απλή εφαρμογή που χρησιμοποιεί αυτή την κλάση, ξεκινώντας από την main():

int main(int argc, char *argv[]) {

	try { return run(); }
	catch(const exception& e) {
		cerr<<"\033[31mFatal: "+string(e.what())+"\033[0m\n";
		return 1;
	}

}

Αυτό είναι απλά μια run() μέσα σε ένα μπλοκ try/catch. Αλλά τι θα πρέπει να κάνει η run(); Λοιπόν, πρώτα απ’ όλα θα πρέπει να δημιουργεί ένα αντικείμενο Client και να ζητά από το χρήστη να διαπιστευτεί. Για την ώρα θα υποθέσω ότι πάντα θα συνδεόμαστε στον localhost:9999:

	Client client("localhost",9999,incomingDGram);
	
	string err=client.getLastError();
	if(err!="") {
		cerr<<err<<endl;
		return 1;
	}
	
	string username,password;
	do {
		err=client.getLastError();
		if(err!="") cerr<<"\033[31m"<<err<<"\033[0m\n";
		cout<<"Username/Password: ";
		cin>>username>>password;
	} while(!client.login(username,password));
	cin.ignore(256,'\n');

Η τρίτη παράμετρος που δίνουμε στον constructor είναι μια συνάρτηση που αποθηκεύει τα εισερχόμενα datagrams:

vector<string> dgrams;
mutex dgramsMutex;

void incomingDGram(DGram d) {
	lock_guard<mutex> lock(dgramsMutex);
	dgrams.push_back(d.toString());
}

Αφού αυτή θα είναι μια callback που σκοπεύουμε να δώσουμε σε ένα άλλο thread, πρέπει να προστατέψουμε την dgrams με ένα mutex.

Πίσω στη run(), μετά από επιτυχή διαπίστευση, θα πρέπει να μπαίνει σε ένα loop όπου θα:

  1. ζητά από το χρήστη να δώσει μια εντολή
  2. την επεξεργάζεται
  3. εκτυπώνει τυχόν λάθη
  4. περιμένει για ένα δευτερόλεπτο
  5. ελέγχει για τυχόν νέα datagrams και τα εκτυπώνει

Το βήμα #4 παραπάνω χρησιμεύει για να δώσουμε στον server χρόνο να απαντήσει, αφού η επικοινωνία πάνω στο UDP είναι ασύγχρονη. Παρακάτω είναι η υλοποίηση όλων αυτών, εκτός του #2:

	string input;
	bool running=true;
	while(running) {
	
		cout<<"> ";
		getline(cin,input);

      	//TODO: process input
      
		err=client.getLastError();
		if(err!="") cerr<<"\033[31m"<<err<<"\033[0m\n";
		
		sleep(1);

		cout<<"\033[33m";
		{
			lock_guard<mutex> lock(dgramsMutex);
			for(auto &d:dgrams) cout<<d;
			dgrams.clear();
		}
		cout<<"\033[0m\n";
		
	}
	
	cout<<"Quitting. Bye!\n";
	return 0;

Όσον αφορά το input, θα το στέλνουμε μέσω UDP εκτός αν ξεκινά με άνω-κάτω τελεία (:), όπου θα το αντιμετωπίζουμεως εντολή. Αν η εντολή είναι αριθμός, θα υποθέτουμε ότο αφορά ένα join στο στιγμιότυπο παιχνιδιού με το συγκεκριμένο id. Αλλιώς, θα πρέπει να είναι ένας χαρακτήρας, είτε l είτε q. Με το q το πρόγραμμα θα τερματίζει, ενώ με το l θα δείχνει μια λίστα με τα διαθέσιμα στιγμιότυπα για σύνδεση:

		if(':'!=input[0]) client.sendUDP({input});
		else if(int n=atoi(input.substr(1).c_str())) client.join(n);
		else if(input.length()>2) cerr<<"\033[31mAll commands are single-character\033[0m\n";
		else switch(input[1]) {
		
			case 'l': {
			
				map<string,clientGameEntry> games=client.getGames();
				map<int,clientWorldEntry> worlds=client.getWorlds();
				
				for(auto [id,world]:worlds) {
					clientGameEntry game=games[world.gameId];
					cout<<id<<"\t"<<game.name<<"\t"<<game.description<<endl;
				}
				
				break;
			}
			
			case 'q':
				running=false;
				break;
				
			default: cerr<<"\033[31mUnknown command '"<<input[1]<<"'\033[0m\n";
			
		}
		

Όπως θα παρατηρήσατε, τα λάθη εκτυπώνονται κόκκινα, ενώ τα δεδομένα UDP έχουν ένα κίτρινο/καφέ χρώμα. Για να κλείσουμε, αυτό είναι ένα screenshot από μια δοκιμαστική εκτέλεση:

Αφήστε μια απάντηση

Η ηλ. διεύθυνση σας δεν δημοσιεύεται. Τα υποχρεωτικά πεδία σημειώνονται με *