Continuing from where Part 1 left off, this article describes the process of prototyping the Symbol Blaster User Interface (UI) in Adobe XD and coding the core aspects of the application architecture and game mechanics in Windows Presentation Foundation (WPF).

Figure:Prototyped UI components displayed in Adobe XD.
Table of Contents:
- Tutorial
- Conclusion
1. Tutorial
The following is a general description of the thought process and steps taken to build this application from scratch, along with several in-depth descriptions of prominent features. It is not a complete step-by-step guide, but readers can follow along with the source code available via GitHub. The code snippets below will most likely not compile if copy/pasted on their own and are included only as quick references to the complete source code.
1.1. Prototyping the UI
The very first step of this project was to figure out how the application should be presented layout-wise. A collapsible sidebar UI was chosen as an appropriate choice for providing unobtrusive access to the game settings during gameplay. I wanted the UI to resemble modern web interfaces of the "flat" variety, so I created some basic controls in Adobe XD to test their appearance and visual cohesiveness:

Figure:Prototyped UI controls layed-out in Adobe XD.
Next, I started arranging the controls in such a way as to facilitate tab-based navigation among the three primary UI sections: "Configure", "Presets" and "Help". After the basic layout of the sidebar was decided upon, I was able to create derivative artboards to show changes in UI state resulting from interaction with specific UI components:

Figure:Prototyped UI artboards in Adobe XD.
An iterative approach was taken to completing each individual artboard, since there were several revisions and improvements made throughout development of the application.
Once the artboards were sufficiently populated, transitions between them were added with triggers on specific UI components. Adobe XD's Auto-Animate Action Type provided excellent default transitions between artboards, often animating the UI in ways that would have been much more difficult to orchestrate from scratch. After the game mechanics were completed, I was able to go back into Adobe XD and add a sample gameplay video to the prototype, which would give clients a closer feel to what the actual application would look like.

Figure:Prototype transitions for the "Menu Open" artboard in Adobe XD.
1.2. Creating the Application
With the UI prototype in-progress, I was able to focus attention on the application architecture. I started with a boilerplate M-V-VM setup that consisted of the default MainWindow.xaml.cs with a ViewModel added as the MainWindow's DataContext object:
namespace SymbolBlaster
{
///
/// Interaction logic for MainWindow.xaml
///
public partial class MainWindow : Window
{
readonly MainViewModel mainViewModel;
public MainWindow()
{
InitializeComponent();
mainViewModel = new MainViewModel();
this.DataContext = mainViewModel;
mainViewModel.GameViewModel.SetGameContainer(canvas);
}
...
}
}
Source:MainWindow.xaml.cs
The MainViewModel class governs the state and behavior of the UI and also contains a child ViewModel, GameViewModel, that governs the state and behavior of the game:
public class MainViewModel : BaseViewModel
{
private GameViewModel gameViewModel = new();
public GameViewModel GameViewModel
{
get => gameViewModel;
set => SetProperty(ref gameViewModel, value);
}
private bool isMenuOpen = true;
public bool IsMenuOpen
{
get => isMenuOpen;
set => SetProperty(ref isMenuOpen, value);
}
...
public ICommand? ToggleMenuVisibility { get; set; }
...
public MainViewModel()
{
ToggleShowUiControlMappings = new DelegateCommand(OnToggleShowUiControlMappings, null);
ToggleShowGameSpriteMappings = new DelegateCommand(OnToggleShowGameSpriteMappings, null);
ToggleMenuVisibility = new DelegateCommand(OnToggleMenuVisibility, null);
ChangeHelpDocsFontSize = new DelegateCommand(OnChangeHelpDocsFontSize, null);
ToggleIsHelpPanelExpanded = new DelegateCommand(OnSetIsHelpPanelExpanded, null);
BringElementIntoView = new DelegateCommand(OnBringElementIntoView, null);
SetMainTabControlSelection = new DelegateCommand(OnSetMainTabControlSelection, null);
SetPresetsTabControlSelection = new DelegateCommand(OnSetPresetsTabControlSelection, null);
}
...
private void OnToggleMenuVisibility(object? obj)
{
IsMenuOpen = !IsMenuOpen;
}
}
Source:ViewModel/ViewModel.cs
The GameViewModel class contains logic that governs several major aspects of the game: management of game state, providing the View access to game state, UI hooks for modifying game settings, and execution of the custom update loop (see the Game Update Loop & Performance section).
Game state management is facilitated by fields of different Types or custom enumerations depicting certain groups of game information, ViewModel Properties, Collections for storing game objects, and functions. One example of these aspects working in tandem is the management of the color scheme applied to all game objects: the possible color schemes are depicted within the ColorScheme enum, the available color schemes are stored in a List, the List of ColorSchemes is the source for an ICollection view, changes to the currently selected item of the ICollection view are handled in an event handler, and a function that applies color scheme changes to game objects is executed when the corresponding event is handled; below is an abbreviated code summary:
// In Game/GameClasses.cs:
public enum ColorScheme
{
Dark = 0,
Light = 1,
Console = 2,
RetroGreen = 3,
Mono = 4,
Custom = 5
};
...
// In ViewModel/ViewModels.cs:
public List colorSchemes = new() { ColorScheme.Dark, ColorScheme.Light, ColorScheme.Console, ColorScheme.RetroGreen, ColorScheme.Mono, ColorScheme.Custom };
...
public CollectionViewSource ColorSchemeCollectionViewSource { get; set; } = new();
public ICollectionView ColorSchemeCollectionView { get; set; }
public CollectionViewSource SpriteGroupCollectionViewSource { get; set; } = new();
...
// Inside GameViewModel():
ColorSchemeCollectionViewSource.Source = colorSchemes;
ColorSchemeCollectionView = new CollectionView(ColorSchemeCollectionViewSource.View);
ColorSchemeCollectionView.CurrentChanged += ColorSchemesCollectionView_CurrentChanged;
...
private void ColorSchemesCollectionView_CurrentChanged(object? sender, EventArgs e)
{
if (ColorSchemeCollectionView.CurrentItem == null)
return;
ColorScheme colorScheme = (ColorScheme)ColorSchemeCollectionView.CurrentItem;
SetColorScheme(colorScheme);
}
...
public void SetColorScheme(ColorScheme colorScheme)
{
if (colorScheme == ColorScheme.RetroGreen)
{
Color Color1 = (Color)ColorConverter.ConvertFromString("#0F380F");
Color Color2 = (Color)ColorConverter.ConvertFromString("#306230");
Color Color3 = (Color)ColorConverter.ConvertFromString("#8BAC0F");
Color Color4 = (Color)ColorConverter.ConvertFromString("#9BBC0F");
ApplyColorSchemeToGameObjects(
playerColor: Color4,
playerDebrisColor: Color2,
ellipseDebrisColor: Color2,
largeEnemyColor: Color2,
smallEnemyColor: Color4,
largeObstacleColor: Color3,
mediumObstacleColor: Color3,
smallObstacleColor: Color3,
projectileColor: Color3,
gameForegroundColor: Color4,
gameBackgroundColor: Color1);
}
...
}
Sources:Game/GameClasses.cs and ViewModel/ViewModel.cs
Providing the View access to game state is facilitated by mainly by ViewModel Properties. Examples of game state that is exposed to the View include the current player control mode, current game score, player lives remaining, and active control inputs. Below is an example of how the View is able to access the state of the current player control mode through the PlayerControlMode Property inside GameViewModel, by setting a XAML data Binding expression:
// Inside Game/GameClasses.cs: public enum PlayerControlMode { Retro = 0, WASD = 1 } // Inside ViewModel/ViewModel.cs: private PlayerControlMode playerControlMode = PlayerControlMode.Retro; public PlayerControlMode PlayerControlMode { get => playerControlMode; set { SetProperty(ref playerControlMode, value); Config.PlayerControlMode = PlayerControlMode; } }
<!-- Inside MainWindow.cs: --> <Style.Triggers> <DataTrigger Binding="{Binding GameViewModel.PlayerControlMode, Mode=OneWay}" Value="{x:Static game:PlayerControlMode.Retro}"> <Setter Property="IsChecked" Value="False" /> ... </DataTrigger> <DataTrigger Binding="{Binding GameViewModel.PlayerControlMode, Mode=OneWay}" Value="{x:Static game:PlayerControlMode.WASD}"> <Setter Property="IsChecked" Value="True" /> ... </DataTrigger> </Style.Triggers>
Sources:Game/GameClasses.cs, ViewModel/ViewModel.cs, and MainWindow.xaml
UI hooks for modifying game settings are implemented with ICommand objects and their corresponding DelegateCommands. ICommands enable very clean separation of the View from the ViewModel and make developing UI interactions much easier. Continuing with the above PlayerControlMode example, an ICommand is used to enable changing the PlayerControlMode from a ToggleButton in the View:
// Inside ViewModel/ViewModels.cs: public ICommand? ToggleGameControlMode { get; set; } // GameViewModel(): ToggleGameControlMode = new DelegateCommand(OnGameControlModeToggled, null); ... private void OnGameControlModeToggled(object? mode) { if (mode is PlayerControlMode controlMode) SetPlayerControlMode(controlMode); } ... public void SetPlayerControlMode(PlayerControlMode mode) { Config.PlayerControlMode = mode; PlayerControlMode = mode; }
<!-- Inside MainWindow.xaml: --> <ToggleButton ToolTip="Toggle game control modes" Margin="0" Checked="ResetFocus" Unchecked="ResetFocus" Command="{Binding GameViewModel.ToggleGameControlMode, Mode=OneWay}"> ... </ToggleButton>
Sources:ViewModel/ViewModels.cs, MainWindow.xaml
1.3. Implementing Game Mechanics
The Game Mechanics were a challenge that necessitated several draft implementations of the main aspects before I settled on a decent approach for each. The main game mechanics involve game objects, game physics, the game update loop, and the game configuration.
1.3.1. Game Objects
Taking an object-oriented approach from the outset, I knew that I had several moving things that I wanted to encapsulate and derive from a common base-class, which in-turn would derive from a WPF class. FrameworkElement is the WPF class that I eventually chose to serve as the parent of my MovingShape base class, for two main reasons: 1) I wanted to see if it was possible performance-wise to work within the high-level APIs provided by FrameworkElement and its parent classes UIElement and 2) I wanted to take advantage of the Canvas as a container for FrameworkElements. Since I was able to achieve decent success using this approach, despite incurring the extra overhead of unused FrameworkElement and UIElement infrastructure, I decided to stick to it despite it not being the ideal approach. The ideal approach would be to use a lower-level WPF base class, such as Visual or even DependencyObject, and write custom rendering logic to position and move the game objects on screen. After attempting the ideal implementation of using the Visual base class, I eventually favored the FrameworkElement base class, since I wanted to see if reinventing-the-wheel of rendering-related functions could be avoided; although I achieved suitable performance with FrameworkElement, more computationally demanding applications would probably necessitate going with the Visual (or DependencyObject or higher) implementation.

Figure:Game objects on screen: Player, LargeObstacle, MediumObstacles, SmallObstacles, LargeEnemies, and Projectiles.
The MovingShape class contains fundamental Properties and methods for controlling game object movement, location, and appearance. The DefiningGeometry Property, VisualChildrenCount Property, and GetVisualChild(int index) method are necessary for managing custom DrawingVisuals, which are the objects used to present the custom Geometries of each game object:
public abstract class MovingShape : FrameworkElement
{
protected Geometry shapeGeometry = Geometry.Empty;
protected VisualCollection children;
static EdgeMode RenderQuality { get; set; }
public SolidColorBrush Fill { get; set; } = new();
public bool NeedsRefresh { get; set; } = false;
public Point Location { get; set; }
public Vector Movement { get; set; }
protected MovingShape(Point location, Vector movement, SolidColorBrush brush)
{
Location = location;
Movement = movement;
Fill = brush;
DataContext = null;
FocusVisualStyle = null;
Resources = null;
LayoutTransform = null;
BindingGroup = null;
Style = null;
children = new VisualCollection(this);
RenderOptions.SetEdgeMode(this, RenderQuality);
}
public static void SetRenderQuality(EdgeMode edgeMode)
{
RenderQuality = edgeMode;
}
public virtual void DrawVisual()
{
children.Clear();
DrawingVisual drawingVisual = new();
DrawingContext drawingContext = drawingVisual.RenderOpen();
drawingContext.DrawGeometry(Fill, null, shapeGeometry);
drawingContext.Close();
children.Add(drawingVisual);
}
public virtual void DrawVisual(Transform transform)
{
children.Clear();
DrawingVisual drawingVisual = new() { Transform = transform };
DrawingContext drawingContext = drawingVisual.RenderOpen();
drawingContext.DrawGeometry(Fill, null, shapeGeometry);
drawingContext.Close();
children.Add(drawingVisual);
}
public virtual void Refresh()
{
DrawVisual();
}
protected Geometry DefiningGeometry => shapeGeometry;
protected Geometry GetDefiningGeometry() { return DefiningGeometry; }
protected override bool IsEnabledCore => false;
protected override int VisualChildrenCount
{
get { return children.Count; }
}
protected override Visual GetVisualChild(int index)
{
if (index < 0 || index >= children.Count)
{
throw new ArgumentOutOfRangeException(nameof(index));
}
return children[index];
}
}
Source:Game/GameClasses.cs
Since a majority, but not all, of the game objects derive their geometries from Unicode strings, I derived the MovingGlpyh class from MovingShape. MovingGlyph contains polymorphic varieties of RefreshGeometry() that generate a Geometry from a given Unicode string; this Geometry is then used in the inherited MovingShape DrawVisual() methods. This is probably the most interesting part of the Symbol Blaster code, since it makes each MovingGlyph's hit test Geometry match its corresponding GameSprite string - meaning subtle variations in gameplay depending upon the GameSprites selected for different MovingGlyph-derived game objects:
public abstract class MovingGlyph : MovingShape
{
protected double FontSize { get; set; } = 34;
public string GameSprite { get; set; } = ".";
protected MovingGlyph(Point location, Vector movement, string sprite, SolidColorBrush brush) : base(location, movement, brush)
{
GameSprite = sprite;
}
public virtual void RefreshGeometry()
{
shapeGeometry = GlyphGeometryBuilder.GetGlyphGeometry(GameSprite, FontSize);
}
public virtual void RefreshGeometry(Transform transform)
{
shapeGeometry = GlyphGeometryBuilder.GetGlyphGeometry(GameSprite, FontSize);
}
public override void Refresh()
{
RefreshGeometry();
DrawVisual();
}
public virtual void Refresh(Transform transform)
{
RefreshGeometry();
DrawVisual(transform);
}
}
Source:Game/GameClasses.cs
The next task involved creating the classes deriving from MovingGlyph: Projectile, Player, Enemy, Obstacle, and PlayerDebris.
The Projectile class, which represents the objects that the Player and Enemies fire, is relatively straight-forward and contains components that govern a Projectile's movement and lifetime:
public class Projectile : MovingGlyph, IDissipate
{
public const int Velocity = 5;
public RotateTransform Rotation = new();
public int DissipationCountdown { get; set; } = 100;
public Projectile(string sprite, SolidColorBrush brush, RotateTransform rotateTransform) : base(new(), new(), sprite, brush)
{
RenderTransform = Rotation;
FontSize = 16;
Refresh(rotateTransform);
}
}
Source:Game/GameClasses.cs
The Player class represents the user-controllable game object, and contains components necessary for governing its movement, lifetime, and ability to fire Projectiles:
public class Player : MovingGlyph, IFireProjectile
{
public const int WarpInterval = 80;
public const int AccelerationLimit = 2;
public const double WASDAccelerationIncrement = .05;
public const double AccelerationIncrement = .0002;
public const double DeccelerationIncrement = .0001;
public const int RotationIncrement = 2;
public RotateTransform Rotation = new();
...
public Player(Point location, Vector movement, string sprite, SolidColorBrush brush, RotateTransform rotateTransform) : base(location, movement, sprite, brush)
{
this.Uid = Guid.NewGuid().ToString();
Tag = this.Uid;
RenderTransform = Rotation;
Refresh(rotateTransform);
}
public Projectile Fire(Projectile projectile, double angleDegrees)
{
double angleRadians = angleDegrees * Math.PI / 180;
Vector vector = new(
(float)Math.Cos(angleRadians),
(float)Math.Sin(angleRadians));
Matrix rotationMatrix = new(vector.X * 2, vector.Y * 2, -vector.Y * 2, vector.X * 2, 0, 0);
Point spawnLocation = Point.Add(this.Location, Vector.Multiply(new Vector(FontSize/3, -2), rotationMatrix));
projectile.Location = spawnLocation;
projectile.Movement = Vector.Multiply(Projectile.Velocity, vector);
projectile.Rotation.Angle = angleDegrees - 180;
projectile.Tag = this.Tag;
return projectile;
}
}
Source:Game/GameClasses.cs
The Enemy class represents the actively hostile game objects, and contains components that govern Enemy movement, score value, and ability to fire projectiles. There are two subclasses of Enemy - LargeEnemy, which fires projectiles in random directions, and SmallEnemy, which fires projectiles directly at the Player's location:
public abstract class Enemy : MovingGlyph, IFireProjectile, IScoreValue
{
public const int DirectionInterval = 256;
public const int FireInterval = 96;
public int ChangeDirectionCounter = DirectionInterval;
public int FireProjectileCounter = FireInterval;
public Rect GeometryBounds = new(new Size(0, 0));
public abstract int ScoreValue { get; }
public Enemy(Point location, Vector movement, string sprite, SolidColorBrush brush) : base(location, movement, sprite, brush)
{
this.Uid = Guid.NewGuid().ToString();
Tag = this.Uid;
}
public Projectile Fire(Projectile projectile, double angleDegrees)
{
double angleRadians = angleDegrees * Math.PI / 180;
Vector vector = new((float)Math.Cos(angleRadians), (float)Math.Sin(angleRadians));
Matrix rotationMatrix = new(vector.X * 2, vector.Y * 2, -vector.Y * 2, vector.X * 2, 0, 0);
Point spawnLocation = Point.Add(this.Location, Vector.Multiply(new Vector(FontSize / 3, 0), rotationMatrix));
projectile.Location = spawnLocation;
projectile.Movement = Vector.Multiply(Projectile.Velocity, vector);
projectile.Rotation.Angle = angleDegrees - 180;
return projectile;
}
}
public class LargeEnemy : Enemy
{
public override int ScoreValue { get => 256; }
public LargeEnemy(Point location, Vector movement, string sprite, SolidColorBrush brush) : base(location, movement, sprite, brush)
{
FontSize = 64;
Refresh();
}
}
public class SmallEnemy : Enemy
{
public override int ScoreValue { get => 512; }
public SmallEnemy(Point location, Vector movement, string sprite, SolidColorBrush brush) : base(location, movement, sprite, brush)
{
FontSize = 40;
Refresh();
}
}
Source:Game/GameClasses.cs
The Obstacle class represents the game objects that will destroy the player upon contact, and contains components that govern Obstacle rotation and score value. There are three subclasses of Obstacle, each worth a unique amount of points - LargeObstacle, MediumObstacle, and SmallObstacle:
public abstract class Obstacle : MovingGlyph, IScoreValue
{
public RotateTransform Rotation = new();
public double RotationRate = 0;
public Rect GeometryBounds = new(new Size(0, 0));
public abstract int ScoreValue { get; }
public Obstacle(Point location, Vector movement, double rotationRate, string sprite, SolidColorBrush brush) : base(location, movement, sprite, brush)
{
Location = location;
Movement = movement;
RotationRate = rotationRate;
RenderTransform = Rotation;
}
}
public class LargeObstacle : Obstacle
{
public override int ScoreValue { get => 32; }
public LargeObstacle(Point location, Vector movement, double rotationRate, string sprite, SolidColorBrush brush)
: base(location, movement, rotationRate, sprite, brush)
{
FontSize = 72;
Refresh();
}
}
public class MediumObstacle : Obstacle
{
public override int ScoreValue { get => 64; }
public MediumObstacle(Point location, Vector movement, double rotationRate, string sprite, SolidColorBrush brush)
: base(location, movement, rotationRate, sprite, brush)
{
FontSize = 48;
Refresh();
}
}
public class SmallObstacle : Obstacle
{
public override int ScoreValue { get => 128; }
public SmallObstacle(Point location, Vector movement, double rotationRate, string sprite, SolidColorBrush brush)
: base(location, movement, rotationRate, sprite, brush)
{
FontSize = 28;
Refresh();
}
}
Source:Game/GameClasses.cs
The PlayerDebris class represents the objects that appear immediately after the Player is destroyed, and contains components that govern PlayerDebris lifetime and rotation. The EllipseDebris class is closely related to the PlayerDebris class, but derives from MovingShape directly and always has an EllipseGeometry:
public class PlayerDebris : MovingGlyph, IDissipate, IDebris
{
public int DissipationCountdown { get; set; } = 500;
public RotateTransform Rotation = new();
public double RotationRate = 0;
public PlayerDebris(Point location, Vector movement, string sprite, SolidColorBrush brush)
: base(location, movement, sprite, brush)
{
FontSize = 14;
RenderTransform = Rotation;
Refresh();
}
}
public class EllipseDebris : MovingShape, IDissipate, IDebris
{
public int DissipationCountdown { get; set; } = 500;
public EllipseDebris(Point location, Vector movement, Rect definingRect, SolidColorBrush brush) : base(location, movement, brush)
{
shapeGeometry = new EllipseGeometry(definingRect);
Refresh();
}
}
Source:Game/GameClasses.cs
Two additional game classes, GameDefs and GameConfiguration, do not appear on-screen and are used to help manage the game. GameDefs is a static utility class containing constants for game parameters and methods for generating values commonly used by other game objects, such as Vectors, Matrices, polarities, and rates of rotation. The GameConfiguration class is used to store the complete state of a game's current settings, including player control mode, render quality, and custom color selections; in addition, it also contains a deep-clone method as well as several utility Properties that enable the View to reference a GameConfiguration object via data Bindings.
public static class GameDefs
{
public const int MIN_LARGE_OBSTACLES = 2;
public const int MAX_LARGE_OBSTACLES = 4;
public const int PLAYER_DEBRIS_AMOUNT = 6;
public const int ELLIPSE_DEBRIS_AMOUNT = 6;
public const int PLAYER_PROJECTILE_LIMIT = 4;
public const int PLAYER_STARTING_LIVES = 3;
public const int MAX_NAME_LENGTH = 4;
public const int MIN_ROTATION_RATE = 1;
public const int MAX_ROTATION_RATE = 3;
public const int SMALL_ENEMY_THRESHOLD = (MAX_LARGE_OBSTACLES / 2) * 7;
public const int ENEMY_SPAWN_CUTOFF = (MAX_LARGE_OBSTACLES - 0) * 7;
public const double DegreesToRadians = (Math.PI / 180);
public const double RadiansToDegrees = (180 / Math.PI);
public static Vector GetRandomVector(Random random, bool randomXPolarity = false, bool randomYPolarity = false)
{
int xPolarity = (randomXPolarity) ? GetRandomPolarity(random) : 1;
int yPolarity = (randomYPolarity) ? GetRandomPolarity(random) : 1;
return new Vector(random.NextSingle() * xPolarity, random.NextSingle() * yPolarity);
}
...
}
public class GameConfiguration : ICloneable
{
public string ConfigurationName { get; set; } = "";
public PlayerControlMode PlayerControlMode { get; set; }
public ColorScheme ColorScheme { get; set; }
public SpriteGroup SpriteGroup { get; set; }
public EdgeMode RenderQuality { get; set; }
public Color GameForeground { get; set; }
public Color GameBackground { get; set; }
...
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
public Geometry PlayerGlyphGeometry { get => GlyphGeometryBuilder.GetGlyphGeometry(PlayerGlyph); }
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
public Geometry ProjectileGlyphGeometry { get => GlyphGeometryBuilder.GetGlyphGeometry(ProjectileGlyph); }
[JsonIgnore(Condition = JsonIgnoreCondition.Always)]
...
public object Clone()
{
return new GameConfiguration
{
ConfigurationName = this.ConfigurationName,
PlayerControlMode = this.PlayerControlMode,
ColorScheme = this.ColorScheme,
SpriteGroup = this.SpriteGroup,
RenderQuality = this.RenderQuality,
GameForeground = this.GameForeground,
GameBackground = this.GameBackground,
...
}
}
}
Source:Game/GameClasses.cs
1.3.2. Game Physics
The game physics are very rudimentary and govern game object movement and collision.
All moving game objects besides the Player move along random vectors at fixed velocities. The Player has the ability to accelerate and decelerate as well as rotate freely; depending upon the player control mode, changes in rotation are either instant (in WASD mode) or gradual (in Retro mode). The Player also has the ability to warp through space-time, which results in the temporary disappearance of the Player followed by a reappearance at random coordinates.
public void HandlePlayerMovement(Player player)
{
double moveX = player.Movement.X;
double moveY = player.Movement.Y;
double locX = player.Location.X;
double locY = player.Location.Y;
double gameContainerWidth = (gameContainer is null) ? 0 : gameContainer.ActualWidth;
double gameContainerHeight = (gameContainer is null) ? 0 : gameContainer.ActualHeight;
switch (PlayerControlMode)
{
case PlayerControlMode.Retro:
{
if (IsLeftPressed)
{
player.Rotation.Angle -= Player.RotationIncrement;
}
else if (IsRightPressed)
{
player.Rotation.Angle += Player.RotationIncrement;
}
if (IsUpPressed)
{
double angleRadians = (player.Rotation.Angle - 90) * GameDefs.DegreesToRadians;
moveX = (float)Math.Cos(angleRadians);
moveY = (float)Math.Sin(angleRadians);
player.DeccelerationTimeElapsed = 0;
player.AccelerationTimeElapsed++;
player.Speed += Player.AccelerationIncrement * player.AccelerationTimeElapsed;
player.Speed = Math.Clamp(player.Speed, 0, Player.AccelerationLimit);
locX = player.Location.X + player.Movement.X * player.Speed;
locY = player.Location.Y + player.Movement.Y * player.Speed;
}
else if (player.Speed > 0)
{
player.AccelerationTimeElapsed = 0;
player.DeccelerationTimeElapsed++;
player.Speed -= Player.DeccelerationIncrement * player.DeccelerationTimeElapsed;
player.Speed = Math.Clamp(player.Speed, 0, Player.AccelerationLimit);
locX = player.Location.X + player.Movement.X * player.Speed;
locY = player.Location.Y + player.Movement.Y * player.Speed;
}
break;
}
...
default:
break;
}
if (locX > gameContainer?.ActualWidth)
locX = 0;
else if (locX < 0)
locX = gameContainerWidth;
if (locY > gameContainer?.ActualHeight)
locY = 0;
else if (locY < 0)
locY = gameContainerHeight;
player.Location = new Point(locX, locY);
player.Movement = new Vector(moveX, moveY);
Canvas.SetLeft(player, player.Location.X);
Canvas.SetTop(player, player.Location.Y);
ExecuteHitTest(player);
}
Figure: When a control input mapped to Player movement is activated, the code calculates new values based on rotation or acceleration input. Player acceleration and deceleration is handled by adding to and subtracting from, respectively, the Player's current Speed until limits are reached. If the Player moves beyond the game world's boundaries, it reappears on the opposite side - the same mechanism is implemented for most of the other moving game objects. After the calculations have been completed, the Player object is repositioned on the game's Canvas and the hit testing function is called.
Source:ViewModel/ViewModels.cs
Obstacle movement is less complicated than Player movement but follows the same general flow of value calculation followed by repositioning on the Canvas:
foreach (Obstacle o in Obstacles)
{
double locX = o.Location.X;
double locY = o.Location.Y;
if (o.Location.X + o.GeometryBounds.Width < 0)
locX = gameContainerWidth + o.GeometryBounds.Width;
else if (o.Location.X - o.GeometryBounds.Width > gameContainerWidth)
locX = 0 - o.GeometryBounds.Width;
if (o.Location.Y + o.GeometryBounds.Height < 0)
locY = gameContainerHeight + o.GeometryBounds.Height;
else if (o.Location.Y - o.GeometryBounds.Height > gameContainerHeight)
locY = 0 - o.GeometryBounds.Height;
locX += o.Movement.X;
locY += o.Movement.Y;
o.Location = new Point(locX, locY);
o.Rotation.Angle += o.RotationRate;
Canvas.SetLeft(o, o.Location.X);
Canvas.SetTop(o, o.Location.Y);
}
Source:ViewModel/ViewModels.cs
Collision detection / hit testing was another of the trickier game mechanic aspects. Ideally, I wanted a positive hit test to occur on a game object when its Geometry intersected one or more other game object Geometries. WPF does have a robust hit-testing API that accommodates geometry intersections, although I struggled tremendously figuring out how to properly implement it. After several hours, I decided on a less than ideal but still functional alternative of using each game object's location Point as the hit testing input to the VisualTreeHelper.HitTest(...) method - this results in positive hit tests only when the location Points of game objects intersect, allowing game objects to partially overlap.
Player hit testing occurs after the Player moves, and if any positive hit tests are detected, based on the logic within PlayerHitTestFilterCallback and HitTestResultCallback, the Player is killed:
public void ExecuteHitTest(Player player)
{
if (!player.IsDead)
{
hitResultsList.Clear();
System.Windows.Media.VisualTreeHelper.HitTest(gameContainer,
PlayerHitTestFilterCallback,
HitTestResultCallback,
new System.Windows.Media.PointHitTestParameters(player.Location));
if (hitResultsList.Count > 0)
{
KillPlayer(player);
}
}
}
...
readonly HitTestResultCallback HitTestResultCallback = new(HitTestResultHandler);
readonly HitTestFilterCallback PlayerHitTestFilterCallback = new(PlayerHitTestFilter);
public static HitTestResultBehavior HitTestResultHandler(System.Windows.Media.HitTestResult result)
{
// Add the hit test result to the list that will be processed after the enumeration.
hitResultsList.Add(result.VisualHit);
// Set the behavior to return visuals at all z-order levels.
return System.Windows.Media.HitTestResultBehavior.Stop;
}
public static HitTestFilterBehavior PlayerHitTestFilter(DependencyObject o)
{
if (o is Player || o is IDebris)
return HitTestFilterBehavior.ContinueSkipSelfAndChildren;
return HitTestFilterBehavior.Continue;
}
Source:ViewModel/ViewModels.cs
Projectile hit testing is similar to Player hit testing in overall structure, but contains conditionals for handling collisions with specific game object Types.
1.3.3. Game Update Loop & Performance
Execution of the custom update loop is slightly trickier than the other management aspects. Since the main game object classes are derived from the FrameworkElement class, I knew that my update loop was going to be dependent upon the UI thread (for pros and cons of this approach, see the Game Objects section ). My first attempt was to execute the update loop on a DispatchTimer's Tick Event handler, but quickly realized that the performance was less than ideal, likely due to the UI thread's refresh mechanisms being out of step with the DispatchTimer. After some research, I found the System.Windows.Media.CompositionTarget.Rendering event, which "Occurs just before the objects in the composition tree are rendered" and allows for custom logic to be executed via an EventHandler. Adding the update loop function to a handler for the CompositionTarget.Rendering event yielded much smoother performance. One caveat I learned after implementing the CompositionTarget.Rendering handler is that it is beneficial performance-wise to detach the handler when not-needed, and re-attach it as necessary; I do this when the game state changes from active gameplay to inactive states.
public void SetCompositionTargetRendering()
{
// Add event handler to run the game's update loop:
CompositionTarget.Rendering -= CompositionTarget_Rendering;
CompositionTarget.Rendering += CompositionTarget_Rendering;
}
private void CompositionTarget_Rendering(object? sender, EventArgs e)
{
if (GameState == GameState.GAME_ACTIVE || GameState == GameState.GAME_OVER)
UpdateLoop();
else
// Detach the game's update loop if the current game state does not involve rendering game object geometries:
CompositionTarget.Rendering -= CompositionTarget_Rendering;
}
...
public void StartGame(bool playerBeatPreviousLevel = false)
{
GameState = GameState.GAME_ACTIVE;
SetCompositionTargetRendering();
if (!playerBeatPreviousLevel)
{
SpawnPlayer();
}
RebuildLivesString(Config.PlayerGlyph);
}
Source:ViewModel/ViewModels.cs
The UpdateLoop() function updates the game by spawning new objects, moving objects in the game world, performing hit testing, removing old objects, and applying any visual updates. Game objects reside in ObservableCollections of their corresponding type, and whenever an object is added or removed from these collections, the CollectionChanged event is handled by logic that adds or removes the game objects from the game Canvas. ShapesToBeRemoved is a List for temporarily storing game objects that need to be removed - rather than immediately removing an object when it despawns, it is more efficient to mark them for removal and then purge those objects together. ApplyVisualUpdates() is the mechanism by which changes to game settings that involve the appearance of the game objects are propagated to the game objects themselves. An outline in the code of each of these aspects is provided below:
public void UpdateLoop() { if (startingObstaclesSpawned == GameDefs.MAX_LARGE_OBSTACLES && Obstacles.Count == 0 && Enemies.Count == 0) { ClearScreen(player1); ResetGame(true); return; } double gameContainerWidth = (gameContainer is null) ? 0 : gameContainer.ActualWidth; double gameContainerHeight = (gameContainer is null) ? 0 : gameContainer.ActualHeight; if (player1 is null) return; // Spawn new obstacle: if ((startingObstaclesSpawned < GameDefs.MAX_LARGE_OBSTACLES) && random.Next(0, 100) == 99) { int spawnPerimeterSelection = random.Next(0, 4); ... SpawnLargeObstacle(new Point(spawnX, spawnY), spawnVector, GameDefs.GetRandomRotationRate(random)); startingObstaclesSpawned++; } // Handle Obstacle movement: foreach (Obstacle o in Obstacles) { double locX = o.Location.X; double locY = o.Location.Y; ... Canvas.SetLeft(o, o.Location.X); Canvas.SetTop(o, o.Location.Y); } // Handle Enemy movement and behavior: foreach (Enemy enemy in Enemies) { ... double locX = enemy.Location.X; double locY = enemy.Location.Y; ... Canvas.SetLeft(enemy, enemy.Location.X); Canvas.SetTop(enemy, enemy.Location.Y); } // Spawn an enemy: if (startingObstaclesSpawned == GameDefs.MAX_LARGE_OBSTACLES && obstaclesDestroyed < GameDefs.ENEMY_SPAWN_CUTOFF && Enemies.Count < 3 && random.Next(0, 256) == 255) { int spawnPerimeterSelection = random.Next(0, 4); double spawnX = 0; double spawnY = 0; Vector spawnVector; ... } // Handle Player movement or spawn debris if Player was killed: if (!player1.IsDead) { ... HandlePlayerMovement(player1); } else { foreach (PlayerDebris debris in PlayerDebrisCollection) { if (debris.DissipationCountdown == 0) { ShapesToBeRemoved.Add(debris); } else { ... Canvas.SetLeft(debris, debris.Location.X); Canvas.SetTop(debris, debris.Location.Y); } } ... } // Move any active EllipseDebris: foreach (EllipseDebris debris in EllipseDebrisCollection) { if (debris.DissipationCountdown == 0) { ShapesToBeRemoved.Add(debris); } else { debris.Location = new Point(debris.Location.X + debris.Movement.X, debris.Location.Y + debris.Movement.Y); debris.DissipationCountdown--; Canvas.SetLeft(debris, debris.Location.X); Canvas.SetTop(debris, debris.Location.Y); } } // Handle projectile movement: foreach (Projectile p in Projectiles) { if (p.DissipationCountdown == 0) { ShapesToBeRemoved.Add(p); } else { double locX = p.Location.X; double locY = p.Location.Y; ... Canvas.SetLeft(p, p.Location.X); Canvas.SetTop(p, p.Location.Y); ExecuteHitTest(p); } } // Remove game objects that need to be despawned: foreach (MovingShape? shape in ShapesToBeRemoved) { if (shape is Projectile projectile) Projectiles.Remove(projectile); else if (shape is Obstacle obstacle) Obstacles.Remove(obstacle); else if (shape is EllipseDebris debris1) EllipseDebrisCollection.Remove(debris1); else if (shape is PlayerDebris debris) PlayerDebrisCollection.Remove(debris); else if (shape is Player player) Players.Remove(player); else if (shape is Enemy enemy) Enemies.Remove(enemy); } ShapesToBeRemoved.Clear(); ApplyVisualUpdates(); }
public void ApplyVisualUpdates() { if (playerNeedsRefresh) UpdateItems(ref playerNeedsRefresh, Players, PlayerBrush, Config.PlayerGlyph, PlayerRotateTransform); if (playerDebrisNeedsRefresh) UpdateItems(ref playerDebrisNeedsRefresh, PlayerDebrisCollection, PlayerDebrisBrush, Config.PlayerDebrisGlyph); if (projectileNeedsRefresh) UpdateItems(ref projectileNeedsRefresh, Projectiles, ProjectileBrush, Config.ProjectileGlyph, ProjectileRotateTransform); if (largeObstacleNeedsRefresh) UpdateItems(ref largeObstacleNeedsRefresh, Obstacles.OfType
().ToList(), LargeObstacleBrush, Config.LargeObstacleGlyph); if (mediumObstacleNeedsRefresh) UpdateItems(ref mediumObstacleNeedsRefresh, Obstacles.OfType ().ToList(), MediumObstacleBrush, Config.MediumObstacleGlyph); if (smallObstacleNeedsRefresh) UpdateItems(ref smallObstacleNeedsRefresh, Obstacles.OfType ().ToList(), SmallObstacleBrush, Config.SmallObstacleGlyph); if (largeEnemyNeedsRefresh) UpdateItems(ref largeEnemyNeedsRefresh, Enemies.OfType ().ToList(), LargeEnemyBrush, Config.LargeEnemyGlyph); if (smallEnemyNeedsRefresh) UpdateItems(ref smallEnemyNeedsRefresh, Enemies.OfType ().ToList(), SmallEnemyBrush, Config.SmallEnemyGlyph); if (ellipseDebrisNeedsRefresh) UpdateItems(ref ellipseDebrisNeedsRefresh, EllipseDebrisCollection, EllipseDebrisBrush); if (renderQualityChanged) ApplyRenderQualityChange(); }
Source:ViewModel/ViewModels.cs
1.3.4. Game Configuration
Configurations of the game are stored in GameConfiguration objects. A global GameConfiguration object, Config, stores the current game state and is updated whenever a relevant change occurs. Several "built-in" GameConfigurations are able to be loaded by the user. In addition, the user is able to save and load custom presets of game configurations by serializing and deserializing JSON representations of GameConfiguration objects. JSON operations are handled by the System.Text.Json API and include error checking for cases in which the user attempts to load a malformed or otherwise bogus preset file. ICommands were implemented for facilitating user-initiated JSON CRUD operations. Below is an outline of the code that shows how the GameConfiguration objects are used in the GameViewModel:
public GameConfiguration Config;
...
public ObservableCollection ConfigurationPresetsCustom = new();
public CollectionViewSource ConfigurationPresetCustomCollectionViewSource { get; set; } = new();
public ICollectionView ConfigurationPresetCustomCollectionView { get; set; }
public List ConfigurationPresetsBuiltIn = gameConfigurations;
public CollectionViewSource ConfigurationPresetBuiltInCollectionViewSource { get; set; } = new();
public ICollectionView ConfigurationPresetBuiltInCollectionView { get; set; }
...
private void OnLoadConfigurationPreset(object? configurationPresetType)
{
if (configurationPresetType == null)
return;
PresetType? presetType = (PresetType)configurationPresetType;
GameConfiguration gc = new();
if (presetType.Value == PresetType.BuiltIn)
gc = (GameConfiguration)ConfigurationPresetBuiltInCollectionView.CurrentItem;
else if (presetType.Value == PresetType.Custom)
gc = (GameConfiguration)ConfigurationPresetCustomCollectionView.CurrentItem;
if (Config == gc)
return;
...
SetPlayerSpriteRotation(gc.PlayerRotation);
SetProjectileSpriteRotation(gc.ProjectileRotation);
ApplyVisualUpdates();
}
private void OnDeleteConfigurationPreset(object? obj)
{
ConfigurationPresetsCustom.Remove((GameConfiguration)ConfigurationPresetCustomCollectionView.CurrentItem);
ConfigurationPresetCustomCollectionView.MoveCurrentToNext();
}
private void OnSaveConfigurationPreset(object? obj)
{
Config.ConfigurationName = SaveConfigurationPresetName;
ConfigurationPresetsCustom.Add((GameConfiguration)Config.Clone());
}
private void OnExportPresetToFile(object? obj)
{
if (ConfigurationPresetCustomCollectionView.CurrentItem == null)
return;
GameConfiguration gameConfiguration = (GameConfiguration)ConfigurationPresetCustomCollectionView.CurrentItem;
string fileName = $"{gameConfiguration.ConfigurationName}.json";
string jsonString = JsonSerializer.Serialize(gameConfiguration, new JsonSerializerOptions() { WriteIndented = true });
var filePath = "";
...
if (filePath != "")
File.WriteAllText(filePath, jsonString);
}
private void OnImportPresetFromFile(object? obj)
{
PresetImportFailed = false;
var filePath = "";
var myDocsDirectory = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
var initialDirectory = (myDocsDirectory != "") ? myDocsDirectory : "c:\\";
...
if (filePath == "")
return;
string jsonString = File.ReadAllText(filePath);
GameConfiguration gameConfiguration;
try
{
gameConfiguration = JsonSerializer.Deserialize(jsonString)!;
}
catch (Exception)
{
PresetImportFailed = true;
return;
}
...
ConfigurationPresetsCustom.Add(gameConfiguration);
ConfigurationPresetCustomCollectionView.MoveCurrentToLast();
}
Source:ViewModel/ViewModels.cs
2. Conclusion
That about covers the aspects of UI Prototying, application architecture, and game mechanics for Symbol Blaster. Part three covers the WPF/XAML UI implementation details and unit testing with NUnit. The complete source is available via GitHub. Thanks for reading!