Videogames Laboratory

O συναρπαστικός κόσμος της ανάπτυξης βιντεοπαιχνιδιών

Arkanoid: Game State Management

Posted by Kostas Anagnostou στο 2 Οκτωβρίου, 2009

Μέχρι στιγμής στο παιχνίδι Arkanoid (μέρος 1, μέρος 2) έχουμε υλοποιήσει την κλάση που εκπροσωπεί τα τουβλάκια (Brick) έχουμε τοποθετήσει τα τουβλάκια στην σωστή θέση, έχουμε υλοποιήσει κίνηση της μπάλας και της ρακέτας, συγκρούσεις μεταξύ των αντικειμένων του παιχνιδιού και αυξάνουμε και το σκορ του παίκτη. Το παιχνίδι είναι λειτουργικό, μπορεί να ελέγξει ο παίκτης τη μπάλα και να καταστρέψει τα τουβλάκια, αλλά δεν έχουμε ορίσει το πότε νικά και πότε χάνει ο παίκτης έτσι ώστε να σταματάμε το παιχνίδι και να του εμφανίζουμε το ανάλογο μήνυμα. Για να το κάνουμε λίγο πιο εύκολο και κομψό αυτό, θα κάνουμε ένα βήμα πίσω και θα υλοποιήσουμε στο παιχνίδι κάτι που ονομάζεται game state management (διαχείριση καταστάσεων παιχνιδιού;).

Το game state management βασίζεται σε κάτι που ονομάζεται μηχανή καταστάσεων (state machine), η οποία είναι ένα σύνολο από καταστάσεις και κανόνες που ορίζουν μεταβάσεις μεταξύ των. Αυτό δεν είναι τόσο τρομακτικό όσο ακούγεται και το εξηγώ με ένα παράδειγμα. Φανταστείτε μια μέρα από τη ζωή σας. Το βράδυ βρίσκεστε στο κρεβάτι σας και κοιμάστε. Στις 8 το πρωί χτυπά το ξυπνητήρι και ξυπνάτε. Αν πεινάτε, μπορεί να φάτε πρωινό ή μπορεί και να φύγετε αμέσως για την δουλειά/σχολείο/πανεπιστήμιο σας. Στο σχολείο (ας πούμε), παρακολουθείτε το μάθημα μέχρι να χτυπήσει το κουδούνι, και μετά βγαίνετε διάλλειμα. Μετά ξαναχτυπά το κουδούνι και μπαίνετε στην τάξη. Όταν έρθει 2 η ώρα πηγαίνετε σπίτι. Εκεί, αν πεινάτε τρώτε και κοιμάστε, διαφορετικά κοιμάστε απευθείας (βαρετή ζωή ομολογουμένως).

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


Κάθε έλλειψη περιέχει μια κατάσταση στην οποία μπορεί να βρίσκεται ο αναγνώστης. Τα βελάκια δείχνουν μεταβάσεις από μια κατάσταση σε μια άλλη όταν λάβει χώρα το συμβάν με μαύρα γράμματα που υπάρχει δίπλα σε κάθε γραμμή μετάβασης. Από την κατάσταση «κοιμάμαι» θα μεταβώ στην κατάσταση «ξύπνησα» όταν χτυπήσει το ξυπνητήρι. Από την κατάσταση «Παρακολουθώ μάθημα» θα μεταβώ στην κατάσταση «Διάλλειμα» όταν χτυπήσει το κουδούνι, και το αντίστροφο.

Ο γράφος αυτός καταστάσεων, και μεταβάσεων μεταξύ τους, ονομάζεται state machine (μηχανή καταστάσεων). Και επειδή ο αριθμός των καταστάσεων είναι περιορισμένος (όχι άπειρος), ο γράφος ονομάζεται πιο συγκεκριμένα finite state machine (μηχανή πεπερασμένων καταστάσεων).

To finite state machine είναι από τα πιο χρήσιμα εργαλεία για την ανάπτυξη βιντεοπαιχνιδιών. Αν σκεφτεί κανείς ένα οποιοδήποτε παιχνίδι, αυτό βρίσκεται ανά πάσα στιγμή σε μια προκαθορισμένη κατάσταση: Εισαγωγή/Τίτλοι, Μενού, Παίξιμο, Παύση (Pause), Οθόνη νίκης, Οθόνη αποτυχίας κλπ. Για να μεταβούμε από την μια κατάσταση σε μια άλλη συνήθως πατάμε κάποιο πλήκτρο στο χειριστήριο ή στο πληκτρολόγιο.

Μπορούμε να αξιοποιήσουμε ένα finite state machine στο Arkanoid λοιπόν για να του δώσουμε μια εισαγωγική οθόνη και οθόνες νίκης και αποτυχίας στο παίκτη. Για να το κάνουμε αυτό χρειαζόμαστε 2 πράγματα, μια απαρίθμηση των καταστάσεων και μια μεταβλητή που θα κρατά την τρέχουσα κατάσταση. Στο αρχείο Game1.cs προσθέτουμε στην κορυφή (εκτός της κλάσης Game1):

namespace Arcanoid
{
    public enum GameState
    {
        Intro,
        Playing,
        Paused,
        Lost,
        Won
    };

Ορίζουμε 5 καταστάσεις στις οποίες μπορεί να βρίσκεται το παιχνίδι, την εισαγωγή (Intro), το κανονικό παίξιμο (Playing), την παύση (Paused), την αποτυχία (Lost) και τη νίκη (Won) του παίκτη. Το enum ορίζει ουσιαστικά ένα τύπο δεδομένων GameState ο οποίο μπορεί να πάρει μόνο κάποια από τις 5 αυτές τιμές. Μπορώ να ορίσω μια μεταβλητή τύπου GameState ως εξής:

    public class Game1 : Microsoft.Xna.Framework.Game
    {
        GameState gameState;

        GraphicsDeviceManager graphics;
        SpriteBatch spriteBatch;
        SpriteFont font;

Η μεταβλητή gameState θα μπορεί να λάβει λοιπόν μια από τις 5 προκαθορισμένες τιμές που περιγράφουν τη κατάσταση του παιχνιδιού. Από τη στιγμή που έχουμε ορίσει τις καταστάσεις του παιχνιδιού πρέπει να ορίσουμε πως θα γίνεται η μετάβαση από τη μια στην άλλη. Φτιάχνουμε ένα finite state machine ανάλογο με αυτό που περιγράψαμε παραπάνω:


Οι ελλείψεις με έντονο περίγραμμα σηματοδοτούν την αρχική και τελική κατάσταση του παιχνιδιού. Έχουμε μια έλλειψη για κάθε κατάσταση παιχνιδιού που αναφέραμε και τις μεταξύ τους μεταβάσεις που ορίζονται από κανόνες. Για παράδειγμα αν είμαστε στην αρχική οθόνη και ο παίκτης πατήσει το πλήκτρο ENTER τότε το παιχνίδι θα αρχίσει (θα αλλάξει η κατάσταση του σε Playing δηλαδή). Αν το παιχνίδι τρέχει (Playing) και ο παίκτης πατήσει το πλήκτρο P τότε το παιχνίδι θα παγώσει (Paused) και θα επιστρέψει στην κατάσταση Playing μόνο αν ο παίκτης ξαναπατήσει το πλήκτρο P. Από τη κατάσταση Playing ο παίκτης θα μεταβεί στη Lost όταν χάσει όλες τις ζωές του (lives = 0). Από την κατάσταση Playing θα μεταβεί στην κατάσταση Won (κερδίσει) μόνο όταν χτυπήσει όλα τα τουβλάκια, και ούτω κάθε εξής.

Με βάση το state machine που δημιουργήσαμε παραπάνω μπορούμε εύκολα να υλοποιήσουμε το game state management του παιχνιδιού μας. Το μόνο που έχουμε να ελέγξουμε είναι το σε ποια κατάσταση είμαστε κάθε χρονική στιγμή (gameState) και αν συντρέχει κάποιος λόγος (πάτημα πλήκτρου κλπ) να μεταβούμε σε κάποια άλλη κατάσταση. Δυνητικά αυτό θα μπορούσε να υλοποιηθεί με μια σειρά από if-then-else αλλά γρήγορα θα δημιουργούσε κώδικα δυσανάγνωστο. Παράδειγμα για την μετάβαση από Intro σε Playing και μόνο, θα είχαμε:

            if (gameState == GameState.Intro)
            {
                if (Keyboard.GetState().IsKeyDown(Keys.Escape))
                {
                    this.Exit();
                }
                else if (Keyboard.GetState().IsKeyDown(Keys.Enter))
                {
                    gameState = GameState.Playing;
                }
            }

Και για τις 5 καταστάσεις η αναγνωσιμότητα του κώδικα θα μειωνόταν σημαντικά. Επιπλέον πολλά παιχνίδια έχουν πολλές περισσότερες από 5 καταστάσεις (αν είχε μενού, προφίλ παίκτη, βοήθεια, inventory κλπ).

Για το λόγω αυτό θα χρησιμοποιήσουμε ένα συνδυασμό switch-statement και αναδιοργάνωσης κώδικα. Το switch-statement είναι μια πιο κομψή έκδοση του if-then-else και υπάρχει σε όλες τις γλώσσες από C/C++/Java, μέχρι και Visual Basic. Στη μορφή που θα το χρησιμοποιήσουμε μοιάζει ως εξής:

            switch (gameState)
            {
                case GameState.Intro:

                    break;
                case GameState.Playing:

                    break;
                case GameState.Paused:

                    break;
                case GameState.Won:

                    break;
                case GameState.Lost:

                    break;
            }

Ανάλογα με την τιμή της gameState, η εκτέλεση του κώδικα θα συνεχίσει σε κάποιο από τα «case GameState.XXXX». Όταν ο κώδικας συναντήσει την εντολή break η εκτέλεση του συγκεκριμένου block κώδικα θα διακοπεί και θα συνεχίσει μετά το τέλος του switch.

Πριν προσθέσω αυτό το switch-statement στην μέθοδο Update(), θα κάνω μια μικρή αναδιαμόρφωση του κώδικα, δημιουργώντας μια νέα μέθοδο updateWorld() στην οποία θα μεταφέρω όλο το κώδικα της Update() που ασχολείται με την κίνηση των αντικειμένων και την ανίχνευση συγκρούσεων. Ο κώδικας αυτός εκτελείται μόνο στην περίπτωση του το παιχνίδι βρίσκεται στην κατάσταση Playing (ο παίκτης μπορεί και παίζει δηλαδή) και δεν αφορά της άλλες καταστάσεις. Εξάγοντας τον σε μια ξεχωριστή μέθοδο κάνω την Update() και το switch-statement πιο ευανάγνωστο:

        private void updateWorld()
        {
            if (Keyboard.GetState().IsKeyDown(Keys.Left))
            {
                paddle.X -= (int)paddleSpeed;
            }

            if (Keyboard.GetState().IsKeyDown(Keys.Right))
            {
                paddle.X += (int)paddleSpeed;
            }

            ball.X += (int)(ballDirection.X * ballSpeed);
            ball.Y += (int)(ballDirection.Y * ballSpeed);

            //check paddle-wall collision
            if (paddle.Left < 0)
            {
                paddle.X = 0;
            }
            else if (paddle.Right > viewWidth)
            {
                paddle.X = viewWidth - paddle.Width;
            }

            //check ball-wall collision
            if (ball.Left <= 0 || ball.Right >= viewWidth)
            {
                ballDirection.X = -ballDirection.X;
            }
            else if (ball.Top <= 0 || ball.Bottom >= viewHeight)
            {
                ballDirection.Y = -ballDirection.Y;

                if (ball.Bottom >= viewHeight)
                {
                    lives--;
                }
            }

            //check ball-paddle collision
            if (ballDirection.Y > 0 &&
                ball.Bottom >= paddle.Top &&
                ((ball.Left >= paddle.Left && ball.Left <= paddle.Right) ||
                  (ball.Right >= paddle.Left && ball.Right <= paddle.Right))
                )
            {
                ballDirection.Y = -ballDirection.Y;
            }

            //check ball-brick collision
            foreach (Brick brick in bricks)
            {
                if (brick.CheckHit(ball))
                {
                    ballDirection.Y = -ballDirection.Y;
                    score += 1000;
                    break;
                }
            }

        }

Η μέθοδος updateWorld() δεν περιέχει καμιά νέα λειτουργία. Την ορίζω ως private γιατί δεν έχω σκοπό να την καλέσω εκτός του κυρίως αντικειμένου του παιχνιδιού (Game1).

Έπειτα ορίζω μια μεταβλητή τύπου KeyboardState στην οποία θα αποθηκεύσω την κατάσταση του πληκτρολογίου (για να μην την ζητώ συνέχεια από την μέθοδο Keyboard.GetState()).

    public class Game1 : Microsoft.Xna.Framework.Game
    {
        GameState gameState;
        KeyboardState keyboardState;

Είμαστε έτοιμοι τώρα να ορίσουμε τις μεταβάσεις μεταξύ των διάφορων καταστάσεων στην Update. Διαβάζοντας το παραπάνω state machine που δημιουργήσαμε και μεταφράζοντας το σε κώδικα έχουμε:

        protected override void Update(GameTime gameTime)
        {
            keyboardState = Keyboard.GetState();

            switch (gameState)
            {
                case GameState.Intro:
                    if (keyboardState.IsKeyDown(Keys.Escape))
                    {
                        this.Exit();
                    }
                    else if (keyboardState.IsKeyDown(Keys.Enter))
                    {
                        gameState = GameState.Playing;
                    }
                break;
                case GameState.Playing:
                    if (keyboardState.IsKeyDown(Keys.Escape))
                    {
                        gameState = GameState.Intro;
                    }
                    else if (keyboardState.IsKeyDown(Keys.P))
                    {
                        gameState = GameState.Paused;
                    }

                    updateWorld();

                    break;
                case GameState.Paused:
                    if (keyboardState.IsKeyDown(Keys.P))
                    {
                        gameState = GameState.Playing;
                    }
                    break;
                case GameState.Won:
                    if (keyboardState.IsKeyDown(Keys.Escape))
                    {
                        gameState = GameState.Intro;
                    }
                    else if (keyboardState.IsKeyDown(Keys.Enter))
                    {
                        gameState = GameState.Playing;
                    }
                    break;
                case GameState.Lost:
                    if (keyboardState.IsKeyDown(Keys.Escape))
                    {
                        gameState = GameState.Intro;
                    }
                    else if (keyboardState.IsKeyDown(Keys.Enter))
                    {
                        gameState = GameState.Playing;
                    }
                    break;
            }

            base.Update(gameTime);
        }

Είναι αρκετά ξεκάθαρο το τι συμβαίνει στο switch-statement. Αν για παράδειγμα το gameState είναι ίσο με GameState.Intro, τότε ελέγχουμε αν το πλήκτρο ESC έχει πατηθεί. Αν ναι τότε βγαίνουμε από το παιχνίδι εντελώς. Αλλιώς, αν έχουμε πατήσει το ENTER, τότε η κατάσταση του παιχνιδιού (gameState) αλλάζει σε GameState.Playing. Την επόμενη φορά που θα κληθεί η Update, το switch-statement θα εκτελέσει το block που αφορά το GameState.Playing. Εκεί θα γίνει ο έλεγχος αν ο παίκτης έχει πατήσει το πλήκτρο ESC και σε μια τέτοια περίπτωση η κατάσταση του παιχνιδιού (gameState) θα αλλάξει σε GameState.Intro. Αλλιώς ελέγχεται αν έχει πατηθεί το πλήκτρο P και αυτή τη περίπτωση το παιχνίδι μεταβαίνει στην κατάσταση GameState.Paused. Επίσης στο block αυτό (GameState.Playing) και μόνο καλούμε και την μέθοδο updateWorld() για να κινήσουμε αντικείμενα και να υπολογίσουμε συγκρούσεις. Παρόμοια είναι και η λογική για τις υπόλοιπες καταστάσεις. Ανάλογα με το πια κατάσταση βρίσκεται το παιχνίδι κάθε φορά και ανάλογα με κάποια συνθήκη το παιχνίδια θα μεταβεί στην προκαθορισμένη κατάσταση.

Μένουν αρκετά να κάνουμε έτσι ώστε να ολοκληρώσουμε το game state management και το tutorial έγινε αρκετά μεγάλο οπότε θα σταματήσουμε εδώ για σήμερα. Ήταν αρκετά θεωρητικό το άρθρο, αλλά δημιουργήσαμε την υποδομή για να προσθέσουμε στο επόμενο tutorial πολλές λειτουργίες όπως αρχική οθόνη, λειτουργία Pause, και οθόνες επιτυχίας και αποτυχίας στο παιχνίδι.

Νέος κώδικας δεν υπάρχει στο Code Repository. Όμως για να μην μείνετε παραπονεμένοι και να σας ανοίξει η όρεξη για περισσότερα πάρτε μια μικρή γεύση από το επόμενο tutorial:



6 Σχόλια to “Arkanoid: Game State Management”

  1. Σπύρος (spahar) said

    Πάρα πολύ ωραίο το άρθρο!

    Να κάνω μια επισήμανση, όχι ιδιαίτερα σημαντική. Στο block που αφορά το GameState.Playing δε θα έπρεπε να είναι κάπως έτσι:

    if (keyboardState.IsKeyDown(Keys.Escape))
    {
    gameState = GameState.Intro;
    }
    else if (keyboardState.IsKeyDown(Keys.P))
    {
    gameState = GameState.Paused;
    }
    else
    {
    updateWorld();
    }

    Δε είναι πιο σωστό να τρέχει η updateWorld() όταν δεν έχει πατηθεί P ή Esc?

  2. Πολύ σωστή παρατήρηση Σπύρο, παράληψη εκ μέρους μου. Στην πράξη δεν κάνει διαφορά, θα τρέξει η updateWorld μια επιπλέον φορά και στο επόμενο καρέ θα μεταφερθεί στο block Pause ή Intro. Όμως το σωστό σωστο! 🙂

  3. […] Tags: news, video games, Videogames Laboratory, βιντεοπαιχνίδια, νέα Στο προηγούμενο tutorial σχεδιάσαμε το game state management του Arkanoid και το υλοποιήσαμε […]

  4. […] προηγούμενο tutorial σχεδιάσαμε το game state management του Arkanoid και το υλοποιήσαμε […]

  5. […] Μέχρι στιγμής στο παιχνίδι Arkanoid (μέρος 1, μέρος 2) έχουμε υλοποιήσει την κλάση που εκπροσωπεί τα τουβλάκια (Brick) έχουμε τοποθετήσει τα τουβλάκια στην σωστή θέση, έχουμε υλοποιήσει κίνηση της μπάλας και της ρακέτας, συγκρούσεις μεταξύ των αντικειμένων του [Περισσότερα] […]

  6. […] Permanent link to Arkanoid- Game State Management […]

Sorry, the comment form is closed at this time.

 
Αρέσει σε %d bloggers: