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

UWP 异形推箱子 - 第 3 部分

starIconstarIconstarIconstarIconstarIcon

5.00/5 (7投票s)

2016年10月26日

CPOL

8分钟阅读

viewsIcon

11428

一个有趣的 UWP 版推箱子游戏实现,展示了一些 XAML 和 C# 6.0 的新特性。第三部分

引言

这是三部分系列文章的第三部分,您将在此系列中探索如何为通用 Windows 平台 (UWP) 实现一个基于 XAML 的游戏。

第二部分 中,您学习了如何将推箱子 Game 连接到应用程序的主页面。您了解了如何使用 UWP 的 MediaElement 控件播放音效。最后,您探索了游戏网格是如何由自定义单元格控件填充的。
 
在本部分中,您将了解地图数据是如何从文件系统中检索的。您将更深入地研究自定义 CellControl,并考察图像是如何用于表示单元格内容的。您将看到如何通过缓存 Bitmap 对象来减少应用的内存占用。您将了解如何响应应用内的触摸事件,并编写线程安全的异步可等待代码。最后,您将看到如何实现一个 SplitView 来为游戏提供一个滑动菜单。

本系列文章链接

检索地图

在游戏中,地图存储在 ASCII 文件中。文件中的每个字符代表一个单元格。有关地图格式的更多信息,请参阅 这篇之前的文章
 
Game 类利用 IInfrastructure 实现来检索关卡地图。地图信息位于 Sokoban.Launcher 项目的 Levels 目录中。
 
每个地图文件的生成操作 (Build Action) 都设置为 Content,这会在构建时将地图放置在应用包中,使其可供应用访问。地图检索是在 Infrastructure 类的 GetMapAsync 方法中执行的,如列表 1 所示。
 
列表 1. GetMapAsync 方法
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;
}
我们使用 ms-appx:/// 协议前缀构建地图的 URI。然后,我们使用异步 StorageFile API 来检索文件。

探索单元格控件

当游戏布局时,每个 Cell 对象都与一个 CellControl 实例配对。CellControl 是一个自定义控件,由多个 Image 对象组成;每个 Image 对象代表单元格的类型和内容。
 
当一个 Cell 被分配给一个 CellControl 时,CellControl 会订阅 CellPropertyChanged 事件。当 CellCellContents 属性发生更改时,会调用 UpdateDisplay 方法。请参阅列表 2。每个 Image 对象的可见性根据 Cell 及其内容进行更新。
 
列表 2. CellControl UpdateDisplay 方法
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);
}

SetVisibility 方法是一个自定义扩展方法,它避免了使用三元表达式来设置 UIElementVisibility 属性。它减少了冗余。请参阅列表 3。

列表 3. UIElementExtensions SetVisibility 方法。

public static void SetVisibility(this UIElement element, bool visible)
{
    element.Visibility = visible ? Visibility.Visible : Visibility.Collapsed;
}

换句话说,它将此

wallImage.Visibility = cell is WallCell ? Visibility.Visible : Visibility.Collapsed;

变为此

wallImage.SetVisibility(cell is WallCell);

顺带一提,微软没有借此机会用一个简单的布尔属性替换 UIElementVisibility 属性,这确实很可惜,因为 UWP 中已经对许多不太重要的东西进行了改造。当然,现在引入一个新的可见性枚举值(如 Android 的“Gone”可见性值)可能有点晚了。

此外,SDK 中没有内置的 BooleanToVisibilityConverter,这意味着新开发者在弄清楚如何根据布尔值隐藏或显示元素时,大部分时间都会卡住。好消息是,随着 Windows 10 创意者更新的发布,x:Bind 现在可以隐式地与 bool 和 Visibility 枚举值相互转换。但这仅在运行创意者更新的计算机和设备上有效。

位图缓存

位图会占用大量内存。如果您的 UWP 应用打算在手机上运行,那么您需要注意将内存使用量降至最低。

注意: 在 Windows Mobile 上,对于内存超过 1GB 的设备,您的应用最多只能使用 390 MB。此限制强制执行,无论有多少系统内存可用,并且如果超出该限制,操作系统将退出您的应用。

为了减少推箱子游戏所需的 RAM 量,BitmapImage 对象被缓存在一个字典中。虽然每个单元格都有自己的一组 Image 对象,但它与其他 CellControls 共享底层的 BitmapImages

图像缓存是 CellControl 本身的一个简单的静态 Dictionary,如下所示。

static readonly Dictionary<string, BitmapImage> imageCache
   = new Dictionary<string, BitmapImage>();

当实例化 CellControl 时,也会创建一组 Image 对象。请参阅列表 4。CreateContentImage 方法首先尝试使用 relativeUrl 参数检索图像。如果之前已创建图像,则将其分配给 Image 的 Source 属性。

列表 4. CellControl CreateContentImage 方法

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;
}

响应触摸事件

有各种触摸和键盘操作来控制我们的小外星朋友。首先,点击任何一个方格都会让外星人尝试使用它能找到的最短路径移动到该位置。我在 之前的文章 中详细介绍了这一点。
 
注意: WinRT 和 UWP 中,一些类型的名称和/或命名空间发生了更改。(WinRT 是我们现在称之为 UWP 的第一个版本)。
其中两个例子是 WPF 的 UIElement MouseEnterMouseExited 事件。它们现在分别是 PointerEnteredPointerExited
 
当用户鼠标悬停在单元格上时,会触发 PointerEnter 事件,该事件调用 CellControlUpdateDisplay 方法,然后该方法显示单元格高亮图像。

使用信号量实现线程安全的异步等待

我已经更新了 Actor 类的移动相关代码,使其使用异步等待而不是线程锁定。在实现线程安全的异步代码时,一个挑战是锁原语不允许在异步代码块中使用。它们可能导致死锁。因此,编译器会阻止您执行以下操作:
lock (aLock)
{
    await DoSomethingAsync();
}
但是,您可以使用 SemaphoreSlim 类来防止异步代码块中的竞态条件。我在 ActorJumpAsync 方法中演示了这一点。请参阅列表 5。
 
名为 moveSemaphoreSemaphoreSlim 通过调用其 WaitAsync 方法来获取。请注意,该方法是可等待的。您需要等待该方法才能访问敏感区域。
 
列表 5. Actor JumpAsync 方法
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 如何找到路径的信息,请参阅 这篇之前的 Silverlight 文章
 
SemaphoreSlim 类同时支持异步和同步代码块。如果您希望使用同一个 SemaphoreSlim 实例来保护异步和非异步代码块,请使用其同步 Wait 方法。您可以在 ActorDoMove 方法中看到这一点。请参阅列表 6。
 
这里我们依赖于同一个 SemaphoreSlim 对象来保护非可等待块。
 
列表 6. Actor DoMove 方法
internal bool DoMove(Move move)
{
 try
 {
  moveSemaphore.Wait();
 
  return DoMoveAux(move);
 }
 finally
 {
  moveSemaphore.Release();
 }
}

关于图像资源

早期的文章使用了 Expression Design 来创建游戏资源。Expression Design 已不再是独立产品。此外,UWP 不支持 RadialGradientBrush,这意味着我无法使用我之前创建的图像资源。因此,我重新开始,在 Photoshop 中创建了图像。
我喜欢将资源放在 XAML 中,因为它提供了无损缩放。但是,由于我打算将 Alien Sokoban 移植到 Android 和 iOS,因此使用 .jpg 和 .png 图像是有意义的。但说实话,我真希望 UWP 中能有一个 RadialGradientBrush

实现滑动菜单

游戏有以下三个命令,用户可以通过游戏中的菜单激活它们:
  • 撤销移动
  • 重做移动
  • 重新开始关卡
这些命令被实现为 ICommands,并且不应与移动命令和撤销/重做移动系统中的 GameCommandBase 类混淆(后者出现得更早)。
 
这三个命令是自定义 DelegateCommand 类的实例。
 
注意: 我曾考虑引用 Calcium 框架,以便能够访问我通常用于大多数项目的所有常用基础设施。但我决定保持项目中的代码简单且易于理解。
 
DelegateCommand 需要一个 Action,以及一个可选的 Func,该 Func 用于评估 Action 是否允许执行。请参阅列表 7。
 
列表 7. DelegateCommand 类
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 非常有用,因为它们与许多 UI 元素(如 Buttons)具有亲和力,这些元素会自动利用 CanExecute 方法来自动更改按钮的 IsEnabled 状态。

 
Sokoban 项目中的 Game 类在其构造函数中创建了三个命令。请参阅列表 8。
 
列表 8. Game 构造函数
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 在执行时调用 RestartLevel 方法。

UndoCommandRedoCommand 调用 GamecommandManager 的相应 UndoRedo 方法。

命令的实现

SplitView 元素用于在按下汉堡按钮时叠加一个菜单窗格。请参阅列表 9。这三个命令绑定到 SplitView.Pane 中的 Button 控件。
 
列表 9. MainPage.xaml SplitView 摘录
<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>
按钮的 Click 事件也用于在按钮被单击或点击时自动关闭 SplitView 窗格。请参阅列表 10。
 
列表 10. MainPage HandleButtonClick 方法
void HandleMenuButtonClick(object sender, RoutedEventArgs e)
{
 splitView.IsPaneOpen = false;
}
汉堡按钮是一个 ToggleButton,其 IsChecked 属性绑定到 SplitViewIsPaneOpen 属性。请参阅列表 11。
 
ToggleButton 使用文本而非图像来显示汉堡图标。该字符位于 Segoe MDL2 Assets 中,该字体是开箱即用的。
 
列表 11. Hamburger ToggleButton
<ToggleButton
 FontFamily="Segoe MDL2 Assets"
 Content="&#xE700;"
 Foreground="White"
 Background="Transparent"
 BorderBrush="Transparent"
 TabIndex="1"
 AutomationProperties.Name="Navigation"
 ToolTipService.ToolTip="Navigation"
 IsChecked="{Binding IsPaneOpen, ElementName=splitView, Mode=TwoWay}" />
点击汉堡按钮会展开 SplitView 窗格,如图 1 所示。
 

图 1. 展开的 SplitView 窗格

结论

在本文中,我们了解了地图数据是如何从文件系统中检索的。我们更深入地研究了自定义 CellControl,并考察了图像是如何用于表示单元格内容的。您看到了如何通过缓存 Bitmap 对象来减少应用的内存占用。您看到了如何响应应用内的触摸事件,并编写线程安全的异步可等待代码。最后,您看到了如何实现一个 SplitView 来为游戏提供一个滑动菜单。
 
在下一系列文章中,我们将探讨如何将推箱子移植到 Xamarin Forms,届时我们将利用我们平台无关的推箱子项目。希望您会加入我。
 
我希望这个项目对您有用。如果觉得有用,请评分和/或在下方留言。

历史

2016年10月26日

  • 首次发布
© . All rights reserved.