65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Xamarin Forms 构建拼图游戏

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (21投票s)

2016年11月25日

CPOL

22分钟阅读

viewsIcon

56570

downloadIcon

1722

一个有趣的 Xamarin Forms 推箱子游戏实现,演示了如何在几乎所有代码都跨平台共享的情况下,将 UWP 益智游戏移植到 Android 和 iOS。

 

 

引言

在之前的三部分系列中,你看到了如何为通用 Windows 平台 (UWP) 创建一个基于图块的游戏。
本文将介绍如何将游戏移植到 iOS 和 Android;在 Visual Studio 中创建一个跨平台的 Xamarin 解决方案。
 
你将看到几乎所有代码都在平台之间共享。你会注意到一些平台差异,例如在定义和引用图像资源以及为每个平台实现音频播放时遇到的差异。你将了解如何抽象这些平台差异,在运行时提供平台特定的实现。
 
你将看到如何在 Xamarin Forms 中使用 XAML。你将探索基本的视图,如文本框、标签和按钮。你还将了解更高级的主题,例如创建滑入式菜单和定义可重用的 XAML 资源。你还将看到如何使用 `AbsoluteLayout` 来布局游戏网格,以及如何利用游戏网格的可用空间。

你还将探索触摸手势,我们引入了一种新的双击移动方式,使推箱子角色可以跨多个地板空间推动宝藏。

了解你的平台

Xamarin Forms 在过去几年中取得了长足的进步。它正日益成为跨平台开发的越来越可行的候选方案。Xamarin Forms 不是一种无外观的 GUI 技术。控件的渲染方式与本地实现时相同。创建一个功能丰富的多面应用程序需要了解底层平台。每个平台都有细微的差别,通常需要调整才能获得所需的外观和感觉。这就是为什么,如果你正在考虑使用 Xamarin 工具创建一个严肃的应用程序,我建议你事先阅读一些关于你所针对的底层平台或平台的资料。平台特定的怪癖通常无法抽象。这就是为什么对底层平台的良好了解对于创建丰富的用户界面至关重要。因此,我鼓励你在开始一个复杂的 Forms 应用程序之前,先对 Android 和 iOS 开发有一个合理的了解。

尽管如此,你仍然可以在不具备任何平台特定知识的情况下,在 Xamarin Forms 中创建一个相对简单的应用程序,并从中获得乐趣。让我们开始吧。

创建 Xamarin 跨平台解决方案

在 Visual Studio 中创建新的 Xamarin 解决方案时,"新建项目" 对话框中提供了多个选项。参见图 1。

我非常喜欢 XAML,所以对于这个项目,我选择了“空白 XAML 应用程序”选项。我还选择了 PCL 项目类型而不是“共享项目”类型,因为我知道我想将所有平台特定代码限制在各自的平台特定项目中,而不是依赖预处理器指令或其他机制来包含或排除代码。我选择 PCL 而不是“共享项目”类型的另一个原因是,我发现使用共享项目时,IntelliSense 有时会行为异常。
 
图 1. 使用“新建项目”对话框创建空白 XAML 应用程序


理解 Xamarin Forms 解决方案结构

当你创建一个 Xamarin Forms XAML 项目时,会创建四个项目:
  • 一个 Xamarin Android 项目
  • 一个 Xamarin iOS 项目
  • 一个 UWP 项目
  • 以及一个 Xamarin Forms 项目

在可下载的解决方案中,我将这三个平台特定项目组合在一起。参见图 2。

Android、iOS 和 UWP 的平台特定项目是入口点应用程序。而 Xamarin Forms 项目包含我们大部分的 GUI 代码,并有效地托管在每个平台特定项目中。

有了新的 Xamarin Forms 解决方案,我们就可以引入推箱子游戏 PCL 并构建 Forms 项目。
 
图 2. Forms 推箱子解决方案

Sokoban PCL 项目 (Sokoban.csproj) 是平台无关的,并且是之前系列文章的延续。为了在 Xamarin Forms 中实现该游戏,我们需要构建 Forms 项目 (Outcoder.Sokoban.Launcher) 并为每个受支持的平台实现基础设施类。Forms 启动器项目包含 Android、iOS 和 UWP 项目使用的 Xamarin Forms 项。

在 Xamarin Forms 中构建游戏 UI

游戏的界面由以下三个部分组成
  • 一个顶部工具栏部分,
  • 一个用于游戏图块的下部部分,
  • 以及一个滑入式菜单。
让我们从滑入式菜单开始。

在 Xamarin Forms 中实现滑入式菜单

为了实现与我为 UWP 版应用程序创建的相同的滑出菜单,我使用了 `MasterDetailPage`。Outcoder.Sokoban.Launcher 项目中的 MainPage.xaml 文件不是从 `Page` 继承,而是从 `MasterDetailPage` 继承。`MasterDetailPage` 允许你将页面分成两部分:主部分,可以看作是一组项目,当选中时,会更改详细信息部分的内容。详细信息部分显示一个或多个不同的页面,代表主页面中选定的项目。
 
我们对 `MasterDetailPage` 的使用并不完全符合该描述;我们将主部分用作菜单,将详细信息页面用作平铺的游戏区域。在此应用程序中,详细信息页面不会更改。

MainPage.xaml 的根元素如下所示

<MasterDetailPage xmlns="http://xamarin.com/schemas/2014/forms"
       xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
       x:Class="Outcoder.Sokoban.Launcher.MainPage">
…
使用 Xamarin Forms `MasterDetailPage` 时,需要构建两个主要元素。第一个对应 `MasterDetailPage.Master` 属性,即滑入部分。第二个对应 `MasterDetailPage.Detail` 属性,在本例中是游戏的固定视图。两者都填充了 `Page` 实例;通常 `Master` 为单个 `Page`,`Detail` 属性为一个或多个页面。

注意: 不要期望 `MasterDetailPage` 在每个设备上的行为都相同。`Master` 部分的行为可能会根据平台和屏幕大小而异。如果屏幕大小足够大,那么 `Master` 部分可能会永久可见。

在推箱子游戏中,`Master` 包含一个 `StackLayout` 和几个 `Label` 视图,它们绑定到 `Game` 类中的命令。参见清单 1。`Game` 类实际上是 `MainPage` 的 ViewModel。

注意:在 UWP 和 WPF 中,术语“控件”经常用于表示交互式 UI 元素。然而,在 Xamarin Forms 中,使用的是术语“视图”。这是因为在 Xamarin Forms 中,UI 元素派生自一个基 `View` 类。如果你来自 Android 开发领域,你会对这种命名法感到熟悉。

基 `View` 类包含一个 `GestureRecognizers` 属性,可以填充一组 `IGestureRecognizers`。除了 `Tapped` 事件外,`TapGestureRecognizer` 还有一个方便的 `Command` 属性,我们将其附加到每个游戏命令中。

我同时使用了 `Tapped` 事件和 `Command` 属性。`Tapped` 事件用于触发菜单的关闭。我对此并不满意,我更希望命令能负责设置一个属性来关闭菜单。但是,这样做更简单。

清单 1. MainPage.xaml MasterDetailPage.Master 摘录
<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>
主页中每个菜单项的 `Tapped` 处理程序都会调用 `MainPage` 的 `CloseMenu` 方法,该方法又将页面的内置 `IsPresented` 属性设置为 false。参见清单 2。当 `IsPresented` 为 true 时,菜单展开。当为 false 时,菜单折叠。

清单 2. MainPage 的 HandleMenuItemTapped 和 CloseMenu 方法
void HandleMenuItemTapped(object sender, EventArgs e)
{
   CloseMenu();
}

void CloseMenu()
{
   IsPresented = false;
}
就像 UWP 和 WPF 一样,Xamarin Forms 支持 `StaticResource` 标记扩展,它提供了一种简单的方法来在基于 XAML 的应用程序中共享资源。游戏的资源位于 Outcoder.Sokoban.Launcher 项目中的 App.xaml 文件中。参见清单 3。

Xamarin Forms 中存在的许多结构与 UWP 或 WPF 中的结构相似,甚至在某些情况下完全相同。在这里,我们看到 `Application.Resources` 填充了一个 `ResourceDictionary`,其中包含整个应用程序中使用的各种颜色。

清单 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>
主游戏 UI 在 MainPage.xaml 的 `MasterDetailPage` 的 `Detail` 部分中定义。参见清单 4。
就像我们之前文章中的 UWP 实现一样,游戏 UI 分为顶部部分(显示关卡编号等)和底部部分(承载游戏图块)。

Xamarin Forms 中的 `Entry` 术语是指文本框,用户可以在其中输入文本。`levelCodeTextBox` 是一个 `Entry` 视图,允许用户在知道与该关卡对应的正确代码时跳转到不同的关卡。`levelCodeTextBox` 保留了我用于 UWP 实现的相同名称,尽管它可能应该重命名为 levelCodeEntry 或类似名称。

游戏网格使用 `AbsoluteLayout` 视图实现。`AbsoluteLayout` 类似于 UWP 或 WPF `Canvas` 控件,允许你使用 X 和 Y 坐标定位项目。

详情页中的最后一个元素是覆盖层,我们用它来遮盖游戏,以便在关卡完成时向用户显示消息。覆盖层的可见性绑定到游戏对象的 `FeedbackVisible` 属性。

清单 4. MainPage.xaml MasterDetailPage.Detail 摘录
<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>

实现平台特定的基础设施

在之前的 UWP 系列文章中,我们需要一种方法让推箱子 `Game` 对象利用一些平台特定的功能。我们通过自定义 `IInfrastructure` 实现,为每个平台抽象了这些代码。

在 Xamarin Forms 推箱子实现中,我们有一个名为 `InfrastructureBase` 的 `IInfrastructure` 接口的基实现;它位于 Outcoder.Sokoban.Launcher 项目中。参见清单 5。

`Game` 对象需要一个自定义 `ISynchronizationContext` 实现的实例。
`InfrastructureBase` 使用了一些 Xamarin Forms API,这些 API 可在所有平台上使用。特别是 `Application.Properties` 集合是一个 `IDictionary`,允许你以平台无关的方式持久化键值对。
 

清单 5. InfrastructureBase 类。
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);
}


各种 `IInfrastructure` 成员,包括 `LevelCount`、`GetMapAsync` 和 `PlayAudioClip`;需要为每个平台实现。我们稍后会关注这一点,但首先让我们检查 `ISynchronizationContext` 成员。

抽象线程同步上下文

在实例化时,`InfrastructureBase` 需要一个实现 `ISynchronizationContext` 的对象。你可能还记得之前的系列文章中,`ISynchronizationContext` 用于在主线程上调用一个动作。它的工作方式是,如果代码已经在 UI 线程上执行,那么动作不会被推送到 UI 线程队列,而是立即执行,这可以提高性能。

Xamarin Forms 中 `ISynchronizationContext` 的实现对于 Android 和 iOS 都是相同的,如清单 6 所示。

在 Android 和 iOS 上,Mono 框架暴露了一个 `Thread.CurrentThread.IsBackground` 属性,它使你能够确定当前线程是否是 UI 线程。UWP 缺少这样的属性,需要一个 `Dispatcher` 实例来确定该信息。因此,UWP 的实现有所不同。UWP 的实现已在之前的系列文章中描述,此处不再赘述。

注意:在使用 Xamarin Forms 时,你会发现 iOS 和 Android 的 .NET API 表面积比 UWP 更大。原因是 iOS 和 Android 能够利用 Mono 框架,该框架拥有更广泛的 API,涵盖了 .NET FCL(框架类库)的大部分。我怀疑这些差异会随着时间的推移逐渐消失。

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

 public void InvokeIfRequired(Action action)
 {
  if (InvokeRequired)
  {
   Xamarin.Forms.Device.BeginInvokeOnMainThread(action);
  }
  else
  {
   action();
  }
 }
}
`IInfrastructure` 类的 Android 和 iOS 实现都名为 `Infrastructure`,并扩展了 `InfrastructureBase` 类。参见清单 7。

除了 `ISynchronizationContext`,`Infrastructure` 类还需要一个 `Activity` 实例来检索关卡地图资源。它还需要一个 `AudioClips` 对象,用于在游戏过程中播放声音片段。

清单 7. Android Infrastructure 类
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);
 }
}
游戏的关卡文件已链接到 Android 启动器项目 (Outcoder.Sokoban.Launcher.Droid) 的 Assets/Levels 目录中。每个关卡文件的“构建操作”都设置为 Android Asset

Android 对资源和任意文件的放置位置相当挑剔。在 Xamarin Android 中,布局文件、图像和音频文件等资源必须位于 `resources` 目录的子目录中。游戏中使用的图像位于 `resources/drawable` 目录中,而游戏中使用的 MP3 文件位于 `resources/raw` 目录中。

`AudioClips` 类创建多个 `MediaPlayer` 对象;每个声音文件一个。参见清单 8。

传递给 `AudioClips` 构造函数的 `Context` 实例用于检索位于 Outcoder.Sokoban.Launcher.Droid 项目的 Resources/raw 目录中的每个 .mp3 文件。

出于性能原因,播放通过调用 `AudioClips` 的 `Play` 方法中的异步 `Task.Run` 在 `ThreadPool` 线程上执行。

`AudioClip` 枚举包含每个音效的值,它是一种跨平台友好的方式,用于指示 `AudioClips` 类播放哪个音效片段。

清单 8. Android AudioClips 类
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;
  }
 }
}

`AudioClips` 类在 Outcoder.Sokoban.Launcher.Droid 项目的 `MainActivity` 类的 `OnCreate` 方法中实例化。参见清单 9。

我们创建一个 `Infrastructure` 实例;传入一个 `UISynchronizationContext` 对象、当前活动和 `AudioClips` 实例。
 

清单 9. Android MainActivity.OnCreate 方法摘录
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));
}


`Xamarin.Forms.Platform.Android.FormsAppCompatActivity` 类的 `LoadApplication` 负责实现自定义的 App 类。参见清单 10。App 扩展了 `Xamarin.Forms.Application`,它是任何 Xamarin Forms App 的基类。


`App` 对象实例化一个 `MainPage` 对象,将指定的 `IInfrastructure` 实例传递给它,该实例随后传递给推箱子 `Game` 对象,允许它检索地图、播放音效等。

`Xamarin.Forms.Application` 类为各种应用程序生命周期事件提供了虚拟方法:`OnStart`、`OnSleep` 和 `OnResume`。

注意: `Application` 类中没有 `OnEnd` 虚拟方法。其中一个原因可能是因为 Android 和 UWP 等平台无法可靠地检测应用程序何时退出。这就是为什么最好假设你的应用程序在调用 `OnSleep` 时将被终止。当调用 `OnSleep` 时,保存应用程序的状态。

清单 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
 }
}
`MainPage` 类构造函数订阅页面的 `Appearing` 事件。参见清单 11。`Appearing` 事件允许我们等到网格布局已完成布局,以便我们可以可靠地确定其可用大小。
 
`Entry` 类(回想一下它们是文本框)有一个 `Keyboard` 属性,允许你指定软件键盘的类型及其特性。例如,数字键盘主要包含数字,而 URL 键盘将包含常用于输入网址的字符。

`levelCodeTextBox` 的文本使用 `CapitalizeSentence` 标志进行大写。

注意: `Keyboard` 属性提供了一个很好的抽象,但请注意,可用的键盘类型是所有受支持平台所有键盘类型的交集。你可能会发现使用本机 API 可以让你访问更适合你需求的键盘。

清单 11. MainPage.xaml.cs 构造函数摘录
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);
 }
…
}
`MainPage` 类的 `HandleAppearing` 方法使用一个加载标志来确保其主体只运行一次。

`Game` 对象是使用 `IInfrastructure` 对象创建的。`BindingContext` 设置为游戏对象。

注意: `BindingContext` 类似于 UWP 和 WPF 中 `FrameworkElements` 的 `DataContext` 属性。

`HandleAppearing` 方法等待 `Task.Yield` 几次,以确保视图已布局。然后,调用 `StartGame` 方法。参见清单 12。

如果宿主窗口的大小发生变化,则需要重绘游戏网格。当设备方向改变时,会触发 `SizeChanged` 事件,这很方便,因为它为我们提供了调整单元格大小的机会。

清单 12. MainPage HandleAppearing 方法
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;
}
`Game` 类实现了 `INotifyPropertyChanged`。我们在 `HandleGamePropertyChanged` 方法中响应 `Game` 属性更改。参见清单 13。

在此实现中,我们只关心 `GameState` 属性何时更改。当它更改时,我们调用 `ProcessGameStateChanged` 方法。

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

大部分 UI 逻辑已移至 `Game` 类中。我们唯一感兴趣的状态是 `Loading` 状态,在该状态下我们初始化关卡。参见清单 14。其余状态提供了 UI 扩展点。例如,你可以在关卡加载时启动动画。

清单 14. MainPage. ProcessGameStateChanged 方法。
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;
}

放置在游戏网格中的单元格保留在一个字典中。这在我们需要刷新网格大小时提高了性能。

初始化关卡涉及丢弃现有单元格,然后调用 `LayoutLevel` 方法。参见清单 15。

`levelCodeTextBox` 的 `Text` 属性设置为游戏的 `LevelCode` 属性。它没有直接绑定到游戏属性,因为我们允许用户尝试输入关卡代码。它应该真正绑定到一个游戏属性,逻辑包含在 `Game` 类中,但我没有时间做这件事。
 

清单 15. InitializeLevel 方法
void InitialiseLevel()
{
 foreach (var control in controlDictionary.Values)
 {  
  DetachCellView(control);  
 }
 controlDictionary.Clear();

 gameLayout.Children.Clear();

 LayoutLevel();

 levelCodeTextBox.Text = game.LevelCode;
}
填充游戏网格需要为游戏中 `Level` 的每个 `Cell` 对象创建一个 `CellView`。参见清单 16。
我们根据可用宽度和高度最大化单元格的大小。

每个 `CellView` 都被添加到 `AbsoluteLayout` 视图的 `Children` 集合中。我们使用 `AbsoluteLayout` 的静态 `SetLayoutBounds` 方法设置 `CellView` 的位置。

清单 16. MainPage LayoutLevel 方法
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;
 }
}

当 `CellView` 被点击时,游戏会尝试将玩家移动到网格上的该位置。参见清单 17。事件由 `Game` 对象处理。

清单 17. MainPage HandleCellTap 方法
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);
}
`ProcessCellTap` 方法执行跳跃或推动。参见清单 18。当玩家直线行走并可能推动宝藏时,这称为推动。或者,跳跃将玩家移动到任何可到达的单元格,而不会推动路径中的任何宝藏。

清单 18. 游戏 ProcessCellTap 方法
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);
}
我为用户的武器库添加了一个双击手势。如果一个 `CellView` 被双击,如果玩家角色能够,它会将一个宝藏推到该单元格。同样,该事件由 `Game` 对象的 `ProcessCellTap` 方法处理。参见清单 19。

清单 19. MainPage HandleCellDoubleTap 方法
void HandleCellDoubleTap(object sender, EventArgs e)
{
 CellView button = (CellView)sender;
 Cell cell = button.Cell;
 game.ProcessCellTap(cell, true);
}
当宿主视图的大小改变时,应用程序会调整其游戏网格中单元格的大小,以利用所有可用空间。回想一下,当设备方向改变时,会触发 `Page` 对象的 `SizeChanged` 事件。

`HandleWindowSizeChanged` 在大小改变发生一秒后安排布局更新。参见清单 20。这可以防止应用程序因同时发生的多个大小改变事件而变慢。

`Xamarin.Forms.Device.StartTimer` 事件提供了一种平台无关的方式来在 UI 线程上安排工作。如果提供给 `StartTimer` 方法的操作返回 true,则该操作将在指定间隔后重复;否则,该操作将不再被调用。
 
清单 20. MainPage HandleWindowSizeChanged 方法
void HandleWindowSizeChanged(object sender, EventArgs eventArgs)
{
 if (layoutScheduled)
 {
  return;
 }
 layoutScheduled = true;

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

   return false;
  });
   
}

实现 Forms CellView

Outcoder.Sokoban.Launcher 项目中的 `CellView` 类代表游戏中的一个图块。它可能显示为墙壁或包含内容的地面图块。地面单元格的内容可以是玩家、宝藏、目标;或这些的组合。

`CellView` 是 `Xamarin.Forms.ContentView` 的子类,我选择它是因为它允许分层多个子视图,并且看起来相当轻量,这很重要,因为游戏网格需要创建许多 `CellView` 对象。但是,这里有优化空间。`CellView` 的 `Content` 属性填充了一个 `Grid` 视图。我有一些间距和布局问题通过这种配置得到了解决,但它不是最优的,如果你打算利用此代码,我建议你考虑改进 `CellView` 的结构。

当创建 `CellView` 时,它会自行设置以响应点击手势。参见清单 21。在 Xamarin Forms 中,触摸事件通常使用 `View` 类的 `GestureRecognizers` 属性来实现,而不是通过例如直接订阅“Tap”事件。因此,你需要额外进行一些设置;创建一个 `TapGestureRecognizer`,订阅它的 `Tapped` 事件,并将其添加到视图的 `GestureRecognizers` 属性中。

`CellView` 中显示的一个或多个图像取决于其关联的推箱子 `Cell` 对象的类型。

清单 21. CellView 构造函数
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();
}
必须为每个单元格类型及其内容创建图像。参见清单 22。

`Xamarin.Forms.Image` 类派生自 `View`,视图始终受限于单个父视图。因此,它不能被缓存并在页面中多次使用。然而,每个 `Image` 都依赖于 `ImageSource` 对象,该对象可以被缓存以减少应用程序的内存占用。

在 `CellView` 类中,有一个名为 `imageCache` 的静态 `Dictionary`。这里存放着每种单元格类型和内容的 `ImageSource` 对象。

清单 22. CellView CreateContentImage 方法
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;
}
`ImageSource.FromFile` 方法允许你在任何 Xamarin 支持的平台上检索图像数据。如果在 iOS 或 Android 上使用,则不应包含目录路径。然而,在 UWP 上,必须提供图像内容资源的完整路径。

`CellView` 类包含一个静态构造函数,根据平台设置 `imageDir` 字段。参见清单 23。你使用 `Xamarin.Forms.Device` 类的静态 `OS` 属性来确定应用程序运行在哪个平台上。在这种情况下,如果应用程序运行在 iOS、Android 或其他平台上,则图像目录设置为空字符串。在 iOS 和 Android 上,资源放置在特定目录中,并使用唯一名称进行定位。然而,在 UWP 应用程序中,图像可以作为内容存在于项目中的任何位置。

清单 23. CellView 静态构造函数
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/";
 }
}
每个图像对象都通过 `AddChildTile` 方法添加到 `CellView` 中。参见清单 24。

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

 return image;
}
当 `CellView` 首次初始化或其关联的 `Cell` 对象的内容更改时,会调用 `UpdateDisplay` 方法。参见清单 25。`CellView` 中每个图像的可见性由 `Cell` 对象的类型及其内容决定。

清单 25. CellView UpdateDisplay 方法
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;
 }
}

处理 CellView 点击手势

我最喜欢这个推箱子游戏实现的一点是它的触摸友好界面。由于游戏具有寻路功能,用户只需轻触一下即可将玩家角色移动到游戏网格上任何可到达的位置。然而,如果没有键盘,推动图块就不那么容易了。在之前的文章中,我展示了如何在按住 Shift 键的同时点击,用户可以沿线性方向推动多个单元格。
当然,如今大多数移动设备都没有硬件键盘,所以我需要另一种方法来执行此操作。我选择了双击手势。当用户双击一个单元格时,如果玩家角色能够,它会将一个宝藏推到该单元格。

回想一下,我们使用 `TapGestureRecognizer` 来通知用户何时点击 `CellView`。不幸的是,在 Xamarin Forms 中没有 `DoubleTapGestureRecognizer`,所以我们必须想出另一种方法来识别用户何时双击 `CellView`。参见清单 26。

`tapCount` 字段用于记录用户在 500 毫秒的短时间内点击单元格的次数。如果用户在此时间内点击两次,则认为是双击;否则认为是单次点击。

清单 26. CellView HandleTapped 方法。
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;
  });
 }
}
当发生 `DoubleTap` 事件时,会调用 `MainPage` 的 `HandleCellDoubleTap` 方法。参见清单 27。该方法调用游戏的 `ProcessCellTap` 方法,传入单元格和一个 true 的 `shiftPressed` 参数;表示请求推动操作。

清单 27. MainPage HandleCellDoubleTap 方法
void HandleCellDoubleTap(object sender, EventArgs e)
{
 CellView button = (CellView)sender;
 Cell cell = button.Cell;
 game.ProcessCellTap(cell, true);
}

推箱子游戏的 iOS 实现

iOS 的 `Infrastructure` 实现使用 `Directory` 类来枚举 Outcoder.Sokoban.Launcher.iOS 项目的 Levels 目录中的关卡文件。参见清单 28。

iOS 实现使用相同的关卡命名策略,并依赖于关卡 (.skbn) 文件具有相同的命名约定。关卡文件是链接文件。也就是说,它们是使用“添加现有项”对话框结合“添加为链接”下拉菜单添加到项目中的。参见图 3。

关卡文件已链接到 Levels 目录中。iOS 项目中每个关卡文件的构建操作都设置为 BundleResource。与 Android 实现不同,资源(例如关卡文件)不需要位于集中的 Resources 目录中,而是可以散布在整个项目中;就像在 UWP 或 WPF 中一样。
 
图 3. 将关卡文件添加为链接。
 

清单 28. iOS Infrastructure 类
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);
 }
}
Outcoder.Sokoban.Launcher.iOS 项目中的 `AudioClips` 类负责播放每个音效。参见清单 29。

这个类遵循与 Android 实现相同的模式。创建了一个 `AVAudioPlayer` 来播放每个音效 .mp3 文件。当 `AudioClips` 对象收到播放 `AudioClip` 的请求时,它会选择适用的 `AVAudioPlayer` 实例并调用其 `PlayAtTime` 方法。

清单 29. iOS AudioClips 类
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;
  }
 }
}
Outcoder.Sokoban.Launcher.iOS 项目中的 `AppDelegate` 类类似于 Android 实现中的 `MainActivity`。参见清单 30。

Infrastructure 类构造函数接收一个 `UISynchronizationContext` 实例和一个 `AudioClips` 实例。然后将 `App` 实例提供给 `FormsApplicationDelegate.LoadApplication` 方法,瞧,iOS 应用程序就活起来了。
 
清单 30. AppDelegate 类
[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);
 }
}
展开的菜单如图 4 所示。
 
 
图 4. 推箱子在 iPad 模拟器上运行,菜单已展开
 
 

结论

在本文中,您了解了如何将推箱子游戏移植到 iOS 和 Android。您看到了几乎所有代码都在两个平台之间共享。您注意到了一些平台差异,例如在定义和引用图像资源以及为每个平台实现音频播放时遇到的差异。您了解了如何抽象平台差异以及如何在运行时提供平台特定的实现。
 
你看到了如何在 Xamarin Forms 中使用 XAML。你探索了基本的视图,例如文本框(也称为 Entry 视图)、标签和按钮。你还了解了更高级的主题,例如创建滑入式菜单和定义可重用的 XAML 资源。你还看到了如何使用 `AbsoluteLayout` 来布局游戏网格,以及如何利用游戏网格的可用空间。

你还探索了触摸手势,我们引入了一种新的双击移动方式,允许推箱子角色跨多个地板空间推动宝藏。
 
我希望这个项目对您有用。如果觉得有用,请评分和/或在下方留言。

历史

2016年11月25日

  • 首次发布
使用 Xamarin Forms 构建益智游戏 - CodeProject - 代码之家
© . All rights reserved.