猜单词 - 使用 WPF 和 WWF 编写的 .NET 3.0 游戏






4.43/5 (17投票s)
2006年7月8日
21分钟阅读

90233

1259
利用新的 Windows Presentation Foundation 的 3D 图形功能和工作流基金会(通过 DmRules)提供的规则库,这款游戏展示了使用 .NET 3.0 编写交互式 3D 应用程序的便捷性。
引言
.Net 3.0 为程序员带来了许多令人兴奋的新工具。终于,3D 应用程序将对所有程序员开放。Vista 将图形处理转移到我们可能已经购买用于游戏的显卡上。这使我们能够做以前计算成本高昂但又不必担心影响性能的事情。
这款游戏最初只是一个概念验证,用来看看我的 DmRules[^] 库是否真的可以在实际应用程序中使用。最初的界面是在 Windows Forms 中实现的,非常简单,足以显示我需要显示的内容。这个界面足以证明 DmRules 库是有效的。但是,如果没有一些炫酷的东西,这款游戏将得不到多少关注。所以,我决定使用 WPF 来做一些 3D 的东西。正如你们从上面的“程序员艺术”中看到的,我不是一个艺术家。不过,你可以试试玩这个游戏,因为 3D 效果很有趣。你需要安装 .Net 3.0 才能运行它。
背景图片是厚颜无耻地 偷来 借来的,来自 Deviant Art:http://www.deviantart.com/deviation/2882587/[^]
游戏规则
游戏很简单。如果你玩过 Yahoo Games 上的 Text Twist,你会发现这里的规则很相似。基本上,你会得到一个打乱顺序的六字母单词,你需要猜出这个单词。你还可以通过找到比两个字母长的、并且使用原单词字母的其他单词来得分。
有很多方法可以编写游戏的规则。每个人都有自己喜欢或不喜欢的规则。例如,有人可能会决定,如果你找到了 10 个不是原单词的单词,那么你就应该赢得这一轮。其他人会更看重猜测单词中字母的数量来计算分数。得分、游戏规则、单词列表、时间限制……所有这些都可以通过规则来控制。这就是我选择这款游戏作为 DmRules 示例的原因。
DmRules 库允许你在 App.config 文件中编写规则。规则是按类类型进行的。这影响了我设计游戏中的类的方式。规则应用于两件事:用户进行的猜测 (SingleGuess
) 和当前游戏 (Game
)。
SingleGuess 类
猜测是指用户选择的字母序列。我想通过猜测来确定两件事:猜测是否正确,以及如果猜测正确,它值多少分?
为了判断猜测是否正确,我写了许多规则。这些规则都写在 App.config 文件中,如下所示。正如你所看到的,你将表达式编写到实际的 XML 中。这使得以后更改规则而无需重新编译变得非常容易。
- 如果猜测的字母少于三个,则不正确。
<DmRule cond="this._GuessText.Length < 3" name="More than 2 letters" haltAfterThen="true" priority="1000"> <ThenStmts> <DmCdStmt xsi:type="Assignment" left="this._IsCorrect" right="false" /> <DmCdStmt xsi:type="Assignment" left="this._ErrorText" right=""Word must have at least 3 letters"" /> </ThenStmts> </DmRule>
- 如果猜测使用了打乱字母列表之外的字母,则不正确。
<DmRule cond="!this._Game.HasLetters(this._GuessText)" name="Has correct letters" haltAfterThen="true" priority="990"> <ThenStmts> <DmCdStmt xsi:type="Assignment" left="this._IsCorrect" right="false" /> <DmCdStmt xsi:type="Assignment" left="this._ErrorText" right=""Letters not in scrambled word"" /> </ThenStmts> </DmRule>
- 如果该猜测已经猜过,则不正确。
<DmRule cond="this._Game.GuessesMade.Contains(this._GuessText)" name="Already guessed" haltAfterThen="true" priority="980"> <ThenStmts> <DmCdStmt xsi:type="Assignment" left="this._IsCorrect" right="false" /> <DmCdStmt xsi:type="Assignment" left="this._ErrorText" right=""You've already guessed that word"" /> </ThenStmts> </DmRule>
- 如果猜测的单词在词典中,则正确。否则,猜测不正确。
<DmRule cond="DictUtil.IsWordInList(this._GuessText)" name="Is in dictionary" haltAfterElse="true" priority="970"> <ThenStmts> <DmCdStmt xsi:type="Assignment" left="this._IsCorrect" right="true" /> </ThenStmts> <ElseStmts> <DmCdStmt xsi:type="Assignment" left="this._IsCorrect" right="false" /> <DmCdStmt xsi:type="Assignment" left="this._ErrorText" right=""Word is not in dictionary"" /> </ElseStmts> </DmRule>
你可能已经注意到了规则上的 priority
属性。这是必须添加到 DmRules 中的,因为工作流基金会的规则系统不保证规则会按特定顺序执行,除非显式指定了优先级。数字越大,规则执行得越早。优先级也可以是负数。
此外,还有 haltAfterThen
和 haltAfterElse
属性。有时,根据特定规则的求值方式,你可能希望停止执行规则,因为执行任何其他规则效率低下,或者因为其他规则可能会以你不希望的方式修改状态。鉴于上述规则的优先级,一旦确定猜测不正确,规则就应该停止执行。工作流基金会实际上有一个带有 halt 命令的规则语句,可以插入到规则列表中的任何位置。但是,我不明白为什么要在中间插入 halt,因为规则语句不允许有循环或条件。所以,我决定只使用该属性,并将 halt 语句添加到规则列表的末尾。
SingleGuess
类还有更多规则。这些规则与正确猜测的计分方式有关。得分基于单词的字母数以及猜测是否与原单词匹配。为了进入下一局游戏,我决定如果你正确猜出了一个六字母单词,你就通过了该关卡。如你所见,这可以轻松更改。
<DmRule cond="this._GuessText == this._Game.OriginalWord"
name="Guessed original word">
<ThenStmts>
<DmCdStmt xsi:type="ExprStmt" expr="this._Game.Complete()"/>
<DmCdStmt xsi:type="Assignment" left="this._Score" right="40"/>
</ThenStmts>
<ElseStmts/>
</DmRule>
<DmRule
cond="this._GuessText != this._Game.OriginalWord &&
this._GuessText.Length == 6"
name="Guessed six-letter word">
<ThenStmts>
<DmCdStmt xsi:type="ExprStmt" expr="this._Game.Complete()"/>
<DmCdStmt xsi:type="Assignment" left="this._Score" right="25"/>
</ThenStmts>
<ElseStmts/>
</DmRule>
<DmRule cond="this._GuessText.Length == 3" name="Score 3-letter">
<ThenStmts>
<DmCdStmt xsi:type="Assignment" left="this._Score" right="10"/>
</ThenStmts>
<ElseStmts/>
</DmRule>
<DmRule cond="this._GuessText.Length == 4" name="Score 4-letter">
<ThenStmts>
<DmCdStmt xsi:type="Assignment" left="this._Score" right="15"/>
</ThenStmts>
<ElseStmts/>
</DmRule>
<DmRule cond="this._GuessText.Length == 5" name="Score 5-letter">
<ThenStmts>
<DmCdStmt xsi:type="Assignment" left="this._Score" right="20"/>
</ThenStmts>
<ElseStmts/>
</DmRule>
Game 类
游戏类最终负责确定用户是否应该进入下一关或是否失败。我决定让游戏基于时间。我有前三关的单词列表,这些单词比较容易猜。之后,我只是从词典中随机选择一个 6 字母的单词。猜这些单词可能会变得非常困难,尤其是因为我连其中一半都没听说过。你可以根据自己的需要更改规则。添加更多单词列表关卡,更改允许的时间,允许用户在不同情况下进入下一关等等。
我设计的规则如下:
- 如果原单词是空字符串(我用它来表示当前关卡已完成)并且已玩过的游戏(关卡)数量少于三,则选择一个新单词,清除已猜测的单词列表,将时间设置为 30 秒,并将关卡设置为未完成。
<DmRule cond="this._OriginalWord == "" && this._GamesPlayed < 3" name="Unassigned original word, level one" priority="1000"> <ThenStmts> <DmCdStmt xsi:type="Assignment" left="this._OriginalWord" right="DictUtil.FindGuessWord(6, Level.One)"/> <DmCdStmt xsi:type="ExprStmt" expr="this.Scramble()"/> <DmCdStmt xsi:type="ExprStmt" expr="this._GuessesMade.Clear()"/> <DmCdStmt xsi:type="Assignment" left="this._TimeLeft" right="TimeSpan.FromSeconds(30)"/> <DmCdStmt xsi:type="Assignment" left="this._IsComplete" right="false"/> </ThenStmts> <ElseStmts /> </DmRule>
- 如果原单词是空字符串,并且已玩过的游戏数量大于或等于三,则从词典中选择一个随机单词,清除已猜测的单词列表,将时间设置为 30 秒,并将关卡设置为未完成。
<DmRule cond="this._OriginalWord == "" && this._GamesPlayed >= 3" name="Unassigned original word, above level one" priority="990"> <ThenStmts> <DmCdStmt xsi:type="Assignment" left="this._OriginalWord" right="DictUtil.FindGuessWord(6, Level.Zero)"/> <DmCdStmt xsi:type="ExprStmt" expr="this.Scramble()"/> <DmCdStmt xsi:type="ExprStmt" expr="this._GuessesMade.Clear()"/> <DmCdStmt xsi:type="Assignment" left="this._TimeLeft" right="TimeSpan.FromSeconds(30)"/> <DmCdStmt xsi:type="Assignment" left="this._IsComplete" right="false"/> </ThenStmts> <ElseStmts /> </DmRule>
- 如果时间已到,并且游戏已完成,则表示用户已成功完成该回合。
<DmRule cond="this._TimeLeft.TotalSeconds == 0 && this._IsComplete" name="Time ran out, game complete" priority="800"> <ThenStmts> <DmCdStmt xsi:type="ExprStmt" expr="this.Success()"/> </ThenStmts> <ElseStmts /> </DmRule>
- 如果时间已到,并且游戏未完成,则表示用户已在该回合失败。
<DmRule cond="this._TimeLeft.TotalSeconds == 0 && !this._IsComplete" name="Time ran out, game not complete" priority="790"> <ThenStmts> <DmCdStmt xsi:type="ExprStmt" expr="this.Failure()"/> </ThenStmts> <ElseStmts /> </DmRule>
Success()
和 Failure()
方法最终只是从 Game
类中触发一个事件。UI 需要订阅该事件才能对其做出响应。
原始 Windows Forms 界面
如果你觉得 3D 界面很难看,那看看这个界面吧。好吧,至少它完成了它的目的。我们在这里看到的是一个已猜单词的列表、分数、打乱的单词、该回合剩余时间、一个指示我们是否已通过该回合的指示器、一个用于输入猜测的 TextBox
、一个改变单词打乱顺序的按钮,以及一个提交打乱单词的按钮。
此界面也包含在源代码中。拥有一个进行测试的“试金石”总是好的。当时间耗尽时,会弹出一个消息框,告诉你是否可以进入下一轮,或者告诉你打乱后的单词并让你开始新游戏。当然,这可以更用户友好,但我很懒,不想费心去做这些工作。
WPF 中的 3D 图形
页面顶部的图片应该能让你对 3D 界面的外观有所了解。有一个显示打乱字母的地方,你可以点击这些字母来选择它们。选中的字母会翻转到用户正在构建的单词中的位置。还有一个按钮可以清除当前猜测,还有一个按钮可以提交猜测。当猜测被尝试或清除时,字母会翻转回它们在打乱单词中的位置。还有一个时钟显示剩余时间,一个已猜单词列表,以及用户的得分指示。
在下面的章节中,我将介绍我实现过程中一些更interesting的点。作为一个 3D 环境,并且没有任何可用基元(谢谢你,微软),事情的工作方式略有不同。
创建字母
每个字母本质上是一个被压扁的立方体。我把它做得像一个 Scrabble 字母。我只是随便找了一张木纹贴图,然后用一个实用程序绘制了字母。那个程序叫做 CreateTextures
,也包含在源代码中。它基本上打开根贴图并绘制一个字母。你必须想象字母是包裹在立方体上的。所以,这是字母“P”的贴图看起来的样子。

一个 P 是颠倒的,另一个是反向的。最简单的思考方式是想象贴图图像是一张包装纸。想象你在包装一本书。将书的顶部放在两个 P 之间,这样书就立起来了。然后把包装纸卷起来包裹住书。
现在,我的包装纸实际上应该翻过来,但没有必要把事情搞混。将贴图包裹在 3D 对象上非常简单。就像设计对象本身一样简单。你只需要用 3D 坐标来思考对象,这有时会有点困难。
我使用一个 LetterFactory
类来创建字母。在 XAML 中,你会为你的网格定义点、三角形索引、法线和纹理坐标。如果你看微软的任何 3D 编程示例,他们通常会在 XAML 中创建网格。但我喜欢把它们放在代码里。阅读 3D 网格已经够难了,更不用说尝试在它全部塞进一个 XML 属性时去阅读了。总之,如果你有兴趣了解网格是如何创建的,所有内容都在代码中。
确定用户是否点击了字母
创建字母后,我需要知道用户何时点击了它。这是我必须在做任何事情之前采取的一个关键步骤。我在 MSDN 上找到了一个例子,说明如何做到这一点。WPF 让确定用户点击了哪个网格变得非常容易。首先,在你的窗体的构造函数中添加这个:
this.myViewport.MouseLeftButtonDown += new MouseButtonEventHandler(HitTest);
其中 myViewport
是你窗口中的 3D 视口。这是 HitTest
方法的代码:
public void HitTest(object sender,
System.Windows.Input.MouseButtonEventArgs args)
{
Point mouseposition = args.GetPosition(myViewport);
PointHitTestParameters pointparams =
new PointHitTestParameters(mouseposition);
VisualTreeHelper.HitTest(myViewport, null, HTResult, pointparams);
}
HitTest
方法使用一个委托来处理实际结果。HTResult
方法的工作原理如下:
public HitTestResultBehavior HTResult(
System.Windows.Media.HitTestResult rawresult)
{
RayHitTestResult rayResult = rawresult as RayHitTestResult;
if (rayResult != null) {
RayMeshGeometry3DHitTestResult rayMeshResult = rayResult as
RayMeshGeometry3DHitTestResult;
if (rayMeshResult != null) {
GeometryModel3D hitgeo = rayMeshResult.ModelHit as GeometryModel3D;
...
}
}
return HitTestResultBehavior.Continue;
}
变量 hitgeo
是被命中的模型。这非常容易处理。只需使用此代码,你就可以找到用户点击了哪个网格,而无需担心在 3D 空间和 2D 空间之间转换坐标,也无需进行任何向量数学运算。WPF 已经为你处理了这些。事实上,你在上面的代码中看到的 HitTestResult
可以用于用户交互以外的其他用途。它可以用于碰撞检测或确定用户的视线。
动画字母
动画是 Windows Presentation Foundation 的另一个酷炫功能。你可以非常轻松地执行简单的动画。但你首先需要了解一些基本的 3D 概念。变换是 3D 编程中的关键。旋转、平移、投影和缩放都是变换。在此应用程序中,我们不使用任何缩放,WPF 会处理投影方面的事情。所以,我们主要关心旋转和平移。利用这些,我们可以将字母从一个位置动画到另一个位置。
我的基本要求是检测用户何时点击了打乱单词中的一个字母。当这种情况发生时,我希望将字母移动到用户正在构建的单词中。为了利用 3D,我希望字母在过程中翻转 360 度。你将看到这有多么容易实现。
实际上,我们执行了 3 种变换:旋转 360 度,沿 Y 轴平移,以及沿 X 轴平移。最简单的是沿 Y 轴平移,因为这个值几乎是固定的。
TranslateTransform3D tt3d = new TranslateTransform3D(new Vector3D(0, 0, 0));
DoubleAnimation da = new DoubleAnimation(-4,
new Duration(TimeSpan.FromSeconds(1)));
tt3d.BeginAnimation(TranslateTransform3D.OffsetYProperty, da);
我们创建一个零向量的平移。动画会随着时间的推移改变平移,所以我们不需要将向量设置为除零以外的任何值。实际动画需要一个值和一个 TimeSpan
。在这种情况下,我想在一秒钟内向下移动 -4 个单位。BeginAnimation
调用指示我想沿哪个轴移动这 -4 个单位。这是 Y 轴。这将使字母向下移动 4 个单位,耗时一秒。
下一个变换是沿 X 轴的平移。这个平移可以根据字母从打乱的单词中的位置以及它要去猜测的单词中的位置而变化。
double oldX = double.Parse(str[1]);
double newX = (_CurrGuess.Length + 1) * -2.5;
TranslateTransform3D tt3d2 = new TranslateTransform3D(new Vector3D(0, 0, 0));
da = new DoubleAnimation(newX - oldX, new Duration(TimeSpan.FromSeconds(1)));
tt3d2.BeginAnimation(TranslateTransform3D.OffsetXProperty, da);
这里的执行方式非常相似。只是获取要移动的单位数量以及是在哪个轴上。所以,让我们继续进行更有趣的变换,即旋转。
RotateTransform3D myRotateTransform = new RotateTransform3D(
new AxisAngleRotation3D(new Vector3D(1, 0, 0), 1));
DoubleAnimation myAnimation = new DoubleAnimation();
myAnimation.From = 0;
myAnimation.To = 360;
myAnimation.Duration = new Duration(TimeSpan.FromMilliseconds(1000));
myAnimation.RepeatBehavior = new RepeatBehavior(1);
myRotateTransform.Rotation.BeginAnimation(AxisAngleRotation3D.AngleProperty,
myAnimation);
我们创建一个绕轴的旋转。向量表示它在 X 轴上。要想象这一点,我想象一个带有字母的手镯。你在字母的两侧钻孔,然后将一根线穿过。然后你可以围绕这根线翻转字母。AxisAngleRotation3D
构造函数的第二个参数是角度测量,即 1。最容易想象动画是以每度一帧的方式进行的。我将动画设置为从帧 0(即 0 度)到帧 360(即 360 度)。我也希望所有这些都在一秒钟内完成。
现在我的最后一步是将这些变换应用到 hitgeo
对象上。
(hitgeo.Transform as Transform3DGroup).Children.Insert(1, myRotateTransform);
(hitgeo.Transform as Transform3DGroup).Children.Add(tt3d);
(hitgeo.Transform as Transform3DGroup).Children.Add(tt3d2);
这段代码看起来有点奇怪,但为了执行多个变换,你必须将字母的 Transform
属性分配给一个 Transform3DGroup
,它只是一个变换组。我在创建字母时执行此操作。我想保留对字母进行的原始变换,并添加新的变换。
变换必须按特定顺序执行。例如,我们想在平移之前旋转。为了想象这一点,想象一下太空中的地球。地球有绕其轴的自转,需要 24 小时。月球绕地球旋转。旋转之间的区别是,月球首先被平移到远离地球的位置,然后旋转。旋转仍然围绕地球的轴发生,这意味着月球绕地球旋转。
总之,结果是旋转必须插入到变换组中的特定顺序。平移可以添加到末尾。最后,我们得到了一个翻转到位的字母。

制作 3D 按钮
按钮是任何界面中的基本组成部分。在我们的 3D 环境中,我们没有它们。所以,我决定自己制作按钮。我的设想是,我需要一个可以通过鼠标点击来“按下”的东西。执行平移动画很容易,所以我们可以用它来实现实际的按下动作。
我最先考虑的一个问题是,用户如何知道按钮已被按下?必须有一个参照点。在 Windows UI 中,按钮看起来像是被按下是因为阴影发生了变化。如果我的 3D 环境有一个来自左上方的光源,也许我可以达到同样的效果。但这也会要求我对网格进行一些更改,使按钮看起来像是被压下。它也意味着我需要一个表面来容纳按钮。我想要一个更简单的解决方案。
我提出的解决方案是制作另一个被压扁的立方体。在一侧,我将绘制按钮上的文本。当按下时,按钮会向内推,然后恢复到原始位置。作为参照点,我创建了一个大的灰色多边形作为背景。按钮放在背景多边形之上。当它向内推时,它实际上是穿过那个多边形的。我想 WPF 默认使用 Z 缓冲区,因为最终结果是按钮看起来像是向下推入了多边形。
但问题是,我如何将文本放到按钮上?这个想法是获取一个图像,将文本绘制到图像上,然后将该图像作为纹理包裹在按钮上。所以,首先,我创建一个文本块:
FormattedText ft = new FormattedText(text,
new CultureInfo("en-us"),
FlowDirection.LeftToRight,
new Typeface(new FontFamily("Arial"), FontStyles.Normal,
FontWeights.Normal, new FontStretch()),
24D,
Brushes.Black);
text
变量保存了我想要写入的文本。我选择了 Arial,24pt 字体,黑色。这基本上会写出一个文本块,并根据容纳文本所需的空间来调整该块的大小。我可以使用这个尺寸来确定我想要的按钮尺寸。你会在 ButtonFactory
代码中看到类似这样的内容:
buttonModel.Geometry = CreateButtonMesh((ft.Width + 4) / (ft.Height));
当我创建网格时,我会改变它的宽度,使其与文本的宽度与高度的比例相匹配。这可以防止字母看起来被拉伸。接下来我们要创建一个 DrawingVisual
对象,因为它可以用于纹理。我们将绘制一个浅灰色的矩形作为按钮的背景色,然后绘制文本。
DrawingVisual drawingVisual = new DrawingVisual(); DrawingContext drawingContext = drawingVisual.RenderOpen(); drawingContext.DrawRectangle(Brushes.LightGray, new Pen(Brushes.LightGray, 1), new Rect(0, 0, ft.Width + 4, ft.Height * 4)); drawingContext.DrawText(ft, new Point(2, ft.Height * 1.5)); drawingContext.Close();
现在我们只需将该视觉效果放入图像中,并将该图像作为材质应用于我们的网格。
RenderTargetBitmap bmp = new RenderTargetBitmap((int)ft.Width + 4,
(int)(ft.Height * 4), 0, 0, PixelFormats.Pbgra32);
bmp.Render(drawingVisual);
buttonModel.Material = new DiffuseMaterial(new ImageBrush(bmp));
计分板
下一个问题是如何显示当前得分。既然我能够将格式化的文本放置在图像上并将其拉伸到按钮网格上,我想我应该可以简单地抓取一个常规的多边形并在其上绘制具有当前得分的纹理。这是一个相当简单的过程。我的计分板创建方式与创建按钮非常相似,只是多边形具有固定的大小。纹理是变化的。所以,每次得分改变时,我都会用以下方法更新它:
private void UpdateScore() {
if (bmpScore != null) {
FormattedText ft = new FormattedText(_CurrGame.Score.ToString(),
new CultureInfo("en-us"),
FlowDirection.LeftToRight,
new Typeface(new FontFamily("Arial"), FontStyles.Normal,
FontWeights.Normal, new FontStretch()),
16D,
Brushes.DarkRed);
DrawingVisual drawingVisual = new DrawingVisual();
DrawingContext drawingContext = drawingVisual.RenderOpen();
drawingContext.DrawRectangle(Brushes.LightGray,
new Pen(Brushes.LightGray, 1), new Rect(0, 0, ft.Width + 4,
ft.Height * 4));
drawingContext.DrawText(ft, new Point(120 - ft.Width - 2, 2));
drawingContext.Close();
bmpScore = new RenderTargetBitmap(120, 25, 0, 0, PixelFormats.Pbgra32);
bmpScore.Render(drawingVisual);
scoreBoard.Material = new DiffuseMaterial(new ImageBrush(bmpScore));
}
}
我想也许仅仅改变纹理位图本身就足以让分数发生变化。然而,这似乎并没有什么效果。由于分数不是每秒变化很多次,我认为直接创建一个带有更改纹理的新材质是没问题的。
显示时间
用户界面的另一个关键元素是显示用户还有多少时间来猜测单词。我可以尝试用和计分板一样的方式来做,显示回合中剩余的分钟和秒数。但这似乎并不是那么有趣。我想也许我可以模拟一个数字显示,但那可能会过于复杂。我最终决定显示一个模拟时钟。通过这种时钟,你可以很容易地看出你还有多少时间在游戏中。它还可以让你大致了解时间流逝的速度。比这更好的是,执行起来很简单,因为我们只需要动画化时钟指针。
我的第一个任务是创建一个时钟。现在,我可以很容易地创建一个矩形并将其纹理化为时钟。但我有个想法,我应该在网格中制作一个真正的圆形。编写计算它的数学很有趣,也许将来我可以稍微动画化它。总之,利用我们亲爱的 sin 和 cos,我创建了一个具有适当纹理坐标的圆形网格。
MeshGeometry3D mg3d = new MeshGeometry3D();
mg3d.Positions.Add(new Point3D(0, 0, 0));
mg3d.TextureCoordinates.Add(new Point(0.5, 0.5));
for (double d = 0; d <= 360; d += 5) {
double x = 4.0 * Math.Sin(d / 180.0 * Math.PI);
double y = 4.0 * Math.Cos(d / 180.0 * Math.PI);
mg3d.Positions.Add(new Point3D(x, y, 0));
x = x / 8.0 + 0.5;
y = y / 8.0 + 0.5;
mg3d.TextureCoordinates.Add(new Point(x, y));
}
for (int i = 1; i <= 360 / 5 + 1; i++) {
mg3d.TriangleIndices.Add(0);
mg3d.TriangleIndices.Add(i);
mg3d.TriangleIndices.Add(i + 1);
}
作为一个喜欢优化事物的程序员,这段代码让我有点纠结。但它只被调用一次,所以我决定不费心去让它更快。基本上,我创建这个圆的方式就像通过添加每一片披萨来组装整个披萨。我创建的第一个点是圆心,其他的点构成围绕圆的三角形。正如你所看到的,创建一个圆形网格很容易,而且很有趣。

与时钟相关的下一件事是中间的指针。我创建了一个非常简单的网格,带有一个三角形,并在其上添加了一个旋转动画。我所要做的就是围绕 Z 轴旋转它,旋转的量是回合实际需要的时间。这是容易的部分。难的部分在下一节。
困难
到目前为止,我已经介绍了许多我喜欢 WPF 的原因以及它如何使 3D 编程变得容易得多。现在是时候谈谈我浪费了很多时间的一些愚蠢的事情了。这与计时回合有关。
这是一个非常简单的概念。在一定时间后,你想结束游戏并继续。对于 Windows Forms 界面,我只需要创建一个 Timer
并监听 elapsed 事件。时间到了,我会调用一个方法来结束回合。方法调用必须在与 GUI 相同的线程中进行,而不是在计时器的线程中。没问题,只需使用 InvokeRequired
和 Invoke
。
你可能会认为 WPF 中会有这样的功能,但我肯定找不到。我找不到一种方法可以在 GUI 线程中调用方法。好吧,我说,我还有什么选择?嗯,动画很可能在 GUI 线程中运行。也许会有一个事件触发,表明动画已经完成。果然,有一个 Completed
事件我可以订阅。太好了!现在我甚至不需要一个单独的计时器,我可以直接从实际的时钟动画中发出回合结束的信号。而且没有同步问题。
但并非一切都好。我的策略是,当回合结束时,我显示一个消息框。当用户点击 OK 时,我进入下一轮。这是一个非常简单的概念。所以,我实现了它,但我注意到在我点击 OK 之后,时钟指针跳了过去。我花了几天时间试图弄清楚为什么会这样。最后,我得出了唯一符合逻辑的结论:
当消息框显示时,WPF 认为 GUI 中正在发生的事情仍然应该继续。所以,从消息框出现到用户点击 OK 之间的时间流逝被用来“快进”GUI 中的动画。
不信?自己试试。这是那些疯狂的错误之一,可能会让你忙碌数天。并非说 WPF 有错误,他们可能是故意这样设计的。但是,在编写软件时,看到事情发生并与你编写到代码中的逻辑相悖是非常令人沮丧的。
为了应对这种情况,我在时钟指针上创建了另一个动画,将其拉回到起始位置。这个动画的设计目的是花费从消息框弹出到用户点击 OK 之间的确切时间。当我收到该动画已完成的通知时,我才进入下一轮。对用户来说,这是无缝的,因为他们永远不会看到 WPF 在“快进”时间。
摘要
我开始时试图找到一种方法来展示我的 DmRules 库如何在应用程序中工作。由于我从小就对 3D 编程感兴趣,我抓住了使用 Windows Presentation Foundation 进行 3D 编程的机会。这个小游戏应该向你介绍我认为会让 Windows Vista 编程变得非常有趣的两件事。
历史
- 0.1 : 2006-07-08 : 初始版本