
Figure 1 - Eat Dots Game in Silverlight 2.0
Introduction
In the previous article we got you started setting up Silverlight and creating a simple budget application. In this article we'll talk about how to take advantage of Silverlight for creating games on the web. This unsophisticated game, called the Silverlight Stone Eater, uses a pacman-like character to eat dots placed randomly around the screen. The player can move the pacman by pressing down on one of the four arrow keys. Each dot is worth a certain amount of points as follows.
|
Bronze Dot = 10 pts
Silver Dot = 150 pts
Gold Dot = 350 pts
Red Dot (Bomb) = -1000 pts |
On each level (easy, medium, hard) the player has a given amount of time to eat as many dots as possible (avoiding the red dots). The game ends when the timer times out. On the higher skill level the player is faster and harder to control. Also on the higher skill level, you have less time before the clock runs out.
Design
The game was designed using object-oriented techniques encapsulating much of the drawing into classes. The Page class holds a Player, a Board, a ScoreBoard, and a GameTimer class. The Board handles the layout and collision detection of all the stones. The Stone class handles the translation and the collision detection of each stone on the board. The ScoreBoard encapsulates displaying and updating the score from each Stone, the HighScore class persists the highest score for the user, and the GameTimer tracks the time from the game engine. The game executes the the game engine by using a trick with the Storyboard class built into Silverlight. The Player class handles all player movement of the player on the page.
Figure 2 - Design of Silverlight Game in UML Class Diagram
The Code
In most graphic games, you need the following elements: the rendered graphics, input detection, collision detection, and a game loop. As we mentioned, Silverlight's game loop is handled with a trick using a StoryBoard which conveniently provides us a place to update our images shown in Listing 1. Every time the game loop hits the completed event of the StoryBoard, the event handler calls Begin() again on the loop to force it to trigger the completed event forever. Inside the GameLoop_Completed event handler is where we can do all our game updates and check for game collisions.
Listing 1 - Using the Storyboard as a Game Loop
|
Storyboard _gameLoop;
public void CreateGameLoop() {
// create the game loop and add it to the resources on the page _gameLoop = new Storyboard(); _gameLoop.SetValue(FrameworkElement.NameProperty, "gameloop"); this.Resources.Add("gameloop", _gameLoop);
// wire up the Completed event _gameLoop.Completed += new EventHandler(gameLoop_Completed);
// kick off the game loop _gameLoop.Begin(); }
void gameLoop_Completed(object sender, EventArgs e) { // do your game loop processing here _gameLoop.Begin(); if (_started) { // update man movement UpdatePlayerMovement(); ... } |
Handling Input in Silverlight
Silverlight gives us convenient event handling for mouse and keyboard input similar to Windows Forms. For the keyboard we hook into the KeyDown event on the page. When an arrow key is pressed, we can handle it in the Page_KeyDown event handler. Depending upon the arrow key pressed, we'll set the appropriate delta movement for the player. When the game loop is reentered, it will apply the offset to the player position.
Listing 2 - Handling Keyboard Input
|
int _speed = 3;
void CreateKeyPressHandlers() { // wire up the key down event this.KeyDown += new KeyEventHandler(Page_KeyDown); }
void Page_KeyDown(object sender, KeyEventArgs e) { // handle player movement based upon the key pressed switch (e.Key) { case Key.Up: _player.OffsetX = 0; _player.OffsetY = -1; break; case Key.Down: _player.OffsetX = 0; _player.OffsetY = 1; break; case Key.Left: _player.OffsetX = -1; _player.OffsetY = 0; break; case Key.Right: _player.OffsetX = 1; _player.OffsetY = 0; break; default: _player.OffsetX = 0; _player.OffsetY = 0; break; }
// take into account the speed of the player _player.OffsetX *= _speed; _player.OffsetY *= _speed; } |
Drawing the Game
The game consists of images, drawn shapes, and controls. The player is a set of 2 images, one with the player's mouth open, and one with the player's mouth closed. By toggling the drawing of these 2 images, we can give the appearance of the player opening and closing its mouth. The stones are simply ellipses drawn and filled with a color. The ScoreBoard and the Timer are both controls. A lot of our initial game components can be declared and constructed directly in the XAML. For example the player images are created in the following XAML lines:
Listing 3 - XAML Markup defining the player Images and Transforms
|
<!-- image pacman mouth open -->
<Image x:Name="_pacman" Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Width="20" Height="20" Source="pacman.jpg" Canvas.ZIndex="2" > <Image.RenderTransform> <TranslateTransform x:Name="_translateTransform1" X="0" Y="0" /> </Image.RenderTransform> </Image>
<!-- image pacman mouth closed -->
<Image x:Name="_pacman2" Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="2" Width="20" Height="20" Source="pacman2.jpg" Canvas.ZIndex="2" Visibility="Collapsed"> <Image.RenderTransform> <TranslateTransform x:Name="_translateTransform2" X="0" Y="0" /> </Image.RenderTransform> </Image>
|
All of the Image information, transform information and variable names are defined in here in Listing3. Notice that we can even initially hide the second image by setting it's Visibility to Collapsed. The transforms allow us to translate the pacman position on the screen in the X-Y coordinate system. We can use the _translateTransform1 and _translateTransform2 objects directly in our code to update the players position as shown in listing 4:
Listing 4 - Updating the translation and toggling of the player images
|
public void UpdatePlayerPosition() { _translateTransform1.X += _offsetX; _translateTransform1.Y += _offsetY; _translateTransform2.X += _offsetX; _translateTransform2.Y += _offsetY; _throttleCount++; // throtte the toggling of the image by a factor of 30x, otherwise it opens and closes its mouth too fast!!
if (_throttleCount % 30 == 0) { if (_toggleImage) { _playerImage.Visibility = Visibility.Visible; // show mouth open _playerImage2.Visibility = Visibility.Collapsed; } else { _playerImage.Visibility = Visibility.Collapsed; // show mouth closed _playerImage2.Visibility = Visibility.Visible; }
_toggleImage = !_toggleImage; }
// don't allow position outside boundaries RestrainCoordinates(); }
private void RestrainCoordinates() { if (_translateTransform1.X < (-_boundsWidth/2 + _playerImage.Width/2)) _translateTransform1.X = -_boundsWidth/2 + _playerImage.Width/2; if (_translateTransform1.Y < -_boundsHeight/2 + _playerImage.Height) _translateTransform1.Y = -_boundsHeight/2 + _playerImage.Height;
if (_translateTransform1.X > _boundsWidth/2) _translateTransform1.X = _boundsWidth/2; if (_translateTransform1.Y > _boundsHeight/2) _translateTransform1.Y = _boundsHeight/2; } |
Stones are created dynamically, so they don't show up in the XAML markup. The creation of the stone is shown in the Stone constructor in listing 5. The stone is an ellipse, so we construct an ellipse and fill in its properties to create the stone. Here, in listing 5 the translation object is also created dynamically and assigned to the shapes RenderTransform property:
Listing 5 - Constructing the Silverlight Stone Object Dynamically
|
public class Stone
{
Ellipse _shape = new Ellipse(); TransformGroup _transformGroup = new TransformGroup(); TranslateTransform _translation = new TranslateTransform();
public Stone(int x, int y, Color fill) { _translation.X = x; // set the position of the stone _translation.Y = y; _shape.Fill = new SolidColorBrush(fill); // fill the stone with the specified color
_shape.Width = 7; // set the stones size (diameter) _shape.Height = 7;
_transformGroup.Children.Add(_translation); // add the translation transform to the transform group
_shape.RenderTransform = _transformGroup; // add the transform group to the ellipse's RenderTransform
}... |
The variety of stones (GoldStone, SilverStone, BronzeStone, BombStone) are created as subclasses of the Stone class. We can use inheritance and polymorphism to extract the score from a collection of these stones since each Stone subclass has it's own Score method. Listing 6 shows a typical Stone subclass. Note that a SilverStone simply needs to pass its color into the Stone base class to construct itself
Listing 6 - The SilverStone class
|
namespace SilverLightGame.Stones { public class SilverStone : Stone { public SilverStone(int x, int y) : base (x, y, Colors.LightGray) { }
public override int Score() { return 100; // eating a silver stone gives you 100 points }
}
} |
Stones are constructed and tracked by the Board class. In the constructor of the Board class, each stone's position is randomly generated to fit inside the 1st cell of the grid. The stone is also added to an overall stone collection used to track stone collisions.
Listing 7 - Constructing all the stones on the Board
|
public Board (int width, int height, Grid containingGrid) { Random generator = new Random((int)DateTime.Now.Ticks); int x = 0; int y = 0;
// add gold stones for (int i = 0; i < 10; i++) { // generate location for stone GenerateRandomStoneLocation(width, height, generator, out x, out y); // construct gold stone at that location Stone nextStone = new GoldStone(x, y); // place stone inside of the grid PlaceStoneIntoCellOfGrid(containingGrid, nextStone); }
// add silver stones for (int i = 0; i < 30; i++) { GenerateRandomStoneLocation(width, height, generator, out x, out y); Stone nextStone = new SilverStone(x, y); PlaceStoneIntoCellOfGrid(containingGrid, nextStone); }
// add bronze stones for (int i = 0; i < 60; i++) { GenerateRandomStoneLocation(width, height, generator, out x, out y); Stone nextStone = new BronzeStone(x, y); PlaceStoneIntoCellOfGrid(containingGrid, nextStone); }
// add bomb stones for (int i = 0; i < 10; i++) { GenerateRandomStoneLocation(width, height, generator, out x, out y); Stone nextStone = new BombStone(x, y); PlaceStoneIntoCellOfGrid(containingGrid, nextStone); }
}
private void PlaceStoneIntoCellOfGrid(Grid containingGrid, Stone nextStone) { nextStone.Shape.SetValue(Grid.RowProperty, 0); // add stone shape to row 0, column 0, spanning 2 columns nextStone.Shape.SetValue(Grid.ColumnProperty, 0); nextStone.Shape.SetValue(Grid.ColumnSpanProperty, 2); containingGrid.Children.Add(nextStone.Shape); // add the stone to the LayoutRoot (grid) dots.Add(nextStone); // add the stone to the collection for detecting collisions }
// Randomly generate the stones location based on the width and height of the grid cell private static void GenerateRandomStoneLocation(int width, int height, Random generator, out int x, out int y) { x = generator.Next(width / 10) * 10 - (width - 10) / 2; y = generator.Next(height / 10) * 10 - (height - 10) / 2; } |
Detecting Collisions
Collisions are detected by seeing if the center point of the stone is inside the player bounding rectangle. The collides method in listing 8 takes advantage of the Silverlight Rect class (uh oh, we are going back to abbreviations again, hope we are not slipping backwards to the days of cryptic MFC coding). The Rect class (as you may have guessed) is a class that allows us to do calculations on a rectangle. I assume that Microsoft called it Rect instead of Rectangle so as not to step on the Rectangle Shape class. However, I suggest that a longer more descriptive name for the Rectangle calculation class would be better than a short ambiguous name like Rect (which could be short for other things). The code for detecting the stone inside the player is shown in listing 8:
Listing 8 - Testing for Collision between Player and Stone
|
public bool Collides(Rect boundsPlayer) {
// Using the infamous Rect class to check if the stone is contained in the player
Rect boundsStone = new Rect(_translation.X + _shape.Width/2, _translation.Y + _shape.Height/2, _shape.Width/2, _shape.Height/2); return (boundsPlayer.Contains(new Point(boundsStone.X, boundsStone.Y))); } |
The collision of each stone is tested for collision in the DoCollision method of the Board class. First the method finds all the stones that collide, and then removes those stones from the collection of viable stones as illustrated in Listing 9. This is one technique for removing items from a collection you are iterating through. Another technique would have been to go through the list backwards in a for loop (instead of a foreach loop) and removed them in place.Iterating backwards through the list prevents you from skipping indices.
Listing 9 - Detecting the Collision of Each Stone
|
public int DoCollision(Player pacman) { int scoreAddition = 0; List<Stone> removalList = new List<Stone>(); // list of stones that have collided with the player foreach (Stone stone in dots) { if (stone.Collides(pacman.GetBounds())) { // add the points gained (each type of stone knows how to score itself through polymorphism) scoreAddition += stone.Score();
// add the stone to the removal list removalList.Add(stone); }
}
// remove all the stones that have collided foreach (Stone stone in removalList) { ((Grid)stone.Shape.Parent).Children.Remove(stone.Shape); dots.Remove(stone); }
return scoreAddition; // at least one collision happened
} |
Game Timer
The GameTimer class inherits from the Silverlight TextBox control. Each time through the Storyboard Completed event, we update the timer simply by decrementing it. The GameTimer knows how to display itself through its Display method which is called whenever we decrement the timer. It seems like the timer's Decrement method is entered every 1/60 second, so we display time based on this assumption.
Listing 10 - Displaying and Decrementing the GameTimer
|
public void Decrement() {
// subtract 1 form the timer value and display it if (_value > 0) { _value -= 1; Display(); } }
public bool IsTimerFinished() { return (_value == 0); }
private void Display() { // calculate the display time based on a loop increment of 1/60 second Text = String.Format("{0:00}:{1:00}:{2:00}", _value/3600, (_value % 3600)/60, _value % 60); } |
Saving the High Score
Silverlight introduces the idea of saving data from the client application in a safe and secure manner. Silverlight allows programmers to write code into their Silverlight application that will save data in isolated storage. Data created using isolated storage is allowed read and write access as defined by the client's security policy. I found a pretty good blog explaining this in more detail. We used isolated storage to persist the high score for the eater game. To track high score, we created a HighScore class that has methods WriteOutHighScore and ReadHighScore. Listing 11 shows the two methods in action. The isolated storage file for the user is obtained from the IsolatedStorageFile class. A stream is then obtained with a particular file name. For the case of reading the file, the application uses the StreamReader to read the isolated file stream the same way you would a regular FileStream. For writing the isolated storage file, we use the StreamWriter class on the isolated storage stream.
Listing 11 - Using isolated storage to read and write the high score
/// <summary> /// Persist the high score /// </summary>
public void WriteOutHighScore() { // get the isolated storage object for the use using (IsolatedStorageFile isoFile = IsolatedStorageFile.GetUserStoreForApplication()) { // get the stream associated with the user and a unique filename using (IsolatedStorageFileStream isoStream = new IsolatedStorageFileStream(m_fileName, FileMode.Create, isoFile)) { // use .net's built in stream writer to write to the stream using (StreamWriter sw = new StreamWriter(isoStream)) { sw.WriteLine(_highestScore); } } }
}
/// <summary> /// read the high score from isolated storage /// </summary> /// <returns></returns>
public string ReadHighScore() { // get the users isolated storage object using (IsolatedStorageFile isoFile = IsolatedStorageFile.GetUserStoreForApplication()) { // get the stream associated with the isolated stored and a unique filename using (IsolatedStorageFileStream isoStream = new IsolatedStorageFileStream(m_fileName, FileMode.OpenOrCreate, isoFile)) { using (StreamReader sr = new StreamReader(isoStream)) { string highScore = sr.ReadLine(); if (highScore != null) // file may be empty { _highestScore = highScore; } return _highestScore; } } }
} |
Animating Objects in the Game
One fun thing to do inside a 2D game is to animate certain objects with some visual effect. In the eater game, we added an animation to the game to cause the gold stones to look like they are pulsating. By altering the Width and Height properties of the ellipse shape, we can grow and shrink the gold stones throughout their lifetimes, giving them the pulsing effect. Animation is carried out by creating a storyboard for each stone and adding two animations to the stone's storyboard: one animation to pulse the width and one animation to pulse the height. Since the Height and Width properties of the ellipse shape are both doubles, we can use the DoubleAnimation class to pulse the two properties. Listing 12 shows the animation code for pulsing a stone. Note that the storyboard and animations are all created dynamically, rather than through the XAML file.
Listing 12 - Pulsing the Gold Stones in the Eater Game
private void AnimateStone() { _stoneFlash = new Storyboard(); // create a storyboard for the stone
// setup width animation to grow and shrink forever DoubleAnimation animation = new DoubleAnimation(); animation.From = 7.0d; // the smallest stone width animation.To = 14.0d; // the largest stone width
Duration duration = new Duration(new TimeSpan(0, 0, 1)); // pulse in 1 second intervals
animation.AutoReverse = true; // allows to grow, then shrink the width animation.RepeatBehavior = RepeatBehavior.Forever; // we want this to repeat animation.Duration = duration;
Storyboard.SetTarget(animation, _shape); // hook the animation to the stone ellipse shape Storyboard.SetTargetProperty(animation, new PropertyPath("Width")); // hook the animation to the shape's Width property
// setup height animation to grow and shrink forever, and repeat the above procedure for the height animation DoubleAnimation animation2 = new DoubleAnimation(); animation2.From = 7.0d; animation2.To = 14.0d; Duration duration2 = new Duration(new TimeSpan(0, 0, 0, 1)); animation2.AutoReverse = true; animation2.RepeatBehavior = RepeatBehavior.Forever; animation2.Duration = duration2; Storyboard.SetTarget(animation2, _shape); Storyboard.SetTargetProperty(animation2, new PropertyPath("Height"));
// add the width and height animations to the storyboard _stoneFlash.Children.Add(animation); _stoneFlash.Children.Add(animation2);
// start the animation of the stone _stoneFlash.Begin();
} |
Game Loop
Once we have created all the elements, we can exercise them in our game loop. The game loop is the heartbeat of the application and is in charge of keeping the game alive. Each time the loop executes, it updates all the elements of the game. Listing 13 shows the full game loop along with all the updates to the different game elements performed at the high level of the game's individual objects. The game loop is repeatedly called within itself through the game loop's Begin method which retriggers the storyboard.
Listing 13 - Exercising the Game Loop
|
void gameLoop_Completed(object sender, EventArgs e) { // do your game loop processing here _gameLoop.Begin(); // if the start button was pressed, update the game elements if (_started) { // update man movement UpdatePlayerMovement();
// test for collision int scoreChange = TestCollision();
// update Score UpdateScore(scoreChange);
// play sound if (scoreChange > 0) _blip.Play();
// update time UpdateTimer();
// check to see if there is any time left if (TimeHasRunOut()) { _started = false; _gameOver.Show(); } } }
private bool TimeHasRunOut() { return _timer.IsTimerFinished(); }
private void UpdatePlayerMovement() { _player.UpdatePlayerPosition(); }
private void UpdateTimer() { _timer.Decrement(); }
void UpdateScore(int scoreChange) { _score.AddScore(scoreChange); }
private int TestCollision() { return _board.DoCollision(_player); } |
Conclusion
This was a simple demonstration on how you can easily develop a 2D game using Silverlight and share it with all your friends on the web. This game could use a bit of improvement to make it more fun to play. It would be nice if there was a Level class, so that if you clear all the dots on the board before time runs out, you get to go to the next level. It would also be nice to have a HighScoreTracker to track the score you need to beat. Also it would be fun if the red bombs moved around the screen, and you had to dodge them. Finally, I thought it would be fun to add some special stones that allowed you to eat bombs for a small duration. Look for these enhancements in the next Silverlight Update of the Silverlight Stone Eater. In any case, enjoy the game and watch out for those red dots!