Recently, I had a sick leave and to had some fun and improve coding skills I got a challenge to write a Snake Game for WPF.
Desired RESULT
1) Game must be in separate .dll library;
2) Must have settings;
3) Must use Canvas as playing field.
SOURCE
If you want to skip reading or just to watch and to make review - you're welcome here.
HOW TO PLAY
If you dowload source there would be additional Escape key to Close Main window
USAGE
This library must be used as you might expect. Just add library to your wpf-project, and instantiate SnakeGame
class with input parameters:
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
Loaded += SnakeGameLoader;
KeyDown += new KeyEventHandler(CloseOnEsc);
}
private void SnakeGameLoader(object sender, RoutedEventArgs e)
{
var snakeGame = new SnakeGame
{
Snake = new Snake(
length: 3,
startPosition: StartPosition.Center,
bodyColor: Brushes.Red,
headColor: Brushes.Black),
GameField = new GameField(
pixelSize: 50,
playingField: playingField,
pixelType: PixelType.Square),
Difficulty = Difficulty.Hard,
FoodColor = Brushes.GreenYellow,
AmountOfFood = 6
};
snakeGame.Start();
}
private void CloseOnEsc(object sender, KeyEventArgs e)
{
if (e.Key == Key.Escape) Close();
}
}
SnakeGame.cs
public class SnakeGame
{
#region Properties
public Snake Snake { get; set; }
public GameField GameField { get; set; }
public Difficulty Difficulty { get; set; }
public int AmountOfFood { get; set; }
public SolidColorBrush FoodColor { get; set; }
#endregion
#region Public methods
public void Start()
{
if (GameField == null)
{
throw new ArgumentNullException(nameof(GameField));
}
if (Snake == null)
{
throw new ArgumentNullException(nameof(Snake));
}
if (Difficulty < Difficulty.Impossible || Difficulty > Difficulty.Easy)
{
throw new ArgumentException($"{nameof(Difficulty)} illegal enum value");
}
Snake.InitializeSnake(GameField, Difficulty);
Snake.OnLackOfFood += InitializeFood;
Snake.OnSnakeCrash += GameOver;
Snake.StartMoving();
}
#endregion
#region Private methods
private void InitializeFood(object sender, EventArgs e)
{
if (FoodColor == null)
{
throw new ArgumentNullException(nameof(FoodColor));
}
if (AmountOfFood <= 0)
{
throw new ArgumentOutOfRangeException($"{nameof(AmountOfFood)} must be positive");
}
var emptyPixels = GameField.GetPixels().Where(s => s.GetFillColor == null).ToArray();
var random = new Random();
int foodCount = 0;
do
{
var randomIndex = random.Next(0, emptyPixels.Length);
emptyPixels[randomIndex].Fill(FoodColor);
emptyPixels[randomIndex].IsFood = true;
}
while (++foodCount < AmountOfFood);
}
private void GameOver(object sender, EventArgs e)
{
GameField.Clear();
GameField.ResultBoard(Snake.MovesCount, Snake.ScoreCount);
Snake.OnLackOfFood -= InitializeFood;
}
#endregion
}
GameField.cs
Actually GameField
is a Matrix[M,N] where M - amount of Rows, N - amount of Columns.
public class GameField
{
#region Fields
private readonly Pixel[,] _pixels;
#endregion
#region Ctor
/// <summary>
/// Initializes a new instance of GameField
/// </summary>
/// <param name="pixelSize">Size of squares in pixels (must be between 10 and 100)</param>
/// <param name="playingField">Playing field</param>
/// <param name="pixelType">Pixels type</param>
public GameField(int pixelSize, Canvas playingField, PixelType pixelType)
{
if (pixelType < PixelType.Circle || pixelType > PixelType.Square)
{
throw new ArgumentException($"{nameof(pixelType)} illegal enum value");
}
Pixel.PixelType = pixelType;
if (pixelSize < 10 || pixelSize > 100)
{
throw new ArgumentOutOfRangeException($"{nameof(pixelSize)} must be between '5' and '100'");
}
Pixel.Canvas = playingField ?? throw new ArgumentNullException(nameof(playingField));
Pixel.Size = pixelSize;
Rows = (int)Math.Floor(playingField.ActualHeight / pixelSize);
Columns = (int)Math.Floor(playingField.ActualWidth / pixelSize);
Pixel.Corrective = Tuple.Create(
playingField.ActualHeight % pixelSize / Rows,
playingField.ActualWidth % pixelSize / Columns);
_pixels = new Pixel[Rows, Columns];
for (int i = 0; i < Rows; i++)
{
for (int j = 0; j < Columns; j++)
{
_pixels[i, j] = new Pixel(i, j);
}
}
}
#endregion
#region Public methods
public void ResultBoard(int movesCount, double scoreCount)
{
var grid = new Grid
{
Height = Pixel.Canvas.ActualHeight,
Width = Pixel.Canvas.ActualWidth
};
var label = new Label
{
Content = $"GAME OVER\nMoves: {movesCount}, Score: {scoreCount: 0}",
Foreground = Brushes.GreenYellow,
FontSize = 50,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center
};
grid.Children.Add(label);
Pixel.Canvas.Children.Add(grid);
}
internal IEnumerable<Pixel> GetPixels()
{
for (int i = 0; i < Rows; i++)
{
for (int j = 0; j < Columns; j++)
{
yield return _pixels[i, j];
}
}
}
public void Clear() => Pixel.Canvas.Children.Clear();
public (int i, int j) GetStartingPoint(StartPosition startPosition)
{
switch (startPosition)
{
case StartPosition.Center:
return (Rows / 2, Columns / 2);
case StartPosition.LeftDownCorner:
return (Rows - 2, 1);
case StartPosition.LeftUpCorner:
return (1, 1);
case StartPosition.RightDownCorner:
return (Rows - 2, Columns - 2);
case StartPosition.RightUpCorner:
return (1, Columns - 2);
default: return (Rows / 2, Columns / 2);
}
}
public bool IsLackOfFood => !GetPixels().Any(p => p.IsFood);
#endregion
#region Indexer
internal Pixel this[int i, int j]
{
get
{
if (i < 0 || i >= Rows)
{
throw new ArgumentOutOfRangeException($"{nameof(i)} must be between '0' and '{Rows}'");
}
if (j < 0 || j >= Columns)
{
throw new ArgumentOutOfRangeException($"{nameof(j)} must be between '0' and '{Columns}'");
}
return _pixels[i, j];
}
}
#endregion
#region Properties
public int Rows { get; }
public int Columns { get; }
#endregion
}
Pixel
Pixel instance is an element of Matrix(gamefield), where i - row, j - column. It has Shape - circle or square - depends on SnakeGame input settings.
internal class Pixel
{
#region Fields
private readonly Shape _pixel;
#endregion
#region Ctor
public Pixel(int i, int j)
{
I = i;
J = j;
double yCoordinate = i * (Size + Corrective.Item1);
double xCoordinate = j * (Size + Corrective.Item2);
if (PixelType == PixelType.Circle)
{
_pixel = new Ellipse();
}
else
{
_pixel = new Rectangle();
}
IsFood = false;
_pixel.Height = Size;
_pixel.Width = Size;
Canvas.Children.Add(_pixel);
Canvas.SetLeft(_pixel, xCoordinate);
Canvas.SetTop(_pixel, yCoordinate);
}
#endregion
#region Public methods
public void Fill(SolidColorBrush brush) => _pixel.Fill = brush;
public void Unfill() => _pixel.Fill = null;
#endregion
#region Properties
public static Canvas Canvas { get; set; }
public static PixelType PixelType { get; set; }
public static Tuple<double,double> Corrective { get; set; }
public static int Size { get; set; }
public Brush GetFillColor => _pixel.Fill;
public int I { get; set; }
public int J { get; set; }
public bool IsFood { get; set; }
#endregion
}
Snake.cs
public class Snake
{
#region Fields
private GameField _gameField;
private readonly DispatcherTimer _dispatcherTimer;
#endregion
#region Ctors
private Snake(int length)
{
if (length <= 0)
{
throw new ArgumentOutOfRangeException($"{nameof(length)} must be positive");
}
Body = new List<Pixel>();
MovingDirection = Direction.Right;
_dispatcherTimer = new DispatcherTimer();
_dispatcherTimer.Tick += Move;
Length = length;
}
/// <summary>
/// Initializes a new instance of Snake
/// </summary>
/// <param name="length">Length (must be between 3 and 10)</param>
/// <param name="startPosition">Start position</param>
/// <param name="bodyColor">Body Color</param>
/// <param name="headColor">Head Color</param>
public Snake(int length, StartPosition startPosition, SolidColorBrush bodyColor,
SolidColorBrush headColor) : this(length)
{
StartPosition = startPosition;
BodyColor = bodyColor;
HeadColor = headColor;
}
#endregion
#region Events
/// <summary>
/// Occurs when snake crashes
/// </summary>
public event EventHandler OnSnakeCrash;
/// <summary>
/// Occurs when there is no food
/// </summary>
public event EventHandler OnLackOfFood;
#endregion
#region Public methods
/// <summary>
/// Initializes a snake on game field
/// </summary>
/// <param name="gamefield">Game Field</param>
/// <param name="difficulty">Difficulty</param>
public void InitializeSnake(GameField gamefield, Difficulty difficulty)
{
_dispatcherTimer.Interval = new TimeSpan(0, 0, 0, 0, (int)difficulty);
_gameField = gamefield ?? throw new ArgumentNullException(nameof(gamefield));
var startingPoint = gamefield.GetStartingPoint(StartPosition);
Head = gamefield[startingPoint.i, startingPoint.j];
Head.Fill(HeadColor);
for (int i = 1; i <= Length; i++)
{
var square = gamefield[startingPoint.i, startingPoint.j - i];
square.Fill(BodyColor);
Body.Add(square);
}
Pixel.Canvas.Focusable = true;
Keyboard.Focus(Pixel.Canvas);
Pixel.Canvas.KeyDown += OnKeyDown;
}
/// <summary>
/// Starts moving in MovingDirection
/// </summary>
public void StartMoving() => _dispatcherTimer.Start();
/// <summary>
/// Stops moving
/// </summary>
public void StopMoving() => _dispatcherTimer.Stop();
#endregion
#region Properties
public SolidColorBrush BodyColor { get; set; }
public SolidColorBrush HeadColor { get; set; }
public Direction MovingDirection { get; set; }
public StartPosition StartPosition { get; set; }
/// <summary>
/// Length without Head
/// </summary>
public int Length { get; private set; }
internal List<Pixel> Body { get; }
internal Pixel Head { get; private set; }
public int MovesCount { get; set; }
public double ScoreCount { get; set; }
#endregion
#region Private methods
private void Move(object sender, EventArgs e)
{
if (MovingDirection < Direction.Up || MovingDirection > Direction.Left)
{
throw new ArgumentException($"{nameof(MovingDirection)} illegal enum value");
}
// Speed changes every 100 moves
if (++MovesCount % 100 == 0)
{
SpeedUp();
}
if (_gameField.IsLackOfFood)
{
OnLackOfFood?.Invoke(this, null);
}
Head.Fill(BodyColor);
Body.Insert(0, Head); // Insert head into body - oh my Gosh
try
{
switch (MovingDirection)
{
//gets a brand new Head ;)
case Direction.Down:
Head = _gameField[Head.I + 1, Head.J];
break;
case Direction.Up:
Head = _gameField[Head.I - 1, Head.J];
break;
case Direction.Left:
Head = _gameField[Head.I, Head.J - 1];
break;
case Direction.Right:
Head = _gameField[Head.I, Head.J + 1];
break;
}
}
catch (ArgumentOutOfRangeException)
{
StopMoving();
_dispatcherTimer.Tick -= Move;
OnSnakeCrash?.Invoke(this, null);
}
Head.Fill(HeadColor);
if (Head.IsFood)
{
Head.IsFood = false;
ScoreCount = Math.Ceiling(10 + (0.5 * MovesCount));
Length++;
}
else
{
Body.Last().Unfill();
Body.RemoveAt(Length);
}
if (IsBodyRammedByHead())
{
StopMoving();
_dispatcherTimer.Tick -= Move;
OnSnakeCrash?.Invoke(this, null);
}
}
private void OnKeyDown(object sender, KeyEventArgs eventArgs)
{
switch (eventArgs.Key)
{
case Key.W:
if (MovingDirection != Direction.Down)
MovingDirection = Direction.Up;
break;
case Key.S:
if (MovingDirection != Direction.Up)
MovingDirection = Direction.Down;
break;
case Key.A:
if (MovingDirection != Direction.Right)
MovingDirection = Direction.Left;
break;
case Key.D:
if (MovingDirection != Direction.Left)
MovingDirection = Direction.Right;
break;
case Key.P:
if (_dispatcherTimer.IsEnabled)
{
StopMoving();
}
else
{
StartMoving();
}
break;
}
}
/// <summary>
/// Indicating whether Body was rammed by Head ;)
/// </summary>
/// <returns></returns>
private bool IsBodyRammedByHead() => Body.Contains(Head);
/// <summary>
/// Accelerates snake movement
/// </summary>
private void SpeedUp()
{
int currentInterval = _dispatcherTimer.Interval.Milliseconds;
int subtractor = currentInterval / 3;
_dispatcherTimer.Interval = new TimeSpan(0, 0, 0, 0, currentInterval - subtractor);
}
#endregion
}
ENUMS
public enum Direction
{
Up,
Down,
Right,
Left
}
public enum PixelType
{
Circle,
Square
}
public enum StartPosition
{
Center,
[Obsolete("Not supported")]
LeftUpCorner,
[Obsolete("Not supported")]
LeftDownCorner,
[Obsolete("Not supported")]
RightUpCorner,
[Obsolete("Not supported")]
RightDownCorner
}
public enum Difficulty
{
Easy = 300,
Normal = 250,
Hard = 200,
VeryHard = 180,
Impossible = 140
}
What I wrote seems to work although would be very helpfull if you point to my strong and weak points in c# codding.
var
it makes the code way less readable. 2) You have some bad indentation 3)SnakeGame.InitializeFood()
in there you create anew Random()
at every call, this behaviour should be avoided so you don't have possibly weird results \$\endgroup\$var
increases readability by minimizing pedantry (Michael Brennan agrees with me, with many well-reasoned arguments). You disagree, and that's fine too (I've heard valid counterarguments as well); but there is a clear divide here between developers and there is no "right" choice. \$\endgroup\$new ...
and decreasing code coupling even if decreasing code coupling is just losing readablity to gain something else so which means the choice has to be determined case by case but as you said and as it often is in our world, there's no perfect answer \$\endgroup\$Random
massively when there was alsornd = new Random();
being done massively. About indentation it's in Snake.OnKeyDown \$\endgroup\$