Φτιάχνοντας έναν Game Server σε C++ #8: Τρίλιζες
Θέλω να φτιάξω ένα δεύτερο παιχνιδάκι για τον server μου. Στην πραγματικότητα, θα φτιάξω δύο διαφορετικές εκδόσεις του (εξ ού κι ο πληθυντικός): μια ενάντια στη μηχανή (PvE) και μια άλλη μεταξύ δύο παιχτών (PvP). Το κίνητρό μου αυτή τη φορά είναι να εξερευνήσω δύο πτυχές που πρέπει να χαρακτηρίζουν τα παιχνίδια σε ένα πλαίσιο client/server, συγκεκριμένα ελάχιστη χρήση δικτύου και αφηρημένη λειτουργία του παιχνιδιού. Θα υποθέσω ότι όλοι ξέρουν πως παίζεται η Τρίλιζα, οπότε ας εξηγήσω τι εννοώ με αυτές τις πτυχές.
Ελάχιστη Χρήση του Δικτύου
Στο RPS, έκανα το παιχνίδι να συνθέτει και να στέλνει μηνύματα κειμένου στην υποθετικά τελική μορφή τους. Αυτό εξυπηρέτησε το σκοπό του μια χαρά, διότι τότε ήθελα να δοκιμάζω τον server με telnet/nc. Όμως, ένας πιο έξυπνος client θα μπορούσε όχι μόνο να μεταφράζει συμβολικά μηνύματα στο πραγματικό τους νόημα, αλλά επίσης να τα παρουσιάσει στον χρήστη με έναν πιο προσαρμοσμένο τρόπο, πχ σε μια άλλη γλώσα ή χρησιμοποιώντας μέσα που δεν είναι κείμενο.
Ακόμα παραπάνω, το δίκτυο είναι τις περισσότερες φορές το σημείο συμφόρησης της αρχιτεκτονικής client/server. Επομένως, όσο λιγότερα και μικρότερα είναι τα πακέτα, τόσο το καλύτερο.
Όσον αφορά την Τρίλιζα, ο χρήστης θα μπορεί να στείλει μόνο τη θέση στον πίνακα που θέλει να παίξει, σε μορφή ενός χαρακτήρα από ‘0‘ έως ‘8‘. Τα πακέτα του server θα είναι επίσης ενός-χαρακτήρα:
- ‘
0‘-‘8‘ ανάλογα με την κίνηση του αντιπάλου - ‘
S‘ (shutdown) όταν τερματίζεται το στιγμιότυπο παιχνιδιού - ‘
F‘ (full) όταν ο χρήστης προσπαθεί να συνδεθεί σε παιχνίδι χωρίς ελεύθερες θέσεις - ‘
P‘ (play) όταν ο χρήστης συνδέεται σε παιχνίδι όπου παίζει πρώτος - ‘
W‘ (wait) όταν ο χρήστης πρέπει να περιμένει για αντίπαλο
Η τελευταία περίπτωση έχει εφαρμογή μόνο στο PvP, είτε όταν ο χρήστης συνδέεται πρώτος ή όταν ο αντίπαλός του αποσυνδέεται, και πρέπει να περιμένει κάποιον άλλο.
Αφηρημένη Λειτουργία του Παιχνιδιού
Όπως θα παρατηρήσατε παραπάνω, δεν στέλνουμε μηνύματα λάθους. Άκυρα δεδομένα από τον χρήστη θα αγνοούνται σιωπηλά, όπως κάναμε και με το RPS. Σε αντίθεση με αυτό όμως, δεν στέλνουμε ούτε το σκορ, κι αυτό διότι δεν το κρατάμε. Έτσι, το βάρος της αποστολής σωστών δεδομένων μετατοπίζεται στον client, όπως και της απόφασης να κρατά κάποιο σκορ ή όχι.
Για να πάμε το θέμα ακόμα πιο μακριά, δεν υπάρχει καν επικοινωνία σχετική με το σύμβολο (Χ ή Ο) που ανατίθεται σε κάθε χρήστη. Από την οπτική του client, ο χρήστης μπορεί να παίζει με οποιοδήποτε από τα δύο. Αυτό συμβαίνει επειδή αυτή η πληροφορία δεν έχει ουσία για το παιχνίδι. Το μόνο που χρειάζεται να ξέρει ο server είναι οι ελεύθερες θέσεις στον πίνακα. Όταν ο χρήστης επιλέγει μία, θα τροποποιούμε έναν μετρητή για τη γραμμή, στήλη ή διαγώνιο στην οποία αυτή ανήκει. Για τον πρώτο παίχτη μειώνουμε τον μετρητή και για τον δεύτερο τον αυξάνουμε. Αν κάποιος μετρητής φτάσει στο -3, αυτό σημαίνει ότι κερδίζει ο πρώτος παίχτης, ενώ όταν κάποιος φτάσει στο 3, κερδίζει ο δεύτερος. Αλλά ακόμα και τότε, το μόνο που χρειάζεται να κάνει ο server είναι να επαναφέρει το παιχνίδι στην αρχική κατάσταση, για να ξεκινήσει ένας νέος γύρος.
Όμως, νέος γύρος ξεκινά κι όταν έχουμε ισοπαλία, που σημαίνει ότι κανείς δεν κέρδισε αφότου έχει καταληφθεί όλος ο πίνακας. Αυτό προϋποθέτει άλλον ένα μετρητή για τον αριθμό των κατηλειμμένων θέσεων. Όταν αυτός ο μετρητής φτάσει στο 9, το παιχνίδι αρχικοποιείται.
Τέλος, οι παίχτες θα αναγνωρίζονται από τον τρόπο με τον οποίο τροποποιούν τους μετρητές. Με άλλα λόγια, ο πρώτος θα είναι ο παίχτης -1 και ο δεύτερος ο παίχτης 1. Για την έκδοση PvE η μηχανή θεωρείται -1, όπως και ο πρώτος που συνδέεται σε PvP. Επιπλέον για το PvP, αν εγκαταλείψει ο -1, ο 1 παίρνει τη θέση του σαν να ήταν ο πρώτος που συνδέθηκε. Ο παίχτης -1 πάντα περιμένει τον 1 να παίξει πρώτος. Μετά από αυτό παίζουν εναλλάξ, και η σειρά του καθένα διατηρείται ακόμα και σε νέο γύρο.
Βασική Λειτουργικότητα
Ας κάνουμε μια περίληψη με αυτά που έχουμε ως τώρα. Η τρέχουσα κατάσταση του παιχνιδιού θα αποτελείται από:
- έναν πίνακα boolean εννέα θέσεων που θα κρατά τις ελεύθερες θέσεις
- οκτώ μετρητές για τις γραμμές, στήλες και διαγωνίους
- έναν μετρητή για τον αριθμό των κατηλειμμένων θέσεων του πίνακα
- μια μεταβλητή που θα δηλώνει ποιανού είναι η σειρά να παίξει (-1 ή 1)
Εφόσον ο καθένας παίζει με τη σειρά του, η πλοκή εξελίσσεται όταν κάποιος στείλει την κίνησή του. Τότε πρέπει να ελέγξουμε την εγκυρότητα της κίνησης, να επαναφέρουμε το παιχνίδι αν ο παίχτης νίκησε ή προκάλεσε ισοπαλία, και να αλλάξουμε τον παίχτη που έχει σειρά. Η παρακάτω κλάση υλοποιεί όλα τα παραπάνω:
class TicTacToe: public Game {
protected:
bool board[9];
int row[3],col[3],diag[2],plays,turn=1,pos;
void resetGame();
virtual bool canPlay(int)=0;
virtual void switchTurns()=0;
bool wins();
public:
TicTacToe(GameInterface*);
void playerSent(int,DGram) override;
};
TicTacToe::TicTacToe(GameInterface *gi): Game(gi) {
resetGame();
}
void TicTacToe::resetGame() {
for(int i=0;i<9;i++) board[i]=false;
for(int i=0;i<3;i++) row[i]=col[i]=0;
for(int i=0;i<2;i++) diag[i]=0;
plays=0;
}
void TicTacToe::playerSent(int player,DGram d) {
if(!canPlay(player)) return;
auto c=((char*)d.getData())[0];
if((c<'0')||(c>'8')) return;
pos=c-'0';
if(board[pos]) return;
if(wins()) resetGame();
switchTurns();
}
Παρόλο που οι canPlay() και switchTurns() διαφέρουν από το PvE στο PvP, η wins() είναι κοινή:
bool TicTacToe::wins() {
board[pos]=true;
plays++;
auto win=3*turn;
auto r=pos/3;
if((row[r]+=turn)==win) return true;
auto c=pos%3;
if((col[c]+=turn)==win) return true;
if(!(pos%2)) {
switch(pos) {
case 4:
diag[0]+=turn;
diag[1]+=turn;
break;
case 0: case 8:
diag[0]+=turn;
break;
case 2: case 6:
diag[1]+=turn;
break;
}
if((diag[0]==win)||(diag[1]==win)) return true;
}
if(9==plays) resetGame();
return false;
}
Αφού η turn παίρνει τιμές -1 ή 1, χρησιμοποιείται αφ’ ενός για να τροποποιήσει τους μετρητές και αφ’ ετέρου για να αποφασιστεί ποιο είναι το άθροισμα της νίκης (-3 ή 3). Αν ένας μετρητής φτάσει αυτό το άθροισμα ο παίχτης κερδίζει, και αν καταληφθούν όλες οι θέσεις του πίνακα (plays==9) έχουμε ισοπαλία.
Αντιμετωπίζοντας τη Μηχανή
Για την κλάση TicTacToePvE θα πρέπει να υλοποιήσουμε έναν destructor, όπως και τις υπόλοιπες αφηρημένες μεθόδους:
class TicTacToePvE: public TicTacToe {
private:
int opponent=0;
int firstEmpty(int);
int nthEmpty(int);
protected:
bool canPlay(int) override;
void switchTurns() override;
public:
TicTacToePvE(GameInterface*);
~TicTacToePvE();
void addPlayer(int) override;
void deletePlayer(int) override;
};
TicTacToePvE::TicTacToePvE(GameInterface *gi): TicTacToe(gi) { }
Το πεδίο opponent περιέχει το αναγνωριστικό χρήστη του παίχτη, ενώ η nthEmpty() χρησιμοποιεί την firstEmpty() για να βρει την n-οστή ελεύθερη θέση στον πίνακα, κάπως έτσι:
int TicTacToePvE::firstEmpty(int from=0) {
while(board[from]) from++;
return from;
}
int TicTacToePvE::nthEmpty(int n) {
int i,next;
i=next=0;
do {
i=firstEmpty(next);
next=i+1;
} while(n--);
return i;
}
Αυτές τις χρειαζόμαστε για να υλοποιήσουμε την switchTurns() στην οποία παίζει η μηχανή και αλλάζει τη σειρά πίσω στο χρήστη. Αποφάσισα να κάνω τη μηχανή να παίζει σε μια τυχαία διαθέσιμη θέση, γιατί ένας πιο έξυπνος αλγόριθμος θα το έκανε αδύνατο για τον παίχτη να κερδίσει:
void TicTacToePvE::switchTurns() {
turn=-turn;
pos=nthEmpty(rand()%(9-plays));
server->sendPlayer(opponent,{to_string(pos)});
if(wins()) resetGame();
turn=-turn;
}
Ένας χρήστης μπορεί να παίξει αν το αναγνωριστικό του αντιστοιχεί στην τιμή του opponent:
bool TicTacToePvE::canPlay(int player) { return opponent==player; }
Ο destructor θα κληθεί όταν τερματίζεται το στιγμιότυπο του παιχνιδιού, οπότε θα πρέπει να ενημερώσουμε τον αντίπαλο:
TicTacToePvE::~TicTacToePvE() {
if(!opponent) return;
server->sendPlayer(opponent,{"S"});
server->unjoinPlayer(opponent);
}
Όταν ένας χρήστης συνδέεται, και με την προϋπόθεση ότι δεν έχουμε αντίπαλο, τον ειδοποιούμε ότι είναι η σειρά του να παίξει. Από την άλλη, όταν ο παίχτης αποχωρεί απλά αρχικοποιούμε το παιχνίδι για τον νέο αντίπαλο:
void TicTacToePvE::addPlayer(int player) {
if(opponent) {
server->sendPlayer(player,{"F"});
server->unjoinPlayer(player);
return;
}
opponent=player;
server->sendPlayer(player,{"P"});
}
void TicTacToePvE::deletePlayer(int player) {
if(player!=opponent) return;
opponent=0;
resetGame();
}
Να σημειώσουμε εδώ ότι το PvE ποτέ δεν στέλνει ‘W‘. Αυτό συμβαίνει επειδή ο παίχτης πάντα παίζει πρώτος μόλις συνδεθεί, και η μηχανή πάντα περιμένει την κίνηση του παίχτη.
Παίζοντας με Άλλους Παίχτες
Για την κλάση TicTacToePvP πρέπει να υλοποιήσουμε τις ίδιες μεθόδους, οπότε θα μοιάζει πολύ με την αντίστοιχη PvE:
class TicTacToePvP: public TicTacToe {
private:
map<int,int> players={{-1,0},{1,0}};
protected:
bool canPlay(int) override;
void switchTurns() override;
public:
TicTacToePvP(GameInterface*);
~TicTacToePvP();
void addPlayer(int) override;
void deletePlayer(int) override;
};
TicTacToePvP::TicTacToePvP(GameInterface *gi): TicTacToe(gi) { }
Η ήδη προφανής διαφορά είναι ότι τώρα αντιστοιχούμε κάθε παίχτη (-1 ή 1) με το αναγνωριστικό χρήστη του. Για ευκολία, θα ορίσω και τρεις macros:
#define PL1 players[-1] #define PL2 players[1] #define PLCUR players[turn]
Όταν συνδεθεί κάποιος χρήστης, πρέπει πλεον να ελέγχουμε αν είναι ο πρώτος ή ο δεύτερος που συνδέεται, προϋποθέτοντας φυσικά ότι υπάρχουν ελεύθερες θέσεις παιχτών:
void TicTacToePvP::addPlayer(int player) {
if(PL1&&PL2) {
server->sendPlayer(player,{"F"});
server->unjoinPlayer(player);
return;
}
if(PL1) {
PL2=player;
server->sendPlayer(player,{"P"});
return;
}
PL1=player;
server->sendPlayer(player,{"W"});
}
Όταν εγκαταλείπει ο PL1, θα πρέπει να “αναβαθμίσουμε” τον PL2 σε PL1. Σε κάθε περίπτωση, ο PL2 θα πρέπει να αρχικοποιηθεί, όπως και το ίδιο το παιχνίδι και η σειρά των παιχτών. Όποιος παραμείνει ως PL1, θα πρέπει να περιμένει για νέο αντίπαλο:
void TicTacToePvP::deletePlayer(int player) {
if(PL1==player) PL1=PL2;
PL2=0;
if(PL1) server->sendPlayer(PL1,{"W"});
resetGame();
turn=1;
}
Όσο για τον τερματισμό του στιγμιότυπου, ο destructor μας θα πρέπει να ειδοποιεί τόσο τον server όσο και τους παίχτες για το γεγονός:
TicTacToePvP::~TicTacToePvP() {
for(auto &[i,p]:players) {
if(!p) continue;
server->sendPlayer(p,{"S"});
server->unjoinPlayer(p);
}
}
Περα από αυτά, η canPlay() απλά ελέγχει αν είναι η σειρά του παίχτη να παίξει, και η switchTurns() αλλάζει την turn στην αντίθετή της τιμή και ενημερώνει τον ενεργό παίχτη για την κίνηση του αντιπάλου του:
bool TicTacToePvP::canPlay(int player) { return PLCUR==player; }
void TicTacToePvP::switchTurns() {
turn=-turn;
server->sendPlayer(PLCUR,{to_string(pos)});
}
Προσθέτοντας τα Παιχνίδια στον Server
Οι δύο εκδόσεις της Τρίλιζάς μας είναι πλεον έτοιμες, αλλά ο server θα πρέπει να γνωρίζει την ύπαρξή τους. Για να γίνει αυτό, θα πρέπει να προσθέσουμε δύο ακόμα εγγραφές στη λίστα games του:
Server::Server(serverParams p) {
/* ... */
games["ttt-pvp"]=new gameEntry{
"Tic tac toe (pvp)",
"Tic tac toe against another human",
[](GameInterface *gi)->Game* { return new TicTacToePvP(gi); }
};
games["ttt-pve"]=new gameEntry{
"Tic tac toe (pve)",
"Tic tac toe against the machine",
[](GameInterface *gi)->Game* { return new TicTacToePvE(gi); }
};
/* ... */
}
