UWP Alien Sokoban - Part 3





5.00/5 (7 votes)
A fun UWP implementation of the game Sokoban, demonstrating some new features of XAML and C# 6.0. Part 3
Introduction
This is part 3 of a 3 part series in which you explore how to implement a XAML based game for the Universal Windows Platform (UWP).
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.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 writing creating thread-safe awaitable code. Finally, you see how to implement a SplitView
to provide a slide in menu for the game.Links to articles in this series:
Retrieving a Map
Game
class leverages the IInfrastructure
implementation to retrieve level maps. Map information is located in the Levels directory of the Sokoban.Launcher project.Infrastructure
class’s GetMapAsync
method, shown in Listing 1.public async Task<string> GetMapAsync(int levelNumber)
{
string fileUrl = string.Format(@"ms-appx:///{0}Level{1:000}.skbn",
levelDirectory, levelNumber);
StorageFile file = await StorageFile.GetFileFromApplicationUriAsync(new Uri(fileUrl));
string levelText = await FileIO.ReadTextAsync(file);
return levelText;
}
StorageFile
APIs.Exploring the Cell Control
Cell
object is married to a CellControl
instance. The CellControl
is a custom control that consists of several Image objects; each representing the type and contents of the cell.Cell
is assigned to a CellControl
, the CellControl
subscribes to the PropertyChanged
event of the Cell
. When the Cell
’s CellContents
property changes the UpdateDisplay
method is called. See Listing 2. The visibility of each Image object is updated according to the Cell
and its contents.void UpdateDisplay()
{
wallImage.SetVisibility(cell is WallCell);
floorImage.SetVisibility(cell is FloorCell || cell is GoalCell);
floorHighlightImage.SetVisibility(hasMouse && (cell is FloorCell || cell is GoalCell));
treasureImage.SetVisibility(cell.CellContents is Treasure);
playerImage.SetVisibility(cell.CellContents is Actor);
goalImage.SetVisibility(cell is GoalCell && !(cell.CellContents is Treasure));
goalActiveImage.SetVisibility(cell is GoalCell && cell.CellContents is Treasure);
}
The SetVisibility
method is a custom extension method that alleviates the need for a ternary expression to set Visibility
property of the UIElement
. It reduces verbosity. See Listing 3.
Listing 3. UIElementExtensions SetVisibility method.
public static void SetVisibility(this UIElement element, bool visible)
{
element.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
}
In other words, it turns this:
wallImage.Visibility = cell is WallCell ? Visibility.Visible : Visibility.Collapsed;
Into this:
wallImage.SetVisibility(cell is WallCell);
As an aside, it’s a pity Microsoft didn’t take the opportunity to replace the UIElement
’s Visibility
property with a simpler boolean property, since many less important things have been overhauled in UWP. Surely, it’s a bit late to introduce a new visibility enum value (such as Android’s ‘Gone’ visibility value).
Moreover, there is no built-in BooleanToVisibilityConverter
in the SDK, which means that new developers predominately get stuck figuring out how to hide or reveal an element based on a boolean value. The good news is that with the Windows 10 Anniversary Update, x:Bind now implicitly converts to and from a bool and a Visibility
enum value. This will, however, only work on machines and devices running the Anniversary update.
Bitmap Caching
Bitmaps can take up large amounts of memory. If you intend your UWP app to run on a phone then you need to be mindful of keeping your memory usage to a minimum.
NOTE: On Windows Mobile, for devices with over 1GB of RAM, your app can use no more than 390 MB. This limit is imposed regardless of how much system memory is available, and if you exceed it, the OS will exit your app.
To reduce the amount of RAM that the Sokoban game needs, BitmapImage
objects are cached in a dictionary. While each cell has its own set of Image objects, it shares the underlying BitmapImages
with other CellControls
.
The image cache is a simple static Dictionary
in the CellControl
itself, as shown:
static readonly Dictionary<string, BitmapImage> imageCache
= new Dictionary<string, BitmapImage>();
When a CellControl
is instantiated, a set of Image
objects are also created. See Listing 4. The CreateContentImage
method first attempts to retrieve an image using the relativeUrl
parameter. If an Image has previously been created, it is assigned to the Source
property of the Image.
Listing 4. CellControl CreateContentImage method
Image CreateContentImage(string relativeUrl)
{
Image image = new Image
{
HorizontalAlignment = HorizontalAlignment.Stretch,
VerticalAlignment = VerticalAlignment.Stretch,
Stretch = Stretch.Fill
};
BitmapImage sourceImage;
if (!imageCache.TryGetValue(relativeUrl, out sourceImage))
{
Uri imageUri = new Uri(BaseUri, relativeUrl);
sourceImage = new BitmapImage(imageUri);
imageCache[relativeUrl] = sourceImage;
}
image.Source = sourceImage;
return image;
}
Responding to Touch Events
NOTE: More than a few types have had their names and/or namespaces changed in WinRT and UWP. (WinRT was the first incarnation of what we now call UWP)
Two such cases are the WPF
UIElement MouseEnter
and MouseExited
events. They they are now PointerEntered
and PointerExited
respectively.PointerEnter
event is raised, which invokes the CellControl
’s UpdateDisplay
method, which then reveals the cell highlight image.Implementing Thread-Safe Async Await with Semaphores
Actor
class’s movement related code to use async await rather than thread locking. One challenge when implementing threadsafe async code is that lock primitives are not allowed in async code blocks. They can lead to deadlocks. So the compiler prevents you from doing the following:lock (aLock)
{
await DoSomethingAsync();
}
SemaphoreSlim
class to prevent race conditions in an async block. I demonstrate this in the Actor
’s JumpAsync
method. See Listing 5.SemaphoreSlim
named moveSemaphore
is engaged by calling its WaitAsync
method. Notice that the method is awaitable. You need to await the method to prevent access to the sensitive region.async Task<bool> JumpAsync(Jump jump)
{
bool result = false;
try
{
await moveSemaphore.WaitAsync();
SearchPathFinder searchPathFinder = new SearchPathFinder(Cell, jump.Destination);
if (searchPathFinder.TryFindPath())
{
for (int i = 0; i < searchPathFinder.Route.Length; i++)
{
Move move = searchPathFinder.Route[i];
/* Sleep for the stepDelayMS period. */
await Task.Delay(stepDelayMS).ConfigureAwait(false);
Location moveLocation = Location.GetAdjacentLocation(move.Direction);
Cell toCell = Level[moveLocation];
if (!toCell.TrySetContents(this))
{
throw new SokobanException("Unable to follow route.");
}
MoveCount++;
}
/* Set the undo item. */
Jump newMove = new Jump(searchPathFinder.Route) { Undo = true };
moves.Push(newMove);
result = true;
}
}
finally
{
moveSemaphore.Release();
}
return result;
}
SearchPathFinder
locates a path, please see this previous Silverlight article.SemaphoreSlim
class supports both asynchronous and synchronous code blocks. If you wish to protect both async and non-async code blocks using the same SemaphoreSlim
instance, use its synchronous Wait
method. You can see this demonstrated in the Actor
’s DoMove
method. See Listing 6.SemaphoreSlim
object to protect a non-awaitable block.internal bool DoMove(Move move)
{
try
{
moveSemaphore.Wait();
return DoMoveAux(move);
}
finally
{
moveSemaphore.Release();
}
}
A Note About Image Assets
RadialGradientBrush
, which meant I couldn't use the image assets I previously created. So, I went back to the drawing board and created the images in Photoshop.I liked having the assets in XAML because of the lossless scaling that afforded. But, since I intend to also port Alien Sokoban to Android and iOS, using .jpg and .png images makes sense. But boy, I’d sure like to see a
RadialGradientBrush
in the UWP.Implementing a Slide-In Menu
- Undo Move
- Redo Move
- Restart Level
ICommands
and are not to be confused with move commands and the GameCommandBase
class in the undo redo move system (that predates them).DelegateCommand
class.DelegateCommand
requires an Action
and optionally a Func
that evaluates if the Action
is allowed to execute. See Listing 7.public class DelegateCommand : ICommand
{
readonly Action<object> executeAction;
readonly Func<object, bool> canExecuteAction;
public DelegateCommand(Action<object> executeAction,
Func<object, bool> canExecuteAction = null)
{
this.executeAction = executeAction;
this.canExecuteAction = canExecuteAction;
}
public bool CanExecute(object parameter)
{
return canExecuteAction?.Invoke(parameter) ?? true;
}
public void Execute(object parameter)
{
executeAction?.Invoke(parameter);
}
public event EventHandler CanExecuteChanged;
protected virtual void OnCanExecuteChanged()
{
CanExecuteChanged?.Invoke(this, EventArgs.Empty);
}
public void RaiseExecuteChanged()
{
OnCanExecuteChanged();
}
}
ICommands
are useful because they share affinity with many UI elements, such as Buttons
, which automatically leverage the CanExecute
method to automatically change the IsEnabled
state of the button.
public Game(IInfrastructure infrastructure)
{
this.infrastructure = ArgumentValidator.AssertNotNull(infrastructure, nameof(infrastructure));
LevelContentBase.SynchronizationContext = infrastructure.SynchronizationContext;
RestartLevelCommand = new DelegateCommand(_ => RestartLevel());
UndoCommand = new DelegateCommand(_ => commandManager.Undo());
RedoCommand = new DelegateCommand(_ => commandManager.Redo());
/* Reset the level number to 0 if a debugger is attached. */
if (Debugger.IsAttached)
{
infrastructure.SaveSetting(levelKey, 0);
}
}
RestartLevelCommand
calls the RestartLevel
method when it is executed.The
UndoCommand
and RedoCommand
call the respective Undo
and Redo
methods of the Game
’s commandManager
.Materializing the Commands
SplitView
element is used to overlay a menu pane when a hamburger button is pressed. See Listing 9. The three commands are bound to Button
controls in the SplitView.Pane
.<SplitView Grid.Row="1" x:Name="splitView"
DisplayMode="Overlay"
OpenPaneLength="320"
PaneBackground="{ThemeResource ApplicationPageBackgroundThemeBrush}"
IsTabStop="False" VerticalAlignment="Stretch" VerticalContentAlignment="Stretch">
<SplitView.Pane>
<StackPanel Background="{ThemeResource ChromeSecondaryBrush}">
<Button Command="{x:Bind Game.RestartLevelCommand}"
Content="Restart Level"
Click="HandleMenuButtonClick"
Style="{ThemeResource MenuItemTextButtonStyle}"/>
<Button Command="{x:Bind Game.UndoCommand}"
Content="Undo"
Click="HandleMenuButtonClick"
Style="{ThemeResource MenuItemTextButtonStyle}"/>
<Button Command="{x:Bind Game.RedoCommand}"
Content="Redo"
Click="HandleMenuButtonClick"
Style="{ThemeResource MenuItemTextButtonStyle}"/>
</StackPanel>
</SplitView.Pane>
<SplitView.Content>
<Grid x:Name="gameCanvasContainer" VerticalAlignment="Stretch"
HorizontalAlignment="Stretch" Background="Transparent">
<Canvas x:Name="gameCanvas" Background="Transparent" />
</Grid>
</SplitView.Content>
</SplitView>
Button
’s Click
event is also leveraged to automatically close the SplitView
pane when the button is clicked or tapped. See Listing 10.void HandleMenuButtonClick(object sender, RoutedEventArgs e)
{
splitView.IsPaneOpen = false;
}
ToggleButton
whose IsChecked
property is bound to the IsPaneOpen
property of the SplitView
. See Listing 11.ToggleButton
doesn’t use an image but rather text to display the hamburger icon. The character is located in the Segoe MDL2 Assets, which is supported out of the box.<ToggleButton
FontFamily="Segoe MDL2 Assets"
Content=""
Foreground="White"
Background="Transparent"
BorderBrush="Transparent"
TabIndex="1"
AutomationProperties.Name="Navigation"
ToolTipService.ToolTip="Navigation"
IsChecked="{Binding IsPaneOpen, ElementName=splitView, Mode=TwoWay}" />
SplitView
pane, as shown in Figure 1.
Figure 1. Expanded SplitView Pane
Conclusion
CellControl
and examined how images are used to represent cell contents. You saw how to reduce the memory footprint of your app by caching Bitmap
objects. You saw how to respond to touch events within an app and at writing thread-safe awaitable code. Finally, you saw how to implement a SplitView
to provide a slide-in menu for the game.History
October 26 2016
- First published