一个“小蛇”带领我们了解 Windows Phone 7 的最重要功能
通过分析贪吃蛇游戏,我们将研究一个针对新 Windows Phone 7 平台的应用,重点关注本地化、控制反转、导航、过渡效果、触发器、隔离存储、音频,我们还将使用 Blend 创建一个圆角发光按钮,以及其他内容...
您好,
一项竞赛激发了我创作一篇文章的灵感,而且我有一个儿子,Marco,他是一个 15 岁的学生,有编程天赋,于是我们各自开始准备一篇关于 Windows Phone 7 的文章。几天后,比较两篇文章时,差异显而易见。我放弃了我的文章,转而发布了我儿子 Marco 的作品,我认为您可能认同我的两个原因:
- 这篇文章(请您自己评估)非常详细和清晰。
- 考虑到 Marco 年纪尚小,真正有资质的人的意见对他来说会非常重要,甚至对孩子的教育也很有益。
这是 Marco 的文章
目录
-
引言
整篇文章介绍 -
游戏基础
关于游戏整体运作的简单介绍 -
核心
本节内容是关于游戏核心:从格子到关卡-
格子
整个游戏的基础部分:格子 -
IoC (控制反转)
关于控制反转的简短教程 -
Windows Phone 7 的容器
这里我们将分析一个专为 Windows Phone 7 平台设计的容器 -
移动控制器
移动控制器的分析 -
目标
游戏目标:吃掉的食物、已用时间以及蛇的长度 -
关卡
对整个应用程序中最复杂的对象进行分析,重点关注本地化和蛇的移动 -
敌蛇
游戏中的敌蛇;重点关注寻路算法的使用 -
保存和加载关卡
对包含关卡的 XML 文件结构进行简要说明
-
格子
-
UI 和设计
本节内容关于应用程序的设计和 UI-
导航
Silverlight 中导航概念的解释 -
过渡
使用 Silverlight for Windows Phone Toolkit 提供的过渡效果 -
圆角发光按钮
使用 Microsoft Expression Blend 创建圆角发光按钮的教程 -
触发器
触发器的解释,包括使用导航触发器的示例 -
简要的数学回顾:极坐标系
关于极坐标系的一些数学回顾,这对理解圆形选择器至关重要 -
圆形选择器
CircularSelector 的使用及其内部工作原理的解释 -
隔离存储:文件和设置
使用隔离存储来存储持久性文件和设置 -
设置
使用隔离存储来存储持久性文件和设置 -
生成操作:内容还是资源?
Visual Studio 的生成操作属性中两个值的区别 -
音频 & XNA
使用 XNA 框架播放音乐的教程
-
导航
引言
首先,我为我的蹩脚英语道歉,但我才 15 岁,还在学校学习它。
本文讨论了针对新的 Windows Phone 7 平台应用程序 Snake Mobile 的开发。游戏构思只是一个机会,旨在展示在一个简单的应用程序(如贪吃蛇)中,我们可以找到许多 Windows Phone 7 平台开发的基本概念。
我不是设计师,事实上,游戏在视觉上并不吸引人,但这个应用程序不是为了公开发布而开发的,而是为了在一个电子游戏中融入许多基本的编程概念。为了达到这个目标,我有时会使用不太方便的解决方案,以创造一个需要使用特定编程技术的场景。例如,控制反转:在后面将描述的场景中使用 IoC 简直是疯狂的,但我还是决定使用这个设计模式,以便更容易理解它,并创建一个适用于 Windows Phone 7 平台的容器,因为在更大的应用程序中这可能会很有用。
事实上,选择开发一款游戏意味着创建一个复杂应用程序,其中结合了图形、音频、数据等各种元素……这篇文章很长,可能会枯燥乏味,但我的目标是尽可能全面地进行阐述,最重要的是,我不想想当然,事实上,在某些特定情况下,我还添加了一些深入的链接。我还在文章末尾添加了一个列表,其中包含项目中所有独立的组件,您可以随意取用并添加到您的项目中。
最后,我提醒您,这篇文章还可以进一步改进:如果您发现我没有注意到的任何错误或任何可以改进的地方,请告诉我,以便我更新这篇文章,让其他人也能受益于这些改进。
您准备好了吗?我们开始吧!
游戏基础
游戏的基本思路是,我们有一个 Grid
,它将被填充一些格子。
我们将 Cell
类的实例存储在一个二维数组中;索引用于从网格位置检索单元格。例如:位于 (2, 5) 的墙将使用索引 [2, 5] 存储在数组中。
要移动蛇,我们有一个 Point
对象,它表示蛇的方向,还有一个 Timer
使蛇移动(实际上,它会更新单元格的 CellType
属性;我们稍后会看到它是什么)。敌蛇也以同样的方式工作。
移动控制器会引发 Up
、Down
、Left
和 Right
事件,这些事件由 Level
对象处理,该对象会改变方向。
在主计时器每次滴答时,都会调用检查目标是否完成;如果返回 true,则关卡完成,否则,什么也不发生。
然而,我之前所说的,将在文章接下来的部分进行更深入的分析。
![]() |
返回文章顶部 |
游戏核心
在本节中,我们将讨论应用程序中最重要的事情:这里分析了所有内容,从最简单的组件(如目标或格子)到最复杂的控件(如 Level 对象)。移动控制器段落包含一个关于 IoC(控制反转)的括号,其中使用了 Castle.Windsor。这并非出于教育目的;它仅用于展示当您希望使项目对新解决方案开放并使对象可重用且更灵活时,它可能有多么有用。但是,我们稍后会讨论这一点。
格子
游戏的基础部分是格子:这个对象有一个属性 CellType
,它表示格子的内容。在构造函数中,我们使用 CellType2ContentConverter
将其绑定到 Content
属性。此转换器将 CellType 枚举的值转换为可视对象,返回相应的图像。最有趣的方法是 HitTest
方法:此方法返回一个值,指示当一个格子碰到另一个格子时应该发生什么。
public HitTestResult HitTest(Cell other) {
//Free
if (this.CellType == CellType.Free || other.CellType == CellType.Free)
return HitTestResult.None;
//Food
else if (this.CellType == CellType.Food || other.CellType == CellType.Food)
return HitTestResult.Eat;
//DeathFood
else if (this.CellType == CellType.DeathFood || other.CellType == CellType.DeathFood)
return HitTestResult.Death;
//Wall
else if (this.CellType == CellType.Wall || other.CellType == CellType.Wall)
return HitTestResult.Death;
//Snakes
else
return HitTestResult.Cut;
}
![]() |
返回文章顶部 |
IoC (控制反转)
让我们来谈谈 IoC!
(
让我们看看维基百科怎么说
“控制反转(IoC)是一种抽象原则,描述了某些软件架构设计的方面,其中系统的控制流与过程式编程相比是反转的。在传统编程中,控制流由中心代码控制。使用控制反转,这种“中心控制”作为设计原则被抛弃了。”
我认为最好用人类的语言来翻译...
我们从一个例子开始:我们需要构建一个能够提取 HTML 页面中标签内容的类的。我们每个人都会写
public class TagGetter
{
public string GetContent(string pageUrl, string tagName)
{
//Downloads the page
string page = new System.Net.WebClient().DownloadString(pageUrl);
//Gets the tag
string tagContent = System.Text.RegularExpressions.Regex.Match(page, "<" + tagName + ">[^>]*</" + tagName + ">").Value;
tagContent = tagContent.Substring(tagName.Length + 2, tagContent.Length - 2 * (tagName.Length + 2) - 1);
//Returns
return tagContent;
}
}
现在分析一下这个类做了什么:首先,它使用 System.Net.WebClient
类下载页面,然后使用 Regex
获取标签,最后返回找到的值。也许您会想,这段代码有什么问题,我回答您说:没有。客观地说,这个类是完美的:它能很好地下载和提取标签,如果您尝试提取 Google 主页的标题标签,它会返回“Google”。但是,请从更普遍的角度来看待这个类:存在 2 个主要问题,需要不同的方法来解决。
- 这个类做了太多事情!良好设计的原则是关注点分离(SoC)。根据这个原则,一个单一的对象必须只做一件简单的事情,并且做得很好。我们应该将这个类分成 2 个不同的对象:一个用于下载页面,一个用于提取标签。
- 如果所需的文档无法通过 HTTP 协议访问怎么办?我们应该更改方法的正文,添加(或替换)此功能为其他功能,例如 FTP。提取标签的过程可以得到改进,但这同样需要更改方法正文。我知道这似乎毫无意义,但请尝试想象一种特殊情况,在这种情况下,这种方法会导致更好的性能。简而言之,TagGetter 对象对全局机制的了解过于深入,这不好,因为它会导致应用程序设计不佳。
由于我将使用 Castle.Windsor(稍后我们将了解是什么)来解决这些问题,因此我必须使用其文档中的相同术语来解释组件和服务概念
“组件是可重用代码的小单元。它应该实现并仅公开一项服务,并且做得很好。在实际中,组件是实现服务的类(接口)。接口是服务的契约,它创建了一个抽象层,以便您可以轻松地替换服务实现。”
现在我们知道了服务和组件是什么,我们可以直接解决问题了。
TagGetter 类做得太多了,我们需要将其拆分:这个对象的两个任务都非常通用,可以以多种方式执行,因此,我们需要创建一个服务(接口)来定义一个对象可以执行的操作,而无需编写具体实现(组件)。以下是 2 个接口,一个用于下载文件,另一个用于提取标签内容。
public interface IPageDownloader {
string Download(string url);
}
public interface ITagExtrator {
string Extract(string content, string tagName);
}
现在我们应该编写这些接口的具体实现。
public class PageDownloader : IPageDownloader {
public string Download(string url) {
return new System.Net.WebClient().DownloadString(url);
}
}
public class TagExtractor : ITagExtrator {
public string Extract(string content, string tagName) {
string tagContent = System.Text.RegularExpressions.Regex.Match(content, "<" + tagName + ">[^>]*</" + tagName + ">").Value;
tagContent = tagContent.Substring(tagName.Length + 2, tagContent.Length - 2 * (tagName.Length + 2) - 1);
return tagContent;
}
}
嗯……这里出现了一个小问题……我们如何将这些对象传递给主要的 TagGetter 类?很简单:我们更改该类的构造函数以接受 IPageDownloader
和 ITagExtractor
类型的 2 个参数,然后将它们存储在某些变量中。这是新代码
public class TagGetter
{
//Stored objects
private IPageDownloader _pageDownloader;
private ITagExtrator _tagExtractor;
public TagGetter(IPageDownloader pageDownloader, ITagExtrator tagExtractor)
{
//Stores the objects
_pageDownloader = pageDownloader;
_tagExtractor = tagExtractor;
}
public string GetContent(string url, string tagName)
{
//Downloads the page
string page = _pageDownloader.Download(url);
//Gets the tag
string tagContent = _tagExtractor.Extract(page, tagName);
//Returns
return tagContent;
}
}
正如您所注意到的,TagGetter.GetContent()
方法的代码更加简洁简单,它不关心如何下载页面或提取标签:接口的实现将完成这些工作!这样,我们就可以轻松地更改下载器或提取器,而无需更改主要的 TagGetter 类!此外,我们还可以轻松地在另一个应用程序中重用单个组件,或者只为某个组件编写特定的测试。
但是,这有一个缺点:要调用此方法,我们应该写
string title = new TagGetter(new PageDownloader(), new TagExtractor()).GetContent("http://www.google.it", "title");
这还不错,但对我来说太长太复杂了。这时 Castle.Windsor 就派上用场了。简而言之,Castle.Windsor 是一个容器,您可以对其进行配置以包含一些对象,然后您可以在需要时获取它们。它就像一个装着一些对象的大盒子;外面有一个索引,上面匹配着服务和组件。当您需要下载器时,您可以查找 IPageDownloader
,然后找到 PageDownloader
类的实例。我认为这个例子比解释更有说服力。
//Creates a new container
WindsorContainer container = new WindsorContainer();
//Registers the downloader
container.Register(Component
.For<IPageDownloader>()
.ImplementedBy<PageDownloader>());
//Registers the tag extractor
container.Register(Component
.For<ITagExtrator>()
.ImplementedBy<TagExtractor>());
//Registers the tag getter
container.Register(Component
.For<TagGetter>());
//Gets the tag getter
TagGetter getter = container.Resolve<TagGetter>();
//Calls the method
string title = getter.GetContent("http://www.google.it", "title");
正如您所注意到的,前 3 个 Register()
调用用于注册接口及其具体实现(也可以使用 XML 文件配置容器),但最有趣的是第四个:Resolve()
调用返回 TagGetter 类的新实例。但是,如果您还记得,这个类有一个带 2 个参数的构造函数,那么发生了什么?当您调用 Resolve()
方法时,WindsorContainer 会检查构造函数的参数,如果存在与参数类型兼容的已注册服务或组件,容器会自动创建正确的新实例(根据配置),这样,就完成了操作。
这只是关于 IoC 的一个附带说明,我甚至没有展示 IoC 可以做什么或 Castle.Windsor 可以做什么的百分之一,所以,我将用这些参考资料结束本段。
) - 我没有忘记段落开头的那个左括号!
![]() |
返回文章顶部 |
Castle.Windsor 很棒,但是...
……但不能在 Windows Phone 7 设备上使用。是的,您没看错。Castle.Windsor 不能在 Windows Phone 7 设备上使用。
嗯……现在怎么办?怎么办?
一开始,我开始寻找适用于 Windows Phone 7 的 Castle.Windsor 的另一个版本,但没有找到任何有趣的东西。这时我开始担心了。幸运的是,我有一个想法:我将自己创建一个容器!容器可能看起来很长很复杂,但一个只有一些基本功能的类却非常非常简单。
在我们的例子中,一个非常简单的容器就足够了,但是,由于我很疯狂,我写了一个具有其他一些功能的容器。它们是
方法名称 | 描述 | |
![]() |
ChangeRegistration<T>(Type)
|
更改类型 |
![]() |
Deregister<T>()
|
注销类型 |
![]() |
GetComponentTypeForService<T>() + 1 个重载。 |
返回与服务 |
![]() |
Register(WP7ContainerEntry) + 4 个重载。 |
向容器注册一个新条目。 |
![]() |
Resolve<T>() + 1 个重载。 |
解析类型 |
另一个需要分析的重要对象是 WP7ContainerEntry
。
成员名称 | 描述 | |
![]() |
组件 (Component)
|
此条目表示的组件的类型。 |
![]() |
Params
|
|
![]() |
Service
|
此条目表示的服务类型。 |
![]() |
For<T>() + 1 个重载。 |
为提供的类型 |
![]() |
ImplementedBy<T>() + 1 个重载。 |
设置 |
![]() |
Parameters(Dictionary<string, object>) |
设置 |
与其写所有这些无用的文字,不如让代码来说明:让我们看一个这些类用法的示例。想象一下我们有这样的东西
简而言之,我们有一个类 MyClass
,它继承自 MyBaseClass
并实现 IMyInterface
;这个类的构造函数需要一个 UsefulObject
作为参数。在这种情况下,您可以通过多种方式使用容器,例如,这个
//Creates the container
WP7Container container = new WP7Container();
//Registers UsefulObject
container.Register<UsefulObject>();
//Registers MyClass as the implementation of IMyInterface
container.Register<IMyInterface, MyClass>();
//Resolves the object
IMyInterface myObj = container.Resolve<IMyInterface>();
我们要做的第一件事是创建一个容器的新实例,然后注册所有需要的类型。现在,您可以随时调用 Resolve
方法,并获取已注册类型之一的实例。一个值得注意的好处是,我们不需要向类的构造函数传递任何参数:容器会自动找到我们可以调用的构造函数,并向其传递所需的一切,甚至通过解析其他类型。在这种情况下,容器注意到唯一可用的构造函数需要一个 UsefulObject
,因此,为了创建一个新的 MyClass
对象,它会向构造函数传递一个新的 UsefulObject
。如果容器找不到任何可以调用的构造函数,则会引发异常。
//Creates the container
WP7Container container = new WP7Container();
//Registers the interface and its implementation
container.Register<IMyInterface, MyClass>(new Dictionary<string, object>() {
{ "PropertyOfTheInterface", "interface" },
{ "PropertyOfTheClass", 10 },
{ "PropertyOfTheBaseClass", "base" }
});
//Registers the useful object
container.Register<UsefulObject>(new Dictionary<string, object>() {
{ "UsefulProperty", "injected!" }
});
//Resolves the useful object
UsefulObject usefulObj = container.Resolve<UsefulObject>();
//Resolves IMyInterface
IMyInterface obj = container.Resolve<IMyInterface>();
这一次,当我们注册类型时,我们指定参数作为我们要注入的属性的名称及其值:这样,当我们调用 Resolve
时,我们的对象已经设置了一些属性。参数也可以用于向构造函数注入值:在这种情况下,我们必须指定构造函数参数的名称及其值。调用 Resolve<UsefulObject>
返回一个新的 UsefulObject
,其中 UsefulProperty
设置为“injected!”。而调用 Resolve<IMyInterface>
,则创建一个新的 MyClass
对象并设置其属性(这些属性可能属于基类,也可能实现接口;这无关紧要);在调用构造函数时,会调用 Resolve<UsefulObject>
:这样,会将一个具有 UsefulProperty
属性设置为“injected!” 的新 UsefulObject
传递给 MyClass
的构造函数。事实上,如果您评估 ((MyClass)obj).UsefulStructFromTheConstructor.UsefulProperty
,您会看到该属性的值是“injected!”。
与 Castle.Windsor 相比,WP7Container
所做的事情微不足道,但我认为对于像它这样一个小类来说已经足够了,不是吗?但是现在是时候深入研究这些类以了解它们的工作原理了。
WP7ContainerEntry
只是一个包含一些数据的类,因此不需要太多解释;相反,WP7Container
可能更有趣。这是基本概念:我们有一个 WP7ContainerEntry
列表;我们应该能够向该列表添加和删除条目(使用 Register
和 Deregister
方法)并解析类型(Resolve
方法)。整个类的核心是 ResolveEntry
方法:它将 WP7ContainerEntry
转换为代表它的对象,找到正确的构造函数并注入参数。
编写这样的方法可能非常困难,但如果您了解反射,那么操作就完成了。不过,这是代码
//Choose the constructor to call
ConstructorInfo ctor =
entry.Component.GetConstructors()
.FirstOrDefault(x =>
x.GetParameters().All(y =>
(entry.Params.ContainsKey(y.Name) && y.ParameterType.IsAssignableFrom(entry.Params[y.Name].GetType()))
|| IsThereEntryForType(y.ParameterType)
)
);
//Checks if the ctor has been found
if (ctor == null)
throw new ArgumentException(string.Format("Cannot create type {0}: constructor not found. Try with different parameters.", entry.Component.FullName));
//Calls the ctor
ParameterInfo[] ctorParams = ctor.GetParameters();
object[] pars = new object[ctorParams.Count()];
for (int i = 0; i < pars.Length; i++) {
ParameterInfo p = ctorParams[i];
if (entry.Params.ContainsKey(p.Name))
pars[i] = entry.Params[p.Name];
else
pars[i] = this.Resolve(p.ParameterType);
}
object obj = ctor.Invoke(pars);
//Checks if there are some properties to set
foreach (var x in entry.Params) {
PropertyInfo prop = entry.Component.GetProperty(x.Key);
if (prop != null) {
if (!prop.PropertyType.IsAssignableFrom(x.Value.GetType()))
throw new InvalidCastException(string.Format("Cannot cast from {0} to {1}", x.Value.GetType().FullName, prop.PropertyType.FullName));
else
prop.SetValue(obj, x.Value, null);
}
}
//Return
return obj;
我们想要做的是创建组件条目的新实例,因此,我们必须做的是找到一个我们可以调用的构造函数。为了达到这个目标,我们遍历类型的每个可用构造函数,并找到第一个其参数全部可用(要么在参数字典中,要么在容器中注册)的构造函数。一旦找到构造函数,我们就必须找到参数:我们检查每个参数,如果它在字典中,我们就使用提供的值,否则,我们解析该类型。下一步是创建对象并将其存储在一个变量中。最后一件事是注入参数:我们遍历字典中的所有提供条目,并检查是否可以设置属性的值。
正如我之前所说,这个类功能有限:我建议您仅在无法使用 Castle.Windsor 时使用它,尽管我尝试保持相同的语法。
![]() |
返回文章顶部 |
移动控制器
移动控制器是当用户输入改变蛇方向时需要引发事件的组件。由于改变方向的方法有很多,我们需要创建一个抽象层及其实现。此外,如果我们想添加一个新的控制器,它真的很简单:我们只需要开发该组件。
这是控制器的服务
![]() |
public interface IMovementController
{
//Events
event EventHandler Up;
event EventHandler Down;
event EventHandler Left;
event EventHandler Right;
//Properties
bool IsVisual { get; }
//Methods
FrameworkElement GetVisual();
}
|
如您所见,此接口包含 4 个事件(每个方向一个);当用户发送相应输入时会引发它们。下一个成员(IsVisual
属性)返回一个值,指示控制器是否需要视觉对象才能正常工作;而 GetVisual()
方法则返回该对象。
现在是时候谈论组件(IMovementController
接口的实现)了!
我们将开发的第一个控制器是 ArrowMovementController
:它基于一个视觉控件,由 4 个箭头组成,每个方向一个。每个箭头都是一个不同的按钮,会引发相应的事件。我不会描述这个组件,因为它只是经典按钮的重新设计,我们将在文章的第二部分讨论图形。但是,它应该看起来像这样
![]() |
返回文章顶部 |
目标
每个关卡都必须有一个目标;由于目标类型有很多种,因此我们必须创建通用服务,然后才能开发单个组件。这是抽象层
![]() |
public interface IGoal
{
bool IsAccomplished(GoalEventArgs e);
Uri ImageUri { get; }
}
|
如您所见,该接口非常简单。主要方法是 IsAccomplished()
:类型为 GoalEventArgs
的 e
参数包含一些数据(如吃掉的食物或蛇的长度);该方法应检查这些值并返回 true
(如果目标已达成),否则返回 false
。ImageUri
属性仅返回相应图像的 URI。
例如,这是 FoodEatenGoal
public class FoodEatenGoal : IGoal
{
private int _Needs;
public FoodEatenGoal(int needs)
{
_Needs = needs;
}
public bool IsAccomplished(GoalEventArgs e)
{
return e.EatenFood >= _Needs;
}
public Uri ImageUri { get { return new Uri("/SnakeMobile.Core;component/Images/Food.png", UriKind.Relative); } }
}
构造函数需要一个 int
参数,这是在目标达成之前蛇必须吃掉的最少食物量;IsAccomplished()
方法返回蛇是否吃够了食物。
另外两个目标(GrowUpGoal
和 TimeGoal
)工作方式相同,我不会详细介绍。
![]() |
返回文章顶部 |
关卡
所以,我们终于来到了应用程序最复杂的部分……您准备好了吗?我们开始吧!
Level
类的目标是:Level
必须管理整个游戏关卡,并在发生有趣的事情时引发某些事件。这些事件是 Win
和 Loose
。顾名思义,第一个事件在用户赢得游戏、达成关卡所有目标时引发;第二个事件则在蛇被敌蛇杀死或撞到墙壁时引发。第三个事件(FoodAdded
)被标记为内部,因为它只能由某些内部类处理,并且用于在添加新食物时通知敌蛇。将在我们讨论 FoodCatcherSnake
时解释对这个事件的需求。
现在是时候讨论 Level
对象的工作原理了。
我们从构造函数开始。它需要很多参数;每个参数随后存储在一个私有字段中,并在以后使用。这是参数列表:
IMovementController movementController
:用于移动主蛇的移动控制器。int speed
:游戏速度。实际上它只是计时器的间隔。IEnumerable<IGoal> goal
:用户必须完成以完成关卡的所有目标。Cell[,] cells
:包含游戏所有格子的二维数组,已填充正确的(已设置 CellType 属性的)内容。IEnumerable<Point> snakeCells
:蛇的单元格坐标列表。IEnumerable<IEnemySnake> enemySnakes
:关卡中的敌蛇列表。我们稍后会讨论。
这是构造函数的正文。
//Component initialization
InitializeComponent();
//Sets resources
switch (System.Threading.Thread.CurrentThread.CurrentUICulture.Name.ToLower())
{
case "it-it":
this.Resources.MergedDictionaries.Add(new ResourceDictionary() {
Source = new Uri("/SnakeMobile.Core;component/Resources/it-IT.xaml", UriKind.Relative)
});
break;
default:
this.Resources.MergedDictionaries.Add(new ResourceDictionary() {
Source = new Uri("/SnakeMobile.Core;component/Resources/en-US.xaml", UriKind.Relative)
});
break;
}
//Initializes some fields
_speed = speed;
_gridSize = new Size(cells.GetLength(0), cells.GetLength(1));
_cells = cells;
_goals = goal;
_snakeQueue = new Queue<Cell>();
foreach (Point p in snakeCells)
_snakeQueue.Enqueue(cells[(int)p.X, (int)p.Y]);
_originalSnakeCells = snakeCells;
_enemySnakes = enemySnakes;
//Backup the cells (for the replay)
_originalCells = new Cell[(int)_gridSize.Width, (int)_gridSize.Height];
CloneAndAssign(cells, _originalCells);
_originalSnakeQueue = new Queue<Cell>();
foreach (Point p in snakeCells)
_originalSnakeQueue.Enqueue(_originalCells[(int)p.X, (int)p.Y]);
//Movement controller
movementController.Up += MovementController_Up;
movementController.Down += MovementController_Down;
movementController.Left += MovementController_Left;
movementController.Right += MovementController_Right;
if (movementController.IsVisual)
ContentPresenterMovementController.Content = movementController.GetVisual();
//Snake timer
_snakeTimer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(speed) };
_snakeTimer.Tick += SnakeTimer_Tick;
//Enemy snake timer
_enemySnakesTimer = new DispatcherTimer() { Interval = TimeSpan.FromMilliseconds(speed * 2) };
_enemySnakesTimer.Tick += EnemySnakesTimer_Tick;
//Private event handlers
this.Loaded += UserControl_Loaded;
this.SizeChanged += UserControl_SizeChanged;
如您所见,在构造函数中,我们只是将参数存储在一些私有字段中,初始化计时器,添加处理程序等……需要解释的一部分是 switch 语句:该应用程序使用英语开发,但由于我是意大利人,所以我决定让整个游戏可本地化。这意味着该应用程序提供英语和意大利语版本,并根据手机的全局语言自动切换语言。显然,对于法国用户来说,游戏将是英语,因为它是默认语言。
让应用程序可本地化似乎非常乏味,但我向您保证,最烦人的部分是……是……什么都没有!
这是想法:您必须拥有所有本地化字符串(或所有需要本地化的内容)的源,当您需要字符串时,只需从该源获取。在这种情况下,源是一个包含一些字符串的 ResourceDictionary
。它应该看起来像这样:
en-US.xaml | it-IT.xaml |
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<!-- General strings -->
<sys:String x:Key="OK">OK</sys:String>
<sys:String x:Key="Retry">Retry</sys:String>
<!-- Goal names -->
<sys:String x:Key="Goal_FoodEaten">Food eaten</sys:String>
<sys:String x:Key="Goal_GrowUp">Length</sys:String>
<sys:String x:Key="Goal_Time">Time</sys:String>
<!-- Overlay text -->
<sys:String x:Key="Overlay_Text_ReportGoal">Goals report</sys:String>
<sys:String x:Key="Overlay_Text_Death">You are dead!</sys:String>
<sys:String x:Key="Overlay_Text_Win">Level completed</sys:String>
<sys:String x:Key="Overlay_ButtonText_Return">Return to menu</sys:String>
</ResourceDictionary>
|
<ResourceDictionary
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:sys="clr-namespace:System;assembly=mscorlib">
<!-- General strings -->
<sys:String x:Key="OK">OK</sys:String>
<sys:String x:Key="Retry">Riprova</sys:String>
<!-- Goal names -->
<sys:String x:Key="Goal_FoodEaten">Cibo mangiato</sys:String>
<sys:String x:Key="Goal_GrowUp">Lunghezza</sys:String>
<sys:String x:Key="Goal_Time">Tempo</sys:String>
<!-- Overlay text -->
<sys:String x:Key="Overlay_Text_ReportGoal">Riepilogo obiettivi</sys:String>
<sys:String x:Key="Overlay_Text_Death">Sei morto!</sys:String>
<sys:String x:Key="Overlay_Text_Win">Livello completato</sys:String>
<sys:String x:Key="Overlay_ButtonText_Return">Ritorna al menu</sys:String>
</ResourceDictionary>
|
如您所见,在这些小字典中有一些字符串对,一个英语,一个意大利语。重要提示:不同语言的相同字符串必须具有相同的键。为什么?因为当您需要一个字符串(例如,“Retry”)时,只需调用 this.Resources["Retry"]
即可获得本地化字符串。回到 Level 对象的构造函数,switch 语句用于用本地化字符串填充控件的资源,以便调用 this.Resources["Retry"]
时返回“Riprova”(如果我们身在意大利),否则返回“Retry”(在所有其他国家)。
回到 Level 对象。也许现在您在问游戏是从哪里开始的。答案在这里:在 UserControl_Loaded()
方法中(基类 UserControl
的 Loaded
事件的处理程序)。在这里……嗯……现在言语是多余的,让代码来说话吧!
private void UserControl_Loaded(object sender, RoutedEventArgs e) {
//Goals message
MainOverlay.Text = Resources["Overlay_Text_ReportGoal"] as string;
MainOverlay.AdditionalContent = _goals.BuildReport(32, 22);
MainOverlay.BackgroundColor = Colors.Orange;
MainOverlay.Show(new KeyValuePair<string,>(Resources["OK"] as string, () => {
//Starts the timer
_beginTime = DateTime.Now;
_snakeTimer.Start();
_enemySnakesTimer.Start();
//Updates rows and columns of the grid
for (int i = 0; i < _gridSize.Width; i++)
MainGrid.ColumnDefinitions.Add(new ColumnDefinition());
for (int i = 0; i < _gridSize.Height; i++)
MainGrid.RowDefinitions.Add(new RowDefinition());
//Adds the cells to the grid
for (int x = 0; x < _gridSize.Width; x++)
for (int y = 0; y < _gridSize.Height; y++) {
Cell cell = _cells[x, y];
MainGrid.Children.Add(cell);
Grid.SetColumn(cell, x);
Grid.SetRow(cell, y);
}
}));
}</string,>
但是……但是……MainOverlay 是什么?它是一个 OverlayMessage
!SnakeMobile.Core.OverlayMessage 是一个控件,它允许您创建一个“消息层”,其中包含一些文本、一些按钮和一个可选的内容。Show()
方法需要一个 KeyValuePair<string, Action>[]
;每个 KeyValuePair 的键是按钮的文本,值是单击按钮时要调用的委托。我编写这个控件是因为我有时需要向用户询问一些问题,而当用户单击按钮时要执行的每个操作都因情况而异,因此,使用 OverlayMessage,我可以避免编写大量无用的代码来管理不同的处理程序。
现在,是时候讨论应用程序中最有趣的部分了:蛇的控制。
在上图的第一部分,我将蛇放在一个网格中(头部在 (2, 1)),我们假设它想吃左边的食物。
蛇的方向由一个 Point
对象(存储在私有字段 _direction
中)表示。它的坐标只有 -1、0、1,因为它们表示头部单元格与下一个单元格之间的坐标变化。举例说明。头部在 (2, 1),用户想将蛇向左移动。左方向是点 (-1, 0),因为如果我们将其加到头部坐标上,我们将得到下一个头部单元格的坐标(图中蓝色圆圈)。
(2, 1) + (-1, 0) = (1, 1)
蛇的单元格使用 Queue<Cell>
进行管理:在构造函数中,我们添加这些单元格,这样队列顶部就是尾部,底部就是头部。
因此,要使蛇移动,我们必须执行很多操作:
- 首先,我们更改最后一个单元格的
CellType
属性,使其成为普通的尾部段。 - 然后,我们调用
Queue<T>.Dequeue()
,移除顶部元素。 - 我们更新顶部单元格,使其成为蛇的最后一个尾部。
- 最后,我们可以将新的头部单元格入队。显然,新的头部单元格是使用前面解释的方向概念计算的。
- 此时,要执行的操作就完成了,我们从第一步重新开始。
我不会写实现这个的代码,因为它很简单,但非常非常长。
但是,SnakeTimer_Tick
方法(使蛇移动的计时器的 Tick
事件的处理程序)还做了另外 2 件我们没有谈过的事情。
蛇撞到墙壁时会发生什么?或者它怎么能长大?关卡何时结束?我们将在下一集发现所有这些问题的答案!不,不,我在开玩笑,答案就在这里,而且也很简单。
在之前解释的程序的第一个和第二个步骤之间,有一个小细节我必须告诉您:在计算下一个头部单元格之后,我们必须看看头部碰到下一个单元格时会发生什么。
//Hit test
bool grown = false;
switch (nextCell.HitTest(head))
{
//Food eaten
case HitTestResult.Eat:
_foodEaten++;
TxtFoodEaten.Text = _foodEaten.ToString();
grown = true;
AddFood();
break;
//Death
case HitTestResult.Death:
KillSnake();
return;
//Cut
case HitTestResult.Cut:
if (_snakeQueue.Contains(nextCell))
foreach (Cell cutted in _snakeQueue.Cut(_snakeQueue.IndexOf(nextCell)))
cutted.CellType = CellType.Free;
else
_enemySnakes.ForEach(x => x.Cut(nextCell));
break;
//No actions
case HitTestResult.None:
break;
}
//Checks if the snake's grown
if (!grown) _snakeQueue.Dequeue().CellType = CellType.Free;
如您所见,我们根据头部单元格和下一个单元格之间的碰撞测试结果进行切换。如果结果是 Eat(其中一个单元格是食物),我们增加 _foodEaten
字段并将 grown
变量设置为 false,因为这允许我们跳过第 2 步(出队最后一个尾部)。如果结果是 Death(其中一个单元格是墙壁或死亡食物),蛇就死亡,游戏结束。如果结果是 Cut(一条蛇切断了另一条蛇),我们检查被切断的蛇是否是用户的蛇,否则,我们尝试切断敌蛇(我们稍后会讨论 _enemySnakes
字段)。
最后一个未回答的问题是“游戏何时结束?”:这取决于您何时完成关卡的所有目标。每个关卡都有自己的目标需要完成。如果您还记得,Level 对象的构造函数需要一个代表关卡目标的参数;这些目标存储在 _goals 字段中。在 SnakeTimer_Tick
方法的末尾,我们会检查它们是否都已完成。如果完成,我们将停止所有计时器并向用户显示一条消息,告知他们关卡已完成,他们已获胜。
//Checks the goals
GoalEventArgs gea = new GoalEventArgs(_snakeQueue.Count, _foodEaten, DateTime.Now.Subtract(_beginTime));
if (_goals.All(x => x.IsAccomplished(gea))) {
//Stops the timer
_snakeTimer.Stop();
_enemySnakesTimer.Stop();
//Message
MainOverlay.AdditionalContent = null;
MainOverlay.Text = Resources["Overlay_Text_Win"] as string;
MainOverlay.BackgroundColor = Colors.Green;
MainOverlay.Show(new KeyValuePair<string,>(Resources["Overlay_ButtonText_Return"] as string, () => RaiseEvent(this.Win, this, gea)));
}</string,>
![]() |
返回文章顶部 |
敌蛇
实际上,游戏中还有一个最后的方面我们还没有谈到:敌蛇。如果您注意到,有些敌蛇是随机移动的,有些则直接指向食物单元格。为了解决这个问题,我创建了一个接口,其中包含敌蛇需要执行的操作。
如您所见,只有一个属性和 3 个方法:属性是 Level
对象,其中包含敌蛇,并且必须尽快设置。Reset
的任务是重置所有私有字段以恢复蛇的初始状态;而 Cut
则必须在给定单元格处切断蛇。
最有趣的是 Move
方法:顾名思义,此方法必须使蛇移动,并且这里(随机移动的蛇和捕食蛇)有两种不同类型的蛇。RandomlyMovingSnake
是最简单的,因为它没有逻辑范围地移动,因此我们不讨论它。相反,我们将解释 FoodCatcherSnake
,因为它比第一个复杂,而且我们不喜欢简单的东西,对吧?:P
这条蛇必须直接指向一些食物单元格。但是……但是……如何找到通往食物的路径?在 Google 上搜索一些寻路算法,我找到了 A* 的许多实现(真的令人印象深刻,您应该看看其中的一个!),但它们超出了我的需求!幸运的是,经过一些搜索后,我找到了这个。我唯一修改的是 FindPath
函数的返回值,从 int[,]
改为 IEnumerable<Point>
,仅此而已。(我建议您阅读解释 MazeSolver 的文章,否则您可能无法理解接下来的内容)
在构造函数中,我们通过设置墙壁来初始化 MazeSolver,即 CellType
属性设置为 Wall 或 DeathFood 的单元格。在 Move 方法中,我们使用 MazeSolver 来查找要遵循的路径。
//Checks if the path must be updated
if (_pathToFollow.Count() == 0 ||
_cells[(int)_pathToFollow.PeekLast().X, (int)_pathToFollow.PeekLast().Y].CellType != CellType.Food) {
//Updates the list of all the food cells
List<point> lst = new List<point>();
for (int x = 0; x < _gridSize.Width; x++)
for (int y = 0; y < _gridSize.Height; y++)
if (_cells[x, y].CellType == CellType.Food)
lst.Add(new Point(x, y));
_foodCells = lst;
//Checks if there is some food to eat
if (_foodCells.Count() == 0) throw new Exception();
//Finds the furthest food
Point furthestFood = _foodCells.OrderBy<point,>(x => Math.Abs((int)x.X) + Math.Abs((int)x.Y)).First();
//Makes all the segments of the tail of snake wall
_snakeQueue.ForEach(x => _mazeSolver[Grid.GetColumn(x), Grid.GetRow(x)] = 1);
_mazeSolver[Grid.GetColumn(head), Grid.GetRow(head)] = 0;
//Finds the path to follow
_pathToFollow = new Queue<point>(_mazeSolver.FindPath(Grid.GetColumn(head), Grid.GetRow(head), (int)furthestFood.X, (int)furthestFood.Y).Skip(1));
//Removes the walls from the tail of the snake
_snakeQueue.ForEach(x => _mazeSolver[Grid.GetColumn(x), Grid.GetRow(x)] = 0);
//Checks if a path has been found
if (_pathToFollow.Count == 0) return;
}</point></point,></point></point>
如果需要新路径,首先,我们找到包含食物的所有单元格,然后找到最远的单元格,并将路径存储在 Queue<Point>
中。在每次调用 Move 方法时,我们从该队列中查看一个元素,并将蛇移动到这些坐标。
![]() |
返回文章顶部 |
保存和加载关卡
如您所见,游戏中有一点默认关卡;显然,每个关卡都不是一个单独的类:在 Levels 文件夹中有一些 XML 文件代表默认关卡;在运行时,它们由 LevelParser
类的 Parse
方法加载,该方法返回一个已初始化的 Level 类的实例。
在我们谈论解析方法之前,我们需要知道 XML 文件是什么样的。这是结构:
根元素 Level
有一个单独的属性 Speed
,表示游戏速度(主蛇计时器每次滴答的间隔)。在它里面有许多其他不同类型的节点:
Info
:此部分包含有关关卡的一些信息,如标题和描述。IsResource
属性是指示Title
和Description
节点中包含的字符串是否是查找资源字典中的键的值。Goals
:关卡目标的容器。每个目标由一个Goal
节点表示,其属性Type
和Param
分别是蛇的类型(RandomlyMovingSnake 或 FoodCatcherSnake)和要传递给构造函数的参数。- Cells:此节点表示游戏网格的初始状态。Width 和 Height 属性正如其名称所示,是网格的大小。此节点的内容有点特别:有一系列字符放在不同的行上;每个字符是一个单元格,这是图例:
- # => 空格
- W => 墙壁单元格
- D => 死亡食物单元格
- F => 食物单元格
- L => 尾部末端单元格
- T => 尾部单元格
- H => 头部单元格
- / => 敌蛇尾部末端单元格
- - => 敌蛇尾部单元格
- * => 敌蛇头部单元格
![]() |
返回文章顶部 |
UI 和设计
所以,我们到这里了。
游戏的核心部分现已完成,是时候构建游戏的图形部分了。在这里我们将讨论图形控件、页面、导航以及一些与游戏相关的方面,例如主菜单或关卡选择屏幕。
导航
如果您是“Silverlighter”,那么导航概念对您来说并不陌生,但复习一下总没坏处,对吧?
不过,让我们从头开始。
想象一下您正在使用浏览器:您通过链接访问页面。当您厌倦了当前页面或犯了错误时,可以按“后退”按钮,然后神奇地返回到上一页。Windows Phone 7 的想法也是一样的:您使用您的应用程序,但您必须能够仅通过单击“后退”按钮返回到上一页。
别害怕!这并不难!大部分工作由系统完成:您只需要实现前进导航。当您想更改页面时,只需调用 NavigationService
类的 Navigate
方法(可以通过访问当前 Page
实例的同名属性来获取该类的实例);参数是指向下一个页面的 Uri
。
public partial class MainPage : PhoneApplicationPage
{
// Constructor
public MainPage()
{
InitializeComponent();
btnNewGame.Click += BtnNewGame_Click;
}
private void BtnNewGame_Click(object sender, RoutedEventArgs e) {
this.NavigationService.Navigate(new Uri("/MyApp;component/Page2.xaml", UriKind.RelativeOrAbsolute));
}
}
完成。您无需做任何其他事情:“后退”操作由系统自动处理。显然,您可以处理此事件并更改行为。
public partial class MainPage : PhoneApplicationPage
{
// Constructor
public MainPage()
{
InitializeComponent();
btnNewGame.Click += BtnNewGame_Click;
BackKeyPress += new EventHandler<System.ComponentModel.CancelEventArgs>(MainPage_BackKeyPress);
}
void MainPage_BackKeyPress(object sender, System.ComponentModel.CancelEventArgs e)
{
if (MessageBox.Show("Are you sure you want to go back?", "", MessageBoxButton.OKCancel) == MessageBoxResult.Cancel)
e.Cancel = true;
}
private void BtnNewGame_Click(object sender, RoutedEventArgs e) {
this.NavigationService.Navigate(new Uri("/MyApp;component/Page2.xaml", UriKind.RelativeOrAbsolute));
}
}
在这种情况下,我唯一做的改变是弹出一个消息框,询问用户是否真的想后退;如果答案是“确定”,则什么也不做,页面会更改,否则,我们通过 e.Cancel = true
取消事件,并取消操作。
![]() |
返回文章顶部 |
过渡
在上一章中,我们谈到了导航,但是,如果您注意到,它非常难看,因为下一页在没有效果的情况下被替换到上一页。这时 **Silverlight for Windows Phone Toolkit** 来拯救我们。(下载链接)
第一步(下载工具包并添加对程序集 Microsoft.Phone.Controls.Toolkit 的引用后),是将 Frame 从 PhoneApplicationFrame
更改为 TransitionFrame
。打开 App.xaml.cs 并在 InitializePhoneApplication
方法中进行如下更改:
// Do not add any additional code to this method
private void InitializePhoneApplication()
{
if (phoneApplicationInitialized)
return;
// Create the frame but don't set it as RootVisual yet; this allows the splash
// screen to remain active until the application is ready to render.
// ------------------------------
RootFrame = new Microsoft.Phone.Controls.TransitionFrame(); // new PhoneApplicationFrame();
// ------------------------------
RootFrame.Navigated += CompleteInitializePhoneApplication;
// ...
}
但是……我们做了什么?要回答这个问题,我们必须回溯一下。
(图片来自 MSDN)
这是每个 Windows Phone 7 应用程序的构成。最顶层的容器是 PhoneApplicationFrame
;这个对象包含一个 PhoneApplicationPage
,它承载您的内容。通过调用 NevigationService.Navigate
,我们告诉 Frame 更改其内容页面,因此,如果我们用能够进行过渡效果的 Frame 替换默认 Frame,就完成了操作。这正是我们之前所做的:实际上,TransitionFrame
是一个特殊的 Frame,它在导航过程中为页面设置动画。
不过,现在是时候选择过渡效果了:幸运的是,这也很简单。该工具包提供了一些非常有用的附加属性,我们可以为每个页面设置。NavigationInTransition
和 NavigationOutTransition
分别是进入页面和退出页面时使用的动画。两者都有两个重要属性:Backward
和 Forward
。前者是向后导航时使用的动画,后者是向前导航时使用的动画。
当您从一个页面导航到另一个页面时,第一个页面开始 ForwardOut 动画,而第二个页面开始 ForwardIn。在向后导航期间,第一个页面开始 BackwardOut 动画,而第二个页面开始 BackwardIn。
这是将过渡效果应用于页面的代码:
<phone:PhoneApplicationPage
xmlns:phone="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone"
xmlns:toolkit="clr-namespace:Microsoft.Phone.Controls;assembly=Microsoft.Phone.Controls.Toolkit">
<!-- Navigation IN -->
<toolkit:TransitionService.NavigationInTransition>
<toolkit:NavigationInTransition>
<toolkit:NavigationInTransition.Backward>
<!-- BackwardIn -->
<toolkit:TurnstileTransition Mode="BackwardIn"/>
</toolkit:NavigationInTransition.Backward>
<toolkit:NavigationInTransition.Forward>
<!-- ForwardIn -->
<toolkit:TurnstileTransition Mode="ForwardIn"/>
</toolkit:NavigationInTransition.Forward>
</toolkit:NavigationInTransition>
</toolkit:TransitionService.NavigationInTransition>
<!-- Navigation OUT -->
<toolkit:TransitionService.NavigationOutTransition>
<toolkit:NavigationOutTransition>
<toolkit:NavigationOutTransition.Backward>
<!-- BackwardOut -->
<toolkit:TurnstileTransition Mode="BackwardOut"/>
</toolkit:NavigationOutTransition.Backward>
<toolkit:NavigationOutTransition.Forward>
<!-- ForwardOut -->
<toolkit:TurnstileTransition Mode="ForwardOut"/>
</toolkit:NavigationOutTransition.Forward>
</toolkit:NavigationOutTransition>
</toolkit:TransitionService.NavigationOutTransition>
<!-- ... Content ... -->
</phone:PhoneApplicationPage>
当然,您可以选择任何您想要的动画效果,而不是 TurnstileTransition
,例如 RollTransition
、RotateTransition
、SlideTransition
或 SwivelTransition
。
![]() |
返回文章顶部 |
圆角发光按钮
不过,是时候回到我们的游戏了。让我们从主菜单开始!
如您所见,这是一个由 3 个项目组成的菜单(其他项目是无用的):第一个项目 Continue,用于恢复当前游戏;New game 会删除所有数据并开始新游戏;Settings 则导航到设置页面。
每个项目都是一个 Button
,我对其进行了模板化以改变其外观。我使用 Microsoft Expression Blend 4 for Windows Phone 来实现这个结果(该工具包含在 Windows Phone 7 开发工具中),以下是我的做法。我声明我不是设计师,我只是一个程序员,分享我使用 Blend 的经验,所以,如果您想告诉我如何提高我的设计技能,任何建议都将受到欢迎!
如果图片中的所有内容都无法阅读,可以单击它们放大。
-
创建一个新项目,并在主页面上添加一个新的
Button
。右键单击按钮并选择编辑模板 > 创建空。 -
在接下来的窗口中,创建一个名为 "RoundedGlowingButton.xaml" 的新资源字典,并将资源名称设置为 "RoundedGlowingButton"。
-
现在,您正在编辑按钮的模板。添加一个新的椭圆,使其填充所有可用区域,然后删除描边并将
Fill
属性绑定到模板化父对象的Background
属性(Template binding > Background)。 -
复制该椭圆并更改其
Fill
属性:设置一个从白色到透明的径向渐变。 -
选择渐变工具并修改渐变,使其看起来像这样(光线似乎来自底部)。
-
再次复制第一个椭圆并将其
Fill
设置为白色,然后将其Opacity
设置为 20%。 -
右键单击最后一个椭圆并将其转换为
Path
对象(Path > Convert to Path)。 -
选择直接选择工具并更改路径的点,使其看起来像这样。
-
现在,是时候显示按钮的内容了:打开其他资源,在搜索框中输入“content”,然后选择
ContentPresenter
控件。 -
添加一个新的
ContentPresenter
并将其水平和垂直居中,然后将其Content
属性绑定到模板化父对象的Content
属性。 -
再次复制我们创建的第一个椭圆,并将其放在后面,然后通过设置 1.5 的缩放比例使其变大一点。
-
将其 Fill 属性更改为从白色到透明的径向渐变,然后选择渐变工具并将白色渐变停止点设置在按钮的边缘,另一个停止点稍远一些。
-
现在,最复杂的部分完成了!但是如果我们添加一些动画呢?我认为这并非坏主意!因此,选择状态选项卡并将默认过渡持续时间设置为 0.1 秒;单击“Pressed”附近的箭头,然后选择* > Pressed。
-
现在单击“Pressed”。您会看到整个绘图区域有一个红色边框:这意味着 Blend 正在记录更改以转换为故事板,换句话说,通过这种方式,我们所做的任何更改都会转换为故事板,该故事板在按钮进入所选状态(在我们的例子中是“Pressed”)时播放。换句话说,当我们单击按钮时,会播放一个故事板,该故事板应用了我们使用 Blend 所做的更改。如果您想查看此故事板的预览,可以激活过渡预览按钮并选择不同的状态:Blend 会自动播放这些故事板,您可以在绘图区域直接看到它们的效果。不过,现在您已经选择了Pressed 状态,请选择InnerGlow 椭圆并将填充的第一个渐变停止点更改为 #FF3D71D8(或任何您想要的颜色)。
![]() |
返回文章顶部 |
触发器
主菜单中另一个有趣的事情是触发器。但是,首先:什么是触发器?
触发器是一个对象,它在事件被触发时调用一个操作。这里的例子可以理清思路:例如,我们想创建一个触发器,允许我们在用户单击按钮(如应用程序中)时导航到页面。
因此,为了达到结果,我们必须做一些事情:
- 首先,我们必须确保引用了程序集
System.Windows.Interactivity
,因为我们正在讨论的所有内容都存在于该程序集中。 - 然后我们必须创建自定义操作:让我们创建一个继承自
TriggerAction<T>
的类,其中T
是此操作可以附加到的对象的类型,在我们的例子中T
是Button
。
public class NavigationTrigger : TriggerAction<Button> { #region Uri property public static readonly DependencyProperty UriProperty = DependencyProperty.Register("Uri", typeof(Uri), typeof(NavigationTrigger), new PropertyMetadata(null)); public Uri Uri { get { return (Uri)GetValue(UriProperty); } set { SetValue(UriProperty, value); } } #endregion protected override void Invoke(object parameter) { //Navigates ((App)Application.Current).RootFrame.Navigate(this.Uri); } }
正如您所注意到的,存在依赖属性
Uri
和重写的方法Invoke
。类中最重要的部分是该方法:它是事件被触发时调用的。在这里,我们编写了触发器的核心,因此,我编写了用于导航到Uri
属性指示的 URI 的代码。 - 现在代码隐藏已完成;我们要做的就是从现在开始,只编辑 XAML 代码。所以,让我们打开 XAML 文件并添加一些新的 xmlns:
<phone:PhoneApplicationPage xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity" xmlns:local="clr-namespace:SnakeMobile"> ... </phone:PhoneApplicationPage>
- 最后,我们必须像这样更改按钮的代码:
<Button Content="Settings"> <i:Interaction.Triggers> <i:EventTrigger EventName="Click"> <local:NavigationTrigger Uri="/SnakeMobile;component/Settings.xaml" /> </i:EventTrigger> </i:Interaction.Triggers> </Button>
有了这段代码,我们就为按钮注册了一个
EventTrigger
,当Click
事件被触发时,它会调用我们的NavigationTrigger
操作,该操作会导航到 /SnakaMobile;component/Settings.xaml。
正如您所见,我们用很少的代码行创建了一个可以随时随地使用的代码单元,只需使用 XAML 代码。仅为导航操作创建触发器似乎是在浪费时间,但我这样做的原因是我想向您解释 Silverlight for Windows Phone 的一些功能,即使它们可能导致我编写一些不必要的代码。想象一个大型业务应用程序:使用触发器可以为您节省大量时间。
不过,如果您想深入了解触发器的概念,我建议您阅读这篇文章,它也讨论了行为:Silverlight and WPF Behaviours and Triggers - Understanding, Exploring And Developing Interactivity using C#, Visual Studio and Blend
![]() |
返回文章顶部 |
简要的数学回顾:极坐标系
在开始讨论圆形选择器之前,我们需要进行一次简短的数学回顾。
让我们从每个人都知道的东西开始:笛卡尔平面。这是维基百科的说法:
“笛卡尔坐标系通过一对数值坐标唯一地指定平面上的每个点,这些坐标是从点到两条固定垂直定向线的有符号距离,以相同的长度单位测量。每条参考线称为坐标轴或系统的轴,它们相交的点是其原点。”
用人类的语言来说,这意味着在我们的平面中有两条垂直线(称为轴),并且平面的每个点都由一对数字标识,这些数字分别表示点在 X 轴和 Y 轴上的投影。
在这个例子中,我在平面上放置了 4 个点:每个点旁边的文本代表点的坐标。例如,蓝点是 (4, 5),因为在水平方向上,它距离原点 4 个单位,在垂直方向上,距离原点 5 个单位。
但是您应该已经知道这些了,对吧?显然,答案是肯定的,所以我们直接进入我们的新系统:极坐标系。像往常一样,让我们看看维基百科是怎么说的:
“在数学中,极坐标系是一个二维坐标系,其中平面上的每个点由与固定点的距离和一个与固定方向的夹角确定。”
这可能看起来比笛卡尔系统复杂,但仔细分析文本:我们有一个固定点和一个固定方向,从这两个点开始测量角度。现在,平面上的每个点都有 2 种不同的坐标类型:一个数字和一个角度。
蓝点:第一个数字 (7) 是与固定点(系统中心)的距离,第二个数字 (60°) 是点与固定方向(水平线)之间的夹角。对于绿点,前面的图是自我解释的。
当我们开始讨论平面上的曲线时,我们可以将每个点定义为
这里,θ 是点的角度,r(θ) 是一个返回点到原点距离的函数。
但是,我为什么要告诉您这些?为什么我应该使用极坐标系而不是笛卡尔坐标系?老实说,我不知道是否有正确的方法来选择它们,但我认为我可以举一个例子来让您理解我的想法:圆。想象一个以原点为中心的圆:这是两个方程。
笛卡尔平面 ![]() 这里,我们有一个二次方程,其中 r 是圆的半径。(并非不可能,但很难看,不是吗?) |
极坐标系 ![]() 这里,方程非常简单:对于 theta 的每个值,我们都返回圆的半径。这意味着对于每个角度,与固定点的距离始终是圆的半径。 |
圆的例子很简单,但请想象一个更复杂的东西,例如螺旋线。
(图片来自维基百科)
我不知道是否有办法在笛卡尔坐标中做到这一点,但我知道这就是这条阿基米德螺旋线的极坐标方程。
很简单,不是吗?改变参数a 会旋转螺旋线,而b 控制臂之间的距离。
极坐标系很酷,但是……我如何在我的应用程序中实现这个系统?.NET Framework 只理解笛卡尔坐标!问题很快解决了:我们只需要创建一个从极坐标系到笛卡尔坐标系的转换函数。
这两个方程的原因非常简单
(图片来自维基百科)
如您所见,我们可以将具有极坐标(r, θ)的点放置在笛卡尔平面上;使用三角函数正弦和余弦,我们可以计算点在 X 轴和 Y 轴上的投影。
但是现在,是时候让代码说话了!这是转换函数:
private Point PolarToCartesian(double r, double theta) {
return new Point(r * Math.Cos(theta), r * Math.Sin(theta));
}
现在呢?什么都没有。我们终于可以开始讨论我们的 CircularSelector
了。
![]() |
返回文章顶部 |
圆形选择器
因此,这是我们控件的屏幕截图;基本上,它由一定数量的项目(圆形扇形)组成,每个项目都可以通过设置标题、颜色或可见性来自定义。不过,这是此控件每个有趣属性的精确列表(这些属性是依赖属性,因此您可以绑定它们):
属性名 | 属性类型 | 描述 | |
![]() |
SweepDirection
|
SweepDirection
|
|
![]() |
LinesBrush
|
Brush
|
用于绘制每个项目边框的 |
![]() |
AlwaysDrawLines
|
bool
|
获取或设置一个值,该值指示每个项目的边框是否应被绘制,即使它不可见。 |
![]() |
SelectedItemPushOut
|
double
|
获取或设置一个值,该值指示所选项目必须向外移动的像素量。 |
![]() |
FontFamily
|
FontFamily
|
标题使用的字体系列。 |
![]() |
FontSize
|
double
|
标题使用的字体大小。 |
![]() |
FontStyle
|
FontStyle
|
标题使用的字体样式。 |
![]() |
FontWeight
|
FontWeight
|
标题使用的字体粗细。 |
![]() |
Foreground
|
Brush
|
用于绘制标题前景的 |
![]() |
ItemsSource
|
CircularSelectorItem[]
|
包含所有元素的数组。 |
![]() |
SelectedItem
|
CircularSelectorItem
|
获取或设置当前选中的项目。 |
每个元素都是一个 CircularSelectorItem
对象;该对象具有以下属性(这些也是依赖属性):
属性名 | 属性类型 | 描述 | |
![]() |
Color
|
Color
|
获取或设置项目的颜色。 |
![]() |
IsVisible
|
bool
|
获取或设置项目的可见性。 |
![]() |
标题
|
字符串
|
获取或设置项目的标题文本。 |
![]() |
Tag
|
object
|
获取或设置一个包含一些额外信息的对象。 |
好了,现在我们的简短概述结束了,我们可以开始讨论代码了!CircularSelectorItem
很无聊:它只是一个带有某些属性的简单类!所以,我们将直接进入 CircularSelector
对象。我们从哪里开始?也许从类图?是的,我认为是这样!
如您所见,有很多属性和方法:它们的含义已经解释过,但现在我将深入研究 UpdatePaths
方法。此方法更新控件的所有圆形扇形;每当控件的大小、ItemsSource或 SweepDirection 发生变化时,都会调用它。它的代码非常复杂,所以我们将把它分成许多单独的部分。
- 第一步:清理所有现有的对象。
ClearPaths();
ClearPaths
函数仅调用base.Children.Clear()
(请记住CircularSelector
继承自Panel
)。 - 我们检查 items source 中是否至少有一个项目。
if (this.ItemsSource == null || this.ItemsSource.Count() == 0) return;
- 现在我们可以计算并存储一些有用的信息,例如每个扇形的角度大小、可用大小和圆的半径。
//Calculates the angle of each item double angle = Math.PI * 2 / this.ItemsSource.Count(); //Calculates the size of the selector double size = Math.Min(this.ActualWidth, this.ActualHeight); if (double.IsNaN(size) || size == 0) return; double radius = size / 2;
- 这里有点复杂。
//Finds the points double cumulativeAngle = 0; List<Point> pointList = new List<Point>(); angles = new Dictionary<int, double>(); for (int i = 0; i < this.ItemsSource.Count(); i++) { pointList.Add(PolarToCartesian(radius, cumulativeAngle) .Multiply((this.SweepDirection == SweepDirection.Counterclockwise ? 1 : -1), -1) .Offset(radius, radius)); angles.Add(i, cumulativeAngle); cumulativeAngle += angle; }
这里我们找到圆周上的点,我们稍后将用它们来绘制弧线。也许画一张图会有帮助……简而言之,我们在寻找红点。
另一个奇怪的事情是for
语句中的第一行:我创建了 2 个扩展方法来帮助处理点。代码如下:public static Point Offset(this Point p, double x, double y) { p.X += x; p.Y += y; return p; } public static Point Multiply(this Point p, double x, double y) { p.X *= x; p.Y *= y; return p; }
代码非常简单,无需解释。但有趣的是我为什么要这样做。让我们从起源开始:在我们的控件中,原点位于容器的中心,Y 轴向下。但实际上,当我们使用极坐标系时,情况有点不同:首先,系统的原点是容器的左上角,然后,Y 轴向上。因此,我们必须对我们的点进行一些转换,特别是:
因此,第一张图是我们的初始情况(如上所述);第二张图告诉我们反转 Y 轴;最后一图是原点的偏移。用代码来说,这意味着:- 我们必须将我们的极坐标转换为笛卡尔坐标。
- 我们必须将点的 Y 坐标值乘以 -1。
- 如果扫描方向是顺时针,我们还需要将 X 坐标乘以 -1。
- 最后,我们必须偏移我们的点以使其居中。
for
语句中的第二行:在这里,我们使用扇区的索引作为键将角度存储在一个字典中。现在这可能看起来有点没用,但稍后我们将看到它并非如此。 -
//Creates the paths for (int i = 0; i < this.ItemsSource.Count(); i++ ) { //Item CircularSelectorItem item = this.ItemsSource.ElementAt(i); //Skips the item if it's not visible if (this.AlwaysDrawLines == false && item.IsVisible == false) continue; //Creates a new path and a geometry Path path = new Path() { Stroke = this.LinesBrush, StrokeThickness = 1, Fill = (item.IsVisible ? CreateBackgroundBrush(item.Color, i) : new SolidColorBrush(Colors.Transparent)) }; PathGeometry geom = new PathGeometry(); PathFigure fig = new PathFigure(); geom.Figures.Add(fig); path.Data = geom; //Draws the sector fig.StartPoint = new Point(radius, radius); Point p1 = pointList[i]; Point p2 = pointList[(i + 1 == pointList.Count ? 0 : i + 1)]; fig.Segments.Add(new LineSegment() { Point = p1 }); fig.Segments.Add(new ArcSegment() { Point = p2, Size = new Size(radius, radius), IsLargeArc = angle > Math.PI, SweepDirection = this.SweepDirection, RotationAngle = 0 }); fig.Segments.Add(new LineSegment() { Point = fig.StartPoint }); base.Children.Add(path); path.Tag = new object[] { item, null }; if (item.IsVisible) path.MouseLeftButtonUp += Path_MouseLeftButtonUp; //Creates the content of the item if (item.IsVisible) { //Creates the textblock TextBlock txt = new TextBlock() { Text = item.Header, Foreground = this.Foreground, FontFamily = this.FontFamily, FontSize = this.FontSize, FontStyle = this.FontStyle, FontWeight = this.FontWeight }; Point middlePoint = new Point((p1.X + p2.X) / 2, (p1.Y + p2.Y) / 2); txt.Margin = new Thickness(middlePoint.X - txt.ActualWidth / 2, middlePoint.Y - txt.ActualHeight / 2, 0, 0); base.Children.Add(txt); txt.MouseLeftButtonUp += Path_MouseLeftButtonUp; //Sets the tag of the path ((object[])path.Tag)[1] = txt; txt.Tag = path.Tag; } }
这可能是该方法最复杂的部分。前几行很简单:我们迭代 ItemsSource 中的所有项目,并检查是否需要绘制当前项目。从这里开始,它有点复杂。首先,我们创建一个新的Path
对象并设置其属性(CreateBackgroundBrush
是一个简单的函数,它将Color
对象转换为Brush
);然后,我们创建一个新的PathGeometry
和一个PathFigure
。下一个块绘制圆形扇形。
起始点是控件的中心。从该点开始,我们创建一个线到弧的起始点(我们之前已经在前面的代码块中找到了所有这些点)。然后,我们绘制一个从起始点到结束点的弧,最后,我们可以通过添加从弧结束点到起始点的最后一条线来关闭我们的路径。要理解ArcSegment
对象的工作原理,我推荐这篇精彩的文章,标题为“ArcSegment 的数学原理”。
如果项目可见,我们为其MouseLeftButtonUp
事件添加一个处理程序;在该处理程序中,我们仅将SelectedItem
属性更改为单击的项目。仅当项目可见时,我们才创建一个新的TextBlock
来显示扇形的标题;然后,我们为MouseLeftButtonUp
事件添加相同的处理程序。
如果您注意到,我们将两个对象的Tag
属性都设置为一个对象数组,其中包含扇形所代表的CircularSelectorItem
实例以及用于标题的TextBlock
。稍后当我们讨论SelectedItem
属性时,您将发现我为什么这样做。
我们终于完成了 UpdatePaths
方法,并且可以立即开始分析控件的另一个焦点:SelectedItem
属性。
public CircularSelectorItem SelectedItem
{
get { ... }
set {
//Gets the value
CircularSelectorItem current = SelectedItem;
//Cheks if it's changed
if (value != current && base.Children.Count > 0) {
//Checks if the ItemsSource contains the new element
if (ItemsSource == null || ItemsSource.Contains(value) == false)
throw new ArgumentException("Item not in the ItemsSource");
//Temp vars
object[] obj = new object[] { };
Path path = null;
int i = 0;
//Animates the current item
if (current != null) {
path = base.Children.OfType<Path>().First(x => ((object[])x.Tag)[0] == current);
obj = (object[])path.Tag;
i = this.ItemsSource.IndexOf((CircularSelectorItem)obj[0]);
AnimatePath(path, false, i);
if (obj[1] != null) AnimatePath((FrameworkElement)obj[1], false, i);
}
//Animates the new value
path = base.Children.OfType<Path>().First(x => ((object[])x.Tag)[0] == value);
obj = (object[])path.Tag;
i = this.ItemsSource.IndexOf((CircularSelectorItem)obj[0]);
AnimatePath(path, true, i);
if (obj[1] != null) AnimatePath((FrameworkElement)obj[1], true, i);
//Stores the value
SetValue(SelectedItemProperty, value);
//Raises the event
OnSelectionChanged(new SelectionChangedEventArgs(
new List<CircularSelectorItem>(new CircularSelectorItem[] { current }),
new List<CircularSelectorItem>(new CircularSelectorItem[] { value })
));
}
}
}
一旦我们将当前项目存储在一个变量中,我们就检查前一个值是否与新值不同,并且是否至少有一个路径已绘制。如果是,我们检查 ItemsSource 是否包含新项目,如果不是,则抛出异常。在所有这些测试之后,我们为当前选中的路径和新路径设置动画。为了找到代表 CircularSelectorItem
的 Path
对象,我们查找 Tag
属性包含该项的 Path
对象。最后,我们存储新值并引发事件。
CircularSelector
的一个有趣功能是更改选定项目时使用的动画。这是通过 AnimatePath
方法完成的。
private void AnimatePath(FrameworkElement path, bool isSelected, int i) {
//Checks if there are the angles
if (angles == null || angles.Count == 0)
return;
//Creates a storyboard
Storyboard story = new Storyboard();
//Creates a TranslateTransform
TranslateTransform transform = new TranslateTransform();
path.RenderTransform = transform;
//Calculates the end point
double radius = Math.Min(this.ActualWidth, this.ActualHeight) / 2;
double middleAngle = angles[i] + Math.PI * 2 / this.ItemsSource.Count() / 2;
Point endPoint =
PolarToCartesian(this.SelectedItemPushOut, middleAngle)
.Multiply((this.SweepDirection == SweepDirection.Counterclockwise ? 1 : -1), -1)
.Offset(radius, radius);
Point difference = endPoint.Offset(-radius, -radius);
//Creates the animation
DoubleAnimation animX = new DoubleAnimation() {
From = (isSelected ? 0 : difference.X),
To = (isSelected ? difference.X : 0),
Duration = TimeSpan.FromMilliseconds(200)
};
Storyboard.SetTarget(animX, transform);
Storyboard.SetTargetProperty(animX, new PropertyPath("X"));
DoubleAnimation animY = new DoubleAnimation() {
From = (isSelected ? 0 : difference.Y),
To = (isSelected ? difference.Y : 0),
Duration = TimeSpan.FromMilliseconds(200)
};
Storyboard.SetTarget(animY, transform);
Storyboard.SetTargetProperty(animY, new PropertyPath("Y"));
//Begins the animation
story.Children.Add(animX);
story.Children.Add(animY);
story.Begin();
}
此方法非常简单:首先,我们创建一个新的 TranslateTransform
并将其添加到路径中,然后计算终点(目标点),最后,我们将其与中心相减以计算 X 和 Y 轴上的差值。之后,我们设置动画并启动故事板。
![]() |
返回文章顶部 |
隔离存储:文件和设置
这一次,如果您是“Silverlighter”,那么隔离存储应该对您来说并不陌生,但稍微复习一下总没坏处,不是吗?
让我们分析一下这个术语:“存储”意味着它是一种存储库,“隔离”意味着它仅供您的应用程序使用。因此,隔离存储是一个您可以放置文件和文件夹的存储库,其他应用程序无法访问它,正如您也无法访问其他应用程序的存储一样。
隔离存储的使用是强制性的,因为没有办法存储持久性数据:事实上,Windows Phone 7 不允许您在隔离存储之外写入或读取。根据 MSDN:
Isolated storage enables managed applications to create and maintain local storage. The mobile architecture is similar to the Silverlight-based applications on Windows. All I/O operations are restricted to isolated storage and do not have direct access to the underlying operating system file system. Ultimately, this helps to provide security and prevents unauthorized access and data corruption.
Application developers have the ability to store data locally on the phone, again leveraging all the benefits of isolated storage including protecting data from other applications.
In other words, this is the schema representing the isolated storage
Every necessary class to manage the isolated storage resides into the System.IO.IsolatedStorage
namespace.
The first thing to do is obtaining a reference to the storage scoped to our application; to achieve this goal, we call the static method GetUserStoreForApplication
of the class IsolatedStorageFile
. The object we obtain is a sort of "file system manager": in fact, with this, we can create or delete file and folders, read and write files. To write data in a file, we must create a new file and a stream: we call the OpenFile
method to get a stream that we'll pass to the constructor of the StreamWriter
class. Once that's done, we can use the StreamWriter
to write data in our file. Translated in code, this means
using (IsolatedStorageFile storage = IsolatedStorageFile.GetUserStoreForApplication())
using (IsolatedStorageFileStream fileStream = storage.OpenFile("MyFile.txt", System.IO.FileMode.OpenOrCreate, System.IO.FileAccess.Write))
using (StreamWriter writer = new StreamWriter(fileStream))
writer.Write("Hello from isolated storage!");
I advice you to use the using
statements, because they allow you to save a lot of time, avoiding a lot of calls to Dispose
and Close
to clean up the memory and close all the streams.
To read data from a file, instead, the procedure is very similar to the previous one
string readText = "";
using (IsolatedStorageFile storage = IsolatedStorageFile.GetUserStoreForApplication())
using (IsolatedStorageFileStream fileStream = storage.OpenFile("MyFile.txt", System.IO.FileMode.Open, System.IO.FileAccess.Read))
using (StreamReader reader = new StreamReader(fileStream))
readText = reader.ReadToEnd();
The only differences are that this time we use a StreamReader
instead of a StreamWriter
, and that the file is opened using System.IO.FileAccess.Read
instead of System.IO.FileAccess.Write
.
The isolated storage can be easily used to store the settings of our application, i.e., in a XML file. We can create a new XML file, add nodes, write values, read them later, etc... This procedure is simple, but a bit long. If you've noticed, in the previous schema explaining the isolated storage, I've divided the settings from all the other things. I've done so because we can treat the settings in a different (and simpler) manner.
Wouldn't be simpler treating the settings like a common dictionary, avoiding I/O actions? Here is where the IsolatedStorageSettings
class comes to rescue us. By the static property ApplicationSettings
, we obtain the settings scoped to our application; the returned object is a IDictionary<string, object>
, already filled with our settings! An example here may clean the ideas
IsolatedStorageSettings settings = IsolatedStorageSettings.ApplicationSettings;
settings["String"] = "value";
settings["Bool"] = true;
settings["DateTime"] = DateTime.Now;
settings.Save();
This means that, the first time that our application is launched, our settings are empty and we add new keys to our dictionary, like a common dictionary. In the end, we save all the settings by calling the Save
method. Remember to call this method, otherwise, nothing will be saved, because these are all in-memory changes! The next time that our code is executed, we call IsolatedStorageSettings.ApplicationSettings
and we obtain an IsolatedStorageSettings
object already filled with our data! The rest of the code, so, updates the dictionary and saves it again.
![]() |
返回文章顶部 |
设置
The settings page is particular because is the example of a strange thing. But before, we should talk about the code behind! This is the diagram of the SnakeMobile.Core.Settings.Settings
class
Let's take a general look to this class: it inherits from DependencyObject
because Acceleration
, Sound
, SondFx
and TotalTimePlayed
are dependency property; OnAccelerationChanged
, OnAccelerationChanged
, OnAccelerationChanged
and OnAccelerationChanged
are the callback methods for the changing of the previous dependency properties. Load
and Save
methods allow us to load and save the settings to the isolated storage; RestoreDefaultSettings
, instead, restores all the default settings.
An interesting property is Instance
, but before of speaking about it, we must go a little back in the time, before this class was created.
I need a class which can hold the settings of my application, so a static class with static properties is perfect, because the settings must be valid for the whole application, not for a single instance of the class. But I must remember that I'm using XAML, so, the binding is compulsory if I want to save code, and I MUST save code, so I need a class with bindable properties. This class, so, must expose some dependency properties (and so it must inherit from DependencyObject), and must be static. In other words, I must only write some static dependency properties inside a static class!
EH?!
What I've just said is impossible! I can't bind to static properties, so I need to bind to instance properties; instance properties means constructor, and constructor CAN'T mean static class! The problem, so, is bigger that what I've thought... How can I bind to a static property inside a static class?
think... think... think... EUREKA!
The static class mustn't be static, but must act as if it was! The settings class mustn't be static and all the properties must be normal instance properties. In this way, it can inherit from DependencyObject and the properties can be dependency properties. Now, I don't make the class static, but, instead, I make it singleton: I make the constructor private, and I create a static read-only property called "Instance", and a static field "_instance". This property must return the "_instance" field, and, if the field is null, sets it to a new instance of the Settings class, and then returns "_instance".
Translating in code what I've just said, this is the most important part
public class Settings : DependencyObject
{
//Private constructor
private Settings() { }
//Static instance
private static Settings _instance = null;
/// <summary>
/// Instance of the class
/// </summary>
public static Settings Instance {
get {
if (_instance == null)
return _instance = new Settings();
return _instance;
}
}
// ...
// Other code ...
// ...
}
But the story isn't finished!
Now, the trick is done! In my Settings page, I set the DataContext to the instance of the class, so that I can use binding inside XAML!
This means that in the Settings.xaml.cs (code behind file of Settings.xaml) I must add only this line of code...
public partial class Settings : PhoneApplicationPage
{
public Settings()
{
//Component initialization
InitializeComponent();
//Data context
this.DataContext = SnakeMobile.Core.Settings.Settings.Instance;
}
}
... to be able to use two-way binding inside XAML
<toolkit:ToggleSwitch x:Name="ToggleSound" IsChecked="{Binding Sound, Mode=TwoWay}" />
In that way, without writing any code to handle the events of the controls, we can write a complete settings page, only using bindings.
About the graphical controls, I should tell you something about the Pivot
, but I'm not a designer, so, I advice you to read this article of Jeff Wilcox, if you want to know something about this control and its brother Panorama
: Panorama and Pivot controls for Windows Phone developers
![]() |
返回文章顶部 |
生成操作:内容还是资源?
(explanation about Visual Studio)
Before we start talking about the audio, I need to make me sure you have understood the difference between Content and Resource. If you already know the difference, you can skip this paragraph and go to the next one, about the audio.
This paragraph talks about a little difference, but that gave me some trouble; and I want to make me sure you've understood the difference between them, because I don't want you to waste a lot of time, as I did. But let's start from the beginning
When you click on an element in the Solution Explorer of Visual Studio, in the properties window you can see the property of the element you've clicked. The one we'll analyze is the Build Action. Its values can be a lot, but we'll focus on Resource and Content, because they are the most common (and most confused).
-
Resource
When you use this value, the file you've selected (e.g. 0.xml) is included in the resources of the DLL, instead of the XAP package.
In fact, if you take a look into the XAP package (using WinRAR or any other program to manage archives) you can notice that there is no file with the name of 0.xml
But if you decompile the DLL (using the .NET Reflector) you can see that it's in the resources
-
Content
Content, instead, is the opposite of Resource: the file (e.g. Music.wav), if Build Action is set to Content, is included inside the XAP package.
Inside the DLL, in fact, there isn't a Music.wav...
... because it's inside the XAP package
To close this paragraph, I want to give you 2 advices when you use Content
- Notice how the structure of the folders that you have in Visual Studio is recreated inside the XAP package.
- This options produces these effects only if you are working on the WP7 project. I mean, if you have 2 projects (like in this application), one that is the Windows Phone application, and the other that is a simple DLL, if you set the Build Action to Content of a file inside the DLL, you get no effect (the file isn't inside the XAP package).
![]() |
返回文章顶部 |
音频 & XNA
One last thing before we start: we must remember that we are programming a Silverlight application, so, to use XNA, we must do some tricks. If you write an XNA application from the ground up, you have to do nothing, but if you want to make XNA and Silverlight work in a single Silverlight application, it's enough a little trick. Here it is: XNA is a big framework, with a lot of objects, and who knows what's behind what we see! But one thing is sure: XNA needs that its internal messages are processed. XNA is able to do this automatically, if the application is written entirely for XNA, but this isn't our case, so, what can we do? Simple: we must write a class that dispatches the messages of the XNA framework! Luckily, exists the method Microsoft.Xna.Framework.FrameworkDispatcher.Update
, which dispatches the message for us, so, the only thing we have to do is calling regularly this method.
public class XNAAsyncDispatcher : IApplicationService
{
private DispatcherTimer frameworkDispatcherTimer;
public XNAAsyncDispatcher(TimeSpan dispatchInterval)
{
this.frameworkDispatcherTimer = new DispatcherTimer();
this.frameworkDispatcherTimer.Tick += new EventHandler(frameworkDispatcherTimer_Tick);
this.frameworkDispatcherTimer.Interval = dispatchInterval;
}
void IApplicationService.StartService(ApplicationServiceContext context) { this.frameworkDispatcherTimer.Start(); }
void IApplicationService.StopService() { this.frameworkDispatcherTimer.Stop(); }
void frameworkDispatcherTimer_Tick(object sender, EventArgs e) { Microsoft.Xna.Framework.FrameworkDispatcher.Update(); }
}
public partial class App : Application
{
public App()
{
// ...
//Enables the XNA Async Dispatcher
this.ApplicationLifetimeObjects.Add(new XNAAsyncDispatcher(TimeSpan.FromMilliseconds(50)));
// ...
}
}
(If you don't know what an application lifetime object is, I advice you to read this short post of Shawn Wildermuth: The Application Class and Application Services in Silverlight 3)
So, this is the code. In the constructor of our dispatcher (XNAAsyncDispatcher
) we create a DispatcherTimer
which calls the FrameworkDispatcher.Update
method. Nothing else, this is the dispatcher class. The last step is adding it to the collection of the lifetime objects, so, in the constructor of the App
class, we add a new XNAAsyncDispatcher
to the ApplicationLifetimeObjects
list.
Well... now that XNA is ready to give us full power, we can start!
What we have to do first, is knowing that all the audio we have can be divided in songs and sound effects: the first ones are a continuous piece of music, while the second ones are a brief sound played in specific moments. For instance, a song is the background music of a game, while a sound effect is the noise played when the snake eats some food.
When we start using the XNA framework to play music, we must be careful to follow the Windows Phone 7 Application Certification Requirements.
6.5.1 Initial Launch Functionality
When the user is already playing music on the phone when the application is launched, the application must not pause, resume, or stop the active music in the phone MediaQueue by calling the Microsoft.Xna.Framework.Media.MediaPlayer class.
If the application plays its own background music or adjusts background music volume, it must ask the user for consent to stop playing/adjust the background music (e.g. message dialog or settings menu).
This requirement does not apply to applications that play sound effects through the Microsoft.Xna.Framework.Audio.SoundEffect class, as sound effects will be mixed with the MediaPlayer. The SoundEffect class should not be used to play background music.
In a few words, you should check that the user isn't already playing music, otherwise, you should ask him for consent to stop his music and play yours. Another important point is that, you should play sound effects like sound effects, because the music cannot be mixed, while the effects can be simultaneously played.
How to check if the user is already playing some music? If Microsoft.Xna.Framework.Media.MediaPlayer.GameHasControl
is true, you can play your music, because the user is listening to nothing; if it's false, you should ask him for the consent to stop the background music. However, we'll see later an example of this.
If we have two different classifications of the audio, we'll have two different ways to manage them using XNA: to play songs, we'll use the Song
object, and to play sound effects, we'll use SoundEffect
. They reside inside the Microsoft.Xna.Framework.Audio and Microsoft.Xna.Framework.Media namespaces, but first of all, we need to add a reference to the main assembly of the XNA framework Microsoft.Xna.Framework.dll
Another important thing to do is preparing the audio: the audio files must be in WAV format to work with XNA, even if MP3 is now supported by the Song
object (not by SoundEffect
). Another important thing is that, in Visual Studio, we must set the Build Action property of the audio file to Content.
But now it's time to let the code speak
//Loads a song
Song song = Song.FromUri("BackgroundMusic", new Uri("Sounds/Music.wav", UriKind.RelativeOrAbsolute));
//Loads a sound effect
SoundEffect soundEffect = SoundEffect.FromStream(TitleContainer.OpenStream("Sounds/Death.wav"));
To create a Song
from a file, we call the static method FromUri
of the Song
object; the first parameter is the name of the song, while the second one is the Uri
of the file, relative to the XAP package (remember that we've set Build Action to Content). To load a SoundEffect
, instead, we call the static method FromStream
of the class SoundEffect
; the parameter is the stream containing the audio file. TitleContainer.OpenStream
returns a Stream pointing to a file inside the XAP package.
Once we've created our objects, we have to play them, isn't it? To play a SoundEffect
, is enough call its Play
method; while to play a Song
we need a media player, so, we must call MediaPlayer.Play
and pass as parameter our Song
object.
//Plays the song
MediaPlayer.Play(song);
//Plays the sound effect
soundEffect.Play();
Coming back to our application, the class which is responsible of managing the sounds is SnakeMobile.Core.Sounds
The 3 properties are the paths of the audio files (relative to the XAP package). These properties are initialized inside the App.xaml.cs file
public partial class App : Application
{
public App()
{
// ...
//Sounds
SnakeMobile.Core.Sounds.BackgroundMusicUri = new Uri("Sounds/Music.wav", UriKind.RelativeOrAbsolute);
SnakeMobile.Core.Sounds.DeathSoundPath = "Sounds/Death.wav";
SnakeMobile.Core.Sounds.EatSoundPath = "Sounds/Eat.wav";
// ...
}
}
The other methods, instead, are divided into 2 categories: PlayBackgroundMusic
and StopBackgroundMusic
are used, as their name says, to play and stop background music; PlayEffect
, instead, plays a single sound effect.
public static void PlayBackgroundMusic() {
//Checks if sound is enabled
if (Settings.Settings.Instance.Sound == false)
return;
//Plays the song
if (BackgroundMusicUri != null && MediaPlayer.GameHasControl) {
MediaPlayer.IsRepeating = true;
MediaPlayer.Play(Song.FromUri("BackgroundMusic", BackgroundMusicUri));
}
}
For instance, let's analyze PlayBackgroundMusic
(the others are very similar): the first thing to is checking if the user has enabled the music inside the game or has disabled it by the Settings page. Then we check if the user isn't playing any music (MediaPlayer.GameHasControl
); at this point, we can play the song and enable the repeating.
![]() |
返回文章顶部 |
Portable components
What do you want from a programming article?
You may want 2 things: you may want to know something theoretical, like a design pattern, a way to solve a problem and some other things like these; or you may want to take some components to import them in your project. For the first one, the solution is reading the article, which explains how I've built this application from the ground up; for the second one, instead, I can give you a list of all the independent component you can take away from this project and use in yours. Remember that you are free to use, modify and improve the code of the whole application, but if you apply a modification to one of these components, please, let me know, so that I can update the project and let other people take advantage of the improvements.
Here is the list
-
圆形选择器
The control used in the page of the level selection to select the level.
/SnakeMobile/InternalControls/CircularSelector.cs
/SnakeMobile/InternalControls/CircularSelectorItem.cs -
Line CheckBox
Style for the CheckBoxes used in the playing page to enable or disable an option using a line.
/SnakeMobile.Core/Resources/GlobalStyle.xaml -
OverlayMessage
Control to produce a message with custom buttons.
/SnakeMobile.Core/InternalControls/OverlayMessage.xaml
/SnakeMobile.Core/InternalControls/OverlayMessage.xaml.cs -
圆角发光按钮
Style for buttons to make a rounded glowing button.
/SnakeMobile/Resources/RoundedGlowingButton.xaml -
WP7 Container
Container for Windows Phone 7 used to apply the Inversion of Control.
/SnakeMobile.Core/WP7Container.cs