Hello Pong: Φτιάχνοντας ένα παιχνίδι σε 10 λεπτά, μέρος 2ο
Δημοσιεύθηκε από Κώστας Αναγνώστου στο Ιουλίου 6, 2009
Στο προηγούμενο άρθρο περιγράψαμε το σκελετό παιχνιδιού που φτιάχνει το XNA Game Studio όταν δημιουργήσουμε ένα καινούργιο project. Ο σκελετός αυτός περιέχει τις βασικές μεθόδους που θα χρησιμοποιήσουμε στο παιχνίδι, και είναι δυνατόν να τρέξει, παράγοντας μια ωραία, μπλε οθόνη. Σήμερα θα βασιστούμε στο σκελετό αυτό για να φτιάξουμε το Pong, ένα απλό παιχνίδι τένις.
Σχεδιασμός παιχνιδιού
Καταρχάς θα κάνουμε ένα πρόχειρο σχεδιασμό για το παιχνίδι μας. Παρατηρώντας το video μιας συνεδρίας Pong και την παρακάτω εικόνα:

ξεχωρίζουμε ότι το παιχνίδι αποτελείται από ένα μαύρο τερέν, 2 ορθογώνιες άσπρες ρακέτες που μπορούν να μετακινηθούν μόνο πάνω-κάτω στην οθόνη, τη μπάλα η οποία είναι ένα άσπρο τετράγωνο και φυσικά το σκορ για να καταγράφουμε ποιος κερδίζει. Αν σας φαίνονται απλοϊκά να σας θυμίσω ότι μιλάμε για το 1972 (αυτό ήταν πριν γεννηθώ καν εγώ!) και εικάζεται ότι η ιδέα του Pong είναι ακόμα πιο παλιά. Είναι χαρακτηριστικό ότι την εποχή αυτή δεν υπήρχε η επεξεργαστική ισχύς να εξομοιωθεί ένα αντίπαλος καθοδηγούμενος από τον επεξεργαστή της παιχνιδομηχανής και το παιχνίδι ήταν υποχρεωτικά 2 παικτών (το ρόλο της τεχνητής νοημοσύνης αναλάμβανε ο δεύτερος παίκτης).
Για να συνεχίσουμε το σχεδιασμό του παιχνιδιού μας, ο κάθε παίκτης προσπαθεί να αποκρούσει την μπάλα όταν αυτή μπαίνει στην δική του μεριά και χάνει ότι του ξεφύγει (περάσει πίσω από την ρακέτα του). Λόγω του ότι το παιχνίδι είναι απλό, δεν χρειάζεται ιδιαίτερα game assets (καλλιτεχνικό περιεχόμενο δηλαδή). Μια απλή εικόνα (υφή) είναι αρκετή.
Αντικείμενα παιχνιδιού
Τα αντικείμενα του παιχνιδιού είναι οι 2 ρακέτες και η μπάλα. Κάθε αντικείμενο στο παιχνίδι χαρακτηρίζεται από την θέση του και το μέγεθος τους. Ένας καλός λοιπόν τρόπος να αναπαραστήσουμε το κάθε αντικείμενο είναι με το τύπο δεδομένων Rectangle του XNA. Ο τύπος αυτός περιλαμβάνει την θέση (Χ,Y) και το μέγεθος (Width, Height) ενός ορθογώνιου αντικειμένου. Οι ρακέτες μπορούν να μετακινηθούν μόνο πάνω ή κάτω όμως η μπάλα έχει μεγαλύτερη ελευθερία κίνησης στο επίπεδο του «τερέν». Για το λόγο αυτό θα χρειαστούμε και κάποιο τρόπο να αναπαραστήσουμε τη κατεύθυνση της. Το τύπος δεδομένων Vector2 είναι κατάλληλος για το σκοπό αυτό μιας και αποτελεί ένα διάνυσμα 2 διαστάσεων (Χ,Υ) (αυτό καλύπτει μια χαρά την κίνηση στο επίπεδο)
Στην κορυφή του class Game1 δηλώνουμε 3 Rectangle μεταβλητές, μια για κάθε αντικείμενο και μια μεταβλητή τύπου Vector2 για την κατεύθυνση της μπάλας.
public class Game1 : Microsoft.Xna.Framework.Game
{
GraphicsDeviceManager graphics;
SpriteBatch spriteBatch;
Rectangle rightPaddle;
Rectangle leftPaddle;
Rectangle ball;
Vector2 ballDirection;
int ballSize;
int paddleWidth;
int paddleHeight;
int viewWidth;
int viewHeight;
Texture2D whiteTile;
Επιπλέον όρισα 2 μεταβλητές για το μέγεθος της ρακέτας (paddleWidth, paddleHeight), 1 μεταβλητή για το μέγεθος της μπάλας (ballSize) και 2 integer μεταβλητές (viewWidth , viewHeight) στις οποίες σύντομα θα αποθηκεύσω το μέγεθος του παραθύρου στο οποίο θα απεικονιστεί το παιχνίδι. Στην μεταβλητή whiteTile θα αναφερθώ αργότερα.
Πρέπει στην συνέχεια να αρχικοποιήσουμε τις ρακέτες και την μπάλα ώστε να έχουν το σωστό μέγεθος και να βρίσκονται στην σωστή αρχική θέση. Αρχικοποιήσεις γενικώς κάνουμε σε μια ειδική μέθοδο που παρέχει το ΧΝΑ στο σκελετό του παιχνιδιού, την Initialize(). Το ΧΝΑ εγγυάται ότι θα καλέσει αυτή τη μέθοδο πριν την LoadContent().
protected override void Initialize()
{
viewWidth = graphics.GraphicsDevice.Viewport.Width;
viewHeight = graphics.GraphicsDevice.Viewport.Height;
ballSize = 15;
paddleWidth = 20;
paddleHeight = 80;
leftPaddle = new Rectangle(20, 100, paddleWidth, paddleHeight);
rightPaddle = new Rectangle(viewWidth - paddleWidth - 20, 100, paddleWidth, paddleHeight);
ball = new Rectangle(100, 100, ballSize, ballSize);
ballDirection = new Vector2(1.0f, 1.0f);
base.Initialize();
}
Αρχικά, μιας και θα το χρησιμοποιήσω αρκετά στο παιχνίδι, αποθηκεύω το μέγεθος του παραθύρου (viewport) στις μεταβλητές viewWidth και viewHeight (πολύ καλή πρακτική για δεδομένα που χρησιμοποιώ συχνά). Αυτό το επιτυγχάνω μέσω του αντικειμένου Viewport που εκπροσωπεί το παράθυρο του παιχνιδιού στην οθόνη. Στην συνέχεια ορίζω το μέγεθος της μπάλας (15 pixel) και το μέγεθος της ρακέτας (20×80 pixels). Τέλος αρχικοποιώ τις μεταβλητές τύπου Rectangle που αντιπροσωπεύουν τις ρακέτες και την μπάλα. Ο τύπος Rectangle δέχεται 4 integer ορίσματα, δύο για την θέση (X,Y) και 2 για το μέγεθος (Width, Height). Επιπλέον ορίζω και την κατεύθυνση της μπάλας (δεν μας απασχολεί ακριβώς προς τα πού).
Μια σύντομη παρένθεση για να καταλάβουμε τα νούμερα που δίνουμε ως ορίσματα παραπάνω. Ένα παράθυρο στο οποίο θα απεικονιστεί το παιχνίδι μοιάζει με το παρακάτω:

Για να προσπελάσω κάθε pixel σε ένα διδιάστατο παράθυρο χρειάζομαι την θέση του (X,Y) και μια θέση αναφοράς (για να έχουν νόημα τα X,Y). Το σύστημα συντεταγμένων παραθύρου (viewport) έχει ως αναφορά την πάνω αριστερή γωνία του (το σημείο 0,0). Τα X αυξάνονται προς τα δεξιά και τα Y προς τα κάτω. Στο συγκεκριμένο παράδειγμα το μέγεθος παραθύρου είναι 800×600, οπότε ένα σημείο (400,300) θα βρίσκονταν στο κέντρο του παραθύρου και το μέγιστο σημείο (799,599) στην κάτω δεξιά γωνία. Το όλο σύστημα δεν διαφέρει ουσιαστικά από τον τρόπο που απεικονίζαμε τις γραφικές παραστάσεις στο σχολείο. Η μόνη διαφορά είναι ότι αντί να μεγαλώνουν οι Y τιμές προς τα πάνω, τώρα αυξάνονται προς τα κάτω.
Οπότε στην αρχικοποίηση που έκανα παραπάνω, έθεσα τη αριστερή ρακέτα να βρίσκεται στο (20,100), δηλαδή 20 pixel δεξιά από την πάνω αριστερή γωνία (αρχή του συστήματος αναφοράς) και 100 pixel κάτω από το ίδιο σημείο. Ανάλογα προκύπτουν και οι υπόλοιπες συντεταγμένες.
Περιεχόμενο παιχνιδιού
Έχοντας ορίσει τα αντικείμενα του παιχνιδιού, πρέπει να φορτώσουμε και την εικόνα που θα απεικονίσουμε πάνω σε αυτά. Ξεκινήστε κάποιο πρόγραμμα επεξεργασίας εικόνας (πχ το Paint) και φτιάξτε μια μικρή, τετράγωνη, άσπρη εικόνα, της οποίας η κάθε διάσταση πρέπει να είναι δύναμη του 2, δηλαδή 16×16 ή 32×32 κλπ και αποθηκεύστε την ως white_tile.jpg (μπορείτε να βρείτε μια τέτοια υφή στο συνοδευτικό zip αρχείο). Είναι καλό η εικόνα να έχει αυτή τη μορφή (οι διαστάσεις να είναι δυνάμεις του 2) έτσι ώστε να υποστηρίζονται και παλιότερες κάρτες γραφικών που δέχονται μόνο αυτή τη μορφή.
Την υφή, όπως και κάθε άλλο αρχείο περιεχομένου (τριδιάστατο μοντέλο, ήχο, γραμματοσειρά, αρχείο XML) που θα χρειαστούμε σε ένα παιχνίδι, τη προσθέτουμε σε ένα ειδικό project που δημιουργεί το XNA Game Studio με το όνομα Content. Το project βρίσκεται ήδη στο solution του παιχνιδιού μας. Για να προσθέσουμε το αρχείο περιεχομένου κάνουμε δεξί κλικ πάνω στο Content και επιλέγουμε Add/Existing Item.

Αν όλα πήγαν καλά στο project Content θα υπάρχει τώρα το αρχείο με την άσπρη εικόνα.

Τα αρχεία περιεχομένου (εικόνες, ήχοι, κείμενο, μοντέλα) που προσθέτουμε σε ένα παιχνίδι δεν μπορούν να χρησιμοποιηθούν αυτούσια, πρέπει πρώτα να περάσουν από μια διαδικασία μετατροπής ώστε να έρθουν σε μια μορφή κατάλληλη για την κάθε πλατφόρμα που στοχεύουμε. Η μετατροπή αυτή περιλαμβάνει συμπίεση, αποδοτικότερη κωδικοποίηση και σωστή αναπαράσταση. Επίσης μας δίνεται η δυνατότητα να «ενώσουμε» πολλά αρχεία περιεχομένου μαζί έτσι ώστε να γίνεται αποδοτικότερη ανάγνωση τους από το δίσκο. Η διαδικασία αυτή μετατροπής περιεχομένου και μεταφοράς του στο παιχνίδι ονομάζεται asset pipeline και υπάρχει σε όλα τα παιχνίδια, όχι μόνο σε αυτά που αναπτύσσονται με το XNA Game Studio. Απλά με το XNA Game Studio η διαδικασία μετατροπής είναι ευκολότερη (γιατί είναι αυτόματη σε μεγάλο βαθμό).
Τώρα πρέπει να φορτώσουμε την υφή αυτή στο παιχνίδι. Το XNA παρέχει ένα τύπο δεδομένων ειδικά για υφές, το Texture2D. Η ανάγνωση του περιεχομένου γίνεται στην μέθοδο LoadContent(). Για να φορτώσουμε την υφή θα χρησιμοποιήσουμε το αντικείμενο Content του XNA ως εξής:
protected override void LoadContent()
{
// Create a new SpriteBatch, which can be used to draw textures.
spriteBatch = new SpriteBatch(GraphicsDevice);
whiteTile = Content.Load<Texture2D>("white_tile");
}
Το αντικείμενο Content μας επιτρέπει να φορτώσουμε οποιοδήποτε τύπου περιεχομένου (υφές, μοντέλα, ήχους). Το τι περιεχόμενο θα φορτώσουμε το δηλώνουμε στην Load<>() στις αγκύλες. Αν το XNA δεν γνωρίζει το τύπο δεδομένων που προσπαθούμε να φορτώσουμε, μπορούμε, με επιπλέον κώδικα, να το οδηγήσουμε να το διαβάσει και αυτό (περισσότερα για αυτό σε κάποιο επόμενο άρθρο). Τέλος δίνουμε το όνομα του αρχείο περιεχομένο ως όρισμα στην Load(), παραλείποντας την προέκταση του αρχείου( δηλαδή δίνουμε “white_tile” αντί του “white_tile.jpg”. Η Content.Load θα επιστρέψει ένα αντικείμενο τύπου Texture2D το οποίο αναπαριστά και περιέχει μια διδιάστατη υφή. Τη μεταβλητή whiteTile, τύπου Texture2D, την ορίσαμε παραπάνω.
Η LoadContent() δημιουργεί εξορισμού και ένα αντικείμενο τύπου SpriteBatch. Sprites γενικά ονομάζουμε τις διδιάστατες εικόνες που απεικονίζουμε στην οθόνη. Τα sprites είναι ο βασικός τρόπος απεικόνισης σε διδιάστατα παιχνίδια όπως ο Mario. Το SpriteBatch είναι απλά μια συλλογή από sprites και έχει ως βασικό στόχο να επιταχύνει την απεικόνιση τους. Στο XNA εκτός από εικόνες, μπορούμε να χρησιμοποιήσουμε ένα spritebatch για να απεικονίσουμε και κείμενο στην οθόνη.
Απεικόνιση παιχνιδιού
Έχοντας ορίσει το μέγεθος και τη θέση των αντικειμένων και έχοντας φορτώσει την εικόνα που θα απεικονίσουμε σε αυτά μπορούμε πλέον να προχωρήσουμε στην απεικόνιση του παιχνιδιού στο παράθυρο. Η απεικόνιση σε ένα παιχνίδι σε XNA γίνεται μέσω της μεθόδου Draw().
protected override void Draw(GameTime gameTime)
{
GraphicsDevice.Clear(Color.Black);
spriteBatch.Begin();
spriteBatch.Draw(whiteTile, rightPaddle, Color.White);
spriteBatch.Draw(whiteTile, leftPaddle, Color.White);
spriteBatch.Draw(whiteTile, ball, Color.White);
spriteBatch.End();
base.Draw(gameTime);
}
Καταρχάς καθαρίζουμε το παράθυρο και θέτουμε το χρώμα του φόντου σε μαύρο, όπως και στο παιχνίδι Pong, με την εντολή GraphicsDevice.Clear(Color.Black). Στην συνέχεια χρησιμοποιούμε το αντικείμενο spriteΒatch για να απεικονίσουμε τα αντικείμενα στο παράθυρο ως sprites. Αυτό γίνεται ως εξής:
Κάθε συλλογή από εικόνες/sprites που απεικονίσουμε στην οθόνη μέσω του αντικείμενου spriteΒatch πρέπει να οριοθετείται από ένα ζεύγος spriteBatch.Begin()/spriteBatch.End(). Αυτά ορίζουν πότε αρχίζει και πότε τελειώνει η συλλογή εικόνων προς απεικόνιση. Αν παραλείψετε κάποιο από τα begin()/end() το παιχνίδι θα ξεκινήσει, αλλά θα λάβετε ένα λάθος εκτέλεσης (runtime error) αργά ή γρήγορα.
Έπειτα με τη μέθοδο Draw του αντικειμένου spriteBatch ορίζω την υφή, θέση, το μέγεθος και το χρώμα του κάθε sprite. Στο συγκεκριμένο παιχνίδι καλούμε την spriteBatch.Draw 3 φορές, μια για κάθε αντικείμενο (2 ρακέτες και μια μπάλα). Ως εικόνα (υφή) του κάθε αντικειμένου ορίζω τη μεταβλητή Texture2D με την εικόνα που φόρτωσαμε παραπάνω, η θέση και το μέγεθος των ρακετών και της μπάλας περιέχεται στις μεταβλητές τύπου Rectangle που ορίσαμε παραπάνω. Τέλος ορίζω το χρώμα του sprite να είναι άσπρο ως να μην επηρεάζει το χρώμα της εικόνας που απεικονίζουμε σε αυτό.
Μετά από όλα αυτά, κρατώ την ανάσα μου και πατώ F5 για να δω αν λειτουργεί εν τέλει. Αν είμαστε τυχεροί θα δούμε να εμφανιστεί ένα παράθυρο όπως το παρακάτω:

Οι ρακέτες είναι στην θέση τους αριστερά και δεξιά του παραθύρου και η μπάλα βρίσκεται σε κάποια θέση. Μην περιμένετε και πολύ για να δείτε την μπάλα και τις ρακέτες να κινούνται γιατί δεν πρόκειται. Δεν έχουμε συμπληρώσει ακόμα το κομμάτι του χειρισμού και κίνησης των αντικειμένων, κάτι που θα κάνουμε στο επόμενο άρθρο.
Εντωμεταξύ, σχόλια και απορίες καλοδεχούμενα.
ΥΓ: Μπορείτε να βρείτε το κώδικα του “παιχνιδιού” και το αρχείο υφής εδώ.

darklynx είπε
So far so good.Και το καλό είναι πως όπως και να υλοποιηθεί ο αντίπαλος παίκτης (computer controled ή πραγματικός παίκτης) αναμένεται και η ανάπτυξη του αντίστοιχου ενδιαφέροντος θέματος (game AI στην πρώτη περίπτωση και multiplayer στη δεύτερη).
konsnos είπε
Χεχε, σωστός ο darklynx.
Θα μπορούσες να εξηγήσεις την διαφορά των υφών, από το χρώμα; Γιατί να δώσουμε υφή;
Κώστας Αναγνώστου είπε
Το χρώμα που δίνουμε ως όρισμα στο spritebatch.Draw() πολλάπλασιάζεται με κάθε pixel της υφής. Για παράδειγμα όλα τα pixel της υφής στην περίπτωση μας έχουν χρώμα ασπρο. Το άσπρο σε αναπαράσταση (Red, Green, Blue) είναι (1.0f, 1.0f, 1.0f). Δίνοντας όρισμα το χρώμα άσπρο (1.0f,1.0f,1.0f) στην spritebatch.Draw() το κάνουμε να μην επηρεάζει την υφή μας διότι: (1.0f, 1.0f, 1.0f) * (1.0f, 1.0f, 1.0f) = (1.0f, 1.0f, 1.0f) (ο πολλ/μος γίνεται στοιχείο προς στοιχείο). Αν για παράδειγμα ορίζαμε ώς χρώμα το κόκκινο (1.0f, 0.0f, 0.0f) τότε το χρώμα της ρακέτας (πχ) θα έβγαινε κόκκινο (1.0f, 1.0f, 1.0f) * (1.0f, 0.0f, 0.0f) = (1.0f, 0.0f, 0.0f). Μπορώ να συνδυάσω το χρώμα αυτό με τα χρωματα των pixel της υφής για να “βάψω” όπως θέλω ένα sprite δηλαδή.
Τώρα, το sprite είναι μια διδιάστατη εικόνα την οποία απεικονίζουμε στο παράθυρο του παιχνιδιού. Είναι αδύνατον να απεικονιστεί μέσω spritebatch χωρίς να αποδώσουμε εικόνα/υφή (με μόνο το χρώμα).
Ένας άλλος τρόπος για να απεικονίσεις ένα sprite με χρώμα μονο (χωρίς υφή) θα ήταν να φτιάξεις ένα ορθογώνιο με 4 κορυφές (vertices) και να ορίσεις δικό σου pixel (ή και vertex) shader. Θα δούμε πως γίνεται αυτό στο μέλλον.
Ελπίζω να σε κάλυψα. Είχα σκοπό να αναφερθώ αργότερα σε αυτό, αλλά καλά έκανες και ρώτησες.
Σπύρος (spahar) είπε
“Ένας άλλος τρόπος για να απεικονίσεις ένα sprite με χρώμα μονο (χωρίς υφή) θα ήταν να φτιάξεις ένα ορθογώνιο με 4 κορυφές (vertices) και να ορίσεις δικό σου pixel (ή και vertex) shader. Θα δούμε πως γίνεται αυτό στο μέλλον.”
Δε θα μπορούσαμε για ευκολία να χρησιμοποιήσουμε την ίδια λογική που εφαρμόσαμε και στις ρακέτες, αλλά αντίστροφα; Εδώ βάλαμε στην spriteBatch.Draw() λευκό χρώμα για να μην επηρεάσουμε το χρώμα του sprite (white_tile), αντίστοιχα δε θα μπορούσαμε για παράδειγμα να χρησιμοποιούμε ένα λευκό sprite έτσι ώστε να δίνουμε στα αντικείμενα ότι χρώμα oρίζουμε στην spriteBatch.Draw();
Κώστας Αναγνώστου είπε
Αν καταλαβαίνω καλά αυτό που προτείνεις είναι ακριβώς αυτό που κάνουμε στο παιχνίδι. Ο λόγος που χρησιμοποίησα λευκή υφή είναι για να μπορούμε αργότερα να θέτουμε ότι χρώμα θέλουμε στο sprite μέσω του τελευταίου ορίσματος της spriteBatch.Draw() (που είναι το χρώμα). Από τη στιγμή που η υφή είναι άσπρη, το sprite θα είναι τελικά το χρώμα που θα θέσω ώς όρισμα (μπορείτε να το δοκιμάσετε ήδη αυτό στο κώδικα του παραδείγματος).
Το ερώτημα του konsnos αφορούσε το αν μπορούμε να παραλειψουμε την υφή και θέσουμε το χρώμα μόνο, πράγμα στην περίπτωση της spritebatch.
darklynx είπε
“Ένας άλλος τρόπος για να απεικονίσεις ένα sprite με χρώμα μονο (χωρίς υφή) θα ήταν να φτιάξεις ένα ορθογώνιο με 4 κορυφές (vertices) και να ορίσεις δικό σου pixel (ή και vertex) shader. Θα δούμε πως γίνεται αυτό στο μέλλον.”
Γιατί χρειάζεται επιπλέον shader για αυτήν την εργασία;Αν φτιάχναμε ένα vertex buffer με ένα ορθογώνιο και τον χρησιμοποιούσαμε για όλα τα σχήματα του παιχνιδιού με matrix transformations ο “default” shader της BasicEffect δεν θα ήταν αρκετός;
Κώστας Αναγνώστου είπε
Καταρχάς ξεφευγουμε από το σκοπό του tutorial αυτού που είναι η επιδειξη του πως απεικονίζουμε sprites με τη χρήση spritebatch. Δεν έχω αναφέρει τι είναι το BasicEffect!
Από την άλλη έχεις δίκιο, μπορεί να γίνει και έτσι αλλά γενικά η χρήση vertex buffer και ενός shader θα έκανε τα πράγματα περισσότερο πολύπλοκα από ότι χρειάζεται για ένα τόσο απλό παιχνίδι, και επιπλέον θα ήταν λιγότερο αποδοτικό. Το spritebatch κατα βάση φτιάχνει και αυτό vertex lists που αναπαριστούν ορθογώνια αλλά τις ομαδοποιεί κατάλληλα ώστε η απεικόνιση τους να είναι αποδοτική.
Βλέπω υπάρχει γνώση πάνω στο XNA και χαίρομαι. θα τα αναφέρουμε όλα αυτά σταδιακά όμως, ας μην προτρέχουμε!