Click here to Skip to main content
16,020,378 members
Articles / Desktop Programming / WPF

WPF: The Classic Snakes WPF'ed

Rate me:
Please Sign up or sign in to vote.
4.93/5 (66 votes)
24 Oct 2007CPOL18 min read 172.4K   2.2K   121   58
The classic Snakes WPF'ed.

Contents

Introduction

This article represents something new for me. Normally my articles are a bit fanciful and are geared around some loose interest that I have. This time, I decided to tackle a really old problem (well, not so much of a problem, but at least something old). I decided to write a game of Snakes. Yes, that's right, a game of snakes, but in WPF. For those of you new to WPF, WPF does not really have any low level rendering methods such as OnPaint(..) (well, it does, but that's not what WPF is all about). As you will see, the games of Snakes is not as easy as one might think. This article will be using a lot of WPF tips and tricks along the way. What I will be doing is showing you a couple of screenshots along the way and discussing the requirements (self imposed) that the Snakes game has, and how and why these requirements were solved the way they are.

Requirements

The basic requirements that I imposed for this WPF version of the seminal Snakes game are as follows:

  • To be able to change the size of the level
  • To be able to change the color of things like walls/food/snake
  • To be able to change the snake from one style to another fairly easily
  • To be able to create a new level by drawing out the level
  • To be able to move the snake around the level (yeah, I know, not much of a game without this, is it?)
  • To be able to run the game in different difficulty levels
  • To show some visual animation when the snake dies
  • To show some visual message when the time expires, or when the user completes the level, or when the snake dies

A Quick Peak at the Demo App

When the application is started, there are basically three choices: Create a level, Start a game, and Set the difficulty.

Image 1

When the user chooses to create a level, they are presented with a level creation wizard which they may use to customize the levels. This is a two page wizard; the first page allows the user to pick colors (the color picker is a custom control hosted in a window, so this alone may be of interest to some folks).

Image 2

And the second wizard page allows users to draw out the level using the mouse:

Image 3

And the game in action looks like the following (in this one, the user just died, so the snake exploded, oooh poor snake):

Image 4

That's what it looks likes, but that's not the fun part. In my opinion, the fun part is seeing how all this fits together, so the next sections of this article are going to talk about all the ins and outs of the design decisions that I made along the way, in the hope that they will help out some of you.

A Blow by Blow Design Explanation of Each of the Requirements

The basic requirements that I imposed for this WPF version of the seminal Snakes game are as follows:

To be able to change the size of the level

I wanted a way that all my changes affected the entire game, a set of global variables if you like. I could of course use static member variables of some class (perhaps called globals), but I wanted to see if there was a better way. Luckily, WPF exposes just such a thing; it's called the Application.Current.Properties object. This object is quite cool, as it keeps all your globals in a nice and easily accessible Dictionary, which you can simply assign and access as follows:

Accessing

C#
(Brush)Application.Current.Properties["WallColor"];

Assigning

C#
Application.Current.Properties["WallColor"] = new SolidColorBrush(Colors.Cyan);

The only thing to remember is that it stores objects as Type Object so you need to cast or use the As keyword. But it's damn handy. So this is how I set all the global variables that are used within the game. Within the Window1.xaml.cs file attached, within the constructor, you will find a code block like:

C#
Application.Current.Properties["maxGameSize"] = _maxGameSize;
Application.Current.Properties["initialSnakeSize"] = _initialSnakeSize;
Application.Current.Properties["foodItemsPerLevel"] = _foodItemsPerLevel;
Application.Current.Properties["SnakeStyle"] = _snakeStyle;
Application.Current.Properties["WallColor"] = Brushes.Orange;
Application.Current.Properties["FoodColor"] = Brushes.PaleGreen;
Application.Current.Properties["SnakeColor"] = Brushes.Yellow;

This is where all the game values are initialised; they are changed as required on various other windows. It's nice though as all other files can simply access this one global Dictionary, which is a nice feature in my opinion.

To be able to change the color of things like walls/food/snake

One of the requirements was for the user to be able to pick their own color for things like walls, snakes, food, etc., etc. This sounds simple enough, doesn't it? Just show a ColorPicker and store the value. It's more complicated than that as you will soon see. But for the moment, let's just stick to the simple fact that if we pick a color using the color picker (code below if you are interested), that is the color we will use, and that is the end of the story.

Image 5

ColorPickerControl is the grid of the color; it's a custom control hosted in a XAML window called ColorPickerWindow.xaml.

C#
using System;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Data;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Shapes;

namespace WPF_Snakes
{
    #region ColorPickerControl CLASS
    /// <summary>
    /// A simple color picker control, with a 
    ///custom event that uses
    /// the standard RoutedEventArgs. 
    /// <br/>
    /// NOTE: I also tried to create a custom event with custom 
    ///inherited
    /// RoutedEventArgs, but this didnt seem to work, so this 
    ///event is commented
    /// out. BUt if anyone knows how to do this please let me 
    ///know, as as far as I know
    /// I am doing everything correctly
    /// </summary>
    public class ColorPickerControl : ListBox
    {
        #region InstanceFields
        //A RoutedEvent using standard RoutedEventArgs, 
        //event declaration
        //The actual event routing
        public static readonly RoutedEvent NewColorEvent =
                    EventManager.RegisterRoutedEvent("NewColor", 
                    RoutingStrategy.Bubble, typeof(RoutedEventHandler), 
                    typeof(ColorPickerControl));
    
        //A RoutedEvent using standard custaom 
        //ColorRoutedEventArgs, event declaration
    
        ////the event handler delegate
        public delegate void NewColorCustomEventHandler(object sender, 
                             ColorRoutedEventArgs e);
    
        ////The actual event routing
        public static readonly RoutedEvent NewColorCustomEvent =
                    EventManager.RegisterRoutedEvent("NewColorCustom", 
                    RoutingStrategy.Bubble, typeof(NewColorCustomEventHandler), 
                    typeof(ColorPickerControl));
        //************************************************************************

        //string array or colors
        private string[] _sColors = 
        {
            "Black", "Brown", "DarkGreen", "MidnightBlue", 
            "Navy", "DarkBlue", "Indigo", "DimGray",
            "DarkRed", "OrangeRed", "Olive", "Green", 
            "Teal", "Blue", "SlateGray", "Gray",
            "Red", "Orange", "YellowGreen", "SeaGreen", 
            "Aqua", "LightBlue", "Violet", "DarkGray",
            "Pink", "Gold", "Yellow", "Lime", 
            "Turquoise", "SkyBlue", "Plum", "LightGray",
            "LightPink", "Tan", "LightYellow", "LightGreen", 
            "LightCyan", "LightSkyBlue", "Lavender", "White"
        };
        #endregion
        #region Constructor
        /// <summary>
        /// Constructor for ColorPickerControl, 
        ///which is a ListBox subclass
        /// </summary>
        public ColorPickerControl()
        {
            // Define a template for the Items, 
            //used the lazy FrameworkElementFactory
            // method
            FrameworkElementFactory fGrid = new FrameworkElementFactory(
                typeof(System.Windows.Controls.Primitives.UniformGrid));
            fGrid.SetValue(
              System.Windows.Controls.Primitives.UniformGrid.ColumnsProperty, 10);
            //update the ListBox ItemsPanel with the new 
            //ItemsPanelTemplate just created
            ItemsPanel = new ItemsPanelTemplate(fGrid);

            // Create individual items
            foreach (string clr in _sColors)
            {
                // Creat bounding rectnagle for items data
                Rectangle rItem = new Rectangle();
                rItem.Width = 20;
                rItem.Height = 20;
                rItem.Margin = new Thickness(1);
                rItem.Fill = 
                  (Brush)typeof(Brushes).GetProperty(clr).GetValue(null, null);
                //add rectangle to ListBox Items
                Items.Add(rItem);

                //add a tooltip
                ToolTip t = new ToolTip();
                t.Content = clr;
                rItem.ToolTip = t;
            }
            //Indicate that SelectedValue is Fill property of 
            //Rectangle item.
            //Kind of like an XPath query, this is the string 
            //name of the property 
            //to use as the the selected item value from the 
            //actual item data. The item
            //data being a Rectangle in this case
            SelectedValuePath = "Fill";
        }
        #endregion
        #region Events
        // Provide CLR accessors for the event
        public event RoutedEventHandler NewColor
        {
            add { AddHandler(NewColorEvent, value); }
            remove { RemoveHandler(NewColorEvent, value); }
        }
    
        // This method raises the NewColor event
        private void RaiseNewColorEvent()
        {
            RoutedEventArgs newEventArgs = new RoutedEventArgs(NewColorEvent);
            RaiseEvent(newEventArgs);
        }

        // Provide CLR accessors for the event
        public event NewColorCustomEventHandler NewColorCustom
        {
            add { AddHandler(NewColorCustomEvent, value); }
            remove { RemoveHandler(NewColorCustomEvent, value); }
        }

        // This method raises the NewColorCustom event
        private void RaiseNewColorCustomEvent()
        {
            ToolTip t = (ToolTip)(SelectedItem as Rectangle).ToolTip;
            ColorRoutedEventArgs newEventArgs = 
                       new ColorRoutedEventArgs(t.Content.ToString());
            newEventArgs.RoutedEvent = ColorPickerControl.NewColorCustomEvent;
            RaiseEvent(newEventArgs);
        }
        #endregion
        #region Overrides
        /// <summary>
        /// Overrides the OnSelectionChanged 
        ///ListBox inherited method, and
        /// raises the NewColorEvent
        /// </summary>
        /// <param name="e">the event args</param>
        protected override void OnSelectionChanged(SelectionChangedEventArgs e)
        {
            base.OnSelectionChanged(e);
            //raise the event with standard 
            //RoutedEventArgs event args
            RaiseNewColorEvent();
            //raise the event with the custom 
            //ColorRoutedEventArgs event args
            RaiseNewColorCustomEvent();
        }
        #endregion
    }
    #endregion
    #region ColorRoutedEventArgs CLASS
    /// <summary>
    /// ColorRoutedEventArgs : a custom event argument class
    /// </summary>
    public class ColorRoutedEventArgs : RoutedEventArgs
    {
        #region Instance fields
        private string _ColorName = "";
        #endregion
        #region Consructor
        /// <summary>
        /// Constructs a new ColorRoutedEventArgs object
        /// using the parameters provided
        /// </summary>
        /// <param name="clrName">the color name string</param>
        public ColorRoutedEventArgs(string clrName)
        {
            this._ColorName = clrName;
        }
        #endregion
        #region Public properties
        /// <summary>
        /// Gets the stored color name 
        /// </summary>
        public string ColorName
        {
            get { return _ColorName; }
        }
        #endregion
    }
    #endregion
}

To be able to change the snake from one style to another fairly easily

Whoa, there you didn't think it was that easy, did you? No way.

It gets more complicated than that, as I have allowed the snake Style to vary (at the moment, only Rectangle and Ellipse style snakes are supported). But wait, let's just think about that for the moment; each cell of the game could be one of the following based on the rules of the snake game:

  • A blank item
  • A wall item
  • A food item
  • A snake item

But part of my requirement was that snakes should be able to change Style easily. At the moment, just Rectangle and Ellipse styles, but I didn't mention anything about the other game items having to be different Styles. So how can we achieve this? We need every cell of the game to be able to be one of the items mentioned above, if required. That means, we need something that can be a filled Rectangle or filled Ellipse, or just a blank cell, or maybe a filled cell. Mmmm, interesting.

Well, the answer lies in a few areas actually. Firstly, standard OO inheritance, and secondly, WPF Styles. What the heck do I mean? Well, let's consider the big picture.

Image 6

Well, the big picture is this, we have a collection of cells that can contain any of the following:

  • Blank cells
  • Wall cells
  • Food cells
  • Snake cells

Of which, snakes can be Rectangle and Ellipse styles. What sort of object could be used to represent this? Well. from a OO inheritance point of view, we could imagine a simple cell that simply has a color associated with it. We could use this for walls and food. But is there a difference between walls and food? There should be, you can't eat a wall after all. So how about a simple property IsFood that would help us distinguish between food and walls within a cell collection? What about snakes? They are a special kind of cell, aren't they? They could be a regular type'ish cell, but a snake can also die if it hits something. So don't we need a new type of cell? Well, yes, actually we do. So with a little bit of OO inheritance pixie dust and voodoo magic, we end up with two cell types.

StandardCell

Which simply stores the selected color. Remember, this was done in the Wizard page 1, as discussed above, and has the rather handy IsFood property that can be used in determining whether the cell is food or not? We will see why this property is so useful later.

C#
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.ComponentModel;

namespace WPF_Snakes
{
    public class StandardCell : INotifyPropertyChanged
    {   
        #region Instance Fields
        private Brush _FillColor = Brushes.Pink;
        private bool _IsFood = false;
        #endregion
        #region Ctor
        public StandardCell()
        {
        }
            
        public StandardCell(bool isFood)
        {
            this._IsFood = isFood;
        }
        #endregion
        #region Public Prioperties

        public bool IsFood
        {
            get { return _IsFood; }
            set { _IsFood = value; }
        }

        public Brush FillColor
        {
            get { return _FillColor; }
            set
            {
                _FillColor = value;
                if (PropertyChanged != null)
                {
                    PropertyChanged(this, 
                       new PropertyChangedEventArgs("FillColor"));
                    }
                }
            }
            #endregion
            #region INotifyPropertyChanged Members
            public event PropertyChangedEventHandler PropertyChanged;
            #endregion
        }
    }

SnakeCell

Which, as we decided a minute ago, is a special type of cell that could be a regular StandardCell, but it could also be another type of cell, in this case, an elliptical cell.

C#
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.ComponentModel;

namespace WPF_Snakes
{
    public class SnakeCell : StandardCell, 
        INotifyPropertyChanged
    {
        #region Instance Fields
        private bool _IsAlive = true;
        private string _SnakeShape = "Rectangular";
        #endregion
        #region Ctor
        public SnakeCell(string snakeShape)
        {
            this._SnakeShape = snakeShape;
        }
        #endregion
        #region Public Prioperties

        public string SnakeShape
        {
            get { return _SnakeShape; }
            set
            {
                _SnakeShape = value;
                if (PropertyChanged != null)
                {
                    PropertyChanged(this, 
        new PropertyChangedEventArgs("SnakeShape"));
                }
            }
        }

        public bool IsAlive
        {
            get { return _IsAlive; }
            set
            {
                _IsAlive = value;
                if (PropertyChanged != null)
                {
                    PropertyChanged(this, new 
        PropertyChangedEventArgs("IsAlive"));
                }
            }
        }
        #endregion
        #region INotifyPropertyChanged Members
        public event PropertyChangedEventHandler PropertyChanged;
        #endregion
    }
}

That's great. We've now got a bunch of cells that hold various bits of data about the game. Basically, the current game state is represented by a collection of cells (CellCollection.cs in the attached demo app). But what can we do with these cells? How do we show them in a UI?

Just what sort of UI element (Visual in WPF speak) could we possibly use to represent a colored Rectangle or Ellipse at any point in time? One might first think of using a Shape which is after all where Rectangle and Ellipse derive from. But Shape is abstract, so can't do what we want. But a Button is a very, very flexible control. We can simply swap out its Template to be whatever we want it to be. We can have various Templates that all target the Button control, and simply apply the correct one we are interested in at any point in time. For example, consider the following Template which targets Button controls:

XML
<!-- STANDARD WALL -->
<Style x:Key="StandardCell" TargetType="{x:Type Button}">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type Button}">
        <Rectangle Name="rectangle" 
    Fill="{Binding Path=FillColor}" 
    Stroke="{Binding Path=FillColor}" 
    Width="Auto" Height="Auto"/>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

Mmmm, as can be seen, we can Style a Button to look like a Rectangle, so surely, we could also Style a Button to look like an Ellipse using the following code:

XML
<!-- ELLIPSE STYLE -->
<Style x:Key="StandardCell" TargetType="{x:Type Button}">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type Button}">
        <Ellipse Name="rectangle" 
    Fill="{Binding Path=FillColor}" 
    Stroke="{Binding Path=FillColor}" 
    Width="Auto" Height="Auto"/>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

Yep, that's OK too. So we are on to something here. Perhaps we should use Button controls to represent our cell collection (CellCollection.cs in the demo app). In fact, this is a good solution. Well, do that. So we now have a collection of cells that are represented by Button controls. But how do we associate one of the cell classes (StandardCell/SnakeCell we talked about earlier) with a particular button? This sounds quite tricky, doesn't it? Well, luckily, WPF comes to our rescue again. We can actually set the DataContext of a Button control to be one of our cell objects (StandardCell/SnakeCell), and then simply apply the correct Style that matches the current object that is applied to the Button control's DataContext. Let's have a look at an example:

C#
//Set button 0,0 to a StandardCell and 
//update its style to be the correct Style 
StandardCell sc = new StandardCell(true);
sc.FillColor = _FoodColor;
_cells[0, 0].DataContext = sc;
_cells[0,0].Style = _FoodStyle;

That's enough to style a Button control and bind it to a cell type object. But how does this cell type object work with the Style? Well, that's down to databinding. Recall that we had a Style like the following:

XML
<!-- STANDARD WALL -->
<Style x:Key="StandardCell" TargetType="{x:Type Button}">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type Button}">
        <Rectangle Name="rectangle" Fill="{Binding 
        Path=FillColor}" 
        Stroke="{Binding Path=FillColor}" 
        Width="Auto" Height="Auto"/>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

The import part to note here is the line, especially the Binding parts.

XML
<Rectangle Name="rectangle" 
    Fill="{Binding Path=FillColor}" 
    Stroke="{Binding Path=FillColor}" Width="Auto" 
    Height="Auto"/>

Where do these come from? Well, these values are bound to the actual value of the cell object that is used on the DataContext of the Button control being styled. Recall, there was a property for FillColor in the StandardCell object:

C#
public Brush FillColor
{
    get { return _FillColor; }
    set
    {
        _FillColor = value;
        if (PropertyChanged != null)
        {
            PropertyChanged(this, 
    new PropertyChangedEventArgs("FillColor"));
        }
    }
}

That's used in the DataBinding. Neat, huh?

To be able to create a new level by drawing out the level

OK, so I wanted to be able to draw out a new level. The thought process starts. Mmmm, I want to be able to draw using the mouse, or click a certain area and have it change to a game object, such as a wall, or a snake, or a bit of food. How could I achieve this? Well, again, isn't a Button control quite a handy control to use? It has an event for MouseEnter and Click, so it seems to be a logical choice. In fact, it turns out to be a perfect choice. We simply subscribe to the MouseEnter and Click events of an array of buttons, and use the value of the currently selected drawing item to style the button accordingly, just as we saw above.

So how do we draw different items? I've given users the choice of menus or buttons, the menus have more options as the UI space had to be considered.

Buttons available

There are buttons available to:

  • Draw walls
    • Styles the current button as a wall.
  • Draw food
    • Styles the current button as a food.
  • Draw the snake (only head)
    • Styles the current button as a snake head. The user must rotate the snake once the head is drawn, using the buttons or the menus. This is done as, if we allow the user to draw a snake, they could draw a head, then draw a tail, but they may not be connected. So this way, the code automatically places the tail based on a rotation request from the user.
  • Save a level
    • Simply writes to a .LEV file. It's basically a text file, so you can edit it in text if you like that sort of thing.
  • Open a level to create a new level from
    • Open a .LEV file, to be used as a basis for a new level. The snake still needs to be placed manually after a file opening.

Image 7

Menus available

There are menus available to:

  • Change the drawing mode
    • Continuous (for walls only, which uses the Button MouseEnter to change the Style automatically).
    • On click, uses the Button Click to change the Style.
    • Erase, uses the Button MouseEnter to change the Style back to blank automatically.
  • Draw walls
    • Styles the current button as wall.
  • Draw food
    • Styles the current button as food.
  • Draw the snake (only head)
    • Styles the current button as a snake head. The user must rotate the snake once the head is drawn, using the buttons or the menus. This is done as, if we allow the user to draw a snake, they could draw a head, then draw a tail, but they may not be connected. So this way, the code automatically places the tail based on a rotation request from the user.
  • Save a level
    • Simply writes to a .LEV file. It's basically a text file, so you can edit it in text if you like that sort of thing.
  • Open a level to create a new level from
    • Open a .LEV file to be used as a basis for a new level. The snake still needs to be placed manually after a file opening.
  • Rotate the snake
    • Rotate the snake Up/Down/Left/Right.
  • Exit the Wizard.

Some of these interactions look pretty similar, don't they? There is some commonality between what the buttons are doing and what the menus are doing. We could simply call the same method for both these events (one for a button click and one for a menu click), but WPF exposes is a more interesting, more extendable technique for dealing with this. This technique is known as Commands. Lets' have a look at one of these.

C#
//define a new command for the SettingsWizard object
public static readonly RoutedCommand DrawWallCommand = 
       new RoutedCommand("DrawWall", typeof(SettingsWizard));
//add the command to the command bindings for the SettingsWizard 
this.CommandBindings.Add(new CommandBinding(
     SettingsWizard.DrawWallCommand,this.WallDrawing));

Then in the XAML, we can get the menu and the button to use this command, by doing the following. Notice the Command attribute. There is also the x:Static, which means look for a static item, and local: simply points to the current assembly namespace.

XML
<MenuItem Name="menWall" Header="Draw Walls" 
      Command="{x:Static local:SettingsWizard.DrawWallCommand}">
  </MenuItem>
  <Button x:Name="btnDrawWall" Width="81" Height="26" 
     Content="Draw Wall" Canvas.Left="8" Canvas.Top="14" 
     Template="{StaticResource toolBarButtons}" 
     Command="{x:Static local:SettingsWizard.DrawWallCommand}"/>

So that's how we can bind one or more objects to the same command. But what about disabling these commands under certain conditions? Turns out, we can also do that with commands. It's just slightly more work. We define the command like:

C#
CommandBinding commandBindingSnakeNorth = 
        new CommandBinding(SettingsWizard.SnakeNorthCommand, 
        this.SnakeNorth);
commandBindingSnakeNorth.CanExecute += new CanExecuteRoutedEventHandler(
     commandBindingSnakePlacement_CanExecute);
this.CommandBindings.Add(commandBindingSnakeNorth);

But this time, there is an event wired up which tells the command whether it can execute or not.

C#
private void commandBindingSnakePlacement_CanExecute(object sender, 
             CanExecuteRoutedEventArgs e)
{
  e.CanExecute = _snakeInitialized;
}

To be able to move the snake around the level

Recall, I previously stated that the current game state was represented by an array of a Button objects which have the appropriate Style applied to them? But how does the Button know which Style to use? Well, that's down to the mouse movement. This is captured on the main window (Window1), and then the movement is sent to the CellCollection to determine what should happen. The logic is as follows:

  • If the cell is blank and not food, turn it into a snake and turn the tail into blank (done by applying the appropriate Style to buttons at the new location).
  • If the cell is wall, for all snake styled buttons, set IsAlive to false so that the attached SnakeCell Style will animate the snake's death.
  • If the cell is blank and is food, turn it into a snake and keep the tail as a snake (done by applying the appropriate Style to the buttons at the new location).

This is all good so far. But how do we do this updating? We need to do it automatically based on the last move, which implies we need some sort of game timer that applies the last direction until the user changes it. So, in comes a WPF Timer, the DispatchTimer. Which expects a time interval and an enabled state to be true in order for it to tick. Basically, for each tick, the game state is updated using the last registered move direction.

That's it, essentially.

One interesting thing that I did find along the way was that, because the cell objects held internal Brush objects that I am trying to animate (well, only for the SnakeCell objects actually), and that these internal Brush objects are classed as being Freezable objects (immutable, not able to change once created), the animations were not working. A little bit of messing about, and I got the animations working using a clone type converter, which simply clones the original databound object within the styles databinding, and uses the clone to animate. The relevant section of the SnakeCell Style is shown below:

XML
<Rectangle x:Name="rect" RenderTransformOrigin="0.5,0.5"
      Fill="{Binding Path=FillColor, Converter={x:Static local:CloneConverter.Instance}}"
      Stroke="{Binding Path=FillColor, Converter={x:Static local:CloneConverter.Instance}}"
      Width="Auto" Height="Auto" Margin="2,2,2,2">

And the value convertor that does the clone for the databinding is as follows:

C#
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace WPF_Snakes
{
    /// <summary>
    /// Used in ControlTemplate that use a Freezable object that
    /// is animated.
    /// This deals with the cannot animate immutable object 
    /// error that will
    /// be seen if you dont clone the immutable object for use 
    ///in the ControlTemplate
    /// ready for it to be animated
    /// In this application the immutable object will be the 
    /// the <see cref="SnakeCell">
    /// SnakeCell</see> FillColor Brush which is animated 
    /// in the ControlTemplate
    /// </summary>
    /// 
    public class CloneConverter : IValueConverter
    {
        #region Instance Fields
        public static CloneConverter Instance = new 
        CloneConverter();
        #endregion
        #region Public Methods
        public object Convert(object value, Type targetType, 
        object parameter, CultureInfo culture)
        {
            if (value is Freezable)
            {
                value = (value as Freezable).Clone();
            }
            return value;
        }

        public object ConvertBack(object value, Type targetType, 
        object parameter, CultureInfo culture)
        {
            throw new NotSupportedException
            ("Can't convert back");
        }
        #endregion
    }
}

I could have probably changed the properties in the cell objects to be dependency properties, and all would have been well, but then I never would have worked this code out; now, would I?

The last thing of note when dealing with the snake movement is the position of the head. If both the tail and head look the same, which way should the user move? I needed something to make the head look different. I could have had a special head Style, but I opted for a head Adorner, which is applied to the current head Button layer (basically, an adorner draws stuff on a layer above the element it is associated with). This adorner is called SnakeHeadAdorner, and is simply responsible for drawing the snakes eyes on the Styled Button, dependant on the current movement direction. The SnakeHeadAdorner is actually shown below:

C#
using System;
using System.Collections.Generic;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Shapes;
using System.Globalization;

namespace WPF_Snakes
{

    public class SnakeHeadAdorner : Adorner
    {
        private string _moveDirection;
        private Brush _bgBrushColor;

        // Be sure to call the base class constructor.
        public SnakeHeadAdorner(UIElement adornedElement, 
            string moveDirection, Brush bgBrushColor)
            : base(adornedElement)
        {
            this._moveDirection = moveDirection;
            this._bgBrushColor = bgBrushColor;
        }

        protected override void OnRender(DrawingContext drawingContext)
        {
            Rect adornedElementRect = new Rect(
                 this.AdornedElement.RenderSize);
            Pen renderPen = new Pen(_bgBrushColor, 1.0);
            double eyeWidth;
            double eyeHeight;
            Point eye1PointStart;
            Point eye2PointStart;

            switch (_moveDirection)
            {
                case "Up":
                    eyeWidth = adornedElementRect.Width / 5;
                    eyeHeight = eyeWidth;
                    eye1PointStart = new Point(eyeWidth-2, 10);
                    drawingContext.DrawRectangle(_bgBrushColor, 
                         renderPen, new Rect(eye1PointStart, 
                         new Size(eyeWidth, eyeHeight)));
                    eye2PointStart=new Point((eyeWidth*3)+2, 10);
                    drawingContext.DrawRectangle(
                    _bgBrushColor, renderPen, new Rect(
                       eye2PointStart, new Size(eyeWidth, eyeHeight)));
                    break;
                case "Down":
                    eyeWidth = (int)adornedElementRect.Width/5;
                    eyeHeight = (int)eyeWidth;
                    eye1PointStart = new Point(eyeWidth - 2, 
                    adornedElementRect.Height - (10 + eyeHeight));
                    drawingContext.DrawRectangle(_bgBrushColor, 
                           renderPen, new Rect(eye1PointStart, 
                           new Size(eyeWidth, eyeHeight)));
                    eye2PointStart = new Point((eyeWidth * 3) + 2, 
                              adornedElementRect.Height - (10 + eyeHeight));
                    drawingContext.DrawRectangle(
                            _bgBrushColor, renderPen, 
                            new Rect(eye2PointStart, 
                            new Size(eyeWidth, eyeHeight)));
                    break;
                case "Left":
                    eyeHeight = adornedElementRect.Height / 5;
                    eyeWidth = eyeHeight;
                    eye1PointStart = new Point(10, eyeHeight);
                    drawingContext.DrawRectangle(_bgBrushColor, 
                         renderPen, new Rect(eye1PointStart, 
                         new Size(eyeWidth, eyeHeight)));
                    eye2PointStart = new Point(10, eyeHeight*3);
                    drawingContext.DrawRectangle(_bgBrushColor, 
                           renderPen, new Rect(eye2PointStart, 
                           new Size(eyeWidth, eyeHeight)));
                    break;
                case "Right":
                    eyeHeight = adornedElementRect.Height / 5;
                    eyeWidth = eyeHeight;
                    eye1PointStart = new Point(adornedElementRect.Width - 
                        (eyeWidth+10), eyeHeight);
                    drawingContext.DrawRectangle(_bgBrushColor, 
                         renderPen, new Rect(eye1PointStart, 
                         new Size(eyeWidth, eyeHeight)));
                    eye2PointStart = new Point(
                        adornedElementRect.Width - (eyeWidth+10), 
                    eyeHeight*3);
                    drawingContext.DrawRectangle(_bgBrushColor, 
                          renderPen, new Rect(eye2PointStart, 
                          new Size(eyeWidth, eyeHeight)));
                    break;
                }
            }
        }
    }

And to apply and remove this SnakeHeadAdorner, the following code is used:

C#
private void SnakeAdornerEvent(object sender, SnakeAdornerEventArgs e)
{
    string moveDirection = "";

    switch (e.MoveDirection)
    {
        case CellCollection.MoveDirection.Up:
            moveDirection = "Up";
            break;
        case CellCollection.MoveDirection.Down:
            moveDirection = "Down";
            break;
        case CellCollection.MoveDirection.Left:
            moveDirection = "Left";
            break;
        case CellCollection.MoveDirection.Right:
            moveDirection = "Right";
            break;
    }

    SnakeHeadAdorner sa = new SnakeHeadAdorner(e.
       SnakeButtons[0], moveDirection, mainGrid.Background);
    if (alSingle == null)
    {
        alSingle = AdornerLayer.GetAdornerLayer(
              e.SnakeButtons[0]);
        alSingle.Add(sa);
        _snakesToClear.Add(e.SnakeButtons[0]);
    }
    else
    {
        try
        {
            alSingle.Remove(alSingle.GetAdorners(
                e.SnakeButtons[1])[0]);
            alSingle = AdornerLayer.GetAdornerLayer(
                e.SnakeButtons[0]);
            alSingle.Add(sa);
            _snakesToClear.Add(e.SnakeButtons[0]);
            _snakesToClear.Add(e.SnakeButtons[1]);
        }
        catch
        {
        }
    }
}

private void ClearAdorners()
{
    foreach (Button btn in _snakesToClear)
    {
        alSingle = AdornerLayer.GetAdornerLayer(btn);
        Adorner[] ads = alSingle.GetAdorners(btn);
        foreach (Adorner ad in ads)
            alSingle.Remove(ad);
    }
}

This SnakeHeadAdorner results in the following rendering:

Image 8

To be able to run the game in different difficulty levels

I wanted to be able to run the game at various levels of difficulty. As mentioned earlier, the basis of moving the snake around the level is based on a DispatchTimer, which is just a timer that has an interval at the end of the day. So, couldn't we make the level harder if we shortened the time between automatically calculated moves? Well, yes, we could. In fact, let's look at that. We have a timer that we want to effect somehow. We could have some sort of UI element to allow the difficulty to be ramped up or down. Perhaps, a Slider may be what we are looking for here. We could then use the value of the slider to adjust the interval for the main level timer (the DispatchTimer).

So in the XAML, we could have a Slider, something like:

XML
<Slider x:Name="sliDifficulty" HorizontalAlignment="Center" 
    VerticalAlignment="Center" LargeChange="100" Maximum="600" 
    Minimum="300" SmallChange="100" AutoToolTipPlacement="TopLeft" 
    TickFrequency="100" TickPlacement="BottomRight" Margin="15,0,0,0" 
    Width="177" Height="23"/>
<Label HorizontalAlignment="Center" VerticalAlignment="Center" 
    Width="100" Height="Auto" 
    Content="{Binding Path=Value, ElementName=sliDifficulty, Mode=Default, 
             Converter={x:Static local:DifficultyConvertor.Instance}}" 
    Margin="5,0,0,0" Background="#00474242" Foreground="#FF000000" 
    FontFamily="Agency FB" FontSize="14" />

And then in the code-behind, we could use the value of the slider to adjust the main level timer's interval, just like:

C#
private void sliDifficulty_ValueChanged(object sender, 
RoutedPropertyChangedEventArgs<double> e)
{
     _TimerInterval = new TimeSpan(0, 0, 0, 0, 
          (int)sliDifficulty.Value);
     _timer.Interval = _TimerInterval;
}

But wait, the actual slider is showing some text, telling the user exactly (in words) how hard the level is. How's that?

The answer is a value convertor. It can be seen in the following line that the label content is bound to the Slider; but that should be a number, and we are seeing a text value. Hmmmm.

XML
<Label ..... Content="{Binding Path=Value, 
  ElementName=sliDifficulty, Mode=Default, 
   Converter={x:Static local:DifficultyConvertor.Instance}}" ... />

The interesting thing here is the Converter part. This tells the XAML that there is a special DataBinding converter that is used to translate the bound value. Let's have a look at the converter, shall we?

C#
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace WPF_Snakes
{

    public class DifficultyConvertor : IValueConverter
    {
        #region Instance Fields
        public static DifficultyConvertor Instance = 
            new DifficultyConvertor();
        #endregion
        #region Public Methods
        public object Convert(object value, Type targetType, 
            object parameter, CultureInfo culture)
        {
            double doubleValue = (double)value;

            if (doubleValue >= 300 && doubleValue < 400)
                return "Hard";
            else if (doubleValue >= 400 && doubleValue < 500)
                return "Medium";
            else if (doubleValue >= 500)
                return "Easy";
            else
                return "Not recognized";
        }
    
        public object ConvertBack(object value, Type 
          targetType, object parameter, CultureInfo culture)
        {
            throw new NotSupportedException
                 ("Can't convert back");
        }
        #endregion
    }
}

Simple, huh? But very powerful.

To show some visual animation when the snake dies

Recall, earlier I stated that we were using Button controls with different Styles applied dependant on the cell type, and that I also gave the user the choice of a rectangular or elliptical SnakeCell, and that snakes can actually die when they hit things. Well, let's see how all that is achieved, shall we? This time, it's all in XAML really.

XML
<!-- SNAKE CELL RECTANGULAR  -->
<Style x:Key="SnakeCellRectangular" TargetType="{x:Type Button}">
  <Setter Property="Template">
    <Setter.Value>
      <ControlTemplate TargetType="{x:Type Button}">
        <ControlTemplate.Resources>
          <Storyboard x:Key="Timeline1">
            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" 
                   Storyboard.TargetName="rect" 
                   Storyboard.TargetProperty= 
                     "(UIElement.RenderTransform).(TransformGroup.Children)[0].(
                            ScaleTransform.ScaleX)">
              <SplineDoubleKeyFrame KeyTime="00:00:00" 
                    Value="6.0"/>
              <SplineDoubleKeyFrame KeyTime="00:00:00.5000000" 
                    Value="4.0"/>
              <SplineDoubleKeyFrame KeyTime="00:00:01" 
                   Value="2.0"/>
              <SplineDoubleKeyFrame KeyTime="00:00:01.5000000" 
                   Value="0.5"/>
              <SplineDoubleKeyFrame KeyTime="00:00:02" 
                   Value="0"/>
            </DoubleAnimationUsingKeyFrames>
            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" 
                    Storyboard.TargetName="rect" 
                    Storyboard.TargetProperty=
                      "(UIElement.RenderTransform).(
                       TransformGroup.Children)[0].(ScaleTransform.ScaleY)">
              <SplineDoubleKeyFrame KeyTime="00:00:00" 
                   Value="6.0"/>
              <SplineDoubleKeyFrame KeyTime="00:00:00.5000000" 
                   Value="4.0"/>
              <SplineDoubleKeyFrame KeyTime="00:00:01" 
                  Value="2.0"/>
              <SplineDoubleKeyFrame KeyTime="00:00:01.5000000" 
                  Value="0.5"/>
              <SplineDoubleKeyFrame KeyTime="00:00:02" 
                  Value="0"/>
            </DoubleAnimationUsingKeyFrames>
            <ColorAnimationUsingKeyFrames BeginTime="00:00:00" 
                    Storyboard.TargetName="rect" 
                    Storyboard.TargetProperty="(Shape.Fill).(
                                   SolidColorBrush.Color)">
              <SplineColorKeyFrame KeyTime="00:00:00" 
                   Value="#00FFFFFF"/>
              <SplineColorKeyFrame KeyTime="00:00:00.5000000" 
                  Value="#FFE50404"/>
              <SplineColorKeyFrame KeyTime="00:00:01" 
                  Value="#00FFFFFF"/>
              <SplineColorKeyFrame KeyTime="00:00:01.5000000" 
                  Value="#FFE50404"/>
              <SplineColorKeyFrame KeyTime="00:00:02" 
                  Value="#00FFFFFF"/>
            </ColorAnimationUsingKeyFrames>
            <ColorAnimationUsingKeyFrames BeginTime="00:00:00" 
                    Storyboard.TargetName="rect" 
                    Storyboard.TargetProperty=
                      "(Shape.Stroke).(SolidColorBrush.Color)">
              <SplineColorKeyFrame KeyTime="00:00:00" 
                 Value="#00FFFFFF"/>
              <SplineColorKeyFrame KeyTime="00:00:00.5000000" 
                 Value="#FFE50404"/>
              <SplineColorKeyFrame KeyTime="00:00:01" 
                 Value="#00FFFFFF"/>
              <SplineColorKeyFrame KeyTime="00:00:01.5000000" 
                 Value="#FFE50404"/>
              <SplineColorKeyFrame KeyTime="00:00:02" 
                 Value="#00FFFFFF"/>
            </ColorAnimationUsingKeyFrames>
          </Storyboard>
        </ControlTemplate.Resources>
        <Rectangle x:Name="rect" RenderTransformOrigin="0.5,0.5"
                 Fill="{Binding Path=FillColor, 
                 Converter={x:Static 
                 local:CloneConverter.Instance}}"
                 Stroke="{Binding Path=FillColor,
                 Converter={x:Static 
                 local:CloneConverter.Instance}}"
                 Width="Auto" Height="Auto" Margin="2,2,2,2">
          <Rectangle.RenderTransform>
            <TransformGroup>
              <ScaleTransform ScaleX="1" ScaleY="1"/>
              <SkewTransform AngleX="0" AngleY="0"/>
              <RotateTransform Angle="0"/>
              <TranslateTransform X="0" Y="0"/>
            </TransformGroup>
          </Rectangle.RenderTransform>
        </Rectangle>
        <ControlTemplate.Triggers>
          <DataTrigger Binding="{Binding IsAlive}" 
                   Value="False">
            <DataTrigger.EnterActions>
              <BeginStoryboard 
                   Storyboard="{StaticResource Timeline1}"/>
            </DataTrigger.EnterActions>
          </DataTrigger>
        </ControlTemplate.Triggers>
      </ControlTemplate>
    </Setter.Value>
  </Setter>
</Style>

The import part to note here is the DataTrigger section which uses the special IsAlive property to trigger a Storyboard animation. This is basically used to kill the snake. All thanks to data binding and Styles. Good stuff. The IsAlive property is set in the CellCollection class when a collision is detected. This is done for all snake style buttons, thus they all carry out their own death animations.

As shown here:

Image 9

To show some visual message when the time expires, or when the user completes the level, or when the snake dies

The idea here is to simply show some text that might popup when some event happens. So I thought to myself, why not have a user control with a piece of text on it which can be set, and that has an animation that flashes the text? This is what I ended up writing:

XML
<UserControl
    xmlns="http://schemas.microsoft.com/winfx/2006/
    xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    x:Class="WPF_Snakes.FlashTextControl"
    x:Name="UserControl"
    Width="700" Height="112">

  <UserControl.Resources>
    <Storyboard x:Key="OnVisibilityChanged">
      <ColorAnimationUsingKeyFrames BeginTime="00:00:00" 
    Storyboard.TargetName="lblText" 
            Storyboard.TargetProperty=
    "(TextElement.Foreground).(SolidColorBrush.Color)">
        <SplineColorKeyFrame KeyTime="00:00:00" 
    Value="#FFFF0F0F"/>
        <SplineColorKeyFrame KeyTime="00:00:00.1000000" 
    Value="#00FFFFFF"/>
        <SplineColorKeyFrame KeyTime="00:00:00.2000000" 
    Value="#FFFF0F0F"/>
        <SplineColorKeyFrame KeyTime="00:00:00.3000000" 
    Value="#00FFFFFF"/>
        <SplineColorKeyFrame KeyTime="00:00:00.4000000" 
    Value="#FFFF0F0F"/>
        <SplineColorKeyFrame KeyTime="00:00:00.5000000" 
    Value="#00FFFFFF"/>
        <SplineColorKeyFrame KeyTime="00:00:00.6000000" 
    Value="#FFFF0F0F"/>
      </ColorAnimationUsingKeyFrames>
    </Storyboard>
  </UserControl.Resources>

  <Grid x:Name="LayoutRoot" Height="Auto">
    <Label x:Name="lblText" HorizontalAlignment="Center" 
        VerticalAlignment="Center" Background="{x:Null}" 
        BorderBrush="{x:Null}" FontFamily="Agency FB" 
        FontSize="109" FontWeight="Bold" 
        Foreground="#FFFF0F0F" Width="695.31" 
        Height="146.18"/>
  </Grid>
</UserControl>

The code-behind:

C#
using System;
using System.IO;
using System.Net;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Media;
using System.Windows.Media.Animation;
using System.Windows.Navigation;
    
namespace WPF_Snakes
{
    public partial class FlashTextControl
    {
        #region Ctor
        public FlashTextControl()
        {
            this.InitializeComponent();
            this.IsVisibleChanged += 
              new DependencyPropertyChangedEventHandler(
              FlashTextControl_IsVisibleChanged);
        }
        #endregion
        #region Private Methods
        void FlashTextControl_IsVisibleChanged(object 
             sender < DependencyPropertyChangedEventArgs e)
        {
            if (this.Visibility == Visibility.Visible)
            {
                ((Storyboard)this.Resources[
                      "OnVisibilityChanged"]).Begin(this);
            }
        }
        #endregion
        #region Public Properties
        public string FlashText
        {

            set { lblText.Content = value; }
        }
        #endregion
    }
}

Pretty simple control that does some flashing text.

That's it

I have tried to make sure that this article's decisions and outcomes are properly explained, and I hope that this article contains enough meat for new and old WPFers out there. I personally think it does. I mean, it uses Templates, DataBinding, Resources, Commands, Styles, Adorners, Convertors, Timers... it's all there (well, no dependency properties or routed events, as I was feeling slightly lazy, but other than that, it's all cool).

So What Do You Think?

I would just like to ask, if you liked the article, please vote for it, and leave some comments, as it lets me know if the article was at the right level or not, and whether it contains what people need to know.

History

  • v1.0: 16/10/07: Initial issue.

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United Kingdom United Kingdom
I currently hold the following qualifications (amongst others, I also studied Music Technology and Electronics, for my sins)

- MSc (Passed with distinctions), in Information Technology for E-Commerce
- BSc Hons (1st class) in Computer Science & Artificial Intelligence

Both of these at Sussex University UK.

Award(s)

I am lucky enough to have won a few awards for Zany Crazy code articles over the years

  • Microsoft C# MVP 2016
  • Codeproject MVP 2016
  • Microsoft C# MVP 2015
  • Codeproject MVP 2015
  • Microsoft C# MVP 2014
  • Codeproject MVP 2014
  • Microsoft C# MVP 2013
  • Codeproject MVP 2013
  • Microsoft C# MVP 2012
  • Codeproject MVP 2012
  • Microsoft C# MVP 2011
  • Codeproject MVP 2011
  • Microsoft C# MVP 2010
  • Codeproject MVP 2010
  • Microsoft C# MVP 2009
  • Codeproject MVP 2009
  • Microsoft C# MVP 2008
  • Codeproject MVP 2008
  • And numerous codeproject awards which you can see over at my blog

Comments and Discussions

 
Questionvery nice Pin
BillW3323-Oct-12 6:07
professionalBillW3323-Oct-12 6:07 
AnswerRe: very nice Pin
Sacha Barber23-Oct-12 6:16
Sacha Barber23-Oct-12 6:16 
GeneralRe: very nice Pin
BillW3323-Oct-12 6:21
professionalBillW3323-Oct-12 6:21 
GeneralMy vote of 5 Pin
ParthDesai8-Oct-12 14:58
ParthDesai8-Oct-12 14:58 
GeneralMy vote of 5 Pin
Jαved24-Nov-11 20:21
professionalJαved24-Nov-11 20:21 
GeneralMy vote of 5 Pin
paladin_t17-Feb-11 19:48
paladin_t17-Feb-11 19:48 
Generaluse this color picker Pin
sudheer muhammed27-Jan-11 1:00
sudheer muhammed27-Jan-11 1:00 
GeneralRe: use this color picker Pin
Sacha Barber27-Jan-11 7:12
Sacha Barber27-Jan-11 7:12 
GeneralMy vote of 5 Pin
Marcelo Ricardo de Oliveira3-Jan-11 0:43
Marcelo Ricardo de Oliveira3-Jan-11 0:43 
GeneralRe: My vote of 5 Pin
Sacha Barber3-Jan-11 0:58
Sacha Barber3-Jan-11 0:58 
GeneralRe: My vote of 5 Pin
Marcelo Ricardo de Oliveira3-Jan-11 2:01
Marcelo Ricardo de Oliveira3-Jan-11 2:01 
GeneralMy vote of 5 Pin
vivekse29-Dec-10 0:46
vivekse29-Dec-10 0:46 
GeneralRe: My vote of 5 Pin
Sacha Barber2-Jan-11 6:36
Sacha Barber2-Jan-11 6:36 
GeneralMain grid initialisation Pin
Mark Sizer28-Sep-10 0:05
Mark Sizer28-Sep-10 0:05 
GeneralRe: Main grid initialisation Pin
Sacha Barber28-Sep-10 2:06
Sacha Barber28-Sep-10 2:06 
GeneralLicence Pin
Basarat Ali Syed8-Mar-10 1:04
Basarat Ali Syed8-Mar-10 1:04 
GeneralRe: Licence Pin
Sacha Barber8-Mar-10 1:06
Sacha Barber8-Mar-10 1:06 
GeneralRe: Licence Pin
Basarat Ali Syed8-Mar-10 1:28
Basarat Ali Syed8-Mar-10 1:28 
GeneralRe: Licence Pin
Sacha Barber8-Mar-10 2:09
Sacha Barber8-Mar-10 2:09 
Generalits awesome Pin
RamMajeti18-Jan-08 19:59
RamMajeti18-Jan-08 19:59 
GeneralRe: its awesome Pin
Sacha Barber18-Jan-08 21:20
Sacha Barber18-Jan-08 21:20 
GeneralReally cool Pin
MG0215-Nov-07 12:13
MG0215-Nov-07 12:13 
GeneralRe: Really cool Pin
Sacha Barber15-Nov-07 20:47
Sacha Barber15-Nov-07 20:47 
GeneralThanks Pin
Dr.Luiji12-Nov-07 22:44
professionalDr.Luiji12-Nov-07 22:44 
GeneralRe: Thanks Pin
Sacha Barber12-Nov-07 23:49
Sacha Barber12-Nov-07 23:49 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.