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

UWP 异形推箱子 - 第 2 部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (15投票s)

2016 年 10 月 18 日

CPOL

7分钟阅读

viewsIcon

19601

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

引言

这是三部分系列文章的第二部分,我们将探讨如何为通用 Windows 平台 (UWP) 实现一个基于 XAML 的游戏。

第一部分 中,您学习了如何创建一个跨平台兼容的类库来放置游戏逻辑。我们对比了文件链接、共享项目、可移植类库以及新的 .NET 标准之间的一些差异。最后,您看到了 Game 页面的实现方式,并简要了解了新的 x:Bind 标记扩展。
 
在本文中,我们将讨论如何将推箱子游戏的 Game 对象连接到应用程序的主页面。您将学习如何使用 UWP 的 MediaElement 控件播放音效。最后,您将了解游戏网格是如何用自定义单元格控件填充的。

本系列文章链接

连接 Game 页面

Sokoban 项目中的 Game 类是游戏逻辑的入口点,实际上也是主页面的 ViewModel。
 
Sokoban 项目是一个 PCL 项目,其中包含的所有代码都是平台无关的。但是,Game 类需要一些抽象的平台特性才能正常工作。首先,它需要一种方法来测试它是否运行在 UI 线程上,以免不必要地将调用回传给 UI 线程。可移植类库项目没有实现此功能的 API。因此,Sokoban Game 类需要一个实现了自定义 ISynchronizationContext 接口的类的实例。
 
ISynchronizationContext 有两个成员
  • bool InvokeRequired { get; }
  • void InvokeIfRequired(Action action)
 
注意: 对于此项目,我提供了一个 UWP 特定的 ISynchronizationContext 实现。在下一篇文章中,您将看到一个针对 Xamarin Forms 的实现。
 
ISynchronizationContext 的 UWP 实现是 UISynchronizationContext 类。请参阅清单 1。
 
清单 1. UISynchronizationContext 类
class UISynchronizationContext : ISynchronizationContext
 {
  CoreDispatcher dispatcher;
  readonly object initializationLock = new object();
 
  public bool InvokeRequired
  {
   get
   {
    EnsureInitialized();
 
    return !dispatcher.HasThreadAccess;
   }
  }
 
  public void InvokeIfRequired(Action action)
  {
   EnsureInitialized();
 
   if (InvokeRequired)
   {
 
#pragma warning disable 4014
    dispatcher.RunAsync(CoreDispatcherPriority.Normal, () => action());
#pragma warning restore 4014
   }
   else
   {
    action();
   }
  }
 
  bool initialized;
 
  void EnsureInitialized()
  {
   if (initialized)
   {
    return;
   }
 
   lock (initializationLock)
   {
    if (initialized)
    {
     return;
    }
 
    try
    {
     dispatcher = CoreApplication.MainView.CoreWindow.Dispatcher;
    }
    catch (InvalidOperationException ex)
    {
     throw new Exception("Initialize called from non-UI thread.", ex);
    }
 
    initialized = dispatcher != null;
   }
  }
 }
UISynchronizationContext 使用 CoreDispatcher,该 CoreDispatcher 通过 CoreApplication.MainView.CoreWindow.Dispatcher 检索。
必须在 UI 线程上检索 Dispatcher,否则会抛出 InvalidOperationException
 
MainPage.xaml.cs 是实例化 Game 对象的地方。请参阅清单 2。
 
LevelCodesGenerator 类允许您为游戏生成一组唯一的关卡代码。通过取消注释 MainPage 构造函数中的注释行,您可以生成 LevelCode 类,然后用它来替换 Sokoban 项目中现有的 LevelCode 类。
 
清单 2. MainPage 构造函数
public MainPage()
{
 /* Uncomment to generate the LevelCode class. */
 //LevelCodesGenerator.GenerateLevelCodes();
 
 InitializeComponent();
 
 var synchronizationContext = new UISynchronizationContext();
 var audioClips = new AudioClips(rootGrid, synchronizationContext);
 
 var infrastructure = new Infrastructure(synchronizationContext, audioClips);
 infrastructure.Initialize();
 
 Game = new Game(infrastructure);
 DataContext = Game;
 
 Loading += HandleLoading;
}
Game 对象需要一个实现 IInfrastructure 接口的对象。IInfrastructure 可以称之为“平台特定内容”。IInfrastructure 是对平台特定 API 的抽象,用于从文件系统(或其他地方)检索地图数据、存储和检索应用程序设置以及播放音效。IInfrastructure 具有以下成员
  • int LevelCount { get; }
  • Task<string> GetMapAsync(int levelNumber);
  • ISynchronizationContext SynchronizationContext { get; }
  • void SaveSetting(string key, object setting);
  • bool TryGetSetting(string key, out object setting);
  • T GetSetting<T>(string key, T defaultValue);
  • void PlayAudioClip(AudioClip audioClip);
请查看 IInfrastructure 代码文件以获取成员描述。

在本文中,我们实现了 UWP 版的该接口。当我们稍后支持 iOS 和 Android 平台时,我们也会为它们做同样的事情。

播放音效

音效能让游戏生动起来。在这个项目中,AudioClips 类负责播放一组已知的音频文件。
 
您可以使用 Windows.UI.Xaml.Controls.MediaElement 来播放音频文件。请参阅清单 3。
 
清单 3. AudioClips 类
class AudioClips
{
 readonly ISynchronizationContext synchronizationContext;
 readonly Panel page;
 
 internal AudioClips(Panel page, ISynchronizationContext synchronizationContext)
 {
  this.page = ArgumentValidator.AssertNotNull(page, nameof(page));
  this.synchronizationContext = ArgumentValidator.AssertNotNull(
      synchronizationContext, nameof(synchronizationContext));
 
  Uri baseUri = page.BaseUri;
 
  const string audioDir = "/Audio/";
  levelIntroductionElement = CreateElement(new Uri(baseUri, audioDir + "LevelIntroduction.mp3"));
  /* MediaElement requires a moment to load its media. Calling Play immediately fails.
   * You can either subscribe to the MediaOpened event or have it auto-play. */
  levelIntroductionElement.AutoPlay = true;
 
  gameCompleteElement = CreateElement(new Uri(baseUri, audioDir + "GameComplete.mp3"));
  levelCompleteElement = CreateElement(new Uri(baseUri, audioDir + "LevelComplete.mp3"));
  footstepElement = CreateElement(new Uri(baseUri, audioDir + "Footstep.mp3"));
  treasurePushElement = CreateElement(new Uri(baseUri, audioDir + "TreasurePush.mp3"));
  treasureOnGoalElement = CreateElement(new Uri(baseUri, audioDir + "TreasureOnGoal.mp3"));
 }
 
 MediaElement CreateElement(Uri uri)
 {
  var result = new MediaElement { Source = uri, AutoPlay = false };
  page.Children.Add(result);
  return result;
 }
 
 readonly MediaElement levelIntroductionElement;
 readonly MediaElement gameCompleteElement;
 readonly MediaElement levelCompleteElement;
 readonly MediaElement footstepElement;
 readonly MediaElement treasurePushElement;
 readonly MediaElement treasureOnGoalElement;
 
 void Play(MediaElement element)
 {
  synchronizationContext.InvokeIfRequired(() =>
  {
   element.Position = TimeSpan.Zero;
   element.Play();
  });
 }
 
 internal void Play(AudioClip audioClip)
 {
  switch (audioClip)
  {
   case AudioClip.Footstep:
    Play(footstepElement);
    return;
   case AudioClip.GameComplete:
    Play(gameCompleteElement);
    return;
   case AudioClip.LevelComplete:
    Play(levelCompleteElement);
    return;
   case AudioClip.LevelIntroduction:
    Play(levelIntroductionElement);
    return;
   case AudioClip.TreasureOnGoal:
    Play(treasureOnGoalElement);
    return;
   case AudioClip.TreasurePush:
    Play(treasurePushElement);
    return;
  }
 }
}
使用一个视觉元素来播放音效音频文件感觉有点奇怪。但在 UWP 中就是这样做的。Windows Phone Silverlight 允许访问一些 XNA API 进行音频播放,这稍微方便一些。
 
注意: 即使我们不需要或不想要我们用户界面中每个音效的视觉表示,我们仍然需要将 MediaElement 添加到视觉树中,否则 MediaElement 将无法工作。
 
我们将 AudioClips 类传递给主页面的根 Grid 控件。然后 AudioClips 对象将其 MediaElement 控件添加到 Grid 中,这样就可以正常工作了。
 
注意: 在播放剪辑之前,必须由 MediaElement 控件加载它。如果您在 MediaElement 加载之前调用 Play,则会错过该剪辑的播放。您可以采取两种方法来确保播放。
  • 订阅 MediaElements MediaOpened 事件,以了解 MediaElement 何时准备就绪。或者
  • MediaElementAutoPlay 属性设置为 true。AutoPlay 会使 MediaElement 在准备好后立即开始播放。我就是这样处理介绍剪辑的,以确保在应用程序启动时播放它。
 
由于 MediaElement 继承自 DependencyObject,所有对它的调用都必须从 UI 线程进行调度。Game 对象在线程池线程上执行一些活动,这意味着如果它选择从后台线程播放音频剪辑,则必须在 UI 线程上调用它。因此,使用了 ISynchronizationContext.InvokeIfRequired 方法。

布局游戏网格

我们使用 MainPageLoading 事件来触发游戏网格的布局。请参阅清单 4。
 
Loading 事件可能在应用程序生命周期中发生多次。因此,使用一个已加载的标志来限制执行一次。
 
清单 4. HandleLoading 方法。
void HandleLoading(FrameworkElement sender, object args)
{
 if (loaded)
 {
  return;
 }
 
 loaded = true;
 
 StartGame();
 
 var window = Window.Current;
 window.CoreWindow.KeyUp += HandleKeyUp;
 window.SizeChanged += HandleWindowResized;
}
当应用程序的窗口大小调整时,游戏网格需要重新布局。窗口的 SizeChanged 事件会触发此操作。请参阅清单 5。

游戏容器元素的大小在引发此事件之前不会更改,因此我们使用 PageDispatcher 来排队调用 LayOutLevel。如果我们不这样做,LayOutLevel 将使用之前的尺寸。
 
注意: WPF 和 Silverlight 中 Dispatcher 类的 BeginInvoke 方法已被 RunAsync 方法取代。您可能会认为委托(在此例中为 LayOutLevel)在线程池线程上执行,但事实并非如此。它与 Task.RunAsync 不同,尽管名称可能暗示这一点。
 
清单 5. HandleWindowResized 方法。
async void HandleWindowResized(object sender, WindowSizeChangedEventArgs e)
{
 await Dispatcher.RunAsync(CoreDispatcherPriority.High, LayOutLevel);
}
当按下某个键时,用户可能正在使用键盘(而不是鼠标)玩游戏。按键被传递给 GameProcessKeyPressed 方法,游戏对象决定如何处理。请参阅清单 6。
 
清单 6. HandleKeyUp 方法。
void HandleKeyUp(CoreWindow sender, KeyEventArgs e)
{
 var state = CoreWindow.GetForCurrentThread().GetKeyState(VirtualKey.Control);
 bool shiftPressed = (state & CoreVirtualKeyStates.Down) == CoreVirtualKeyStates.Down;
 
 Game.ProcessKeyPressed(e.VirtualKey.ToKeyboardKey(), shiftPressed);
}
 
VirtualKey 枚举是平台特定的,所以我们不能将其传递给平台无关的 Game 对象。相反,我们使用自定义扩展方法 ToKeyboardKey 将实际键映射到游戏 KeyboardKey 枚举中的键。

开始游戏

MainPageStartGame 方法(请参阅清单 7)订阅 GamePropertyChanged 事件。
 
清单 7. MainPage StartGame 方法
void StartGame()
{
 Game.PropertyChanged -= HandleGamePropertyChanged;
 Game.PropertyChanged += HandleGamePropertyChanged;
   
 Game.Start();
}
GameGameState 属性发生变化时,将调用 MainPageProcessGameStateChanged 方法。请参阅清单 8。
 
清单 8. HandleGamePropertyChanged 方法
void HandleGamePropertyChanged(object sender, PropertyChangedEventArgs e)
{
 switch (e.PropertyName)
 {
  case nameof(Game.GameState):
   ProcessGameStateChanged();
   break;
 }
}
Game 的状态从 Loading 变为 Running 时,必须布局游戏。请参阅清单 9。

这可以作为一种扩展点,您可以通过添加动画等方式来丰富游戏的各种游戏状态。
 
清单 9. 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;
}
MainPageInitializeLevel 方法在调用 LayOutLevel 方法之前,取消订阅每个单元格的各种事件,并从 gameCanvas 元素中移除所有单元格控件。请参阅清单 10。
 
清单 10. MainPage InitializeLevel 方法。
void InitialiseLevel()
{
 foreach (CellControl control in controlDictionary.Values)
 {
  DetachCellControl(control);
 }
 
 controlDictionary.Clear();
 
 gameCanvas.Children.Clear();
 
 LayOutLevel();
 
 levelCodeTextBox.Text = Game.LevelCode;
}
注意: levelCodeTextBox 元素的 Text 属性实际上应该绑定到一个 Game 属性。之所以没有这样做,是因为我重用了之前文章中的逻辑,还没有来得及重构。
 
游戏关卡本质上是一个二维的游戏单元格数组。请参阅清单 11。在 LayOutLevel 方法中,游戏单元格的大小被最大化以包含可用空间。
 
我们使用 Dictionary<Cell, CellControl> 将每个单元格与一个 CellControl 实例关联起来。CellControl 对象被添加到游戏 Canvas 的适当位置。
 
清单 11. MainPage LayOutLevel 方法
void LayOutLevel()
{
 Level level = Game.Level;
 int rowCount = level.RowCount;
 int columnCount = level.ColumnCount;
   
 /* Calculate cell size and offset. */
 double availableWidth = gameCanvasContainer.ActualWidth;
 double availableHeight = gameCanvasContainer.ActualHeight - topContentGrid.Height;
 int cellWidthMax = (int)(availableWidth / columnCount);
 int cellHeightMax = (int)(availableHeight / rowCount);
 int cellSize = Math.Min(cellWidthMax, cellHeightMax);
 
 int gameHeight = rowCount * cellSize;
 int gameWidth = columnCount * cellSize;
 
 int leftStart = 0;
 double left = leftStart;
 double top = (availableHeight - gameHeight) / 2;
 
 /* 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];
 
   CellControl cellControl;
   if (!controlDictionary.TryGetValue(cell, out cellControl))
   {
    cellControl = new CellControl(cell);
    controlDictionary[cell] = cellControl;
 
    DetachCellControl(cellControl);
    AttachCellControl(cellControl);
 
    gameCanvas.Children.Add(cellControl);
   }
 
   cellControl.SetValue(Canvas.LeftProperty, left);
   cellControl.SetValue(Canvas.TopProperty, top);
   cellControl.Width = cellSize;
   cellControl.Height = cellSize;
 
   left += cellSize;
  }
 
  left = leftStart;
  top += cellSize;
 }
 
 gameCanvas.Width = gameWidth;
 gameCanvas.Height = gameHeight;
}
 

结论

在本文中,我们讨论了如何将推箱子游戏 Game 连接到应用程序的主页面。您学习了如何使用 UWP MediaElement 控件播放音效。最后,您了解了游戏网格是如何用自定义单元格控件填充的。
 
下一部分 中,您将了解地图数据如何从文件系统中检索。我们将更深入地研究自定义 CellControl,并研究如何使用图像来表示单元格内容。您将学习如何通过缓存 Bitmap 对象来减少应用程序的内存占用。您将学习如何响应应用程序中的触摸事件,以及如何编写线程安全的异步代码。最后,您将学习如何实现 SplitView 来为游戏提供一个滑动式菜单。
 
我希望这个项目对您有用。如果觉得有用,请评分和/或在下方留言。

历史

  • 2016年10月14日
    • 首次发布
© . All rights reserved.