|

Φτιάχνοντας έναν Game Server σε C++ #3: Πέτρα Ψαλίδι Χαρτί

Series Navigation<< Φτιάχνοντας έναν Game Server σε C++ #2: Και εγένετο ΚώδικαςΦτιάχνοντας έναν Game Server σε C++ #4: Περί πρωτοκόλλου >>

Στο προηγούμενο άρθρο φτιάξαμε μια πολύ βασική έκδοση του server η οποία δέχεται συνδέσεις TCP και διατηρεί στοιχεία για τις συνεδρίες. Αλλά τι αξία έχει ένας game server χωρίς παιχνίδια; Σε αυτό το άρθρο θα υλοποιήσουμε ένα απλό, text-based παιχνίδι Πέτρα Ψαλίδι Χαρτί.

Προετοιμάζοντας τον Server

Πριν απ’ ολα, εφόσον θα αρχίσουμε να δουλεύουμε με το UDP, ας ορίσουμε μια απλή κλάση που θα κρατά τα δεδομένα και το μέγεθος ενός datagram:

class DGram {

	private:

		void *data;
		ssize_t size;
		
	public:
	
		DGram(ssize_t);
		DGram(void*,ssize_t);
		DGram(char);
		DGram(string);
		
		DGram(const DGram&);
		
		~DGram();
		
		void *getData();
		ssize_t getSize();
		
		string toString();
		
};

DGram::DGram(ssize_t size): size(size) { data=(void*)malloc(size); }
DGram::DGram(void *data,ssize_t size): size(size), data(data) { }
DGram::DGram(char c): DGram((ssize_t)sizeof(char)) { memcpy(data,&c,size); }
DGram::DGram(string s): DGram((ssize_t)s.length()) { memcpy(data,s.c_str(),size); }

DGram::DGram(const DGram& d): DGram(d.size) { memcpy(data,d.data,size); }

DGram::~DGram() { free(data); }

void *DGram::getData() { return data; }
ssize_t DGram::getSize() { return size; }

string DGram::toString() { return string((char*)data,size); }

Ξέρω ότι στο πρώτο άρθρο είπα πως, προκειμένου κάποιος να συμμετάσχει σε παιχνίδι, θα πρέπει πρώτα να διαπιστευτεί (authenticate). Αυτό θα το αγνοήσουμε για την ώρα, μιας που η διαδικασία της διαπίστευσης θέλει ακόμα δουλειά. Κάτι που δεν ανέφερα ρητά, αλλά νομίζω εννοείται, είναι ότι θα πρέπει επίσης να έχει ολοκληρώσει το TCP/UDP handshake. Αυτό δεν μπορούμε να το αγνοήσουμε, επειδή είναι ουσιώδες για την επικοινωνία του παίχτη με το παιχνίδι. Θα αναφερθούμε ξανά σε αυτό παρακάτω στο άρθρο.

Για τώρα, η παρακάτω μέθοδος θεωρεί ότι έχει γίνει το handshake, και χρησιμοποιείται από τα παιχνίδια για να στέλνουν δεδομένα σε έναν παίχτη με συγκεκριμένο id:

struct DGram {
	void *data;
	ssize_t size;
};

class GameInterface {
  
	public:
		virtual void sendPlayer(int,DGram)=0;
  
};

class Server: public SessionInterface, public GameInterface {

	public:
  
	  	/* ... */
  	
		void sendPlayer(int,DGram) override;
 	
	  	/* ... */

};

void Server::sendPlayer(int player,DGram d) {
	sockaddr_in a=getSessionUDPAddr(player);
	sendto(udp,d.getData(),d.getSize(),0,(sockaddr*)&a,sizeof(a));
}

Σε κάθε χρονική στιγμή θα υπάρχει μια πληθώρα από ενεργά στιγμιότυπα παιχνιδιών (worlds) κάθε είδους. Παρόλα αυτά, ο χρήστης θα μπορεί να είναι συνδεδεμένος μόνο σε ένα από αυτά, κι αυτό με τη σειρά του σημαίνει ότι ο server πρέπει να ξέρει ποιο στιγμιότυπο (τυχόν) αντιστιοχεί σε ποια συνεδρία. Όπως φάνηκε παραπάνω, η λίστα των συνεδριών μπορεί να βοηθήσει στην επικοινωνία των παιχνιδιών προς τους παίχτες, αλλά όχι αντίστροφα. Όταν φτάνει ένα πακέτο UDP, ο server πρέπει να ξέρει που να το δρομολογήσει, δηλαδή σε ποιο παιχνίδι και ποιον παίχτη. Η μόνη πληροφορία που μπορεί να χρησιμοποιήσει προς τούτο είναι η διεύθυνση του client που έλαβε με το πακέτο. Όντως έχουμε αυτή την πληροφορία στη λίστα συνεδριών, αλλά θα χρειαζόταν επίπονη αναζήτηση για να συμπεράνουμε το id από αυτήν.

Αντ’ αυτού, θα διατηρούμε άλλη μια λίστα, για τους παίχτες αυτή τη φορα. Κάθε στοιχείο της θα περιέχει το παιχνίδι που παίζει ο χρήστης και το session id του. Κάθε χρήστης που έχει συνδεθεί σε παιχνίδι θα αντιπροσωπεύεται σε αυτή τη λίστα με τη UDP διεύθυνση/port του. Και φυσικά, και αυτή η λίστα χρειάζεται να είναι thread-safe, επομένως θα την προστατέψουμε με τον δικό της mutex:

struct playerEntry {
	Game *game;
	int session=0;
};

class Server: public SessionInterface, public GameInterface {

	private:
  
	  	/* ... */
  	
		map<pair<uint32_t,uint16_t>,playerEntry*> players;
		recursive_mutex playerMutex;
  	
	  	/* ... */

};

Όταν ένας χρήστης αποσυνδέεται, θα πρέπει πλεον να ειδοποιούμε το παιχνίδι που (τυχόν) παίζει για να τον διαγράψει. Αυτή η πράξη θα πρέπει τελικά να οδηγεί στη διαγραφή της αντίστοιχης εγγραφής από τη λίστα παιχτών, αλλά θα μιλήσουμε γι’ αυτό σε μελλοντικό άρθρο. Για την ώρα, απλά θα τροποποιήσουμε την clientDisconnect():

void Server::clientDisconnect(int id) {

	lock_guard<recursive_mutex> sLock(sessionMutex);
	sockaddr_in a=sessions[id]->udpAddr;
	
	pair<uint32_t,uint16_t> p={a.sin_addr.s_addr,a.sin_port};
	lock_guard<recursive_mutex> pLock(playerMutex);
	if(players[p]) players[p]->game->deletePlayer(id);

	delete sessions[id];
	sessions.erase(id);
	log->info("[TCP] Client "+to_string(id)+" disconnected.");

}

Πριν αναφερθούμε στη νέα κλάση Game, ας φτιάξουμε μια μέθοδο για να μπορούν οι χρήστες να συνδέονται στα παιχνίδια:

class Server: public SessionInterface, public GameInterface {

	private:
  
	  	/* ... */

		void joinGame(int,Game*);
  	
	  	/* ... */

};

void Server::joinGame(int player,Game *game) {

  	lock_guard<recursive_mutex> sLock(sessionMutex);
	sockaddr_in a=sessions[player]->udpAddr;

	pair<uint32_t,uint16_t> p={a.sin_addr.s_addr,a.sin_port};
	lock_guard<recursive_mutex> pLock(playerMutex);
	players[p]=new playerEntry{game,player};
	
	game->addPlayer(player);
	
}

Απλά δημιουργούμε μια εγγραφή στη λίστα παιχτών με το παιχνίδι και το id του χρήστη και ειδοποιούμε το παιχνίδι για να τον προσθέσει.

Η τελευταία, αλλά εξίσου σημαντική επέμβαση που θα κάνουμε στην κλάση Server είναι η τροποποίηση της μεθόδου handleUDP(), ώστε να υλοποιεί το handshaking και τη δρομολόγησαη του UDP:

void Server::handleUDP() {
		
	void *buffer=(void*)malloc(1024);
	sockaddr_in clientAddr{};
	socklen_t len=sizeof(clientAddr);
	ssize_t bytes=recvfrom(udp,buffer,sizeof(buffer)-1,0,(sockaddr*)&clientAddr,&len);
	if(bytes<=0) return;
	
	lock_guard<recursive_mutex> sLock(sessionMutex);

	pair<uint32_t,uint16_t> p={clientAddr.sin_addr.s_addr,clientAddr.sin_port};
	lock_guard<recursive_mutex> pLock(playerMutex);
	if(players[p]) {
		players[p]->game->playerSent(players[p]->session,{(void*)buffer,bytes});
		return ;
	}
	
	char *c=(char*)buffer;
	c[bytes]='\0'; 
	int id=atoi(c);
	free(buffer);
	
	if(!sessions[id]) return;
	
	sessions[id]->udpAddr=clientAddr;

	joinGame(id,new RPSGame(this)); // temp

}

Όπως και πριν, λαμβάνουμε το πακέτο από το socket. Εφόσον περιέχει έγκυρα δεδομένα, ελέγχουμε αν η διεύθυνση του αποστολέα αντιστοιχεί με κάποια εγγραφή στη λίστα παιχτών. Αν ναι, ειδοποιούμε το αντίστοιχο παιχνίδι ότι ο χρήστης έστειλε δεδομένα. Αλλά στην αντίθετη περίπτωση, θεωρούμε ότι ο client έστειλε πίσω το id του για handshaking, επομένως προσπαθούμε να το μετατρέψουμε σε ακέραιο αριθμό και να ενημερώσουμε την αντίστοιχη συνεδρία για τη διεύθυνση του χρήστη.

Επιπλέον, αφού το Πέτρα Ψαλίδι Χαρτί (Rock Paper Scissors) είναι (ή μάλλον θα είναι) το μόνο παιχνίδι που υπάρχει, δημιουργούμε αυτόματα ένα νέο στιγμιότυπό του και συνδέουμε τον χρήστη σε αυτό. Αυτή η γραμμή κώδικα θα πρέπει να διαγραφεί όταν ορίσουμε κάποια λειτυργικότητα ώστε να μπορεί ο χρήστης να επιλέξει ποιο στιγμιότυπο θα δημιουργήσει ή/και σε ποιο θα συνδεθεί. Αλλά αυτό είναι θέμα για άλλο άρθρο.

Ξεκινώντας με τα παιχνίδια

Ωραία, ας μιλήσουμε επιτέλους για παιχνίδια!

Από όσα είδαμε μέχρι τώρα, το RPSGame είναι ένας πιο συγκεκριμένος τύπος της αφηρημένης κλάσης Game. Εκτός από τους constructor και destructor, όλα τα παιχνίδια θα πρεπει να υλοποιούν τουλάχιστον άλλες τρεις μεθόδους:

  • την joinPlayer() για να ειδοποιούμε το παιχνίδι ότι συνδέθηκε νέος χρήστης
  • την deletePlayer() για να πούμε στο παιχνίδι ότι ο συγκεκριμένος χρήστης δεν παίζει πια
  • την playerSent()για να προωθούμε στο παιχνίδι τα δεδομένα του παίχτη

Έχοντας αυτά υπ’ όψιν, αυτή είναι μια αφηρημένη κλάση για όλα τα παιχνίδια:

class Game {

	protected:
		GameInterface *server;
		
	public:
		Game(GameInterface*);
		virtual ~Game();
		virtual void addPlayer(int)=0;
		virtual void deletePlayer(int)=0;
		virtual void playerSent(int,DGram)=0;
	
};

Game::Game(GameInterface *gi): server(gi) { }
Game::~Game() { }

Παρατηρείτε ότι η addPlayer() δεν επιστρέφει κάποια τιμή, που σημαίνει ότι δεν ρωτάμε το παιχνίδι αν μπορεί να δεχτεί τον χρήστη, απλά το ειδοποιούμε για τη σύνδεση. Αν για κάποιο λόγο τον απορρίψει, θα πρέπει να τον ενημερώσει μέσω UDP. Και ναι, θα πρέπει να ειδοποιηθεί και ο server, αλλά αυτό δεν θα μας απασχολήσει ακόμα, οπότε ας το αγνοήσουμε.

Τώρα για το συγκεκριμένο παχνίδι μας, όπως είπαμε παραπάνω το RPSGame θα πρέπει να είναι μια κλάση που κληρονομεί από την Game. Ο ορισμός της είναι κάπως έτσι:

class RPSGame: public Game {

	private:
		int opponent,myScore=0,opponentScore=0;

	public:
		RPSGame(GameInterface *);
		void addPlayer(int) override;
		void deletePlayer(int) override;
		void playerSent(int,DGram) override;
	
};

Ο χρήστης θα παίζει ενάντια στη μηχανή, οπότε μόνο το δικό του id (opponent) χρειαζόμαστε, παρόλα αυτά χρειάζεται να κρατάμε και τα δύο σκορ. Όπως είδαμε παραπάνω, ο χρήστης συνδέεται αυτόματα με το handshake, και δεν υπάρχει τρόπος να φύγει από το παιχνίδι αν δεν αποσυνδεθεί από τον server. Ο constructor και η addPlayer() είναι πολύ απλες:

RPSGame::RPSGame(GameInterface *gi): Game(gi) { }
	
void RPSGame::addPlayer(int player) { opponent=player; }

Αλλά πως λειτουργεί το παιχνίδι; Ο κάθε παίχτης έχει στη διάθεσή του τρεις επιλογές: Πέτρα (r), Ψαλίδι (s) ή Χαρτί (p). Η πέτρα κερδίζει το ψαλίδι, το ψαλίδι κερδίζει το χαρτί και το χαρτί κερδίζει την πέτρα. Αν και οι δύο παίξουν το ίδιο, είναι ισοπαλία και το σκορ δεν αλλάζει. Υποτίθεται ότι και οι δύο φανερώνουν την επιλογή τους συγχρόνως, αλλά εφόσον μόνο ο αντίπαλος είναι άνθρωπος στην περίπτωσή μας, αυτός θα πρέπει να ξεκινά τον κάθε γύρο στέλνοντας την επιλογή του. Τότε η μηχανή θα αποφασίζει τυχαία για τη δική της επιλογή και υπολογίζει το αποτέλεσμα. Ο χρήστης θα πρέπει να μπορεί να στείλει πολλές επλογές μαζί για να τις επεξεργαστεί το παιχνίδι μία προς μία.

Έχοντας πει αυτά, παρακάτω είναι η υλοποίηση της playerSent():

map<string,bool> winCombo={
	{"rp",false},{"rs",true},
	{"pr",true},{"ps",false},
	{"sp",true},{"sr",false}
};

string validHands="rps";

void RPSGame::playerSent(int player,DGram d) {

	if(player!=opponent) return;

	auto input=string((char*)d.getData(),d.getSize());
	string hands="__",response;
	for(auto &c:input) {
	
		hands[1]=c;
		if(validHands.find(hands[1])==string::npos) continue; // Invalid hand
		
		hands[0]=validHands[rand()%3];
		if(hands[0]==hands[1]) 
			response=hands+" Tie!\n";
		else {
			if(winCombo[hands]) myScore++; else opponentScore++;
			response=hands+" score: "+to_string(myScore)+"-"+to_string(opponentScore)+"\n";
		}

		server->sendPlayer(player,{response});
		
	}
	
}

Οι έγκυρες επιλογές ορίζονται στην validHands, ενώ η winCombo περιέχει τους συνδυασμούς με τους οποίους κερδίζει η μηχανή. Για κάθε νέο πακέτο δεδομένων, επεξεργαζόμαστε τους έγκυρους χαρακτήρες έναν προς έναν. Συγκεκριμένα αναθέτουμε μια τυχαία επιλογή στη μηχανή και, αν οι δύο επιλογές δεν είναι ίδιες, τις αντιστοιχίζουμε με τη winCombo και ενημερώνουμε τα σκορ αναλόγως. Σε κάθε περίπτωση (εκτός από άκυρο χαρακτήρα), ενημερώνουμε τον χρήστη για το αποτέλεσμά.

Το μόνο που λείπει ακόμα είναι η deletePlayer(), η οποία θα στέλνει ένα αποχαιρετιστήριο μήνυμα στον χρήστη και θα μηδενίζει το παιχνίδι:

void RPSGame::deletePlayer(int player) { 
	if(player!=opponent) return;
	server->sendPlayer(opponent,{"Nice playing with you. Bye!\n"});
	opponent=0;
	myScore=0;
	opponentScore=0;
}

Κι αυτό είναι όλο. Ένας τρόπος να το τεστάρουμε αφού το εκτελέσουμε είναι με το nc:

$ nc localhost 9999
id 1

Έλαβα ένα id μέσω TCP, οπότε τώρα πρέπει να συνδεθώ από άλλο τερματικό μέσω UDP, να το στείλω πίσω και να παίξω το παιχνίδι:

$ nc -u localhost 9999
1
s
ps score: 0-1
p
pp Tie!
p
rp score: 0-2
r
pr score: 1-2
rsppspssr
sr score: 1-3
ps score: 1-4
pp Tie!
rp score: 1-5
rs score: 2-5
pp Tie!
ss Tie!
ps score: 2-6
sr score: 2-7
^C

Ουάου, σκίζω! Τέλος πάντων, αυτή είναι και η καταγραφή από το τερματικό του server:

[2025-11-05 18:37:36] Game Server v0.1.0
[2025-11-05 18:37:36] Server listening at port 9999
[2025-11-05 18:37:45] [TCP] Client 1 connected

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

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

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