Introduction
While it has not happened yet, Silverlight will change the development world much in the way that .NET did years ago. The fusion of .NET power with a lightweight web footprint is truly going to take your everyday, run of the mill ASP.NET site to new heights. Not to mention the powerful implications on desktop software, as well. (By this, I mean that with some tweaking, Silverlight can also be distributed as a desktop application.)
Now, I love all the fancy animations and super transformations of which I’ve seen plenty of samples. Lord knows I enjoy watching colors fade, and circles grow and shrink, and all that general interactive design goodness. But, that’s not what really excites me about the technology. Call me crazy, but it’s the fact that I have a standardized, .NET based interface for delivering applications to the client. Yes, the web is standardized, but browsers don’t seem to be. Yes, you can use JavaScript, or VBScript, or whatever else is out there, but I like C#, and I like .NET, and I love the fact that I can now deliver a solution entirely developed in that space. We’ve had C# in the database for some time now (even in Oracle!), and for a while, we had those ActiveX documents… oops, I mean XBAPs that brought the power of WPF to the web. It’s not been since the advent of SL2, however, that we’ve really seen a technology that has such far reaching scenarios, yet is still highly lightweight (2MB). Years ago, I wrote a Falling Blocks clone - Blox - that illustrated the power of GDI+ and some of what can be accomplished with it. I thought it would be cool to rewrite the entire app from scratch using Silverlight 2.
Click here to play.
The Rules of the Game
For those of you who spent the Eighties and Nineties in a cave, Tetris is a puzzle video game, originally designed and programmed by Alexey Pajitnov, in June of 1985, while working for the Dorodnicyn Computing Centre of the Academy of Science of the USSR in Moscow. (I would suggest reading an article on Wikipedia to gather more information on the subject, as describing it in detail is beyond the scope of this article.) It features a configuration of four blocks into seven shapes: I, L, J, S, Z, T, O. These shapes fall from the sky, and must be placed in such a way as to fill a line completely, leaving no gaps. The shapes may also be rotated to assist in attaining the best placement. When a level is filled using the variously shaped falling blocks, the line disappears and points are awarded. That’s basically it.
Developing Blox
As with any game, before actually diving into the semantics of points and even forms, it is important to understand the physics of the game. These are the factors we must consider that affect the artifacts on the screen. They typically do not require user interaction, but can. In the case of our Blox API for building Falling Blocks games, there are five primary considerations:
- Gravity - As soon as a shape materializes in the Falling Blocks world, it immediately begins falling towards the floor of the game. Over time, the force of gravity can increase to make the game more difficult. This means that a timer is required to process the block’s descent.
- Rotation - Shapes in the Falling Blocks world can be rotated in a clockwise manner to 90, 180, 270, and 360/0 degrees. The rotation must obey all other rules.
- Boundaries - Shapes cannot move through other shapes, or move past the boundaries of the game.
- Alignment - Since shapes cannot pass though each other, it stands to reason that shapes can pile up atop one another. In fact, the game is lost when there are so many shapes piled up that it is no longer possible for a new block to be dropped.
- Motion - Shapes can be moved sideways, left and right, at will (provided their paths are not blocked by any other shape or the boundaries of the game).
There are obviously more things to consider, but these five will do to explain the basic functionality.
Key Components
The primary classes of the Blox API are GameField
, Shape
, Block
, and LineManager
(depicted below):
The GameField
is a Silverlight User Control which represents the surface area of the game. It provides the background colors, size, boundaries, and the speed of the game. It also provides a hosting surface for Shape
s and LineManager
s. Visually, GameField
contains only one element, a grid. The general pattern is as follows. At load time, GameField
populates its internal grid with enough Block
objects to fill the entire gaming surface (determined by GameHeight
and GameWidth
). The parts of GameField
’s Loaded
method that are relevant to this discussion are listed below:
_blocks = new BlockCollection (this.GameWidth, this.GameHeight);
foreach (int game_width in Enumerable.Range(0, this.GameWidth))
{
ColumnDefinition col_def = new ColumnDefinition();
LayoutRoot.ColumnDefinitions.Add(col_def);
}
foreach (int game_height in Enumerable.Range(0, this.GameHeight))
{
RowDefinition row_def = new RowDefinition();
LayoutRoot.RowDefinitions.Add(row_def);
}
foreach (int game_height in Enumerable.Range(0, this.GameHeight))
{
LineManagers.Add(new LineManager(this.GameWidth, game_height));
foreach (int game_width in Enumerable.Range(0, this.GameWidth))
{
Block block = new Block();
block.SetValue(Grid.ColumnProperty, game_width);
block.SetValue(Grid.RowProperty, game_height);
LayoutRoot.Children.Add(block);
Blocks.Add(block, game_width, game_height);
}
}
As you can see from the sample, GameHeight
and GameWidth
determine how many rows and columns the internal grid of GameField
will have. Next, a Block
object is added to each cell of the grid. The block is also added to a BlockCollection
represented by the Blocks
property. BlockCollection
a simple class that encapsulates a two dimensional array of Block
objects, the code for it is seen below.
public class BlockCollection
{
Block[,] _blocks;
int _width, _height;
public BlockCollection(int width, int height)
{
this._height = height;
this._width = width;
_blocks = new Block[width, height];
}
public Block this[int left, int top]
{
get
{
if(left >= _width )
left = _width - 1;
if(top >= _height)
top = _height -1;
return _blocks[left, top];
}
}
internal void Add(Block block, int left, int top)
{
_blocks[left, top] = block;
}
}
The Blox API does not use WPF animation – it did not seem necessary; rather, the appearance of motion is achieved by turning individual Block
objects within the grid ‘on’ or ‘off’. The Block class provides the Occupy
and Clear
objects for doing this.
public void Occupy(Shape shape)
{
LayoutRoot.Background = shape.Background;
_isoccupied = true;
}
public void Occupy(Shape shape, Thickness borders, CornerRadius corners)
{
LayoutRoot.Background = shape.Background;
LayoutRoot.BorderThickness = borders;
LayoutRoot.CornerRadius = corners;
_isoccupied = true;
}
public void Occupy(Brush background)
{
LayoutRoot.Background = background;
_isoccupied = true;
}
public void Clear()
{
LayoutRoot.Background = GameField.Singleton.FieldBackground;
_isoccupied = false;
}
This process is managed in two ways. The LineManager
can do this on a line by line basis, moving every block on a given line down one notch. The Shape
can also do this, in which case, it is coordinating the re-configuration of blocks in response to gravity or rotation. Let’s look at the Shape
first.
Shape
Shape
is an abstract base class for all the possible shapes in the Falling Blocks universe. As mentioned earlier, this can be I, J, L, S, Z, T, or O. Knock yourself out with some new shapes if you so desire. Adding a new shape to the GameField
is as simple as the following:
SquareShape square = new SquareShape();
square.Background = new SolidColorBrush(Colors.Purple);
square.Left = 10;
square.Top = 0;
control_gamefield.AddShape(square);
Inside the GameField
, AddShape
looks like this:
public void AddShape(Shape shape)
{
if (Blocks[shape.Left, shape.Top].IsOccupied)
{
if (GameOver != null)
GameOver();
}
else
{
ActiveShape = shape;
ActiveShape.Wedged += (target_shape) =>
{
ActiveShape = null;
if (ShapeWedged != null)
ShapeWedged();
};
shape.Draw(this);
shape.Initialize(this);
}
}
From this sample, you can see some of the key mechanics of the game. First is the condition by which the game ends; this is when there is no space for a newly added shape to be placed (indicated be checking the IsOccupied
property of the block at the shape’s top left corner). Note that this is not necessarily at the top of the GameField
. In the previous listing, we saw that the SquareShape
was placed at the top, but this does not have to be the case. IsOccupied
exposes the _isoccupied
private field of Block
. If there is indeed space, the new shape is set as the ActiveShape
of the GameField
, a Wedged
event is set on the shape, the shape is actually drawn on the screen, and finally, the shape is initialized. We will start the discussion with Draw
.
Draw/Clear
Draw is one of the abstract methods of the Shape
class. For the Square
class, Draw
looks like this:
public override void Draw(IShapeRenderer field)
{
field.Blocks[Left, Top].Occupy(this);
field.Blocks[Left + 1, Top].Occupy(this);
field.Blocks[Left, Bottom].Occupy(this);
field.Blocks[Left + 1, Bottom].Occupy(this);
}
As you can see, the general idea here is to call Occupy
on the appropriate blocks for the given shape. Based on this revelation, it should be easy to see why the subsequent Clear
for Square
would be defined as follows:
field.Blocks[Left, Top].Clear();
field.Blocks[Left + 1, Top].Clear();
field.Blocks[Left, Bottom].Clear();
field.Blocks[Left + 1, Bottom].Clear();
Given that some shapes will occupy different blocks when rotated, for any shape other than Square
, the shape object has a ShapeAxis
property (which indicates the degree of rotation a shape is presently in). For the I shape (Line
class), which has only two distinct representations, the Draw
method looks like this:
switch (this.ShapeAxis)
{
case 0: case 180:
field.Blocks[Left , Top].Occupy(this);
field.Blocks[Left - 1, Top].Occupy(this);
field.Blocks[Left + 1, Top].Occupy(this);
field.Blocks[Left + 2, Top].Occupy(this);
break;
case 90: case 270:
field.Blocks[Left, Top].Occupy(this);
field.Blocks[Left, Top + 1].Occupy(this);
field.Blocks[Left, Top + 2].Occupy(this);
field.Blocks[Left, Top + 3].Occupy(this);
break;
}
For the L shape which has a representation for each of its axes, Draw
looks like this:
switch (this.ShapeAxis)
{
case 0:
field.Blocks[Left, Top].Occupy(this);
field.Blocks[Left - 1, Top].Occupy(this);
field.Blocks[Left + 1, Top].Occupy(this);
field.Blocks[Left + 1, Top - 1].Occupy(this);
break;
case 90:
field.Blocks[Left, Top].Occupy(this);
field.Blocks[Left, Top - 1].Occupy(this);
field.Blocks[Left, Top + 1].Occupy(this);
field.Blocks[Left + 1, Top + 1].Occupy(this);
break;
case 180:
field.Blocks[Left, Top].Occupy(this);
field.Blocks[Left - 1, Top ].Occupy(this);
field.Blocks[Left - 1, Top + 1 ].Occupy(this);
field.Blocks[Left + 1, Top].Occupy(this);
break;
case 270:
field.Blocks[Left, Top].Occupy(this);
field.Blocks[Left , Top - 1].Occupy(this);
field.Blocks[Left - 1, Top - 1].Occupy(this);
field.Blocks[Left, Top + 1].Occupy(this);
break;
}
Initialize
Initialize on the base abstract class looks like this:
public virtual void Initialize(GameField field)
{
_timer_descent = new DispatcherTimer();
_timer_descent.Interval = TimeSpan.FromSeconds(field.GameSpeed);
_timer_descent.Tick += (sender, args) =>
{
Decend(field);
};
_timer_descent.Start();
}
This means that the primary purpose of Initialize
is to start a timer associated with each Shape
, which allows the shape to fall. It makes much more sense to have this defined as part of GameField
, since for the moment, each shape uses the general game speed defined by GameField
; however, I chose to do it this way as an extensibility mechanism, should one intent to provide for mass in the game, for example. After all, a new shape could be devised which fell faster (or slower) than GameSpeed
by some factor. Descend
, the function called by every GameSpeed
, is defined in the base class Shape
, and looks like this:
public virtual void Descend(GameField field)
{
if (CanDescend(field))
{
ClearShape(field);
Top += 1;
DrawShape(field);
}
else
{
_timer_descent.Stop();
Wedge(field);
if (Wedged != null)
Wedged(this);
}
}
As you can see from the sample above, this is where the Wedge
event is fired. Basically, when the shape can’t move down any further, it falls into the wedged state, which fires the Wedged
event. Descend
always calls the abstract method CanDescend
, which has a unique definition for every shape type. For instance, the Z shape has a CanDescend
defined as follows:
if (_timer == null)
{
_timer = new DispatcherTimer();
_timer.Interval = TimeSpan.FromSeconds(GameField.Singleton.GameSpeed);
_timer.Tick += (A, B) =>
{
for (int line = GameHeight - 1; line >= 0; line--)
{
LineManager manager = LineManagers[line];
if (manager.IsFull())
{
manager.ClearBlocks();
if (Score != null)
Score(ScoreIncrement);
manager.ShiftDown();
}
}
};
_timer.Start();
}
Line Managers
If you re-examine the AddShape
listing from earlier, you will notice that once the shape object is wedged, it is set to null
(via the ActiveShape
property). No more shape object as far as the GameField
is concerned. The blocks remain (in fact, since Clear is not called on the shape, the individual blocks that constitute the shape remain, meaning you will still see the shape on screen). This behavior is consistent with Falling Blocks. The question is, what manages the continued descent of blocks after the blocks beneath disappear? The answer is the LineManager
class. If you remember the GameField
’s Loaded
listing above, a LineManager
is created for each row in the GameField
's internal grid. The parts of GameField
’s Loaded
method that are relevant to this discussion are listed below:
if (_timer == null)
{
_timer = new DispatcherTimer();
_timer.Interval = TimeSpan.FromSeconds(GameField.Singleton.GameSpeed);
_timer.Tick += (A, B) =>
{
for (int line = GameHeight - 1; line >= 0; line--)
{
LineManager manager = LineManagers[line];
if (manager.IsFull())
{
manager.ClearBlocks();
if (Score != null)
Score(ScoreIncrement);
manager.ShiftDown();
}
}
};
_timer.Start();
}
Besides initializing the boundaries of the game surface, Loaded
also initializes and starts a timer which, based on the GameSpeed
property, loops though each line, calling IsFull
. If the line is indeed full, the manager for that line clears all the blocks on the line, fires a Score
event, passing in the GameField
’s ScoreIncrement
property. Once that is completed, the ShiftDown
is called on the manager. The purpose of ShiftDown
is to copy all the blocks from the LineManager
above to this current LineManager
, then redraw each block on the new line, effectively shifting the line above down one line.
public bool ShiftDown()
{
if (_blocks.Count == 0)
return false;
if (LineManagers[_line_number - 1]._blocks.Count > 0)
{
ClearBlocks();
_blocks = new List<BlockInfo>(LineManagers[_line_number - 1]._blocks);
foreach (BlockInfo block_info in _blocks)
{
GameField.Singleton.Blocks[block_info.Left,
_line_number].Occupy(block_info.BlockColor);
}
}
else
{
foreach (BlockInfo block_info in _blocks)
{
GameField.Singleton.Blocks[block_info.Left, _line_number].Clear();
}
_blocks = new List<BlockInfo>( LineManagers[_line_number - 1]._blocks);
}
return LineManagers[_line_number - 1].ShiftDown();
}
As you can see from the sample, this is recursive, starting from the cleared line, and moving upwards.
Registering Blocks to LineManagers
The last thing to note is the abstract Wedge
method on Shape
. For the LineManager
functionality to work, it must have pre-existing knowledge of what blocks are on any given line and what positions these blocks occupy. Since each shape is different and occupies different configurations of the blocks during its descent, when it finally stops, this functionality is deferred to it. We already showed that this happens in the Descend
function. When there is no space to draw a shape, the following code executes:
_timer_descent.Stop();
Wedge(field);
if (Wedged != null)
Wedged(this);
As you can see, before the Wedged
event is fired, Wedge
is called on the Shape
(causing the actual shape’s unique wedge implementation to be called). Here is how Wedge
is defined in the Square
class:
field.LineManagers[Top].AddBlock(new BlockInfo
{
Left = this.Left,
BlockColor = this.Background,
});
field.LineManagers[Top].AddBlock(new BlockInfo
{
Left = this.Right,
BlockColor = this.Background,
});
field.LineManagers[Bottom].AddBlock(new BlockInfo
{
Left = this.Left,
BlockColor = this.Background,
});
field.LineManagers[Bottom].AddBlock(new BlockInfo
{
Left = this.Right,
BlockColor = this.Background,
});
Putting it all Together
There are really two ways to get started relatively quickly with this library. The first is very simple. Open a Silverlight application project in Visual Studio (see the References for how to get access to Visual Studio and get the Silverlight toolkit). Once you have it open, add the library from this article, Block.Silverlight.Library, to your project. Now, open Page.xaml. At this point, it should look like this:
<UserControl x:Class="Blox.Silverlight.Page"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
>
<Grid x:Name="LayoutRoot" Background="White">
</Grid>
</UserControl>
Next, add a XAML reference to the Page.xaml markup:
xmlns:blox="clr-namespace:Blox.Silverlight;assembly=Blox.Silverlight"
I use blox
as the namespace, but you can use anything.
Now, add the following into the Grid:
<blox:SimpleTetrisGame />
Your final listing should look like this:
<UserControl x:Class="Blox.Silverlight.Page"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:blox="clr-namespace:Blox.Silverlight;assembly=Blox.Silverlight.Library"
>
<Grid x:Name="LayoutRoot" Background="White">
<blox:SimpleTetrisGame />
</Grid>
</UserControl>
With this completed, build and hit F5. You should see a screen that looks like the following:
This is the screen for the simple Falling Blocks game. It represents a basic Falling Blocks implementation, complete with levels (achieved by passing multiples of 100). Of course, this is based on my preferences as far as what score you get for filling a line. You might want a different score, or perhaps give more points for consecutive scores, for getting back down to the first line, etc. To get more ‘jiggy’ with it, you can use the GameField
control directly.
<Border BorderThickness="2" BorderBrush="White"
Margin="10,10,10,100" Grid.Column="0" >
<blox:GameField x:Name="control_gamefield" GameHeight="20"
GameWidth="20" GameSpeed=".5" ScoreIncrement="10" >
<blox:GameField.FieldBackground>
<SolidColorBrush Color="Black" Opacity=".85" />
</blox:GameField.FieldBackground>
</blox:GameField>
</Border>
If you choose to do this, you will need to handle the appropriate events to get the basic game functionality working properly. Here is the way the game field is utilized in the SimpleTetrisControl
constructor:
control_gamefield.ShapeWedged += () =>
{
LoadShape();
};
control_gamefield.GameOver += () =>
{
txt_game_over_message.Text = "Game Over!";
txt_final_score.Text = "Final Score:" + Score.ToString();
border_game_over.Visibility = Visibility.Visible;
};
control_gamefield.Score += (points) =>
{
Score += points;
txt_score.Text = Score.ToString();
if (Score % 100 == 0)
{
switch (Score){
case 100:
control_gamefield.FieldStopDark.Color = Colors.DarkGray;
break;
case 200:
control_gamefield.FieldStopDark.Color = Colors.LightGray;
break;
case 300:
control_gamefield.FieldStopDark.Color = Colors.Yellow;
break;
}
control_gamefield.GameSpeed -= .1;
}
};
Please explore the included code for further details.
Conclusion
Hey, I hope you enjoy dissecting the code and creating new and exciting Tetris implementations.