A Simple Particle System
Source code: http://silverlightrocks.com/cs/files/folders/slg101_tutorials/entry248.aspx
Particles are a key part of game programming. When it comes to explosions, smoke effects, and many other types of effects, it is often easier and gives a better effect to compose the full effect out of several smaller particles. These particles can vary in velocity, position, color, and opacity, among other properties, based on the effect desired. In some games, the particles need to behave according to the game physics, such as gravity. Particles typically have a short life span and then disappear. Since there can be a large amount of particles being created and destroyed on a regular basis, a particle engine is typically employed to manage them.
For our particle system, we will keep it simple and give the particles a constant velocity in a constant direction. By doing this, we can take advantage of some simple but powerful animation classes built into Silverlight. We have seen one of these classes before, when we created our game loop, namely the Storyboard class.
A Storyboard typically contains one or many animation objects, with each animation object acting on a single property of an object, such as Canvas.Left to control the left position of the object. The animation object will also contain such values as the start and end values of the property, and the duration of the animation. Then once Begin is called on the Storyboard object, the current value of the property will be calculated for each frame while the Storyboard is active, and the property will be modified to contain that value.
The first particles we will add to the game is the exhaust trail coming out of the back of the ship when the thrust key is pressed. To do this, we will create a new particle in each frame, and calculate a velocity opposite to the current direction of the ship. The particles will fade from full opacity to partial opacity over the life of the particle, and then disappear at the end of its life span.
Since particles are such an important part of game development, I have added a ParticleFactory class to the SLG101Utilities library to make creating particles simple. To use the ParticleFactory class, set the StartPosition, the LifeSpanSeconds, and optionally the StartPositionDeviation and FromOpacity and ToOpacity. Then simply call AddParticle, passing in the visual element that you would like to use as your particle, and the velocity of the particle.
In the case of the exhaust trail, we will use ellipses (actually circles), but your particle could be a canvas with many children, an image, or any other FrameworkElement. By adding this flexibility to the ParticleFactory, you should be able to easily modify the code to create whatever effect is appropriate for your game. You may want to modify ParticleFactory to allow you to modify the scale of the object, or the rotation, or the color. Hopefully the included class will give you a good starting point.
Let's take a look at the ParticleFactory.AddParticle method:
public void AddParticle(FrameworkElement particle, Vector velocity)
{
string particleName = "slg101particle" + currentCount.ToString();
currentCount++;
particle.SetValue<string>(FrameworkElement.NameProperty, particleName);
StringWriter sw = new StringWriter();
sw.Write("<Storyboard xmlns:x=\"http://schemas.microsoft.com/winfx/2006/xaml\" x:Name=\"{1}_animation\" Duration=\"{0}\">");
if (velocity.Length > 0)
{
sw.Write("<DoubleAnimation Storyboard.TargetName=\"{1}\" Storyboard.TargetProperty=\"(Canvas.Left)\" From=\"{2}\" To=\"{3}\" Duration=\"{0}\"/><DoubleAnimation Storyboard.TargetName=\"{1}\" Storyboard.TargetProperty=\"(Canvas.Top)\" From=\"{4}\" To=\"{5}\" Duration=\"{0}\"/>");
}
if (FromOpacity != 1 || ToOpacity != 1)
{
sw.Write("<DoubleAnimation Storyboard.TargetName=\"{1}\" Storyboard.TargetProperty=\"(Opacity)\" From=\"{6}\" To=\"{7}\" Duration=\"{0}\"/>");
}
sw.Write("</Storyboard>");
Point start = StartPosition;
Vector offset = MathHelper.CreateVectorFromAngle(rand.Next(360), rand.Next((int)(StartPositionDeviation * 100)) / 100.0);
start += offset;
TimeSpan tsDuration = TimeSpan.FromSeconds(LifeSpanSeconds);
string duration = string.Format("{0}:{1}:{2}.{3:000}", tsDuration.Hours, tsDuration.Minutes, tsDuration.Seconds, tsDuration.Milliseconds);
string animationXaml = string.Format(sw.ToString(), duration, particleName, start.X, start.X + velocity.X * LifeSpanSeconds, start.Y, start.Y + velocity.Y * LifeSpanSeconds, FromOpacity, ToOpacity);
Storyboard sb = XamlReader.Load(animationXaml) as Storyboard;
sb.Completed += new EventHandler(sb_Completed);
HostCanvas.Resources.Add(sb);
HostCanvas.Children.Add(particle);
sb.Begin();
}
You'll see that it uses some of the same dynamic creation of XAML used in the previous tutorial. Note that all Storyboards need to have a unique name before they are added to the Canvas.Resources collection, so we generate a name here. Then it's a matter of adding the particle to the host canvas' children collection, the storyboard to the host canvas' resources collection, and beginning the Storyboard. Also note that we wired up a handler for the Storyboard's Completed event. This is where we'll remove the particle from the canvas, and remove the storyboard from the resources collection. If you don't do this, you'll run out of memory pretty quickly. Here is the cleanup code:
void sb_Completed(object sender, EventArgs e)
{
Storyboard sb = sender as Storyboard;
HostCanvas.Resources.Remove(sb as DependencyObject);
string particleName = sb.Name.Substring(0, sb.Name.Length - 10);
FrameworkElement fe = HostCanvas.FindName(particleName) as FrameworkElement;
HostCanvas.Children.Remove(fe);
}
So now that we have a general purpose particle factory, it's time to call it. I have added the following fields to the Ship class:
ParticleFactory exhaustFactory;
static Random rand = new Random();
string exhaustXaml = "<Ellipse Fill=\"White\" Width=\"4\" Height=\"4\"/>";
In the Ship's constructor, we'll set some fields in the particle factory to specify how we want the particles to behave:
exhaustFactory = new ParticleFactory();
exhaustFactory.FromOpacity = 1;
exhaustFactory.ToOpacity = .3f;
exhaustFactory.LifeSpanSeconds = .2;
exhaustFactory.StartPositionDeviation = 5;
So the particles will start at full opacity and fade to 30% opacity, and will live for .2 seconds. The StartPositionDeviation specifies a number of pixels to randomly vary the starting point from the one specified. This helps to scatter the particles a bit so they don't all form a straight line. This value is a maximum radius for the deviation from the starting position specified.
And then also in the Ship class, I have added an AddExhaust method, which creates one particle moving opposite to the ship's direction:
public void AddExhaust()
{
Ellipse ellipse = XamlReader.Load(exhaustXaml) as Ellipse;
Point p = Position;
p.X -= 2;
p.Y -= 2;
Vector v = MathHelper.CreateVectorFromAngle(RotationAngle, 1);
Vector exhaustVelocity = -v;
p += exhaustVelocity * 17;
exhaustVelocity *= rand.Next(150, 250);
exhaustFactory.StartPosition = p;
exhaustFactory.AddParticle(ellipse, exhaustVelocity);
}
and then at the end of the end of the gameLoop_Update method in the Page class, if the ship is currently thrusting, I call ship.AddExhaust. I do this after the ship.Update is called, so that the ship's new position is taken into account before creating the particle, otherwise the exhaust didn't look right because it overlapped the ship. For a thicker trail, you could call AddExhaust twice per frame.
EDIT: I forgot to mention that we need to tell the ParticleFactory class which canvas to add its visual elements to. To make it so that you can create particles on any canvas, there is a static HostCanvas field on the ParticleFactory class. In the Page.Page_Loaded method, set the HostCanvas field to the page's canvas, like this:
ParticleFactory.HostCanvas = this;
Since it's a static field, you can only have one host canvas per application. If you were to change the HostCanvas to a nonstatic field, then you would be able to put each PartcleFactory's particles on a different canvas if you wanted to.
