UWP 异形推箱子 - 第 2 部分






4.98/5 (15投票s)
一个有趣的 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
何时准备就绪。或者 - 将
MediaElement
的AutoPlay
属性设置为 true。AutoPlay
会使MediaElement
在准备好后立即开始播放。我就是这样处理介绍剪辑的,以确保在应用程序启动时播放它。
由于
MediaElement
继承自 DependencyObject
,所有对它的调用都必须从 UI 线程进行调度。Game
对象在线程池线程上执行一些活动,这意味着如果它选择从后台线程播放音频剪辑,则必须在 UI 线程上调用它。因此,使用了 ISynchronizationContext.InvokeIfRequired
方法。布局游戏网格
我们使用
MainPage
的 Loading
事件来触发游戏网格的布局。请参阅清单 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。游戏容器元素的大小在引发此事件之前不会更改,因此我们使用
Page
的 Dispatcher
来排队调用 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);
}
当按下某个键时,用户可能正在使用键盘(而不是鼠标)玩游戏。按键被传递给
Game
的 ProcessKeyPressed
方法,游戏对象决定如何处理。请参阅清单 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
枚举中的键。开始游戏
MainPage
的 StartGame
方法(请参阅清单 7)订阅 Game
的 PropertyChanged
事件。清单 7. MainPage StartGame 方法
void StartGame()
{
Game.PropertyChanged -= HandleGamePropertyChanged;
Game.PropertyChanged += HandleGamePropertyChanged;
Game.Start();
}
当
Game
的 GameState
属性发生变化时,将调用 MainPage
的 ProcessGameStateChanged
方法。请参阅清单 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;
}
MainPage
的 InitializeLevel
方法在调用 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
方法中,游戏单元格的大小被最大化以包含可用空间。我们使用
清单 11. MainPage 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日
- 首次发布