Introduction
This is part 2 of a 3 part series in which we explore how to implement a XAML based game for the Universal Windows Platform (UWP).
In
part 1 you looked at creating a cross-platform compatible class library in which to place game logic. We contrasted some of the differences between file linking, shared projects, portable class libraries, and the new .NET standard. Finally you saw how the Game page is implemented, and we briefly looked at the new x:Bind markup extension.
In this part we look at wiring up the the sokoban Game
object to the app’s main page. You see how to play sound effects using the UWP MediaElement control. Finally, you explore how the game grid is populated with custom cell controls.
Links to articles in this series:
Wiring up the Game Page
The Game
class, within the Sokoban project, is the entry point for the game logic, and effectively the ViewModel of the main page.
The Sokoban project is a PCL project and all code contained therein is platform agnostic. However, the Game
class requires several abstracted platform features to function correctly. Firstly, it requires a means to test if it is running on the UI thread, so as to avoid invoking calls unnecessarily back on the UI thread. The Portable Class Library project does not have the APIs needed to do that. So, the Sokoban Game
class expects an instance of a class implementing a custom ISynchronizationContext
interface.
ISynchronizationContext
has two members:
- bool InvokeRequired { get; }
- void InvokeIfRequired(Action action)
NOTE: For this project I provide a UWP specific implementation of ISynchronizationContext
. In the next article you see a Xamarin Forms targeted implementation.
The UWP implementation of ISynchronizationContext
is the UISynchronizationContext
class. See Listing 1.
Listing 1. UISynchronizationContext class
class UISynchronizationContext : ISynchronizationContext
{
CoreDispatcher dispatcher;
readonly object initializationLock = new object();
public bool InvokeRequired
{
get
{
EnsureInitialized();
return !dispatcher.HasThreadAccess;
}
}
public void InvokeIfRequired(Action action)
{
EnsureInitialized();
if (InvokeRequired)
{
#pragma warning disable 4014
dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => action());
#pragma warning restore 4014
}
else
{
action();
}
}
bool initialized;
void EnsureInitialized()
{
if (initialized)
{
return;
}
lock (initializationLock)
{
if (initialized)
{
return;
}
try
{
dispatcher = CoreApplication.MainView.CoreWindow.Dispatcher;
}
catch (InvalidOperationException ex)
{
throw new Exception("Initialize called from non-UI thread.", ex);
}
initialized = dispatcher != null;
}
}
}
UISynchronizationContext
uses the CoreDispatcher
, retrieved via CoreApplication.MainView.CoreWindow.Dispatcher
The dispatcher must be retrieved on the UI thread, otherwise an InvalidOperationException
is thrown.
MainPage.xaml.cs is where the Game
object is instanciated. See Listing 2.
The LevelCodesGenerator
class allows you to generate a unique set of level codes for your game. By uncommenting the commented lines in the MainPage
constructor you generate the LevelCode
class which can then replace the existing LevelCode
class in the Sokoban project.
Listing 2. MainPage constructor
public MainPage()
{
InitializeComponent();
var synchronizationContext = new UISynchronizationContext();
var audioClips = new AudioClips(rootGrid, synchronizationContext);
var infrastructure = new Infrastructure(synchronizationContext, audioClips);
infrastructure.Initialize();
Game = new Game(infrastructure);
DataContext = Game;
Loading += HandleLoading;
}
The Game
object requires an object implementing the IInfrastructure
interface. IInfrastructure
could well be called ‘IPlatformSpecificStuff’. IInfrastructure
is an abstraction over the platform specific APIs to retrieve map data from the file system (or elsewhere), to store and retrieve app settings, and to play sound effects. IInfrastructure
has the following members:
- int LevelCount { get; }
- Task<string> GetMapAsync(int levelNumber);
- ISynchronizationContext SynchronizationContext { get; }
- void SaveSetting(string key, object setting);
- bool TryGetSetting(string key, out object setting);
- T GetSetting<T>(string key, T defaultValue);
- void PlayAudioClip(AudioClip audioClip);
Please take a look at the IInfrastructure
code file for the member descriptions.
In this article we implement this interface for UWP. We’ll do the same for iOS and Android when we go on to support those platforms in a later article.
Playing Sound Effects
Sound effects bring a game alive. In this project, the AudioClips
class performs the playback of a known set of audio files.
You use the Windows.UI.Xaml.Controls.MediaElement
to playback an audio file. See Listing 3.
Listing 3. AudioClips class
class AudioClips
{
readonly ISynchronizationContext synchronizationContext;
readonly Panel page;
internal AudioClips(Panel page, ISynchronizationContext synchronizationContext)
{
this.page = ArgumentValidator.AssertNotNull(page, nameof(page));
this.synchronizationContext = ArgumentValidator.AssertNotNull(
synchronizationContext, nameof(synchronizationContext));
Uri baseUri = page.BaseUri;
const string audioDir = "/Audio/";
levelIntroductionElement = CreateElement(new Uri(baseUri, audioDir + "LevelIntroduction.mp3"));
levelIntroductionElement.AutoPlay = true;
gameCompleteElement = CreateElement(new Uri(baseUri, audioDir + "GameComplete.mp3"));
levelCompleteElement = CreateElement(new Uri(baseUri, audioDir + "LevelComplete.mp3"));
footstepElement = CreateElement(new Uri(baseUri, audioDir + "Footstep.mp3"));
treasurePushElement = CreateElement(new Uri(baseUri, audioDir + "TreasurePush.mp3"));
treasureOnGoalElement = CreateElement(new Uri(baseUri, audioDir + "TreasureOnGoal.mp3"));
}
MediaElement CreateElement(Uri uri)
{
var result = new MediaElement { Source = uri, AutoPlay = false };
page.Children.Add(result);
return result;
}
readonly MediaElement levelIntroductionElement;
readonly MediaElement gameCompleteElement;
readonly MediaElement levelCompleteElement;
readonly MediaElement footstepElement;
readonly MediaElement treasurePushElement;
readonly MediaElement treasureOnGoalElement;
void Play(MediaElement element)
{
synchronizationContext.InvokeIfRequired(() =>
{
element.Position = TimeSpan.Zero;
element.Play();
});
}
internal void Play(AudioClip audioClip)
{
switch (audioClip)
{
case AudioClip.Footstep:
Play(footstepElement);
return;
case AudioClip.GameComplete:
Play(gameCompleteElement);
return;
case AudioClip.LevelComplete:
Play(levelCompleteElement);
return;
case AudioClip.LevelIntroduction:
Play(levelIntroductionElement);
return;
case AudioClip.TreasureOnGoal:
Play(treasureOnGoalElement);
return;
case AudioClip.TreasurePush:
Play(treasurePushElement);
return;
}
}
}
It feels a little strange using a visual element to play a sound effect audio file. But, that’s how it’s done in UWP. Windows Phone Silverlight allowed access to some XNA APIs for audio playback, which was a tad more convenient.
NOTE: Even though we don’t need or want a visual representation of each sound effect in our user interface, we still need to add the MediaElement
to the visual tree, or the MediaElement
won’t work.
We pass the AudioClips class the root Grid
control from the main page. The AudioClips
object then adds its MediaElement
controls to the grid and we’re good to go.
NOTE: Before a clip can be played it must be loaded by the MediaElement
control. If you call Play
on the MediaElement
before it’s loaded, then playback of the clip will be missed. You can do two things to ensure playback.
- Subscribe to the
MediaElements MediaOpened
event to know when the MediaElement
is ready. Or - Set the
AutoPlay
property of the MediaElement
to true. AutoPlay
causes the MediaElement
to begin playback as soon as it is ready. This is what I did with the introduction clip to ensure it plays when the app is launched.
Because MediaElement
derives from DependencyObject
, all calls to it must be dispatched from the UI thread. The Game
object performs some activities on a threadpool thread, which means that if it chooses to play an audio clip from a background thread, the call must be invoked on the UI thread. Hence the use of the ISynchronizationContext.InvokeIfRequired
method.
Laying Out the Game Grid
We use the MainPage
’s Loading
event to trigger laying out of the game grid. See Listing 4.
The Loading
event may occur multiple times during the app’s lifecycle. Therefore a loaded flag is used to limit execution to one time.
Listing 4. HandleLoading method.
void HandleLoading(FrameworkElement sender, object args)
{
if (loaded)
{
return;
}
loaded = true;
StartGame();
var window = Window.Current;
window.CoreWindow.KeyUp += HandleKeyUp;
window.SizeChanged += HandleWindowResized;
}
When the app’s Window is resized, the game grid needs to be laid out again. The Window's SizeChanged
event triggers that. See Listing 5.
The size of the game container element does not change until after this event is raised, therefore we queue the LayOutLevel
call using the Page
’s Dispatcher
. If we didn’t do that, the LayOutLevel
would use the previous dimensions.
NOTE: The BeginInvoke
method of the Dispatcher
class in WPF and Silverlight has been replaced by a RunAsync
method. You’d be forgiven for thinking that the delegate (in this case LayOutLevel
) is executed on a Threadpool
thread, but it isn’t. It’s not the same as Task.RunAsync
, although the name would imply otherwise.
Listing 5. HandleWindowResized method.
async void HandleWindowResized(object sender, WindowSizeChangedEventArgs e)
{
await Dispatcher.RunAsync(CoreDispatcherPriority.High, LayOutLevel);
}
When a key is pressed, the user may be using the keyboard to play the game (rather than the mouse). Keystrokes are passed to the Game
’s ProcessKeyPressed
method and the game object decides what to do. See Listing 6.
Listing 6. HandleKeyUp method.
void HandleKeyUp(CoreWindow sender, KeyEventArgs e)
{
var state = CoreWindow.GetForCurrentThread().GetKeyState(VirtualKey.Control);
bool shiftPressed = (state & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down;
Game.ProcessKeyPressed(e.VirtualKey.ToKeyboardKey(), shiftPressed);
}
The VirtualKey
enum is platform specific, so we can’t pass that to the platform agnostic Game
object. Instead we use the custom extension method ToKeyboardKey
to map the actual key to a key within the game’s KeyboardKey
enumeration.
Starting the Game
The MainPage StartGame
method (See Listing 7) subscribes to the Game
’s PropertyChanged
event.
Listing 7. MainPage StartGame method
void StartGame()
{
Game.PropertyChanged -= HandleGamePropertyChanged;
Game.PropertyChanged += HandleGamePropertyChanged;
Game.Start();
}
The <font face="Courier New">MainPage</font>
’s <font face="Courier New">ProcessGameStateChanged </font>
method is called when the Game
’s GameState
property changes. See Listing 8.
Listing 8. HandleGamePropertyChanged method
void HandleGamePropertyChanged(object sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
case nameof(Game.GameState):
ProcessGameStateChanged();
break;
}
}
When the Game
’s state changes from Loading
to Running
, the game must be laid out. See Listing 9.
This may serve as an extensibility point where you could enrich the game by adding animations and so forth for the various game states.
Listing 9. MainPage ProcessGameStateChanged method
void ProcessGameStateChanged()
{
switch (Game.GameState)
{
case GameState.Loading:
break;
case GameState.GameOver:
break;
case GameState.Running:
if (gameState == GameState.Loading)
{
InitialiseLevel();
}
break;
case GameState.LevelCompleted:
break;
case GameState.GameCompleted:
break;
}
gameState = Game.GameState;
}
The MainPage
’s InitializeLevel
method unsubscribes to each cell’s various events and removes all cell controls from the gameCanvas
element, before calling the LayOutLevel
method. See Listing 10.
Listing 10. MainPage InitializeLevel method.
void InitialiseLevel()
{
foreach (CellControl control in controlDictionary.Values)
{
DetachCellControl(control);
}
controlDictionary.Clear();
gameCanvas.Children.Clear();
LayOutLevel();
levelCodeTextBox.Text = Game.LevelCode;
}
NOTE: The levelCodeTextBox
element’s Text
property should really be bound to a Game
property. The reason it isn't is because I reused the logic from a previous article and didn’t get around to refactoring it.
A game Level is essentially a two dimensional array of game cells. See Listing 11. In the LayOutLevel
method the size of the game cells are maximized to encompass the available space.
We associate each cell with a CellControl
instance using a Dictionary
<Cell
, CellControl
>. CellControl
objects are added to the game Canvas
at the appropriate position.
Listing 11. MainPage LayOutLevel method
void LayOutLevel()
{
Level level = Game.Level;
int rowCount = level.RowCount;
int columnCount = level.ColumnCount;
double availableWidth = gameCanvasContainer.ActualWidth;
double availableHeight = gameCanvasContainer.ActualHeight - topContentGrid.Height;
int cellWidthMax = (int)(availableWidth / columnCount);
int cellHeightMax = (int)(availableHeight / rowCount);
int cellSize = Math.Min(cellWidthMax, cellHeightMax);
int gameHeight = rowCount * cellSize;
int gameWidth = columnCount * cellSize;
int leftStart = 0;
double left = leftStart;
double top = (availableHeight - gameHeight) / 2;
for (int row = 0; row < rowCount; row++)
{
for (int column = 0; column < columnCount; column++)
{
Cell cell = Game.Level[row, column];
CellControl cellControl;
if (!controlDictionary.TryGetValue(cell, out cellControl))
{
cellControl = new CellControl(cell);
controlDictionary[cell] = cellControl;
DetachCellControl(cellControl);
AttachCellControl(cellControl);
gameCanvas.Children.Add(cellControl);
}
cellControl.SetValue(Canvas.LeftProperty, left);
cellControl.SetValue(Canvas.TopProperty, top);
cellControl.Width = cellSize;
cellControl.Height = cellSize;
left += cellSize;
}
left = leftStart;
top += cellSize;
}
gameCanvas.Width = gameWidth;
gameCanvas.Height = gameHeight;
}
Conclusion
In this article we looked at wiring up the the sokoban Game
to the app’s main page. You saw how to play sound effects using the UWP MediaElement
control. Finally, you explored how the game grid is populated with custom cell controls.
In the
next part you see how the map data is retrieved from the file system. We look closer at the custom
CellControl
and examine how images are used to represent cell contents. You see how to reduce the memory footprint of your app by caching
Bitmap
objects. You see how to respond to touch events within an app and at how to write thread-safe awaitable code. Finally, you see how to implement a
SplitView
to provide a slide in menu for the game.
I hope you find this project useful. If so, then please rate it and/or leave feedback below.
History