DevLabs: Reactive Extensions for .NET (Rx)
Before proceeding with reading the rest of the article, you might at this point be somehow curious about how is game in action, so I uploaded a video for you:
The game rules are quite simple: you must walk (or crawl) your Snail around the maze and collect all pearls there, while getting away from the four squids.
Once you get all pearls of the maze, you move on to the next level, with a different maze where you'll be collecting those pearls too.
When you finish the last level, you get a congratulations message and the game ends.
The game intro shows the game title with the cool font "Jokerman". At first I thought of using the "Comic Sans" on it, but I believe Jokerman fits better.
This screen shows some bubbles at tbe background, as you can see. Those bubbles are dinamically generated by a function, and animated using WPF animation. There are 10 fixed bubbles, which are animated vertically, each one with a different diameter and speed. A second animation applies to the opacity of the bubbles, and makes them appear suddenly and vanish in the deep ocean when they reach the top of the screen.
The animation goes on and on, lasting forever. This is achieved by setting the RepeatBehavior of <class>Storyboard class to <class>RepeatBehavior.Forever value.
private void CreateBubbles()
{
Storyboard sbPressSpace = this.FindResource("sbPressSpace") as Storyboard;
sbPressSpace.Begin();
var linearBubbleBrush = new LinearGradientBrush()
{ StartPoint = new Point(1, 0), EndPoint = new Point(0, 1) };
linearBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0xFF, 0x00, 0x20, 0x40), 0.0));
linearBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0x00, 0xFF, 0xFF, 0xFF), 1.0));
var radialBubbleBrush = new RadialGradientBrush()
{ Center = new Point(0.25, 0.75), RadiusX = .3, RadiusY = .2, GradientOrigin = new Point(0.35, 0.75) };
radialBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0xFF, 0xFF, 0xFF, 0xFF), 0.0));
radialBubbleBrush.GradientStops.Add(new GradientStop(Color.FromArgb(0x00, 0xFF, 0xFF, 0xFF), 1.0));
for (var i = 0; i < 10; i++)
{
var diameter = 10 + (i % 4) * 10;
var ms = 1000 + i % 7 * 500;
var ellBubble = new Ellipse()
{
Width = diameter,
Height = diameter,
Stroke = linearBubbleBrush,
Fill = radialBubbleBrush,
StrokeThickness = 3
};
ellBubble.SetValue(Canvas.LeftProperty, i * (40.0 + 40.0 - diameter / 2));
ellBubble.SetValue(Canvas.TopProperty, 0.0 + 40.0 - diameter / 2);
cnvBubbles.Children.Add(ellBubble);
var leftAnimation = new DoubleAnimation()
{
From = 40.0 * i,
To = 40.0 * i,
Duration = TimeSpan.FromMilliseconds(ms)
};
var topAnimation = new DoubleAnimation()
{
From = 200,
To = 0,
Duration = TimeSpan.FromMilliseconds(ms)
};
var opacityAnimation = new DoubleAnimation()
{
From = 1.0,
To = 0.0,
Duration = TimeSpan.FromMilliseconds(ms)
};
Storyboard.SetTarget(leftAnimation, ellBubble);
Storyboard.SetTargetProperty(leftAnimation, new PropertyPath("(Canvas.Left)"));
Storyboard.SetTarget(topAnimation, ellBubble);
Storyboard.SetTargetProperty(topAnimation, new PropertyPath("(Canvas.Top)"));
Storyboard.SetTarget(opacityAnimation, ellBubble);
Storyboard.SetTargetProperty(opacityAnimation, new PropertyPath("Opacity"));
leftAnimation.EasingFunction = new BackEase()
{ Amplitude = 0.5, EasingMode = EasingMode.EaseOut };
topAnimation.EasingFunction = new BackEase()
{ Amplitude = 0.5, EasingMode = EasingMode.EaseOut };
var sb = new Storyboard();
sb.Children.Add(leftAnimation);
sb.Children.Add(topAnimation);
sb.Children.Add(opacityAnimation);
sb.RepeatBehavior = RepeatBehavior.Forever;
bubbles.Add(ellBubble);
storyBoards.Add(sb);
sb.Begin();
}
}
The mazes are created dinamically, from plain text files. Each text file must rectangular, having 15 x 10 characters, where different characters have different meanings:
- 1 - Indicates the blocks of glass that makes up the walls in the maze. Each block might have different shapes, based on the values of the neighboring cells.
- [blank space] - A blank space allows snail and squids to walk freely inside the maze corridors.
- A, B, C and D - The initial positions of the red, yellow, white and blue squids, respectively.
- o - The position of each pearl.
- * - The position of each starfish.
- S - The initial position of the snail.
I would have been nice to create a level editor, but I think it's not the focus of the game. Instead, you can use an ordinary text editor to do it.
private void LoadMaze(int level)
{
collectedPearls.Clear();
collectedStarfishes.Clear();
grdMaze.Children.Clear();
for (var i = 0; i < starfishes.Count(); i++)
{
cnvMain.Children.Remove(starfishes[i]);
}
starfishes.Clear();
for (var i = 0; i < pearls.Count(); i++)
{
cnvMain.Children.Remove(pearls[i]);
}
pearls.Clear();
for (var i = 0; i < mazeGlasses.GetLength(0); i++)
{
for (var j = 0; j < mazeGlasses.GetLength(1); j++)
{
mazeGlasses[i, j] = null;
}
}
for (var i = 0; i < mazeValues.GetLength(0); i++)
{
for (var j = 0; j < mazeValues.GetLength(1); j++)
{
mazeValues[i, j] = ' ';
}
}
var fileName = string.Format(@"Mazes\Level{0}.txt", level);
using (var sr = new StreamReader(fileName))
{
var l = 0;
while (!sr.EndOfStream)
{
string line = sr.ReadLine();
for (var c = 0; c < line.Length; c++)
{
mazeValues[c, l] = line[c];
if (mazeValues[c, l] == '1')
{
var glass = new Glass();
glass.SetValue(Grid.ColumnProperty, c);
glass.SetValue(Grid.RowProperty, l);
grdMaze.Children.Add(glass);
mazeGlasses[c, l] = glass;
}
else if (mazeValues[c, l] == '*')
{
var starfish = new Starfish();
starfish.SetValue(Canvas.LeftProperty, 0.0);
starfish.SetValue(Canvas.TopProperty, 0.0);
starfish.SetValue(Canvas.ZIndexProperty, -1);
cnvMain.Children.Add(starfish);
starfish.Throw(new Point(c, l), new Point(c, l),
TimeSpan.FromMilliseconds(50), null);
starfishes.Add(starfish);
}
else if (mazeValues[c, l] == 'o')
{
var pearl = new Pearl()
{
Width = 30,
Height = 30
};
pearl.SetValue(Canvas.LeftProperty, 0.0);
pearl.SetValue(Canvas.TopProperty, 0.0);
pearl.SetValue(Canvas.ZIndexProperty, -1);
cnvMain.Children.Add(pearl);
pearl.PlaceAt(new Point(c, l));
pearls.Add(pearl);
}
else if (mazeValues[c, l] == 'S')
{
snail.OriginalCellPoint = new Point(c, l);
}
else if ("ABCD".Contains(mazeValues[c, l]))
{
var index = "ABCD".IndexOf(mazeValues[c, l]);
squids[index].OriginalCellPoint = new Point(c, l);
}
}
l++;
}
}
for (var c = 0; c < mazeWidth; c++)
{
for (var l = 0; l < mazeHeight; l++)
{
var topValue = ' ';
var bottomValue = ' ';
var leftValue = ' ';
var rightValue = ' ';
if (l > 0)
topValue = mazeValues[c, l - 1];
if (l < mazeHeight - 1)
bottomValue = mazeValues[c, l + 1];
if (c > 0)
leftValue = mazeValues[c - 1, l];
if (c < mazeWidth - 1)
rightValue = mazeValues[c + 1, l];
var glass = mazeGlasses[c, l];
if (glass != null)
{
glass.LeftValue = leftValue;
glass.RightValue = rightValue;
glass.TopValue = topValue;
glass.BottomValue = bottomValue;
}
}
}
}
Each block of the maze is made up of glass, hence the <class>Glass UserControl. This user control has 9 sections, and the central sections of it can be filled or not, depending on wether there are neighboring blocks. We do it by assigning dependency properties to the user control:
public partial class Glass : UserControl
{
#region DPs
private static DependencyProperty LeftValueProperty =
DependencyProperty.Register("Left", typeof(char), typeof(Glass), new PropertyMetadata(LeftValueChanged));
public char LeftValue
{
get { return (char)this.GetValue(LeftValueProperty); }
set {this.SetValue(LeftValueProperty, value);}
}
static void LeftValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var glass = (Glass)d;
glass.rct01.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden;
}
private static DependencyProperty RightValueProperty =
DependencyProperty.Register("Right", typeof(char), typeof(Glass), new PropertyMetadata(RightValueChanged));
public char RightValue
{
get { return (char)this.GetValue(RightValueProperty); }
set { this.SetValue(RightValueProperty, value); }
}
static void RightValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var glass = (Glass)d;
glass.rct21.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden;
}
private static DependencyProperty TopValueProperty =
DependencyProperty.Register("Top", typeof(char), typeof(Glass), new PropertyMetadata(TopValueChanged));
public char TopValue
{
get { return (char)this.GetValue(TopValueProperty); }
set { this.SetValue(TopValueProperty, value); }
}
static void TopValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var glass = (Glass)d;
glass.rct10.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden;
}
private static DependencyProperty BottomValueProperty =
DependencyProperty.Register("Bottom", typeof(char), typeof(Glass), new PropertyMetadata(BottomValueChanged));
public char BottomValue
{
get { return (char)this.GetValue(BottomValueProperty); }
set { this.SetValue(BottomValueProperty, value); }
}
static void BottomValueChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var glass = (Glass)d;
glass.rct12.Visibility = (char)e.NewValue == '1' ? Visibility.Visible : Visibility.Hidden;
}
#endregion DPs
Just to clarify a little bit how it works: if you have a block alone in the middle of the maze, it appears as just a little block on the screen. But if you put a block at its side, you won't see 2 blocks, but rather a single wall, extending from one block to the other. Depending on how the neighboring blocks, you can also have "L" or "T" or cross-shaped layouts.
The snail is our hero, and, for some strange reason, the squids don't like him (sorry, I don't know sea animals behavior quite well). Also, it happens our hero must collect all pearls in the maze to complete each level.
Most of the videogame heroes, like Mario and Sonic, are quite charismatic. Our Snail here is not different. It blinks and smiles, blinks and smiles. Also, when it dies, it fades in and out, and shows a screaming face. These emotions are intended to generate compassion on the player and identification with our hero.
<Storyboard x:Name="sbBlink" x:Key="sbBlink" Duration="0:0:3"
RepeatBehavior="Forever" AutoReverse="True" FillBehavior="HoldEnd">
<DoubleAnimation Storyboard.TargetName="leftPupil"
Storyboard.TargetProperty="Height" From="7" To="1" Duration="0:0:0.200"
BeginTime="0:0:0.000" FillBehavior="HoldEnd"/>
<DoubleAnimation Storyboard.TargetName="leftPupil"
Storyboard.TargetProperty="Height" From="1" To="7" Duration="0:0:0.200"
BeginTime="0:0:0.200" FillBehavior="HoldEnd"/>
<DoubleAnimation Storyboard.TargetName="rightPupil"
Storyboard.TargetProperty="Height" From="7" To="1" Duration="0:0:0.200"
BeginTime="0:0:2.000" FillBehavior="HoldEnd"/>
<DoubleAnimation Storyboard.TargetName="rightPupil"
Storyboard.TargetProperty="Height" From="1" To="7" Duration="0:0:0.200"
BeginTime="0:0:2.200" FillBehavior="HoldEnd"/>
</Storyboard>
<Storyboard x:Name="sbDie" x:Key="sbDie" Duration="0:0:3" FillBehavior="HoldEnd">
<DoubleAnimation Storyboard.TargetName="grdMain"
Storyboard.TargetProperty="Opacity" From="0" To="1" RepeatBehavior="3"
Duration="0:0:0.200" BeginTime="0:0:0.000" FillBehavior="HoldEnd"/>
</Storyboard>
<Storyboard x:Name="sbBorn" x:Key="sbBorn" Duration="0:0:3" FillBehavior="HoldEnd">
<DoubleAnimation Storyboard.TargetName="grdMain"
Storyboard.TargetProperty="Opacity" From="1" To="1" RepeatBehavior="3"
Duration="0:0:0.200" BeginTime="0:0:0.000" FillBehavior="HoldEnd"/>
</Storyboard>
public void Die(AnimationCompleted endAnimationCallback)
{
rotateEyeBrow1.Angle = -15;
rotateEyeBrow2.Angle = 15;
pthTeeth.Visibility =
pthMouth.Visibility = System.Windows.Visibility.Hidden;
pthMouth2.Visibility = System.Windows.Visibility.Visible;
Storyboard sbDie = this.FindResource("sbDie") as Storyboard;
sbDie.Completed += (s, e) =>
{
if (endAnimationCallback != null)
endAnimationCallback();
};
IsDying = true;
sbDie.Begin();
}
The Snail moves according with the player gestures (the arrow keys, as we are going to see later in the article). Each movement is done by a vertical or horizontal animation (depending on the movement direction), and the snail can move only one cell at a time. The movement is only started if the intended new position falls between the boundaries of the maze, and if it doesn't collide with the maze walls:
private void ProcessNextAnimation(Queue<Point> queue, Storyboard sb)
{
if (queue.Count > 0)
{
var deltaPoint = queue.Dequeue();
AnimateTopLeft(deltaPoint, this, sb);
}
}
private void AnimateTopLeft(Point deltaPoint, FrameworkElement animal, Storyboard sb)
{
if (deltaPoint.X > 0)
SnailDirection = Controls.SnailDirection.Right;
else if (deltaPoint.X < 0)
SnailDirection = Controls.SnailDirection.Left;
if (deltaPoint.Y > 0)
SnailDirection = Controls.SnailDirection.Down;
else if (deltaPoint.Y < 0)
SnailDirection = Controls.SnailDirection.Up;
var left1 = (double)animal.GetValue(Canvas.LeftProperty);
var top1 = (double)animal.GetValue(Canvas.TopProperty);
var left2 = left1 + deltaPoint.X * cellWidth;
var top2 = top1 + deltaPoint.Y * cellWidth;
var newX = (int)(left2) / cellWidth;
var newY = (int)(top2) / cellWidth;
var ms = animationMs;
var badMove = false;
if (left2 < 0 || left2 > mazeWidth * cellWidth ||
top2 < 0 || top2 > mazeHeight * cellWidth)
{
left2 = left1;
top2 = top1;
ms = 100;
badMove = true;
}
else if (MazeValues[newX, newY] == '1')
{
left2 = left1;
top2 = top1;
ms = 100;
badMove = true;
}
var leftAnimation = new DoubleAnimation()
{
From = left1,
To = left2,
Duration = TimeSpan.FromMilliseconds(ms),
};
var topAnimation = new DoubleAnimation()
{
From = top1,
To = top2,
Duration = TimeSpan.FromMilliseconds(ms),
};
Storyboard.SetTarget(leftAnimation, animal);
Storyboard.SetTargetProperty(leftAnimation, new PropertyPath("(Canvas.Left)"));
Storyboard.SetTarget(topAnimation, animal);
Storyboard.SetTargetProperty(topAnimation, new PropertyPath("(Canvas.Top)"));
sb.Children.Add(leftAnimation);
sb.Children.Add(topAnimation);
sb.Begin();
}
Maybe this is the part that more resembles Pac Man: Just like the ghosts in the classic game, the squids are the bad guys (at least in this game, no offense intended to real squids) and their role is to chase our hero wherever he goes.
Unlike our snail hero, the squids have fixed eyes and cold behavior (like villains such as Darth Vader and Jason Vorhees) and the only animations that apply to them are the eyes that follow their walking direction (just like Pac Man ghosts) and the tentacles movement.
For the tentacles movement, I don't use traditional WPF animations. Instead, I switch between 2 possible tentacle configurations, applying different drawing data to the <class>Path of the squids, in a timely fashion:
#region events
void timer_Tick(object sender, EventArgs e)
{
if (feetState == -1)
{
pthBottom.Data = PathGeometry.Parse(@"
M0,0
C00,0 05,20 10,10
C10,10 15,0 20,10
C20,10 25,20 30,10
C30,10 35,0 40,10
C40,10 45,20 50,0");
}
else
{
pthBottom.Data = PathGeometry.Parse(@"
M0,0
C00,00 05,20 10,10
C10,10 15,00 20,10
C20,10 25,20 30,10
C30,10 35,00 40,10
C40,10 45,20 50,10
C50,10 55,00 60,10
C60,10 65,20 70,0");
}
feetState *= -1;
}
#endregion events
The keyboard arrow keys move the snail, while the space bar starts the game and throws starfishes (this sounds a bit strange, but I'll explain this later).
Each arrow key pressed generates a different DeltaPoint [deltaX, deltaY] that corresponds to moves measured in cell coordinates.
private void Window_KeyDown(object sender, KeyEventArgs e)
{
var deltaX = 0;
var deltaY = 0;
var spacePressed = false;
switch (e.Key)
{
case Key.Right:
deltaX = 1;
break;
case Key.Left:
deltaX = -1;
break;
case Key.Up:
deltaY = -1;
break;
case Key.Down:
deltaY = 1;
break;
case Key.Space:
spacePressed = true;
break;
}
When the space bar is pressed in the beginning of the game, the game is started:
if (spacePressed)
{
if (splashScreen.Opacity == 1.0)
{
levelScreen.LevelNumber = level;
Storyboard sbStart = this.FindResource("sbStart") as Storyboard;
sbStart.Begin();
Storyboard sbLevel = this.FindResource("sbLevel") as Storyboard;
sbLevel.Begin();
midiHelper.StopAll();
midiHelper.Play("km_start",
() =>
{
movementHalted = false;
PlayStage1Music();
});
}
else
{
As stated before, the snail can use starfishes as shurikens (ninja stars) to kill the approaching squids. But these starfishes are only available as weapons after they are picked up. The snail can collect as many starfishes as possible.
To collect a starfish, the snail must reach the cell the starfish is in. We know that a starfish has been collected when the snail rectangle intersects with the starfish rectangle:
if (starfish.Visibility == System.Windows.Visibility.Visible)
{
var rectStarfish = starfish.GetRect(cnvMain);
var starfishCellPoint = starfish.GetCellPoint();
if (rectSnail.IntersectsWith(rectStarfish))
{
irrKlangEngine.Play2D(@"Sounds\Reload.wav");
gotStarfish = true;
starfish.Visibility = System.Windows.Visibility.Hidden;
AddStarfish(starfish);
mazeValues[(int)starfishCellPoint.X, (int)starfishCellPoint.Y] = ' ';
break;
}
Besides, there are also functions to control the starfish count and display the remaining number in the score panel:
private void AddStarfish(Starfish starfish)
{
cnvMain.Children.Remove(starfish);
collectedStarfishes.Push(starfish);
txtStarfishes2.Text = string.Format("x{0}", collectedStarfishes.Count());
}
private Starfish RemoveStarfish()
{
var starfish = collectedStarfishes.Pop();
txtStarfishes2.Text = string.Format("x{0}", collectedStarfishes.Count());
cnvMain.Children.Add(starfish);
return starfish;
}
During the game play, the space bar is pressed to throw the starfishes (actually, our snail has ninja skills that allow him to handle starfishes like deadly shurikens). The starfish thrown follows the direction the snail is pointing to.
if (collectedStarfishes.Count() > 0)
{
var starfishPoint1 = snail.GetCellPoint();
var starfish = RemoveStarfish();
var xDirection = 0;
var yDirection = 0;
switch (snail.SnailDirection)
{
case SnailDirection.Right:
xDirection = 1;
yDirection = 0;
break;
case SnailDirection.Left:
xDirection = -1;
yDirection = 0;
break;
case SnailDirection.Down:
xDirection = 0;
yDirection = 1;
break;
case SnailDirection.Up:
xDirection = 0;
yDirection = -1;
break;
}
Once thrown, the starfish can move up to 3 cells away from the snail. But like other things in the game, the starfish movement must respect the maze boundaries. We can ensure this restrictions by testing if there is no boundary or wall blocking the starfish way, step by step:
var targetX = (int)starfishPoint1.X;
var targetY = (int)starfishPoint1.Y;
var starfishPoint2 = new Point(targetX, targetY);
var length = 0;
for (var i = 1; i <= 3; i++)
{
targetX = (int)starfishPoint1.X + i * xDirection;
targetY = (int)starfishPoint1.Y + i * yDirection;
if (targetX >= 0 & targetX < mazeWidth &
targetY >= 0 & targetY < mazeHeight)
{
if (mazeValues[targetX, targetY] == '1')
{
break;
}
else
{
starfishPoint2 = new Point(targetX, targetY);
length = i;
}
}
}
When the starfish is thrown, it makes an interesting boomerang sound. We make use of the <class>IrrKlang framework (we'll talk more on this subjecat later).
After some calculations, we define the starting point and ending point of the starfish, and the Throw on the <class>Starfish class does the animation.
irrKlangEngine.Play2D(@"Sounds\boomerang.wav");
starfish.Throw(starfishPoint1, starfishPoint2,
TimeSpan.FromMilliseconds((animationMs / 3.0) * length),
() =>
{
var starfishPoint = starfish.GetCellPoint();
foreach (var squid in squids)
{
var squidPoint = squid.GetCellPoint();
var x1 = (starfishPoint1.X < starfishPoint2.X)
? starfishPoint1.X : starfishPoint2.X;
var x2 = (starfishPoint1.X < starfishPoint2.X)
? starfishPoint2.X : starfishPoint1.X;
var y1 = (starfishPoint1.Y < starfishPoint2.Y)
? starfishPoint1.Y : starfishPoint2.Y;
var y2 = (starfishPoint1.Y < starfishPoint2.Y)
? starfishPoint2.Y : starfishPoint1.Y;
if ((x1 <= squidPoint.X & squidPoint.X <= x2
& squidPoint.Y == starfishPoint.Y) ||
(y1 <= squidPoint.Y & squidPoint.Y <= y2
& squidPoint.X == starfishPoint.X))
{
irrKlangEngine.Play2D(@"Sounds\bulle.wav", false);
squid.Die(() =>
{
squid.IsDying = true;
squid.Born(null);
}
);
break;
}
}
});
}
}
}
else
{
if (!movementHalted)
{
snail.TryMoveXY(new Point(deltaX, deltaY));
}
}
}
The <class>Throw method, on the other side, set up the animation needed to make the starfish rotate and fly, according to the parameters provided:
public void Throw(Point fromCellPoint, Point toCellPoint, TimeSpan duration, AnimationCompleted animationCompleted)
{
this.Visibility = System.Windows.Visibility.Visible;
var leftAnimation = new DoubleAnimation()
{
From = fromCellPoint.X * cellWidth + 15,
To = toCellPoint.X * cellWidth + 15,
Duration = duration,
};
var topAnimation = new DoubleAnimation()
{
From = fromCellPoint.Y * cellWidth + 15,
To = toCellPoint.Y * cellWidth + 15,
Duration = duration,
};
Storyboard.SetTarget(leftAnimation, this);
Storyboard.SetTargetProperty(leftAnimation, new PropertyPath("(Canvas.Left)"));
Storyboard.SetTarget(topAnimation, this);
Storyboard.SetTargetProperty(topAnimation, new PropertyPath("(Canvas.Top)"));
var sb = new Storyboard();
sb.Children.Add(leftAnimation);
sb.Children.Add(topAnimation);
sb.Completed += (s, e) =>
{
sbRotate.Stop();
IsMoving = false;
if (animationCompleted != null)
animationCompleted();
};
IsMoving = true;
sbRotate.Begin();
sb.Begin();
}
One good thing with videogames it that you can kill without remorse. The squids are trying to kill you anyway, so get all starfishes you can and shoot. The bubbles sound indicates the squid has been killed.
Since there are WPF animations involved, we must calculate the initial and final points of the starfish, and find out if there are starfishes between this space. In this case, the squids found are killed.
foreach (var starfish in starfishes)
{
if (starfish.IsMoving)
{
var starfishPoint = starfish.GetCellPoint();
var x1 = (starfishPoint.X < snailPoint.X) ? starfishPoint.X : snailPoint.X;
var x2 = (starfishPoint.X > snailPoint.X) ? starfishPoint.X : snailPoint.X;
var y1 = (starfishPoint.Y < snailPoint.Y) ? starfishPoint.Y : snailPoint.Y;
var y2 = (starfishPoint.Y > snailPoint.Y) ? starfishPoint.Y : snailPoint.Y;
if ((x1 <= squidPoint.X & squidPoint.X <= x2 & starfishPoint.Y == squidPoint.Y) ||
(y1 <= squidPoint.Y & squidPoint.Y <= y2 & starfishPoint.X == squidPoint.X))
{
AddScore(10);
irrKlangEngine.Play2D(@"Sounds\bulle.wav", false);
squid.Die(() =>
{
squid.Born(() =>
{
squid.ResetAnimations();
squid.IsDying = false;
});
}
);
break;
}
}
}
I had a hard time trying to find out the algorithm needed to make the squids chase the snail. Most of the times, the squids got stuck, or, at best, they walked around looking bored and uninterested in the snail.
But then I remembered our friend Sacha Barber once published a great article on A* Search algorith, dealing with finding the best path between any two stations of the London Underground.
I wondered myself if the idea could be used in this game, and gave it a try. For my surprise, it worked like a charm. I only had to change the concepts: Sacha's article deals with a person trying to find the optimal path between the current station and the, respecting the geographical connections between those stations. On the other side, in Snail Quest the person is represented by the squid. The desired station is the cell where the snail is found, the stations are the empty cells inside the maze, and instead of connection between stations, we now have connections between empty cells, which are the "corridors" inside the maze.
public List<MovementType> DoSearch(Point squidPoint, Point snailPoint)
{
pathsSolutionsFound = new List<List<Point>>();
pathsAgenda = new List<List<Point>>();
List<Point> pathStart = new List<Point>();
pathStart.Add(squidPoint);
pathsAgenda.Add(pathStart);
while (pathsAgenda.Count() > 0)
{
List<Point> currPath = pathsAgenda[0];
pathsAgenda.RemoveAt(0);
if (currPath.Count(
x => x.Equals(snailPoint)) > 0)
{
pathsSolutionsFound.Add(currPath);
break;
}
else
{
Point currPoint = currPath.Last();
List<Point> successorPoints =
GetSuccessorsForPoint(currPoint);
foreach (var successorPoint in successorPoints)
{
if (!currPath.Contains(successorPoint) &
pathsSolutionsFound.Count(x => x.Contains(successorPoint)) == 0)
{
List<Point> newPath = new List<Point>();
foreach (var station in currPath)
newPath.Add(station);
newPath.Add(successorPoint);
pathsAgenda.Add(newPath);
}
}
}
}
if (pathsSolutionsFound.Count() > 0)
{
var solutionPath = pathsSolutionsFound[0];
var movementList = new List<MovementType>();
var point = solutionPath[0];
for (var i = 1; i < solutionPath.Count(); i++)
{
var movement = MovementType.None;
if (solutionPath[i].X > point.X)
movement = MovementType.Right;
if (solutionPath[i].X < point.X)
movement = MovementType.Left;
if (solutionPath[i].Y > point.Y)
movement = MovementType.Bottom;
if (solutionPath[i].Y < point.Y)
movement = MovementType.Top;
movementList.Add(movement);
point = solutionPath[i];
}
return movementList;
}
return null;
}
One point of attention: the squids don't just chase the squid all the time. They only go chasing when they have nothing else to do. But once they start chasing the squid, they walk all the path positions, and finally they find out where is the squid and go for the updated position.
This behavior seems a little stupid, but I did it this way to make the game easier to complete. I'm sure there are better way to do this, and maybe I change this behavior later.
The code below shows when the squids should look for a new path and when they shouldn't. The way to do this is to enqueue/dequeue from a variable named <class>squidAnimationQueue which holds all the movements needed for the solution path (i.e. the one that leads the squid to the current position of the snail).
public void ChaseSnail(Point snailPoint)
{
var squid = this;
var xSquid = (int)((double)squid.GetValue(Canvas.LeftProperty) / cellWidth);
var ySquid = (int)((double)squid.GetValue(Canvas.TopProperty) / cellWidth);
var squidPoint = new Point(xSquid, ySquid);
if (squidAnimationQueue.Count() == 0)
{
var solutionPath = DoSearch(squidPoint, snailPoint);
if (solutionPath != null)
{
foreach (var movement in solutionPath)
{
var deltaX = 0;
var deltaY = 0;
switch (movement)
{
case MovementType.Right:
deltaX = 1;
break;
case MovementType.Left:
deltaX = -1;
break;
case MovementType.Bottom:
deltaY = 1;
break;
case MovementType.Top:
deltaY = -1;
break;
}
squidAnimationQueue.Enqueue(new Point(deltaX, deltaY));
}
}
}
MovementHalted = false;
}
When the snail is killed, some actions are taken. First, the snail animation changes, so the snail face looks freightened. Then the whole body begins to blink. In the end, it appears again (that is, if there are at least one life left) at its original position.
private void RemoveLive()
{
if (lives == 0)
{
snail.Die(ResetPositions);
snail.ResetAnimations();
gameOverScreen.Text = "Game Over";
Storyboard sbGameOver = this.FindResource("sbGameOver") as Storyboard;
sbGameOver.Begin();
midiHelper.Play("km_gameover",
() =>
{
this.Dispatcher.Invoke((Action)delegate
{
LoadMaze(level);
AddLive();
AddLive();
AddLive();
Storyboard sbSplashScreen = this.FindResource("sbSplashScreen") as Storyboard;
sbSplashScreen.Begin();
});
});
}
else
{
lives--;
txtLives2.Text = string.Format("x{0}", lives);
snail.Die(ResetPositions);
snail.ResetAnimations();
var osWaitBeforeReborn = Observable.Interval(TimeSpan.FromMilliseconds(3000)).Take(1);
osWaitBeforeReborn.Subscribe(e =>
{
PlayStage1Music();
movementHalted = false;
}
);
}
}
To collect a pearl, the snail must reach the pearl position. We test this kind of collision by discovering if the snail's rectangle has intersected with the pearl's rectangle:
foreach (var pearl in pearls)
{
if (pearl.Visibility == System.Windows.Visibility.Visible)
{
var rectPearl = pearl.GetRect(cnvMain);
var pearlCellPoint = pearl.GetCellPoint();
if (rectSnail.IntersectsWith(rectPearl))
{
AddScore(100);
midiHelper.Play("km_crystal", null);
AddPearl(pearl);
pearl.Visibility = System.Windows.Visibility.Hidden;
mazeValues[(int)pearlCellPoint.X, (int)pearlCellPoint.Y] = ' ';
break;
}
}
}
Every time you kill a squid, collect a pearl or complete a level, your score on the screen is raised. This is done by a simple function:
private void AddScore(int points)
{
score += points;
txtScore1.Text =
txtScore2.Text = score.ToString("00000");
}
When all pearls in a given level have been collected, the game moves on to the next level. Each level has its own .txt file, and if this file doesn't exist the game ends with a different music.
Notice that the midiHelper.Play method receives an anonimous method, which is executed only after the whole music has been played:
private void GoNextLevel()
{
movementHalted = true;
midiHelper.StopAll();
var fileName = string.Format(@"Mazes\Level{0}.txt", level + 1);
if (!File.Exists(fileName))
{
midiHelper.StopAll();
gameOverScreen.Text = "Congratulations!";
Storyboard sbGameOver = this.FindResource("sbGameOver") as Storyboard;
sbGameOver.Begin();
midiHelper.Play("km_ending",
() =>
{
this.Dispatcher.Invoke((Action)delegate
{
Storyboard sbSplashScreen =
this.FindResource("sbSplashScreen") as Storyboard;
sbSplashScreen.Begin();
});
});
}
else
{
irrKlangEngine.Play2D(@"Sounds\bjp.wav", false);
AddScore(200);
var osWaitBeforeNextLevel =
Observable.Interval(TimeSpan.FromMilliseconds(3000)).Take(1);
osWaitBeforeNextLevel.Subscribe(e =>
{
this.Dispatcher.Invoke((Action)delegate
{
level++;
LoadMaze(level);
ResetPositions();
levelScreen.LevelNumber = level;
Storyboard sbLevel = this.FindResource("sbLevel") as Storyboard;
sbLevel.Begin();
midiHelper.Play("km_start",
() =>
{
movementHalted = false;
PlayStage2Music();
});
});
});
}
}
Leslie Sanford's C# Midi Toolkit is a great article contribution here in The Code Project. If you have time to dig into his code, you will find out that Leslie's work is really awesome. I use it in Snail Quest solution as compiled dll's, so if you are interested in C# Midi Toolkit code, please download it from Leslie's article.
As you have seen, the game use midi music in some situations: the game opening, at the start of the levels, at game over event, and at the end of the game.
There are situations when we have to use overlapping midi executions. If you are using C# Midi toolkit, usually you have only one sequencer. It turns out that when you are playing a midi file with one sequencer, you can't play another midi at the same time. To overcome this problem, I creted a <class>MidiHelper that can hold a dicionary of <class>Sequencer objects, which in turn can be played concurrently.
public class MidiHelper
{
private int outDeviceID = 0;
private OutputDevice outDevice;
private Dictionary<string, Sequence> dicSequence = new Dictionary<string,Sequence>();
private Dictionary<string, Sequencer> dicSequencer = new Dictionary<string,Sequencer>();
private Dictionary<string, NoArgDelegate> dicPlayingCompleteDelegate = new Dictionary<string, NoArgDelegate>();
private Dictionary<string, int> dicSequencerMessageCount = new Dictionary<string, int>();
private Dictionary<string, bool> dicSequencerInitialized = new Dictionary<string, bool>();
private bool playing = false;
private bool closing = false;
public delegate void NoArgDelegate();
NoArgDelegate loadCompleted;
NoArgDelegate playingCompleted;
#region ctor
public MidiHelper()
{
if (outDevice == null)
outDevice = new OutputDevice(outDeviceID);
}
#endregion ctor
#region methods
public void InitializeSequencer(string midiKey)
{
var sequence = dicSequence[midiKey];
var sequencer = dicSequencer[midiKey];
sequencer.Stop();
playing = false;
sequence.Format = 1;
sequencer.Position = 0;
sequencer.Sequence = sequence;
sequencer.ChannelMessagePlayed +=
new System.EventHandler<Sanford.Multimedia.Midi.ChannelMessageEventArgs>(this.HandleChannelMessagePlayed);
sequencer.Stopped +=
new System.EventHandler<Sanford.Multimedia.Midi.StoppedEventArgs>(this.HandleStopped);
sequencer.SysExMessagePlayed +=
new System.EventHandler<Sanford.Multimedia.Midi.SysExMessageEventArgs>(this.HandleSysExMessagePlayed);
sequencer.Chased +=
new System.EventHandler<Sanford.Multimedia.Midi.ChasedEventArgs>(this.HandleChased);
sequence.LoadCompleted += HandleLoadCompleted;
}
public void Load(string midiKey, string midiFile)
{
dicSequence.Add(midiKey, new Sequence());
dicSequencer.Add(midiKey, new Sequencer(midiKey));
dicPlayingCompleteDelegate.Add(midiKey, null);
dicSequencerMessageCount.Add(midiKey, 0);
dicSequencerInitialized.Add(midiKey, false);
InitializeSequencer(midiKey);
dicSequencer[midiKey].Stop();
dicSequencer[midiKey].ChannelMessagePlayed += (s, e) =>
{
dicSequencerMessageCount[midiKey]++;
};
dicSequencer[midiKey].PlayingCompleted += (s, e) =>
{
if (dicSequencerMessageCount[midiKey] > 0)
{
var playingCompleted = dicPlayingCompleteDelegate[midiKey];
if (playingCompleted != null)
playingCompleted();
dicSequencer[midiKey].Stop();
dicSequencerMessageCount[midiKey] = 0;
}
};
playing = false;
dicSequence[midiKey].LoadAsync(midiFile);
}
public void Play(string midiKey, NoArgDelegate playingCompleted)
{
playing = true;
dicPlayingCompleteDelegate[midiKey] = playingCompleted;
if (!dicSequencerInitialized[midiKey])
{
dicSequencerInitialized[midiKey] = true;
dicSequencer[midiKey].GetTracks();
}
dicSequencer[midiKey].Stop();
dicSequencer[midiKey].Start();
}
public void Continue(string midiKey)
{
playing = true;
dicSequencer[midiKey].Continue();
}
public void Stop(string midiKey)
{
playing = false;
dicSequencer[midiKey].Stop();
}
public void StopAll()
{
foreach (var kv in dicSequencer)
{
kv.Value.Stop();
}
}
#endregion methods
#region events
private void HandleChannelMessagePlayed(object sender, ChannelMessageEventArgs e)
{
if (closing)
{
return;
}
outDevice.Send(e.Message);
}
private void HandleChased(object sender, ChasedEventArgs e)
{
foreach (ChannelMessage message in e.Messages)
{
outDevice.Send(message);
}
}
private void HandleSysExMessagePlayed(object sender, SysExMessageEventArgs e)
{
outDevice.Send(e.Message);
}
private void HandleStopped(object sender, StoppedEventArgs e)
{
foreach (ChannelMessage message in e.Messages)
{
outDevice.Send(message);
}
}
private void HandleLoadCompleted(object sender, AsyncCompletedEventArgs e)
{
if (loadCompleted != null)
loadCompleted();
}
#endregion events
}
IrrKlang is an awesome cross platform audio library, very easy to use and it's free to use for non-commercial purposes. For commercial applications, you should purchase a licence.
But fortunately (for you) I'm not going to make money with Snail Quest, so I can share it with Code Project readers.
You might be wondering why I bothered to use 2 sound engines (C# Midi Toolkit and IrrKlang) in the game. Of course .mp3 and .wav files have better quality than midi files. But there's a size payoff in using .mp3 or .wav files, because 2 minutes of music could mean some megabytes, while a complex midi music can be stored in much less space. Anyway, I find it a good coding exercise, and also it's more convenient for the Code Project readers, since they have to download fewer megabytes.
It's really easy to use. See below how many lines of code I needed to play a sound:
IrrKlang.ISoundEngine irrKlangEngine;
IrrKlang.ISound currentlyPlayingSound;
...
irrKlangEngine = new IrrKlang.ISoundEngine();
...
irrKlangEngine.Play2D(@"Sounds\boomerang.wav");
That's it. I had a lot of fun in writing the code and the article, and hope you like it. If you liked the article, please leave a comment below. And if you don't like it, leave a comment too. Your feedback is very important for me.
- 2011-03-29: Initial version.
- 2011-04-05: Minor corrections.