Videogames Laboratory

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

Arkanoid μέρος 2ο

Posted by Kostas Anagnostou στο 24 Σεπτεμβρίου, 2009

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

Στο σημερινό άρθρο, για να το κρατήσουμε σε λογικό μέγεθος, θα προσθέσουμε στο παιχνίδι μόνο κίνηση της μπάλας και της ρακέτας, ανίχνευση συγκρούσεων (collision detection) και σκορ παίκτη.
Το πώς υλοποιούμε την κίνηση της ρακέτας και της μπάλας το εξηγήσαμε κατά την δημιουργία του Pong. Ορίζουμε ένα Rectangle paddle το οποίο περιγράφει την θέση και το μέγεθος της ρακέτας, και παρομοίως ένα Rectangle ball για τη μπάλα. Επιπλέον χρειαζόμαστε ένα διάνυσμα Vector2 με το όνομα ballDirection το οποίο δείχνει την κατεύθυνση κίνησης της μπάλας και μια μεταβλητή ballSpeed η οποία καθορίζει πόσο γρήγορα κινείται η μπάλα και μια αντίστοιχη paddleSpeed για τη ρακέτα.

public class Game1 : Microsoft.Xna.Framework.Game
{
     GraphicsDeviceManager graphics;
     SpriteBatch spriteBatch;
     Texture2D whiteTile;
     Rectangle paddle;
     Vector2 paddleDirection;
     float paddleSpeed;
     Rectangle ball;
     Vector2 ballDirection;
     float ballSpeed;

Η ρακέτα στο Arkanoid κινείται μόνο αριστερά δεξιά, όποτε για να πετύχουμε τη κίνηση αυτή πρέπει να μεταβάλλουμε τη μεταβλητή paddle.X στην μέθοδο Update ανάλογα με το αν ο παίκτη πατά το αριστερό ή το δεξί βελάκι (cursor key):

protected override void Update(GameTime gameTime)
{
     // Allows the game to exit
     if (Keyboard.GetState().IsKeyDown(Keys.Escape))
     {
          this.Exit();
     }

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

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

     base.Update(gameTime);
}

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

Για να μετακινήσουμε τη μπάλα, δεν έχουμε παρά να προσθέσουμε στις X και Y συντεταγμένες του Rectangle ball μια μετατόπιση που προκύπτει από το διάνυσμα κατευθυνσης ballDirection και τη ταχύτητα της μπάλας ballSpeed.

Στην Initialise αρχικοποιούμε τις δυο αυτές μεταβλητές ως:

protected override void Initialize()
{
     viewHeight = graphics.GraphicsDevice.Viewport.Height;
     viewWidth = graphics.GraphicsDevice.Viewport.Width;

     paddle = new Rectangle(150, 550, 90, 15);
     paddleSpeed = 10;

     ball = new Rectangle(150, 400, 15, 15);
     ballDirection = new Vector2(1, -1);
     ballDirection.Normalize();
     ballSpeed = 5;

Η αρχική κατεύθυνση της μπάλας είναι δηλαδή προς τα πάνω και δεξιά και η ταχύτητα της είναι 5. Επίσης κανονικοποιώ το διάνυσμα της κατεύθυνσης ballDirection με τη μέθοδο Normalize() ώστε αυτό να έχει μήκος ένα και η τελική μετατόπιση τη μπάλας να εξαρτάται μόνο από την ταχύτητα της ballSpeed.

Επιστροφή στην Update για υπολογισμό της νέας θέσης της μπάλας:

protected override void Update(GameTime gameTime)
{
     // Allows the game to exit
     if (Keyboard.GetState().IsKeyDown(Keys.Escape))
     {
          this.Exit();
     }

     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);
     base.Update(gameTime);
}

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

Καταρχάς ας περιορίσουμε τη κίνηση της ρακέτας μέσα στα όρια της πίστας:

protected override void Update(GameTime gameTime)
{
     // υπόλοιπος κώδικας

     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;
     }
     base.Update(gameTime);
}

Αυτό γίνεται εύκολα με το να μην αφήσουμε τη Left μεταβλητή του paddle να γίνει μικρότερη του μηδενός και την Right μεγαλύτερη του viewWidth (πλάτος πίστας) μείον το μήκος της ρακέτας.

Παρόμοια είναι και η λογική για τη μπάλα μόνο που πρέπει να ελέγξουμε εκτός από τα αριστερά και δεξιά τοιχώματα και τα πάνω και κάτω:

protected override void Update(GameTime gameTime)
{
     // υπόλοιπος κώδικας

     //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;
     }
     base.Update(gameTime);
}

Σε περίπτωση που η μπάλα συγκρουστεί με κάποιο κάθετο τοίχωμα απλά αλλάζουμε τη κατεύθυνση στο Χ άξονα, διαφορετικά στο Υ. Επιπλέον πρέπει να ελέγξουμε αν η μπάλα συγκρούστηκε με τη ρακέτα:

protected override void Update(GameTime gameTime)
{
     // υπόλοιπος κώδικας

     //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;
     }

     //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;
     }
     base.Update(gameTime);
}

Η λογική της ανίχνευσης σύγκρουσης με τη ρακέτα είναι ανάλογη με αυτή του Pong. Καταρχάς εξασφαλίζουμε ότι η μπάλα έχει τη σωστή κατεύθυνση (προς τα κάτω, δηλαδή ballDirection.Y > 0). Έπειτα ελέγχουμε αν υπάρχει επικάλυψη. Αν ισχύουν και τα δύο, τότε απλά αλλάζουμε τη κατεύθυνση της μπάλας στο Υ άξονα και η μπάλα αναπηδά προς τα πάνω.

Τρέχοντας τώρα το παιχνίδι (F5), η ρακέτα και η μπάλα κινούνται εντός ορίων και επιπλέον η μπάλα αναπηδά πάνω στην ρακέτα. Αυτό που απομένει τώρα είναι τα μπορέσουμε επιτέλους να καταστρέψουμε και κανένα τουβλάκι. Αυτό γίνεται εύκολα με την λειτουργικότητα που έχουμε προσθέσει στη κλάση Brick στο προηγούμενο άρθρο. Το μόνο που έχουμε να κάνουμε είναι να διατρέξουμε όλο το πίνακα με τα τουβλάκια (bricks) και να καλέσουμε την μέθοδο CheckHit του κάθε brick περνώντας το Rectangle της μπάλας ως όρισμα:

protected override void Update(GameTime gameTime)
{
     // υπόλοιπος κώδικας

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

     base.Update(gameTime);
}

Πάλι χρησιμοποιώ ένα for-each loop για να διατρέξω στο πίνακα. Δεν έχει ιδιαίτερες διαφορές με το κλασσικό for-loop απλά στη περίπτωση αυτή είναι πιο βολικό. Η μέθοδος CheckHit() θα ελέγξει αν υπάρχει επικάλυψη μεταξύ του τρέχοντος brick και της μπάλας. Αν ναι τότε θα κάνει το brick αόρατο (visible = false;) και θα επιστρέψει τη τιμή true. Διαφορετικά θα επιστρέψει false.

public bool CheckHit(Rectangle ball)
{
     if (visible && Intersects(ball))
     {
          visible = false;
          return true;
     }
     return false;
}

Σε περίπτωση του η μέθοδος CheckHit() επιστρέψει true, το if-statement θα είναι αληθές και θα αλλάξει την κάθετη κατεύθυνση της μπάλας έτσι ώστε αυτή να αναπηδήσει από το τουβλάκι. Κάτι τελευταίο, όταν ανιχνεύσουμε σύγκρουση με ένα τουβλάκι (το if-statement είναι αληθές δηλαδή), καλούμε την εντολή break. Η εντολή αυτή σταματά άμεσα την επανάληψη σε οποιοδήποτε loop. Από τη στιγμή που υπήρξε σύγκρουση με ένα τουβλάκι δεν υπάρχει λόγος να συνεχίσω την ανίχνευση για αυτό το frame.

Τώρα μπορούμε να τρέξουμε το παιχνίδι και να παίξουμε κανονικά καταστρέφοντας τουβλάκια. Λείπουν μόνο το σκορ και η συνθήκη τερματισμού του παιχνιδιού.

Για το σκορ προσθέτουμε μια μεταβλητή score στη κλάση του παιχνιδιού Game1. Επιπλέον προθέτουμε μια μεταβλητή για τον αριθμό ζώων του παίκτη:

public class Game1 : Microsoft.Xna.Framework.Game
{
     //υπόλοιπος κώδικας

     int score;
     int lives;

Την μεταβλητή lives την αρχικοποιούμε σε 3 στην μέθοδο Intialize(). Κάθε φορά που ανιχνεύουμε μια σύγκρουση της μπάλας με το κάτω μέρος της πίστας αφαιρούμε μια ζωή. Κάθε φορά που ανιχνεύουμε μια σύγκρουση της μπάλας με ένα τουβλάκι αυξάνουμε το σκορ:

protected override void Update(GameTime gameTime)
{
     // υπόλοιπος κώδικας

     //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-brick collision
     foreach (Brick brick in bricks)
     {
          if (brick.CheckHit(ball))
          {
               ballDirection.Y = -ballDirection.Y;
               score += 1000;
               break;
          }
     }

     base.Update(gameTime);
}

Αυξάνουμε το σκορ κατά 1000 κάθε φορά που ο παίκτης καταστρέφει ένα τουβλάκι. Ο λόγος που αυξάνουμε τόσο πολύ, αντί για +1 τη φορά είναι ότι ένας παίκτης πάντα αισθάνεται μεγαλύτερη ικανοποίηση όταν πετυχαίνει μεγάλο σκορ, παρά μικρό έστω και αν αυτό το σκορ είναι φαινομενικά μεγάλο. Είναι εντυπωσιακότερο να καταστρέφω 100 τουβλάκια και να πετυχαίνω σκορ 100.000 πάρα να καταστρέφω 100 τουβλάκια και να πετυχαίνω σκορ 100.

Πρέπει τέλος να απεικονίσουμε το σκορ και τις ζωές στην οθόνη. Προσθέτουμε ένα font στο project Content του παιχνιδιού με τον τρόπο που περιγράψαμε εδώ. Το ονομάζουμε Arial. Επιπλέον, ανοίγουμε το αρχείο του font (Arial.spritefont) και αλλάζουμε το μέγεθος σε 25 (γραμμή <Size>14</Size>).

Για να φορτώσουμε το font ορίζουμε μια μεταβλητή SpriteFont:

public class Game1 : Microsoft.Xna.Framework.Game
{
     GraphicsDeviceManager graphics;
     SpriteBatch spriteBatch;
     SpriteFont font;

Τέλος φορτώνουμε το font στην μέθοδο Load όπως κάθε άλλο αρχείο περιεχομένου:

protected override void LoadContent()
{
     // Create a new SpriteBatch, which can be used to draw textures.
     spriteBatch = new SpriteBatch(GraphicsDevice);
     font = Content.Load("arial");

Στην μέθοδο Draw τώρα, απεικονίζουμε το σκορ και τις ζωές με τη χρήση της spriteBatch.DrawString():

protected override void Draw(GameTime gameTime)
{
     GraphicsDevice.Clear(Color.Black);

     string message = String.Format("Lives: {0}, Score: {1}", lives, score);
     spriteBatch.Begin();

     spriteBatch.DrawString(font, message, new Vector2(10, 10), Color.Yellow);

     foreach (Brick brick in bricks)
     {
          brick.Draw(spriteBatch);
     }

     spriteBatch.Draw(whiteTile, paddle, Color.White);
     spriteBatch.Draw(whiteTile, ball, Color.Yellow);

     spriteBatch.End();

     base.Draw(gameTime);
}

Δημιουργούμε ένα νέο string το οποίο θα αποθηκεύσει το κείμενο που θέλουμε να απεικονίσουμε. Χρησιμοποιούμε την String.Format η οποία έχει το χαρακτηριστικό να αντικαθιστά ότι επισημάνσεις του στυλ «{X}» βρει στο κείμενο με τις τιμές των μεταβλητών που της δίνουμε ως όρισμα (η String.Format μοιάζει λίγο με την printf της C για όποιον είναι γνώστης). Στην περίπτωση μας θα αντικαταστήσει το {0} με τη τιμής της lives και το {1} με την τιμή της μεταβλητής score.

Στο τέλος τυπώνουμε το κείμενο στην οθόνη με τη χρήση της spriteBatch.DrawString(), στη θέση (10,10), με χρώμα κίτρινο.

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

Μπορείτε να βρείτε το κώδικα που αναπτύξαμε μέχρι στιγμής σε .zip αλλά και μέσω SVN στο Code Repository.


7 Σχόλια to “Arkanoid μέρος 2ο”

  1. konsnos said

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

  2. «Με χάλασε πως αν πετύχεις από τα πλάγια τα τουβλάκια δεν θα αλλάξει η κίνηση της μπάλας στον Χ άξονα»

    Ωραία παρατήρηση, αυτό το στοιχείο μου είχε ξεφύγει. Τρανή απόδειξη ότι η ανάπτυξη βιντεοπαιχνιδιών είναι αποτέλεσμα συλλογικής προσπάθειας! Δεν είναι δύσκολο να το πετύχουμε, κάνετε τις προτάσεις σας είτε στα σχόλια ειτε βελτιώστε το κώδικα στο Code Repository. Θα το διορθώσουμε αυτό το σχεδιαστικό σφάλμα σε επόμενο tutorial του Arkanoid.

    «Το κακό είναι πως έχω κολλήσει με το βιβλίο σου, και μόνο όταν το τελειώσω, θα ασχοληθώ με τον κώδικα.»

    Αυτό είναι καλό και όχι κακό! 🙂

  3. jupiter said

    Καλησπέρα, έκανα commit στο branch μου κάποιες μικρές προσθήκες στο Arcanoid. Έφτιαξα κάποια bonus τουβλάκια, προς το παρών υπάρχουν μόνο 2 είδη bonuses, ένα που δίνει extra score και ένα που κάνει την μπάλα να πάει πιο αργά. Εύκολα μπορούν να υλοποιηθούν και άλλα bonuses (αρκεί η δημιουργία μιας κλάσσης που κληρονομεί απτην Bonus και λίγος κώδικας για να γίνεται apply). Ο τρόπος που το υλοποιήσα δεν είναι ο πιο αποδοτικός, είναι όμως νομίζω εύκολος για να τον κατανοήσει κάποιος.

  4. Πολύ καλά! Μια πρόταση μόνο που αφορά τη συνεργατική ανάπτυξη κώδικα. Όταν κάνετε αλλαγές σε κώδικα που δουλεύουν πολλά άτομα είναι καλό να σημειώνετε με το όνομα σας τι αλλαγές κάνετε:

    //kostasan: άλλαξα το τρόπο υπολογισμού της σύγκρουσης γιατί ήταν ανακριβής
    bool result = CheckCollision();
    ……

    Επίσης καλό θα ήταν να κάνετε τέτοιες ανακοινώσεις για προσθήκες στο Code Repository και στο http://videogameslab.freeforums.org/code-repository-t3.html ώστε να βρίσκονται όλες συγκεντρωμένες.

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

  6. […] Permanent link to Arkanoid μέρος 2ο […]

  7. […] Ξεκινήσαμε με το προηγούμενο άρθρο τη δεύτερη σειρά tutorial πάνω στην ανάπτυξη βιντεοπαιχνιδιών, εστιάζοντας στο παιχνίδι Arkanoid αυτή τη φορά. Το άρθρο βγήκε αναγκαστικά μεγάλο διότι έπρεπε να ορίσουμε ένα νέο αντικείμενο το οποίο θα αναπαριστά το τουβλάκι στο παιχνίδι, καθώς και [Περισσότερα] […]

Sorry, the comment form is closed at this time.

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