Click here to Skip to main content
13,139,254 members (54,610 online)
Click here to Skip to main content
Add your own
alternative version

Stats

18.1K views
559 downloads
28 bookmarked
Posted 25 Nov 2016

Building a Puzzle Game with Xamarin Forms

, 25 Nov 2016
Rate this:
Please Sign up or sign in to vote.
A fun Xamarin Forms implementation of the game Sokoban, demonstrating how to port a UWP puzzle game to both Android and iOS while sharing nearly all code across platforms.

 

 

Introduction

In the previous 3 part series, you saw how to create a tile based game for the Universal Windows Platform (UWP).
In this article you look at porting the game to both iOS and Android; creating a cross-platform Xamarin solution in Visual Studio.
 
You see how nearly all code is shared across platforms. You observe some platform difference, such as those encountered when defining and referencing image resources, and at implementing audio playback for each platform. You look at abstracting platform these differences, providing platform specific implementations at runtime.
 
You see how to work with XAML in Xamarin forms. You explore rudimentary views, such as text boxes, labels and buttons. You also look at more advanced topics, such as creating a slide-in menu and defining reusable XAML resources. You also see how to use an AbsoluteLayout to layout the game grid, and at utilizing the game grid's available space.

You also explore touch gestures, and we introduce a new double tap move which causes the Sokoban character to push a treasure across multiple floor spaces.

Know Your Platforms

Xamarin Forms has come a long way over the last couple of years. It’s becoming an increasingly viable candidate for cross-platform development. Xamarin Forms is not a lookless GUI technology. Controls render as they would if implemented natively. Creating a rich multi-faceted app requires an understanding of the underlying platform. There are nuances to each platform that often require tweaking to get the desired look and feel. That’s why, if you’re considering creating a serious app using the Xamarin tooling, I recommend some preliminary reading on the underlying platform or platforms you’re targeting. Platform specific quirks invariably cannot be abstracted. That’s why a good knowledge of the underlying platform is essential to creating rich user interfaces. So, I encourage you to ground yourself in a reasonable knowledge of Android and iOS development before embarking on a complex Forms app.

With that said, you can still have fun creating a relatively simple app in Xamarin Forms without any prior platform specific knowledge. Let’s begin.

Creating a Xamarin Cross-Platform Solution

When creating a new Xamarin solution in Visual Studio there are several options to choose from in the New Project dialog. See Figure 1.

I’m a rather fond of XAML, so for this project I chose the Blank XAML App option. I also chose a PCL project type rather than the Shared project type because I knew that I wanted to constrain all platform specific code to each of the respective platform specific projects, as opposed to relying on preprocessor directives or some other mechanism to include or exclude code. One other reason I chose PCL over the Shared project type is that I have found intellisense to sometimes misbehave when using Shared projects.
 
Figure 1. Creating a Blank XAML App using the New Project Dialog


Understanding the Xamarin Forms Solution Structure

When you create a Xamarin Forms XAML project, four projects are created:
  • An Xamarin Android project
  • An Xamarin iOS project
  • A UWP project
  • And a Xamarin Forms project

In the downloadable solution I’ve grouped the three platform specific projects together. See Figure 2.

The platform specific projects for Android, iOS , and UWP; are entry point applications. While the Xamarin Forms project contains the majority of our GUI code and is effectively hosted within each of the platform specific projects.

With the new Xamarin Forms solution in place we can bring in the Sokoban game PCL and build out the Forms project.
 
Figure 2. Forms Sokoban Solution

The Sokoban PCL project (Sokoban.csproj) is platform agnostic, and is a carry over from the previous article series. To implement the game for Xamarin Forms we need to build out the Forms project (Outcoder.Sokoban.Launcher) and implement the infrastructure classes for each supported platform. The Forms launcher project contains the Xamarin Forms items that are used by each of the Android, iOS and UWP projects.

Constructing the Game UI in Xamarin Forms

The interface for the game consists of the following three sections:
  • a top toolbar section,
  • a lower section for the game tiles,
  • and a slide in menu.
Let’s start by taking a look at the slide-in menu.

Implementing a Slide-In Menu in Xamarin Forms

To achieve the same slide-out menu that I created for the UWP version of the app I use a MasterDetailPage. The MainPage.xaml file in the Outcoder.Sokoban.Launcher project inherits not from Page, but from MasterDetailPage. A MasterDetailPage allows you to split a page into two parts: the master part, which can be thought of as a set of items that, when selected, change the content in the detail part. The detail part displays one or more different pages representing the selected item in the master page.
 
Our use of the MasterDetailPage doesn’t quite fit that description; we use the master section as a menu, and the detail page as the tiled game area. In this app, the detail page doesn’t change.

The root element of MainPage.xaml looks like this:

<MasterDetailPage xmlns="http://xamarin.com/schemas/2014/forms"

       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"

       x:Class="Outcoder.Sokoban.Launcher.MainPage">
When using a Xamarin Forms MasterDetailPage, there are two main elements you need to build out. The first corresponds to the MasterDetailPage.Master property, which is the slide in part. The second corresponds to the MasterDetailPage.Detail property, which, in this case, is the fixed view of the game. Both are populated with a Page instance; generally a single Page for the Master and one or more pages for the Detail property.

NOTE: Don’t expect the MasterDetailPage to behave the same on every device. The behaviour of the Master part may vary according to platform and screen size. If the screen size is substantial enough, then the Master part may remain permanently in view.

In the Sokoban game, the Master contains a StackLayout and several Label views that are bound to commands in the Game class. See Listing 1. The Game class is effectively the ViewModel of the MainPage.

NOTE: In UWP and WPF the term control is used frequently to denote interactive UI elements. In Xamarin Forms, however, the term view is used.  This is because in Xamarin Forms UI elements derive from a base View class. If you’re coming from the Android development world you’ll feel at home with this nomenclature.

The base View class contains a GestureRecognizers property, which can be populated with a set of IGestureRecognizers. As well as a Tapped event, the TapGestureRecognizer has a convenient Command property that we attach to each of the Games commands.

I make use of both the Tapped event and the Command property. The Tapped event is used to trigger the closing of the menu. I’m not pleased with that, and I would have prefered if the command took care of setting a property to close the menu. But, this was simpler.

Listing 1.  MainPage.xaml MasterDetailPage.Master excerpt
<MasterDetailPage.Master>
 <ContentPage Title="Menu" BackgroundColor="{StaticResource ChromePrimaryColor}">
  <StackLayout Padding="12">
   <Label Text="Undo" Style="{StaticResource MenuItemTextStyle}">
    <Label.GestureRecognizers>
     <TapGestureRecognizer Command="{Binding UndoCommand}"

                Tapped="HandleMenuItemTapped" />
    </Label.GestureRecognizers>
   </Label>
   <Label Text="Redo" Style="{StaticResource MenuItemTextStyle}">
    <Label.GestureRecognizers>
     <TapGestureRecognizer Command="{Binding RedoCommand}"

                Tapped="HandleMenuItemTapped" />
    </Label.GestureRecognizers>
   </Label>
   <Label Text="Restart Level" Style="{StaticResource MenuItemTextStyle}">
    <Label.GestureRecognizers>
     <TapGestureRecognizer Command="{Binding RestartLevelCommand}"

                Tapped="HandleMenuItemTapped" />
    </Label.GestureRecognizers>
   </Label>
  </StackLayout>
 </ContentPage>
</MasterDetailPage.Master>
The Tapped handler for each menu item in the master page calls the CloseMenu method of the MainPage, which, in turn, sets the Page’s built-in IsPresented property to false. See Listing 2. When IsPresented is true, the menu expands. When false, it collapses.

Listing 2. MainPage HandleMenuItemTapped and CloseMenu methods
void HandleMenuItemTapped(object sender, EventArgs e)
{
   CloseMenu();
}

void CloseMenu()
{
   IsPresented = false;
}
Just like UWP and WPF, Xamarin Forms supports a StaticResource markup extension, which gives you a simple way to share resources across your XAML based app. The resources for the game are located in the App.xaml file in the Outcoder.Sokoban.Launcher project. See Listing 3.

Many of the structures present in Xamarin Forms resemble, or in some cases, match those in UWP or WPF. Here we see that the Application.Resources are populated with a ResourceDictionary with various colors used throughout the app.

Listing 3. App.xaml
<Application xmlns="http://xamarin.com/schemas/2014/forms"

             xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"

             x:Class="Outcoder.Sokoban.Launcher.App">
 <Application.Resources>

  <ResourceDictionary>
   <Color x:Key="ChromePrimaryColor">#ff9a00</Color>
   <Color x:Key="ChromeSecondaryColor">#ee9000</Color>
   <Color x:Key="GameShadeColor">#55111111</Color>
   <Color x:Key="GameBackgroundColor">#303030</Color>
  </ResourceDictionary>

 </Application.Resources>
</Application>
The main game UI is defined in the Detail section of the MasterDetailPage in MainPage.xaml. See Listing 4.
Just like our UWP implementation in a previous article, the game UI is divided into a top section which shows the level number and so forth, and a bottom section which hosts the game tiles.

An Entry in Xamarin Forms parlance is a text box, where the user can enter text. The levelCodeTextBox is an Entry view that allows the user to jump to a different level if the user knows the correct code corresponding to that level. levelCodeTextBox retains the same name that I used in the UWP implementation, though it should probably be renamed levelCodeEntry or something like that.

The game grid is implemented using an AbsoluteLayout view. AbsoluteLayout is analogous to a UWP or WPF Canvas control and allows you to position items using X and Y coordinates.

The final element within the detail page is an overlay, which we use to obscure the game to display messages to the user upon level completion. The visibility of the overlay is bound to the FeedbackVisible property of the game object.

Listing 4. MainPage.xaml MasterDetailPage.Detail excerpt
<MasterDetailPage.Detail>
 <ContentPage Title="Game"

  BackgroundColor="{StaticResource GameBackgroundColor}">
      
  <Grid>
   <Grid.RowDefinitions>
    <RowDefinition Height="Auto" />
    <RowDefinition Height="*" />
   </Grid.RowDefinitions>
   <Grid x:Name="topContentGrid" BackgroundColor="{StaticResource ChromePrimaryColor}">
    <Grid.ColumnDefinitions>
     <ColumnDefinition Width="*" />
     <ColumnDefinition Width="Auto" />
     <ColumnDefinition Width="Auto" />
     <ColumnDefinition Width="Auto" />
    </Grid.ColumnDefinitions>
   
    <Grid HorizontalOptions="Start" Padding="12,0,0,0" 

          WidthRequest="30" HeightRequest="30">
     <Image Source="MenuButton.png" Aspect="AspectFit" 

            HorizontalOptions="Fill" VerticalOptions="Fill">
      <Image.GestureRecognizers>
       <TapGestureRecognizer Tapped="HandleMenuButtonTapped" />
      </Image.GestureRecognizers>
     </Image>
    </Grid>
      
    <StackLayout Orientation="Horizontal" Grid.Column="1" Padding="12,0,0,0">
     <Label Text="Level" Style="{StaticResource LabelStyle}" />
     <Label Text="{Binding Level.LevelNumber, Mode=OneWay}" 

              WidthRequest="30" Style="{StaticResource LabelStyle}"  />
    </StackLayout>
   
    <StackLayout Orientation="Horizontal" Grid.Column="2">
     <Label Text="{Binding Level.Actor.MoveCount, Mode=OneWay}" 

            Style="{StaticResource LabelStyle}" />
     <Label Text="Moves" Style="{StaticResource LabelStyle}" />
    </StackLayout>
  
    <StackLayout Orientation="Horizontal" Grid.Column="3" Padding="12,0,12,0">
     <Label Text="Code" Style="{StaticResource LabelStyle}" />
     <Entry x:Name="levelCodeTextBox" Style="{StaticResource LevelCodeEntryStyle}"

         Focused="HandleLevelEntryFocused"

         Unfocused="HandleLevelEntryUnfocused"

         Completed="HandleLevelEntryCompleted"

         BackgroundColor="Transparent" />
    </StackLayout>
   </Grid>
   <AbsoluteLayout x:Name="gameLayout" Grid.Row="1"

     BackgroundColor="Transparent" VerticalOptions="FillAndExpand" 

     HorizontalOptions="FillAndExpand" />
     
   <ContentView x:Name="overlayView"  HorizontalOptions="Fill" VerticalOptions="Fill"

      Grid.RowSpan="2"

      BackgroundColor="{StaticResource GameShadeColor}"

      IsVisible="{Binding FeedbackVisible, Mode=OneWay}">
     <Label

      Text="{Binding FeedbackMessage, Mode=OneWay}"

      FontSize="Large"

      TextColor="White"

      IsVisible="{Binding ContinuePromptVisible, Mode=OneWay}"

      VerticalTextAlignment="Center"

      HorizontalTextAlignment="Center" />
   </ContentView>
  </Grid>

 </ContentPage>
</MasterDetailPage.Detail>

Implementing the Platform Specific Infrastructure

In the previous UWP article series, we needed a way for the Sokoban Game object to leverage some platform specific functionality. We did this by abstracting that code for each platform via a custom IInfrastructure implementation.

In the Xamarin Forms implementation of Sokoban, we have a base implementation of the IInfrastructure interface, named InfrastructureBase; which is located in the Outcoder.Sokoban.Launcher project. See Listing 5.

The Game object requires an instance of an implementation of the custom ISynchronizationContext.
InfrastructureBase makes use of some of the Xamarin Forms APIs, which are available across all platforms. In particular the Application.Properties collection is an IDictionary<string, object> that allows you to persist key value pairs in a platform agnostic way.
 

Listing 5. InfrastructureBase class.
public abstract class InfrastructureBase : IInfrastructure
{

 protected InfrastructureBase(ISynchronizationContext synchronizationContext)
 {
  SynchronizationContext = ArgumentValidator.AssertNotNull(synchronizationContext, 
                                                           "synchronizationContext");
 }

 public abstract int LevelCount { get; }

 public abstract Task<string> GetMapAsync(int levelNumber);

 public ISynchronizationContext SynchronizationContext { get; }

 public virtual void SaveSetting(string key, object setting)
 {
  Application.Current.Properties[key] = setting;
 }

 public virtual bool TryGetSetting(string key, out object setting)
 {
  return Application.Current.Properties.TryGetValue(key, out setting);
 }

 public virtual T GetSetting<T>(string key, T defaultValue)
 {
  object result;
  if (TryGetSetting(key, out result))
  {
   return (T)result;
  }

  return defaultValue;
 }

 public abstract void PlayAudioClip(AudioClip audioClip);
}


Various IInfrastructure members, include LevelCount, GetMapAsync, and PlayAudioClip; need to be implemented for each platform. We turn our attention to that in a moment, but first let’s examine the ISynchronizationContext member.

Abstracting the Thread Synchronization Context

Upon instantiation, InfrastructureBase requires an object implementing ISynchronizationContext. You may recall from the previous series that ISynchronizationContext is used to invoke an action on the main thread. It does this in such a way that if the code is already executing on the UI thread then the action is not pushed onto the UI thread queue but rather executed immediately, which can improve performance.

The Xamarin Forms implementation of ISynchronizationContext is identical for both Android and iOS and is shown in Listing 6.

On Android and iOS the Mono framework exposes a Thread.CurrentThread.IsBackground property that enables you to determine if the current thread is the UI thread. UWP lacks such a property and requires a Dispatcher instance to ascertain that information. Hence the different implementation for UWP. The UWP implementation is described in the previous article series and won’t be covered here.

NOTE: When working with Xamarin Forms you’ll find that the .NET API surface area of iOS and Android is larger than UWP. The reason for this is that iOS and Android are able to leverage the Mono framework, which has a wider set of APIs that encompass much of the .NET FCL (Framework Class Library). I suspect that these differences will gradually disappear over time.

Listing 6. UISynchronizationContext class
class UISynchronizationContext : ISynchronizationContext
{
 public bool InvokeRequired => Thread.CurrentThread.IsBackground;

 public void InvokeIfRequired(Action action)
 {
  if (InvokeRequired)
  {
   Xamarin.Forms.Device.BeginInvokeOnMainThread(action);
  }
  else
  {
   action();
  }
 }
}
The Android and iOS implementations of the IInfrastructure class are both named Infrastructure and extend the InfrastructureBase class. See Listing 7.

In addtition to the ISynchronizationContext, the Infrastructure class requires an Activity instance to retrieve the level map assets. It also requires an AudioClips object, which is used to play the sound clips during gameplay.

Listing 7. Android Infrastructure class
class Infrastructure : InfrastructureBase
{
 const string levelDirectory = "Levels";
 readonly List<string> fileList;

 readonly Activity activity;
 readonly AudioClips mediaClips;

 public Infrastructure(
  ISynchronizationContext synchronizationContext,
  Activity activity,
  AudioClips mediaClips)
  : base(synchronizationContext)
 {
  this.activity = ArgumentValidator.AssertNotNull(activity, "activity");
  this.mediaClips = ArgumentValidator.AssertNotNull(mediaClips, nameof(mediaClips));

  fileList = activity.Assets.List(levelDirectory).Where(name => name.EndsWith(".skbn")).ToList();
 }

 public override int LevelCount => fileList?.Count ?? 0;

 public override Task<string> GetMapAsync(int levelNumber)
 {
  string fileName = $@"{levelDirectory}/Level{levelNumber:000}.skbn";

  using (var stream = activity.Assets.Open(fileName))
  {
   using (StreamReader reader = new StreamReader(stream))
   {
    string levelText = reader.ReadToEnd();

    return Task.FromResult(levelText);
   }
  }
 }

 public override void PlayAudioClip(AudioClip audioClip)
 {
  mediaClips.Play(audioClip);
 }
}
The game’s level files have been linked into the Assets/Levels directory of the Android launcher project (Outcoder.Sokoban.Launcher.Droid). Each level file has its Build Action set to Android Asset.

Android is rather picky where you place resources and arbitrary files. In Xamarin Android, resources such as layout files, images, and audio files must be located in subdirectories of the resources directory. Images used in the game are located in the resources/drawable directory, while MP3 files used in the game are located in the resources/raw directory.

The AudioClips class creates multiple MediaPlayer objects; one for each sound file. See Listing 8.

The Context instance, which is passed to the AudioClips constructor, is used to retrieve each of the .mp3 files located in the Outcoder.Sokoban.Launcher.Droid project’s Resources/raw directory.

For performance reasons, playback is performed on a ThreadPool thread via a call to the asynchronous Task.Run in the AudioClips Play method.

The AudioClip enum contains values for each of the sound effects and is a cross-platform friendly way of indicating to the AudioClips class which sound clip to play.

Listing 8. Android AudioClips class
class AudioClips
{
 readonly Context context;
 const string logTag = "AudioClips";

 readonly MediaPlayer levelIntroductionElement;
 readonly MediaPlayer gameCompleteElement;
 readonly MediaPlayer levelCompleteElement;
 readonly MediaPlayer treasurePushElement;
 readonly MediaPlayer treasureOnGoalElement;

 internal AudioClips(Context context)
 {
  this.context = ArgumentValidator.AssertNotNull(context, nameof(context));

  levelIntroductionElement = CreateElement(Resource.Raw.LevelIntroduction);
  gameCompleteElement = CreateElement(Resource.Raw.GameComplete);
  levelCompleteElement = CreateElement(Resource.Raw.LevelComplete);
  treasurePushElement = CreateElement(Resource.Raw.TreasurePush);
  treasureOnGoalElement = CreateElement(Resource.Raw.TreasureOnGoal);
 }

 MediaPlayer CreateElement(int audioResourceId)
 {
  var result = MediaPlayer.Create(context, audioResourceId);
  return result;
 }

 void Play(MediaPlayer element)
 {
  Task.Run(() =>
  {
   try
   {
    element.SeekTo(0);
    element.Start();
   }
   catch (Exception ex)
   {
    Android.Util.Log.Error(logTag,
     "Unable to play audio clip.",
     Throwable.FromException(ex));
   }
  });
 }

 internal void Play(AudioClip audioClip)
 {
  switch (audioClip)
  {
   case AudioClip.Footstep:
    /* Playing a sound effect on Android degrades performance. */
    //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;
  }
 }
}

The AudioClips class is instantiated in the OnCreate method of the MainActivity class in the Outcoder.Sokoban.Launcher.Droid project. See Listing 9.

We create an Infrastructure instance; passing in a UISynchronizationContext object, the current activity, and the AudioClips instance.
 

Listing 9. Android MainActivity.OnCreate method excerpt
protected override void OnCreate(Bundle bundle)
{
 ...

 base.OnCreate(bundle);

 global::Xamarin.Forms.Forms.Init(this, bundle);

 var audioClips = new AudioClips(this);
 var infrastructure = new Infrastructure(new UISynchronizationContext(), this, audioClips);
 LoadApplication(new App(infrastructure));
}


LoadApplication of the Xamarin.Forms.Platform.Android.FormsAppCompatActivity class takes care of materializing the custom App class. See Listing 10. App extends Xamarin.Forms.Application, which the base class for any Xamarin Forms App.


The App object instantiates a MainPage object, passing it the specified IInfrastructure instance, which is subsequently passed down to the Sokoban Game object, allowing it to retrieve maps and play sound effects and so forth.

The Xamarin.Forms.Application class provides virtual methods for the various application lifecycle events: OnStart, OnSleep, and OnResume.

NOTE: There is no OnEnd virtual method in the Application class. One of the reasons is presumably because platforms like Android and UWP don’t offer a reliable way to detect when your app is exiting. That’s why it’s best to assume your app is going to be terminated when OnSleep is called. When OnSleep is called, save your app's state.

Listing 10. App.xaml.cs
public partial class App : Application
{
 public App(IInfrastructure infrastructure)
 {
  InitializeComponent();

  var mainPage = new MainPage {Infrastructure = infrastructure};
  MainPage = mainPage;
 }

 protected override void OnStart()
 {
  // Handle when your app starts
 }

 protected override void OnSleep()
 {
  // Handle when your app sleeps
 }

 protected override void OnResume()
 {
  // Handle when your app resumes
 }
}
The MainPage class constructor subscribes to the page’s Appearing event. See Listing 11. The Appearing event allow us to wait until the grid layout has been laid out so that we can reliably ascertain its available size.
 
The Entry class (recall they are the text boxes) has a Keyboard property, which allows you to specify the type of software keyboard and its characteristics. For example, a numeric keyboard contains mostly digits while a URL keyboard will have characters commonly used for entering a web address.

The levelCodeTextBox’s text is capitalized using the CapitalizeSentence flag.

NOTE: The Keyboard property offers a nice abstraction but be mindful that the available keyboard types is the intersection of all keyboard types across all supported platforms. You may find that using a native API gives you access to a keyboard more appropriate to your needs.

Listing 11. MainPage.xaml.cs constructor excerpt
public partial class MainPage : MasterDetailPage
{
 bool loaded;
 GameState gameState = GameState.Loading;
 readonly Dictionary<Cell, CellView> controlDictionary
     = new Dictionary<Cell, CellView>();
 Game game;

 public Game SokobanGame => game;

 public IInfrastructure Infrastructure { get; set; }

 public MainPage()
 {
  /* Uncomment to generate the LevelCode class. */
  //LevelCodesGenerator.GenerateLevelCodes();

  InitializeComponent();

  Appearing += HandleAppearing;

  var overlayTapRecognizer = new TapGestureRecognizer();
  overlayTapRecognizer.Tapped += HandleOverlayTap;
  overlayView.GestureRecognizers.Add(overlayTapRecognizer);

  levelCodeTextBox.Keyboard = Keyboard.Create(KeyboardFlags.CapitalizeSentence);
 }
…
}
The MainPage class’s HandleAppearing method uses a loaded flag to guarantee that its body runs only once.

The Game object is created using the IInfrastructure object. The BindingContext is set to the game object.

NOTE: BindingContext is analogous to the DataContext property of FrameworkElements in UWP and WPF.

The HandleAppearing method awaits Task.Yield a couple of times to ensure that the view has been laid out. Then, the StartGame method is called. See Listing 12. 

If the size of the host window changes then the game grid needs to be redrawn. The SizeChanged event is raised when the orientation of the device is changed, which is handy as it gives us the opportunity to resize the cells.

Listing 12. MainPage HandleAppearing method
async void HandleAppearing(object sender, EventArgs e)
{
 if (loaded)
 {
  return;
 }

 loaded = true;

 game = new Game(Infrastructure);
 BindingContext = game;

 /* Here we give the UI a chance to layout
  * so that sizes can be correctly determined. */
 await Task.Yield();
 await Task.Yield();

 game.PropertyChanged += HandleGamePropertyChanged;

 game.Start();

 SizeChanged += HandleWindowSizeChanged;
}
The Game class implements INotifyPropertyChanged. We respond to Game property changes in the HandleGamePropertyChanged method. See Listing 13.

In this implementation we’re only interested in when the GameState property changes. When it does, we call the ProcessGameStateChanged method.

Listing 13. MainPage.HandleGamePropertyChanged method
void HandleGamePropertyChanged(object sender, PropertyChangedEventArgs e)
{
 switch (e.PropertyName)
 {
  case nameof(Game.GameState):
   ProcessGameStateChanged();
   break;
 }
}

Most of the UI logic has been moved into the Game class. The only state we’re interested in is the Loading state, upon which we initialize the level. See Listing 14. The rest of the states offer UI extensibility points. You could potentially start an animation when the level is loading for example.

Listing 14. 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;
}

Cells that are placed in the game grid are retained in a dictionary. This improves performance when we need to refresh the size of the grid.

Initializing a level involves discarding existing cells and then calling the LayoutLevel method. See Listing 15.

The levelCodeTextBox Text property is set to the game’s LevelCode property. It’s not directly bound to the game property because we allow the user to attempt to enter a level code. It should really be bound to a game property, with the logic contained with the Game class, but I didn’t get around to doing that.
 

Listing 15. InitializeLevel method
void InitialiseLevel()
{
 foreach (var control in controlDictionary.Values)
 {  
  DetachCellView(control);  
 }
 controlDictionary.Clear();

 gameLayout.Children.Clear();

 LayoutLevel();

 levelCodeTextBox.Text = game.LevelCode;
}
Populating the game grid involves creating a CellView for each Cell object in the game’s Level. See Listing 16.
We maximize the size of the cells based on the available width and height.

Each CellView is added to the AbsoluteLayout view’s Children collection. We set the position of the CellView using the AbsoluteLayout’s static SetLayoutBounds method.

Listing 16. MainPage LayoutLevel method
void LayoutLevel()
{
 Level level = game.Level;
 int rowCount = level.RowCount;
 int columnCount = level.ColumnCount;

 /* Calculate cell size and offset. */
 double windowWidth = gameLayout.Width;
 double windowHeight = gameLayout.Height;
 int cellWidthMax = (int)(windowWidth / columnCount);
 int cellHeightMax = (int)(windowHeight / rowCount);
 int cellSize = Math.Min(cellWidthMax, cellHeightMax);

 int gameHeight = rowCount * cellSize;
 int gameWidth = columnCount * cellSize;

 int leftStart = (int)((windowWidth - gameWidth) / 2);
 int left = leftStart;
 int top = (int)((windowHeight - gameHeight) / 2);

 /* Add CellControls to represent each Game Cell. */
 for (int row = 0; row < rowCount; row++)
 {
  for (int column = 0; column < columnCount; column++)
  {
   Cell cell = game.Level[row, column];

   CellView cellControl;
   if (!controlDictionary.TryGetValue(cell, out cellControl))
   {
    cellControl = new CellView(cell);
    controlDictionary[cell] = cellControl;

    DetachCellView(cellControl);
    AttachCellView(cellControl);

    gameLayout.Children.Add(cellControl);
   }

   AbsoluteLayout.SetLayoutBounds(cellControl, new Rectangle(left, top, cellSize, cellSize));

   left += cellSize;
  }

  left = leftStart;
  top += cellSize;
 }
}

When a CellView is tapped, the game attempts to walk the player to the location on the grid. See Listing 17. The event is processed by the Game object.

Listing 17. MainPage HandleCellTap method
void HandleCellTap(object sender, EventArgs e)
{
 if (levelCodeTextBox.IsFocused)
 {
  return;
 }

 CellView button = (CellView)sender;
 Cell cell = button.Cell;

 /* This event is passed on to the Game object. */
 game.ProcessCellTap(cell, false);
}
The ProcessCellTap method performs either a jump or a push. See Listing 18. When the player walks in a straight line and potentially pushes a treasure, this is called a push. Alternatively, a jump moves the player to any reachable cell without pushing any treasures in its path.

Listing 18. Game ProcessCellTap method
public void ProcessCellTap(Cell cell, bool shiftPressed)
{
 /* When the user clicks a cell, we want to
  have the actor move there. */

 GameCommandBase command;

 if (shiftPressed)
 {
  command = new PushCommand(Level, cell.Location);
 }
 else
 {
  command = new JumpCommand(Level, cell.Location);
 }

 commandManager.Execute(command);
}
I’ve added a double tap gesture to the user's arsenal. If a CellView is double tapped, the game will attempt to walk the player in a straight line; pushing a treasure if there is one in the player’s path. Again, the event is handled by the Game object’s ProcessCellTap method. See Listing 19.

Listing 19. MainPage HandleCellDoubleTap method
void HandleCellDoubleTap(object sender, EventArgs e)
{
 CellView button = (CellView)sender;
 Cell cell = button.Cell;
 game.ProcessCellTap(cell, true);
}
When the size of the host view changes the app resizes the cells in its game grid to utilize all available space. Recall that the Page object’s SizeChanged event is raised when the orientation of the device changes.

HandleWindowSizeChanged schedules a layout update one second after a size change occurs. See Listing 20. This prevents the app from being slowed down by multiple size changed events occurring at about the same time.

The Xamarin.Forms.Device.StartTimer event provides a platform agnostic way to schedule work on the UI thread. If the action provided to the StartTimer method returns true, the action will recur after the specified interval; otherwise the action isn’t invoked again.
 
Listing 20. MainPage HandleWindowSizeChanged method
void HandleWindowSizeChanged(object sender, EventArgs eventArgs)
{
 if (layoutScheduled)
 {
  return;
 }
 layoutScheduled = true;

 Device.StartTimer(TimeSpan.FromSeconds(1.0),
  () =>
  {
   layoutScheduled = false;
   LayoutLevel();

   return false;
  });
   
}

Implementing the Forms CellView

The CellView class in the Outcoder.Sokoban.Launcher project represents a tile in the game. It may appear as a wall or a floor tile containing content. A floor cell’s content can be the player, a treasure, a goal; or a combination of these.

CellView is a subclass of Xamarin.Forms.ContentView, which I chose because it allows layering of multiple child views and appears to be fairly lightweight, which is important because the game grid requires many CellView objects to be created. There is, however, room for optimization here. The CellView’s Content property is populated with a Grid view. I had some spacing a layout issues that were solved by this configuration, however it’s not optimal and if you plan on leveraging this code I recommend looking at improving the structure of CellView.

When a CellView is created it sets itself up to respond to tap gestures. See Listing 21. In Xamarin Forms touch events are generally implemented using the GestureRecognizers property of the View class and not by, for exmple, direct subscription to a ‘Tap’ event. So, there’s a little extra plumbing you need to put in place; create a TapGestureRecognizer, subscribe to it’s Tapped event, and add it to the view’s GestureRecognizers property.

The image or images that are displayed in the CellView depend on the type of its associated Sokoban Cell object.

Listing 21. CellView constructor
public CellView(Cell cell) : this()
{
 this.cell = cell;

 var tapRecognizer = new TapGestureRecognizer();
 tapRecognizer.Tapped += HandleTapped;
 GestureRecognizers.Add(tapRecognizer);

 if (!(cell is SpaceCell))
 {
  if (cell is WallCell)
  {
   wallImage = AddChildTile(imageDir + "Wall.jpg");
  }
  else
  {
   if (cell is FloorCell || cell is GoalCell)
   {
    floorImage = AddChildTile(imageDir + "Floor.jpg");
    floorHighlightImage = AddChildTile(imageDir + "FloorHightlight.png");
   }

   if (cell is GoalCell)
   {
    goalImage = AddChildTile(imageDir + "Goal.png");
    goalActiveImage = AddChildTile(imageDir + "GoalActive.png");
   }

   treasureImage = AddChildTile(imageDir + "Treasure.png");
   playerImage = AddChildTile(imageDir + "Player.png");
   playerImage.Opacity = 0;
  }
 }

 cell.PropertyChanged += HandleCellPropertyChanged;

 UpdateDisplay();
}
An image must be created for each cell type and its content.  See Listing 22.

The Xamarin.Forms.Image class is derived from View and views are always limited to a single parent view. It cannot, therefore, be cached and used multiple times within a page. However, each Image relies on an ImageSource object, which may be cached to reduce the app’s memory footprint.

Within the CellView class there is a static Dictionary<string, ImageSource> named imageCache. This is where ImageSource objects for each cell type and content are placed.

Listing 22. CellView CreateContentImage method
Image CreateContentImage(string fileName)
{
 Image image = new Image
 {
  Aspect = Aspect.AspectFill
 };

 ImageSource sourceImage;

 if (!imageCache.TryGetValue(fileName, out sourceImage))
 {
  sourceImage = ImageSource.FromFile(fileName);
  imageCache[fileName] = sourceImage;
 }

 image.Source = sourceImage;

 return image;
}
The ImageSource.FromFile method allows you to retrieve your image data on any of the Xamarin supported platforms. If it's being used on iOS or Android, the directory path should not be included. On UWP, however, the full path to the image content resource must be supplied.

The CellView class contains a static constructor that sets the imageDir field according to the platform. See Listing 23. You use the Xamarin.Forms.Device class’s static OS property to determine what platform the app is running on. In this case if the app is running on iOS, Android, or some other platform then the image directory is set to an empty string. On iOS and Android resources are placed in a specific directory and are located using a unique name. In a UWP app, however, images can exist as content anywhere with the project.

Listing 23. CellView static constructor
static CellView()
{
 /* Btw. Xamarin.Forms on Android ignores the directories. */

 var os = Device.OS;
 if (os == TargetPlatform.Android
  || os == TargetPlatform.iOS
  || os == TargetPlatform.Other)
 {
  imageDir = string.Empty;
 }
 else
 {
  imageDir = "/Controls/CellControl/CellImages/";
 }
}
Each image object is added to the CellView via the AddChildTile method. See Listing 24.

Listing 24. CellView AddChildTile
Image AddChildTile(string relativeUrl)
{
 Image image = CreateContentImage(relativeUrl);
 layout.Children.Add(image);

 return image;
}
When the CellView is first initialized or its associated Cell object’s content changes the UpdateDisplay method is called. See Listing 25. The visibility of each image within the CellView is determined by the Cell object’s type and it’s content.

Listing 25. CellView UpdateDisplay method
void UpdateDisplay()
{
 if (wallImage != null)
 {
  wallImage.IsVisible = cell is WallCell;
 }

 if (floorImage != null)
 {
  floorImage.IsVisible = cell is FloorCell || cell is GoalCell;
 }

 if (floorHighlightImage != null)
 {
  floorHighlightImage.IsVisible = /*hasMouse &&*/ (cell is FloorCell || cell is GoalCell);
 }

 if (treasureImage != null)
 {
  treasureImage.IsVisible = cell.CellContents is Treasure;
 }

 if (playerImage != null)
 {
  playerImage.Opacity = 1;
  playerImage.IsVisible = cell.CellContents is Actor;
 }

 if (goalImage != null)
 {
  goalImage.IsVisible = cell is GoalCell && !(cell.CellContents is Treasure);
 }

 if (goalActiveImage != null)
 {
  goalActiveImage.IsVisible = cell is GoalCell && cell.CellContents is Treasure;
 }
}

Handling the CellView Tapped Gesture

One of the things I like most about this Sokoban game implementation is its touch friendly interface. Because of the game’s pathfinding capability, the user is able to move the player character to any reachable place on the game grid with a single tap. However, pusing a tile is not so easy without a keyboard. In a previous article I showed how when holding down the shift key while tapping, the user could push a cell multiple cells in a linear direction.
Well, of course, most mobile devices these days don’t have a hardware keyboard, so I needed another way to perform this move. I chose a double tap gesture. When the user double taps on a cell, if the player character is able, it will push a treasure to that cell.

Recall that we use a TapGestureRecognizer to be notified when the user taps the CellView. Unfortunately, in Xamarin Forms there isn’t a DoubleTapGestureRecognizer, so we have to come up with another way to recognize when the user double-taps a CellView. See Listing 26.

A tapCount field is used to record the number of times the user has tapped the cell within the short time-frame of 500 milliseconds. If the user taps two times within this timeframe it’s considered a double tap; otherwise it’s considered a single tap.

Listing 26. CellView HandleTapped method.
void HandleTapped(object sender, EventArgs e)
{
 tapCount++;
 if (tapCount >= 2)
 {
  tapCount = 0;
  isTimerSet = false;

  OnDoubleTap(EventArgs.Empty);
  return;
 }

 if (!isTimerSet)
 {
  isTimerSet = true;
  Device.StartTimer(new TimeSpan(0, 0, 0, 0, 500), () =>
  {
   if (isTimerSet && tapCount == 1)
   {
    OnTap(e);
   }
   isTimerSet = false;
   tapCount = 0;
   return false;
  });
 }
}
When a DoubleTap event occurs, the MainPage’s HandleCellDoubleTap method is called. See Listing 27. The method calls the game’s ProcessCellTap method with the cell and a true shiftPressed argument; indicating a push move is requested.

Listing 27. MainPage HandleCellDoubleTap method
void HandleCellDoubleTap(object sender, EventArgs e)
{
 CellView button = (CellView)sender;
 Cell cell = button.Cell;
 game.ProcessCellTap(cell, true);
}

iOS Implementation of the Sokoban Game

The Infrastructure implementation for iOS uses the Directory class to enumerate the level files in the Levels directory of the Outcoder.Sokoban.Launcher.iOS project. See Listing 28.

The iOS implementation uses the same level naming strategy and relies on the level (.skbn) files having the same naming convention. Level files are linked files. That is, they were added to the project using the Add Existing Item dialog in combination with the Add As Link drop down. See Figure 3.

The level files have been linked into the Levels directory. The build action of each level file in the iOS project is set to BundleResource. Unlike the Android implementation, resources, such as the level files, don’t need to be located within a centralized Resources directory, but rather can be strewn throughout your project; much like in UWP or WPF.
 
Figure 3. Adding a Level file as a link.
 

Listing 28. iOS Infrastructure class
class Infrastructure : InfrastructureBase
{
 readonly AudioClips audioClips;
 const string levelDirectory = "Levels";
 readonly List<string> fileList;

 public Infrastructure(ISynchronizationContext synchronizationContext, AudioClips audioClips) : base(synchronizationContext)
 {
  this.audioClips = ArgumentValidator.AssertNotNull(audioClips, "audioClips");
  fileList = Directory.EnumerateFiles(levelDirectory).ToList();
 }

 public override int LevelCount => fileList?.Count ?? 0;

 public override Task<string> GetMapAsync(int levelNumber)
 {
  string fileName = $@"{levelDirectory}/Level{levelNumber:000}.skbn";

  using (var stream = File.Open(fileName, FileMode.Open))
  {
   using (StreamReader reader = new StreamReader(stream))
   {
    string levelText = reader.ReadToEnd();

    return Task.FromResult(levelText);
   }
  }
 }

 public override void PlayAudioClip(AudioClip audioClip)
 {
  audioClips.Play(audioClip);
 }
}
The AudioClips class in the Outcoder.Sokoban.Launcher.iOS project is responsible for playing each sound effect. See Listing 29.

This class follows the same pattern as the Android implementation. An AVAudioPlayer is created to play back each of the sound effects .mp3 files. When the AudioClips object receives a request to play an AudioClip, it selects the applicable AVAudioPlayer instance and calls its PlayAtTime method.

Listing 29. iOS AudioClips class
class AudioClips
{
 readonly AVAudioPlayer levelIntroductionElement;
 readonly AVAudioPlayer gameCompleteElement;
 readonly AVAudioPlayer levelCompleteElement;
 readonly AVAudioPlayer footstepElement;
 readonly AVAudioPlayer treasurePushElement;
 readonly AVAudioPlayer treasureOnGoalElement;

 internal AudioClips()
 {
  const string audioDir = "Audio/";

  levelIntroductionElement = CreateElement(new NSUrl(audioDir + "LevelIntroduction.mp3"));
  gameCompleteElement = CreateElement(new NSUrl(audioDir + "GameComplete.mp3"));
  levelCompleteElement = CreateElement(new NSUrl(audioDir + "LevelComplete.mp3"));
  footstepElement = CreateElement(new NSUrl(audioDir + "Footstep.mp3"));
  treasurePushElement = CreateElement(new NSUrl(audioDir + "TreasurePush.mp3"));
  treasureOnGoalElement = CreateElement(new NSUrl(audioDir + "TreasureOnGoal.mp3"));
 }

 AVAudioPlayer CreateElement(NSUrl url)
 {
  string fileTypeHint = url.PathExtension;
  NSError error;
  var result = new AVAudioPlayer(url, fileTypeHint, out error);

  if (error != null)
  {
   Debug.WriteLine(error.LocalizedFailureReason);
   Debugger.Break();
  }

  return result;
 }

 void Play(AVAudioPlayer element)
 {
  element.PlayAtTime(0.0);
 }

 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;
  }
 }
}
The AppDelegate class in the Outcoder.Sokoban.Launcher.iOS project is analogous to the MainActivity in the Android implementation. See Listing 30.

The Infrastructure class constructor receives a UISynchronizationContext instance and an AudioClips instance. The App instance is then provided to the FormsApplicationDelegate.LoadApplication method, et voilà, the iOS app comes alive.
 
Listing 30. AppDelegate class
[Register("AppDelegate")]
public partial class AppDelegate : global::Xamarin.Forms.Platform.iOS.FormsApplicationDelegate
{
 public override bool FinishedLaunching(UIApplication app, NSDictionary options)
 {
  global::Xamarin.Forms.Forms.Init();

  var audioClips = new AudioClips();
  var infrastructure = new Infrastructure(new UISynchronizationContext(), audioClips);
  LoadApplication(new App(infrastructure));

  return base.FinishedLaunching(app, options);
 }
}
The expanded menu is shown in Figure 4.
 
 
Figure 4. Sokoban Running on iPad Simulator with Menu Expanded
 
 

Conclusion

In this article you looked at porting the Sokoban game to both iOS and Android. You saw how nearly all code is shared across both platforms. You observed some platform difference, such as those encountered when defining and referencing image resources, and at implementing audio playback for each platform. You looked at abstracting platform differences and how to provide platform specific implementations at runtime.
 
You saw how to work with XAML in Xamarin forms. You explored rudimentary views, such as text boxes (aka Entry views), labels and buttons. You also looked at more advanced topics, such as creating a slide-in menu and defining reusable XAML resources. You also saw how to use an AbsoluteLayout to layout the game grid, and at utilizing the game grid's available space.

You also explored touch gestures, and we introduced a new double tap move which allows the Sokoban character to push a treasure across multiple floor spaces.
 
I hope you find this project useful. If so, then please rate it and/or leave feedback below.

History

November 25 2016

  • First published

License

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

Share

About the Author

Daniel Vaughan
President Outcoder
Switzerland Switzerland
Daniel Vaughan is a eight-time Microsoft MVP and co-founder of Outcoder, a Swiss software and consulting company dedicated to creating best-of-breed user experiences and leading-edge back-end solutions, using the Microsoft stack of technologies--in particular Xamarin, WPF, and the UWP.

Daniel is the author of Windows Phone 8 Unleashed and Windows Phone 7.5 Unleashed, both published by SAMS.

Daniel is the developer behind several acclaimed mobile apps including Surfy Browser for Android and Windows Phone. Daniel is the creator of a number of popular open-source projects, most notably Codon.

Would you like Daniel to bring value to your organisation? Please contact

Blog | MVP profile | Twitter


Xamarin Experts
Windows 10 Experts

You may also be interested in...

Pro
Pro

Comments and Discussions

 
GeneralMy vote of 5 Pin
NandaKumer20-Dec-16 3:44
memberNandaKumer20-Dec-16 3:44 
GeneralMy vote of 5 Pin
Dmitriy Gakh9-Dec-16 2:53
professionalDmitriy Gakh9-Dec-16 2:53 
GeneralRe: My vote of 5 Pin
Daniel Vaughan10-Dec-16 4:30
memberDaniel Vaughan10-Dec-16 4:30 
GeneralRe: My vote of 5 Pin
bogel bogel15-Dec-16 21:36
memberbogel bogel15-Dec-16 21:36 
QuestionGreat work Pin
AshtonAsh29-Nov-16 3:38
memberAshtonAsh29-Nov-16 3:38 
AnswerRe: Great work Pin
Daniel Vaughan2-Dec-16 1:26
memberDaniel Vaughan2-Dec-16 1:26 
AnswerRe: Great work Pin
bogel bogel16-Dec-16 16:57
memberbogel bogel16-Dec-16 16:57 
QuestionYummy, scrummy, gummy mummy Pin
Pete O'Hanlon26-Nov-16 6:33
protectorPete O'Hanlon26-Nov-16 6:33 
AnswerRe: Yummy, scrummy, gummy mummy Pin
Daniel Vaughan26-Nov-16 12:18
memberDaniel Vaughan26-Nov-16 12:18 
PraiseOutstanding Xamarin Forms Teaching Pin
Karl Shifflett25-Nov-16 4:33
professionalKarl Shifflett25-Nov-16 4:33 
GeneralRe: Outstanding Xamarin Forms Teaching Pin
Daniel Vaughan25-Nov-16 4:40
memberDaniel Vaughan25-Nov-16 4:40 

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.

Permalink | Advertise | Privacy | Terms of Use | Mobile
Web04 | 2.8.170915.1 | Last Updated 25 Nov 2016
Article Copyright 2016 by Daniel Vaughan
Everything else Copyright © CodeProject, 1999-2017
Layout: fixed | fluid