赞助广告

 

年份

最新评论

评论 RSS

游戏组件——挑战:创建NextBlock游戏组件

clock 七月 29, 2010 15:39 by author alex
挑战:创建NextBlock游戏组件 本章不是真的讨论NextBlock游戏组件,为此有一个原因。我想你去自己创建它。 在本书的源代码中NextBlock类是空的,你的任务是填充它。NextBlock游戏组件已经添加到TetrisGrid类,你已经看过了所有要用到的方法。如果你对自己的解决方案不自信,你可以检查做了全部改进的完整源代码,并且在我做的XNA Tetris全部项目源代码也有完整的NextBlock类,不过我建议你尝试编写游戏组件,也许一些对游戏的其他改进 让你更熟悉所有这些新类。 NextBlock组件应该显示下一个砖块的型号。只要使用来自于TetrisGrid类的BlockTypes枚举,在一个5×5的网格中显示下一个砖块,并且在顶部添加一个小文本(见图 4-11)。 图 4-11 一点提示:不要浪费太多时间玩俄罗斯方块。这是个好游戏,但是本书中还有更好的。

游戏组件——更多的辅助类

clock 七月 29, 2010 15:34 by author alex
更多的辅助类 在上一章谈论的辅助类还不够么?是的。我们将在Tetris游戏中使用的两个新类在这里还没有被进一步深入讨论,它们只是本书中我真实使用的类的精简版。不过它们仍然很有用,并且有助于你使得游戏编写过程更加容易。 TextureFont类 你已经知道了XNA中缺少字体支持,你也知道位图字型是在XNA中显示文本的唯一选择(也许使用某些自定义3D字体渲染除外)。 在本书第一个游戏中,你正是使用某些精灵来显示游戏或者菜单中的固定文本。这种方式非常容易,不过对于记分板,你在游戏中肯定需要一种方式,提供一个允许你写下任何文本和数字的动态字型。 让我们继续前进,看一看TetrisGame 类中的TestScoreboard单元测试,渲染了记分板的背景盒,然后写下所有的文本行以显示当前的level级别、分数、最高分纪录、当前游戏中你销毁的行数: int level = 3, score = 350, highscore = 1542, lines = 13; TestGame.Start("TestScoreboard", delegate { // Draw background box TestGame.game.backgroundSmallBox.Render(new Rectangle( (512 + 240) - 15, 40 - 10, 290 - 30, 190)); // Show current level, score, etc. TextureFont.WriteText(512 + 240, 50, "Level: "); TextureFont.WriteText(512 + 420, 50, (level + 1).ToString()); TextureFont.WriteText(512 + 240, 90, "Score: "); TextureFont.WriteText(512 + 420, 90, score.ToString()); TextureFont.WriteText(512 + 240, 130, "Lines: "); TextureFont.WriteText(512 + 420, 130, lines.ToString()); TextureFont.WriteText(512 + 240, 170, "Highscore: "); TextureFont.WriteText(512 + 420, 170, highscore.ToString()); }); 你可能注意到你目前在使用类TestGame 类启动单元测试。对于这个测试你使用了一系列变量(level、score等等),这些变量被游戏代码中的实际值所替换。在渲染循环中,你首先绘制背景盒(background box),并立刻显示它以避免后面绘制精灵时的显示错误。接下来你借助于新的TextureFont 类的WriteText 方法在指定的屏幕位置,写下四行文本。你实际上调用了8次WriteText 方法,以正确对齐所有的数字在你的背景盒右边,这样比只写4行文本看上去好很多。 在编写这个单元测试之后,你将得到一个编译错误,告诉你TextureFont 类还不存在。在创建了一个带有空的WriteText 方法的空类之后,你应该能够编译和启动测试了。就像展示背景盒,借助于你在上一章学过的SpriteHelper类,背景盒被在屏幕右上部。 甚至在你考虑实现TextureFont 类之前,你将会需要一个带有字型的位图纹理,以便于在屏幕上渲染文本。没有这样一个纹理,你就只能做理论工作,单元测试是要做游戏功能的实际测试的。你需要一个像图 4-3这样的纹理来显义所有的字母、数字和符号。你甚至可以在更大的纹理中使用更多的Unicode字母,或者用多个纹理实现它,不过这就离本章太远了。请查看源代码中,我在TextureFont 类顶部的注释提供的网站,你可以学到更多关于这个话题。 图 4-3 看一看TextureFont类的实现(见图 4-4)。调用TextureFont类非常容易;你只要像不久前在单元测试中那样调用WriteText 方法 。不过内部的代码不是很容易。该类保存了GameFont.png纹理的每一个字母的矩形,这个纹理被用在WriteAll方法中,通过在屏幕上绘制一个接一个的字母来渲染文本。该类也包含GameFont.png这个字体纹理、一个sprite batch对象来帮助渲染屏幕上的字体精灵,以及几个辅助变量来决定字型的高度。要检查屏幕上的文本占了多少宽度,你就要使用GetTextWidth 方法了。 图 4-4 FontToRender 是一个内部类,它负责你想在每一帧渲染的所有文本,这个过程非常类似于SpriteHelper类在每一帧最后渲染屏幕上的所有精灵。SpriteHelper.DrawAll方法被BaseGame类以同样的方式调用,TextureFont.WriteAll方法也被调用,绘制了屏幕上的一切,并且清空所有列表。要更深入学习TextureFont类,察看源代码,运行单元测试或者尝试逐步调试 WriteAll 方法。 Input类 另一个用在Tetris 游戏中的新类是Input,这个类封装了所有的输入处理,检查,并且更新了你在前几章所做的。第十章进一步深入谈论Input类,一些优秀的类确实需要来自于Input类的所有特性。(如图 4-5) 图 4-5 如你所见,Input类有相当多的属性,和一些辅助方法来访问键盘、gamepad和鼠标数据。借助于Update 静态方法,每一帧将被更新,这个方法直接被BaseGame 类调用。对于这个游戏,你主要使用键盘和gamepad 的press方法,诸如 GamePadAJustPressed 或者 KeyboardSpaceJustPressed。和RandomHelper 类很相似,Input 类如何工作不再难以理解,你已经在上一章从功能上实现了很多了。更多细节和应用你可以查看第十章。 Sound类 好了,你已经在第二章的第一个游戏中接触了声音,在第三章的Breakout 游戏也用到了。为了保持事情的简单性,为了后面不修改任何游戏类,就允许你添加更多的声音功能,sound management现在被移到了Sound类。快速浏览一下Sound类(如图 4-6)。这个版本看起来很简单,但是在第九章,还会深入谈论XACT,你将大幅度扩展Sound类,使得在本书最后它能为你那个伟大的竞速游戏准备就绪。   图 4-6 如你所见,所有的声音变量目前在Sound类中了,Game类不再包含任何声音变量。Sound构造器是静态的,并且当你第一次使用Play方法播放声音文件时将自动地被调用。Update方法自动地被BaseGame类调用。 Sounds枚举值和TestPlayClickSound 单元测试方法依赖于你当前游戏的实际内容。这些值对于你所写的每一个游戏而言都是变化的, 但是修改Sounds枚举值是非常简单的。你也许问为什么不正好借助于保存在XACT中的cue名称播放声音 ? 好的,可能由于错误打印一个声音的Cue名称而发生许多错误,万一你删除、重命名,或者改变一个声音的Cue名称就很难跟踪。 Sounds枚举也使得快速添加一个声音特效、以及从IntelliSense查看可用的声音特效非常容易。. BlockMove向左、右、下移动砖块时播放。这是非常安静的声音特效。 BlockRotate用于你旋转当前砖块的时候,并且听起来非常“速度感”。 BlockFalldown用于当前砖块到达地面的时候,并且最终着陆。 LineKill在游戏中每一次你决定销毁一行的时候被播放。 Fight在每一次玩家启动游戏的时候被播放。 Victory用于当玩家到达下一level关卡的时候,并且包含一阵鼓掌声。 Lose一个老面孔了,当玩家游戏失败时播放。

游戏组件——游戏组件

clock 七月 29, 2010 15:29 by author alex
游戏组件 TetrisGame类也拥有Components属性中的所有游戏组件,该属性继承于game类。你能添加任何继承于GameComponent类的子类到这个列表,并且当你的游戏被启动和被更新的时候,该子类会自动地被调用。当你绘制游戏的时候,因为GameComponent类没有Draw方法,所以GameComponent类的子类不会被调用。不过你可以实现自己的绘制方法,或者仅仅使用DrawableGameComponent类,这个类有Draw方法。对于游戏组件,XNA没有直接的Draw方法支持;你必须亲自调用它,以确保所有组件被按照正确的顺序调用。因为这个以及其他一些原因(强迫你使用这个几乎没有益处的模型,会使得单元测试更难,而你自己的游戏类可能更有效或者更特殊),在本书后面你将不会使用很多游戏组件。这通常是个好主意,不过没有它你也能生活,因为无论如何你必须创建自己的游戏组件,并且你不得不为它们亲自调用Draw方法。仅仅为了Update方法,使用它们还不算太有意义。 就像我在第一章提及的,其基本思路是为了让用户合作,分享用户的游戏组件,从而允许其他人使用他们的游戏引擎的一部分。 例如,一个帧计算组件或者甚至一个充分发展的3D地形渲染引擎都可能被作为一个游戏组件执行,不过仅仅因为某人不使用游戏组件 不意味着难以拷贝。 例如,你有一个复杂的游戏组件,像一个地形渲染模块,它可能也涉及一些其他的类,并且使用它自己的渲染引擎,如果你只是拷贝一个文件上去,这个引擎不会超出你的引擎工作。插入外部代码经常需要相当多次的重构,直到它可以用在你自己的引擎。在XNA Framework的beta 1版本中,XNA Game Studio Express中有一个图形化的设计器可用于游戏组件,你可以拖拽组件到你的游戏类,或者甚至其它组件中,不编写一行代码就给你的游戏添加特性。由于这个特色非常复杂,bug成堆,在Xbox 360上不能运行,在XNA Framework的beta 2 发行版中,就被丢弃了。 游戏组件是否被使用是不能确定的事,也许对大多数程序员来说缺少设计器,以及亲自调用那些Draw方法都不是麻烦。许多游戏组件可能用到,并且知道它们的所有基础也是有用的。在俄罗斯方块的案例中,一些组件需要注意: 格子自身有彩色砖块和当前掉落的砖块 记分板有当前的level关卡、得分、最高纪录、你销毁的行数 游戏中,下一个砖块的模型盒 更简单的东西,诸如帧计数器、处理输入等等 我决定只是把俄罗斯方块的网格和下一个砖块特性作为游戏组件来实现;所有的代码刚好以这种简单方式实现,只要为它们几个新类。如果你将复用记分板,例如,你可能一直把它放在游戏组件,但我不考虑任何其他游戏,我喜欢用到记分板就编写一个。 仔细看看 Game 类,以及要添加给它的组件(如图 4-2)。 图 4-2 灰色箭头标明由于TetrisGrid类和NextBlock类被添加到Game类的组件列表,这些方法会自动被调用。在TetrisGame.Draw 中调用TetrisGrid中的Draw 方法,后者又调用NextBlock.Draw 方法。TetrisGame自身拥有的只是TetrisGrid类的一个实例。NextBlock 实例仅仅被用在TetrisGrid类内部. 你能看出对于这三个类使用游戏组件强制你考虑了调用顺序,并且只是由于没有把一切放进一个大类中就使得你的游戏更有组织。这是件好事,如果你是一个有经验的程序员,虽然能自己做所有这些,在XNA中预先考虑游戏组件化对于初学者可能是个好主意。

游戏组件——Game类

clock 七月 29, 2010 15:25 by author alex
Game类 你已经在前几章使用了Game类,不过除了通过调用来自于Program类的Run方法启动游戏和单元测试、使用Initialize、Update、Draw方法之外,那些章节没有谈论基础设计。好的,如果你只是创建一些简单的游戏,你确实不需要知道其他的一切,但是当游戏越来越大,有更多特性的时候,你可能想要考虑类的概观,以及游戏中类的设计。 Game 自身通常持有GraphicsDeviceManager实例中的graphics device和content 字段中的 content manager。你仅仅必须从Program 类创建一个你的游戏类实例,并且调用Run方法来让一切被启动。不同于Managed DirectX或者OpenGL伴随的旧岁月,你不是必需管理自己的窗口、创建自己的游戏循环、处理Window消息等等。XNA为你做了所有的,因为以这样一种方式处理,你的游戏甚至可能运行在没有窗体类或者Windows事件可用的Xbox 360平台。 通过游戏类的Window 属性访问游戏的窗口还是可能的。这常常用来设置窗体的标题,改变窗体尺寸(如果用户被允许),调用interop进行windows 底层处理等等。所有这些方法在Xbox 360平台什么也不做。没有窗体、没有窗体标题、当然也不能改变窗体尺寸。正如你在上一个游戏中看到的,你使用Window.Title 属性给标题栏设置一些简单的文本,为用户显示当前level和分数。你这么做的原因是在XNA中没有字体支持;要在屏幕上渲染文本,你不得不自己创建位图字型,然后渲染每一个字母。在下个游戏中,甚至对于俄罗斯方块游戏,你将需要这个特性,所以TextureFont类要花几分钟介绍。 另外值得一提的是,像下例中通过设置图像属性那样,在游戏类的构造器中设置最佳分辨率是可行的,该例中俄罗斯方块使用了全屏模式的1024*768分辨率。 graphics.PreferredBackBufferWidth = 1024; graphics.PreferredBackBufferHeight = 768; graphics.IsFullScreen = true; 不保证游戏将来实际运行在这个模式;例如,在一个只支持1024*768的系统设置了1600*1200,将只使用最大可支持分辨率。 你已经知道了Update和Draw方法被每一帧调用,不过没有游戏类自身的重载,如何合并这些新的游戏组件呢?是看一看俄罗斯方块克隆游戏的game类和组件概观的时候了(见图4-1)。 图 4-1 你要注意的第一件事就是现在你有3个game类,而不像在上一个游戏范例中仅仅只有一个。那样做的原因是使得主游戏类简洁。BaseGame 类持有带有 graphics device的graphics manager、content manager,并且它保存了你在游戏中使用的当前分辨率的宽度、高度值。Update和Draw 方法也处理了新的Input、Sound和TextureFont类以避免在主游戏类中更新它们。然后TetrisGame 类用来加载所有来自于内容管道的图像,并且初始化所有精灵和游戏组件,稍后我们会学习到。 最后,TestGame类继承于TetrisGame类以便于访问所有的纹理、sprite、游戏组件,并且只使用debug模式启动单元测试。TestGame类的功能和上几章的非常相似,不过这一次它被以一种好的方式组织,并且从你的主游戏类中分离出了。TetrisGame类使用几个单元测试来确保游戏的每一个部分如你计划的工作。

游戏组件——概览

clock 七月 29, 2010 15:21 by author alex
概览 本章讨论Game 类背后的构思,以及你能为之添加的组件。为了在下一章建立你的图像引擎并且运行,在开始3D概念之前,你还需要一些新的辅助类。BaseGame通常实现更多的特性,并且包含至今为止其他所有你写过的类。它被Game类继承,以利用XNA所有现成的特性。我们的主测试类TestClass以同样的方式继承于BaseGame,以帮助你执行游戏中的静态单元测试。然后你将添加TextureFont 类到你的Helpers命名空间,以允许你在屏幕上绘制文本,这不可能是XNA中的超纲内容。最后,你也把一些现成的功能,例如来自于前几章的输入、控制器处理、声音输出,添加到一些特制的类中,以便于更容易编写一个新游戏。本章使得你在后面要开发的游戏成为一个原型范例,而不仅仅是进行一些普通的设想。 对照上一章,你将不再先写任何辅助类,取而代之的是将要首先编写单元测试和游戏类,然后添加所有在工程项目需要的游戏组件。在最后的少数项目中,问题相当简单,并且一旦你解决它们就再也不会经历了。对于本章你要开发的游戏将要进行许多改进,随着游戏项目越来越大你会看到这一点越来越正确。你必须记住当现有代码不能胜任以及改进游戏的时候,重构仍然是最重要的事。有时你甚至将会看到用在单元测试的代码终止在最终游戏代码的某处。 一个简单的Tetris克隆被用来作为一个简单的范例游戏。它的特色是彩色砖块掉落在一个大型比赛场地,支持键盘和gamepad 输入,next block 区域展示给你将到来的下一转块,一个小型记分板,包含了当前level关卡、分数、最高分纪录和你消灭的行数。如果你像我一样是Tetris的爱好者,并且每次喜欢玩上一会儿这游戏,就太好了。俄罗斯方块曾经是最流行的解谜类街机游戏。它在1985年被俄罗斯人Alexey Pazhitnov发明,并且自1989年Nintendo 在 Game Boy 系统上发布以来就变得非常流行。

辅助类——总结

clock 七月 28, 2010 11:00 by author alex
总结 本章您学习到了大量的类,它们在以后的项目中会非常有用。单元测试也是一个重要的知识点,并且我希望说服你先写单元测试作为开始游戏开发的方式。本章最后关注了Breakout游戏。本章内容很多,幸好您可以跳过Breakout游戏的一些知识点,因为游戏的很多部分都可以重用上一章的Pong游戏。 下一章的中心将稍微放在游戏编程上,以及如何使用组件来构建游戏。本章中您看到了助手类的用处,现在你准备把这种思想扩展到游戏代码中,这对于创建更复杂的游戏来说非常重要。 这里是你在本章所学的扼要重述: 尽可能使用辅助类,把代码封装到方法或者辅助类中,尤其是那些会多次使用的。 始终先写单元测试,然后进行具体实现。 单元测试要保持简单。您不必浪费时间为辅助类编写过于复杂的单元测试,也不要使得静态单元测试比游戏本身还复杂。单元测试对于最终的游戏并不重要(实际上,可以使用#if DEBUG命令把它们排除),它们只是让您的工作更从容,帮助您测试并重用新的和已存在的代码。 每次修改代码,都要运行单元测试确保不会出问题: 使用TestDriven.Net运行单个测试,或者运行解决方案资源管理器中每个类或命名空间中的每个测试。 或者使用NUnit GUI测试程序集中的所有单元测试。对于静态单元测试,使用Program类进行手动测试。 对于游戏中最复杂的部分要尽可能多地测试,比如Breakout游戏中的碰撞检测。

辅助类——挑战:改进Breakout游戏

clock 七月 28, 2010 10:59 by author alex
  挑战:改进Breakout游戏 您可以尝试给玩家设置更多的生命次数,也可以像在上一章那样添加一个菜单以改进游戏。您也可以把Breakout游戏作为一个测试平台,来检验到目前为止实现了的辅助类。例如,玩家每次完成level 关卡或者打碎了一个砖块的时候,可以写下一条日志消息。 下一章将介绍Input类,它可以使捕获控制器的所有输入更加容易。就像上一章实现Gamepad的Rumble支持那样,游戏可以做一个非常好的改进,以及当砖块被打碎的时候抖动屏幕或者添加简单的爆炸图像。天空背景是个局限;如果您确实喜欢街机游戏(Arcade Games),您会有很多种方式改进这个游戏让它更加好玩。 或许您对辅助类更有兴趣,并且还想利用你的新知识来添加、测试几个你自己的辅助类。现在正是这么做的时候。下一章更是聚焦在可重用性(reusability)上;后续几章只谈论创建一个3D引擎,并且您可能忘了全部有用的辅助类了,直到错过某个特性you might forget about all the useful helper classes until you miss some feature。不过辅助类有个好处就是易于随时改进。它们可能会变得像StringHelper类稍微庞大,但您能一直对其重构(Refactor)。

辅助类——Breakout游戏

clock 七月 28, 2010 10:58 by author alex
Breakout游戏 好极了,本章谈论了很多辅助类,最终是时候把它们派上些用场了。这里我将跳过游戏的构思阶段,Breakout游戏大体上说只是一个Pong游戏缩略版本,它只有单人模式,面对的是一墙砖块。最初Breakout游戏是由Nolan Bushnell和Steve Wozniak发明的,并在1976年由Atari公司发行。在这个早期版本中,它就像Pong游戏一样仅仅是个黑白游戏,但为了让它更让人振奋,给显示器上蒙了一层透明条纹来给砖块上色(如图3-13所示)。 图 3-13 实际上,您将通过复用一些Pong的组件以及本章学到的辅助类,走一条相似的路。Breakout是一个比Pong更加复杂的游戏;它有很多level关卡,而且有相当大的改进空间。例如,Arkanoid是Breakout的一个克隆,并且在20世纪80-90年代有许多游戏都是基于这个游戏创意,它们添加了武器、更好的图形特效,以及许多砖块摆放位置不同的level关卡。 正如图3-14中所见,BreakoutGame类以和上一章的Pong类相似的方式构建。sprite处理被忽略是因为现在是使用SpriteHelper类完成的。其它一些内部方法和调用也被某些辅助类取代了。例如,StartLevel方法基于当前level值产生一个新的随机level值,这里你将使用RandomHelper类产生这些随机值。 图 3-14 请注意,在这个类中还可以看到许多测试方法。在下一章中将对辅助类做一些类似的改进,下一章将介绍BaseGame和类对处理游戏类的TestGame类,尤其是单元测试变得更加简单和更有条理。 看一看图3-15对接下来几页将要开发的Breakout游戏有一个快速概览。它相当有趣,肯定比Pong游戏更有可玩性,无论如何Pong游戏只有两个人玩的时候才有意思。Breakout游戏使用了相同的背景纹理和两个来自于Pong项目的声音文件,不过你也为球板(paddle)添加了一个新纹理(BreakoutGame.png)、球、砖块,以及胜出level关卡(BreakoutVictory.wav)和打碎砖块(BreakoutBlockKill.wav)的新的声音文件。 图 3-15 Breakout中的单元测试 在开始从头到尾复制/粘贴上一个项目的代码、使用新的辅助类以及绘制新的游戏元素之前,应该考虑一下游戏以及你可能遇到的问题。当然,您可以继续前进,并且实现游戏,但后面可能会困难很多,比如检测碰撞,它是这个游戏最难的部分。单元测试帮助您解决,至少是提供了一种简单的方式,检查游戏的所有基本部分,帮助您组织代码,强制你只编写真正需要的东西。就如以前一样,先从游戏最直观的部分开始,并测试它,然后添加更多的单元测试直到你都完成,最后把一切组合起来并测试最终的游戏。 下面是Breakout游戏中单元测试的要点概览;本章的更多内容是察看完整的源代码。你还没有TestGame类,所以您仍然得使用上一章用过的同一种单元测试。查看下一章,有更好的方式做静态单元测试。你只有三个单元测试,但它们会被使用和修改很多次,就像我实现游戏的时候那样。 TestSounds -只是一个快速测试以检查项目中所有新增的声音文件。按下space键、Alt键、Control键和Shift键来播放声音。我还在播放下一个声音之前添加了一个小小的暂停,这样更容易听清楚。这个测试被用来检查为这个游戏新建的XACT项目。 TestGameSprites -这个测试最初用来测试SpriteHelper类,不过后来所有代码都被移到了游戏类的Draw方法中。该测试还用来初始化游戏中的所有砖块;这部分代码被移到了构造器中,它将被展示在本章结尾。这告诉你结尾处的复杂测试并不重要,因为该测试现在只有4行代码,重点是利用测试让您编写游戏的生活更加简单。每当需要的时候,可以复制/粘贴单元测试有用的部分到你的代码。静态单元测试也没有必要像辅助类的动态单元测试那样完整,因为您只在构建和测试游戏的时候使用它们。当游戏运行,您就不再需要这些静态单元测试,除非游戏的测试部分在后面的时间还有需要。 TestBallCollisions -就像上一章检测球的碰撞那样是最有用的单元测试。这里,你要检测碰撞是否如预期的发生在屏幕边缘和球板上。要完成这一点只需要一些小改动。然后就是更加复杂的砖块碰撞代码,这个稍后将详细说明。你甚至可能想出更多的方法来检测碰撞,如果你喜欢还可以改进游戏。例如,把球发射到砖块墙的后面,看看它能否正确的打碎所有砖块,就很有意义。 Breakout级别 因为你要用到很多Pong游戏里现成的东西,所以可以跳过那些相似或者相同的代码。就现在来说,您应该关注的是那些新变量: /// <summary> /// How many block columns and rows are displayed? /// </summary> const int NumOfColumns = 14, NumOfRows = 12; /// <summary> /// Current paddle positions, 0 means left, 1 means right. /// </summary> float paddlePosition = 0.5f; /// <summary> /// Level we are in and the current score. /// </summary> int level = 0, score = -1; /// <summary> /// All blocks of the current play field. If they are /// all cleared, we advance to the next level. /// </summary> bool[,] blocks = new bool[NumOfColumns, NumOfRows]; /// <summary> /// Block positions for each block we have, initialized in Initialize(). /// </summary> Vector2[,] blockPositions = new Vector2[NumOfColumns, NumOfRows]; /// <summary> /// Bounding boxes for each of the blocks, also precalculated and /// checked each frame if the ball collides with one of the blocks. /// </summary> BoundingBox[,] blockBoxes = new BoundingBox[NumOfColumns, NumOfRows]; 首先你定义砖块有多少列和你能拥有砖块数量的最大值;在第一level关卡不会填满砖块行,只使用砖块最大数量的10%。球板定位也比在Pong游戏中的简单一些,因为你只有一个玩家。之后保存当前游戏的level级别和得分,这些是新内容。在Pong游戏中每个玩家只有三个球,如果所有球丢失游戏就结束了。在Breakout中,玩家从level 1开始,直到他最后丢掉球为止,可以不断地向上升级。这里不会得到高分,也没有任何游戏字体,所以级数和分数就直接在窗口的标题栏上进行更新。 接下来定义所有的砖块;最重要的数组就是blocks,它会告诉您哪个砖块当前被使用。Blocks在每一级开始之前初始化,然而blockPositions和blockBoxes只在游戏类的构造器中初始化一次;blockPositions用来确定待渲染砖块的中心位置,blockBoxes用来确定砖块的碰撞检测的边界盒(bounding box)。要注意的是,无论这些数组还是位置数值都没有使用屏幕坐标系。所有的位置数据被保存为0-1的格式:0代表左边或者顶部,1代表右边或者底部。这种方式可以使游戏独立于分辨率,并且使得渲染和碰撞检测都更容易。 level级别是在StartLevel方法中产生的,这个方法在游戏开始以及每次升一级的时候被调用: void StartLevel() { // Randomize levels, but make it more harder each level for (int y = 0; y < NumOfRows; y++) for (int x = 0; x < NumOfColumns; x++) blocks[x, y] = RandomHelper.GetRandomInt(10) < level+1; // Use the lower blocks only for later levels if (level < 6) for (int x = 0; x < NumOfColumns; x++) blocks[x, NumOfRows - 1] = false; if (level < 4) for (int x = 0; x < NumOfColumns; x++) blocks[x, NumOfRows - 2] = false; if (level < 2) for (int x = 0; x < NumOfColumns; x++) blocks[x, NumOfRows - 3] = false; // Halt game ballSpeedVector = Vector2.Zero; // Wait until user presses space or A to start a level. pressSpaceToStart = true; // Update title Window.Title = "XnaBreakout - Level " + (level+1) + " - Score " + Math.Max(0, score); } // StartLevel 在第一个for循环里,你只是依照level级别重新填充整个砖块数组的值。在level 1中,level值设为0,并且只填充10%的砖块。RandomHelper.GetRandomInt(10)方法返回0-9范围内的值,这样小于1的概率只有10%。在level 1中,这个概率就上升到20%,直到你到达在level 10或更高,那就是100%了。实际上游戏没有上限,只要想玩就可以一直玩下去。 然后,你清除底下三行的砖块,让游戏开始的几级容易一些。在level 3 的时候,只有2行被移除,在level 5的时候就只移除1行,一直到level 7用到所有的行。 不同于Pong,在新游戏开始的时候球的速度向量。球停留在球板上,直到用户按下space键或者A键。然后球朝着一个随机位置弹离球板,球会在砖块墙、屏幕边界以及球板之间来回运动,直到所有的砖块都被打碎了玩家就通了一关,或者玩家由于没有接到球而输了。 最后,更新窗口的标题栏来显示到目前为止玩家的到达的关卡数和得分。在这个非常简单的游戏中,玩家每打碎一个砖块只得到1分;达到100分就非常棒了。但就如我之前所说,游戏没有限制。尽量取得更高的分数来体验游戏的快乐。 游戏循环 在Pong中游戏循环非常简单,主要包含用户输入以及碰撞检测代码。Breakout就稍微复杂一些,因为你必须处理球的两种状态。一种状态是球停留在球板上,等待用户按下space键;另一种状态是游戏进行中,必须要检测球与屏幕边界、球板、每个砖块之间的碰撞。 Update方法的大部分代码看起来和上一章的很像;处理第二个玩家的代码被删掉了,同时也在底部增加了一些新代码: // Game not started yet? Then put ball on paddle. if (pressSpaceToStart) { ballPosition = new Vector2(paddlePosition, 0.95f - 0.035f); // Handle space if (keyboard.IsKeyDown(Keys.Space) || gamePad.Buttons.A == ButtonState.Pressed) { StartNewBall(); } // if } // if else { // Check collisions CheckBallCollisions(moveFactorPerSecond); // Update ball position and bounce off the borders ballPosition += ballSpeedVector * moveFactorPerSecond * BallSpeedMultiplicator; // Ball lost? if (ballPosition.Y > 0.985f) { // Play sound soundBank.PlayCue("PongBallLost"); // Game over, reset to level 0 level = 0; StartLevel(); // Show lost message lostGame = true; } // if // Check if all blocks are killed and if we won this level bool allBlocksKilled = true; for (int y = 0; y < NumOfRows; y++) for (int x = 0; x < NumOfColumns; x++) if (blocks[x, y]) { allBlocksKilled = false; break; } // for for if // We won, start next level if (allBlocksKilled == true) { // Play sound soundBank.PlayCue("BreakoutVictory"); lostGame = false; level++; StartLevel(); } // if } // else 首先检查球是否还没有启动。如果球的位置没有变更,就把它放在玩家的球板中心。然后检查space键或者A键如果被按下,就同时启动球(你只要随机化ballSpeedVector的值,球就弹向砖块墙)。 最重要的方法是CheckBallCollisions,这个方法稍后再仔细察看。然后就像在Pong游戏中那样更新球,并检查是否没有接到球。如果玩家没有接到球,游戏结束,玩家可以从第1级重新开始。 最后,检查是否所有砖块都被打碎了以及关卡背完成。如果所有砖块被打碎,则播放胜利的音效,并且开始下一关卡。玩家看到屏幕上出现一条“You Won!”的信息(见Draw方法),按下space键就可以进入下一关卡了。 绘制游戏 归功于SpriteHelper类,Breakout 游戏的Draw方法变得简洁了: protected override void Draw(GameTime gameTime) { // Render background background.Render(); SpriteHelper.DrawSprites(width, height); // Render all game graphics paddle.RenderCentered(paddlePosition, 0.95f); ball.RenderCentered(ballPosition); // Render all blocks for (int y = 0; y < NumOfRows; y++) for (int x = 0; x < NumOfColumns; x++) if (blocks[x, y]) block.RenderCentered(blockPositions[x, y]); if (pressSpaceToStart && score >= 0) { if (lostGame) youLost.RenderCentered(0.5f, 0.65f, 2); else youWon.RenderCentered(0.5f, 0.65f, 2); } // if // Draw all sprites on the screen SpriteHelper.DrawSprites(width, height); base.Draw(gameTime); } // Draw(gameTime) 从渲染背景开始。你不必清空背景,因为背景纹理会填充整个背景。为了确保所有游戏元素都能渲染在背景之上,你要在渲染其他的游戏精灵之前立即绘制好背景。 接下来绘制球板和球,因为使用了SpriteHelper辅助类中的RenderCentered方法,这个操作非常简单,操作如下:(这个方法有三个重载版本,只要使用最方便的) public void RenderCentered(float x, float y, float scale) { Render(new Rectangle( (int)(x * 1024 - scale * gfxRect.Width/2), (int)(y * 768 - scale * gfxRect.Height/2), (int)(scale * gfxRect.Width), (int)(scale * gfxRect.Height))); } // RenderCentered(x, y) public void RenderCentered(float x, float y) { RenderCentered(x, y, 1); } // RenderCentered(x, y) public void RenderCentered(Vector2 pos) { RenderCentered(pos.X, pos.Y); } // RenderCentered(pos) RenderCentered方法接收一个Vector2类型的参数,或者x、y两个float类型的参数,并且从0-1的格式(你在游戏中使用的格式)重新缩放定位到1024×768的分辨率。然后,SpriteHelper类的Draw方法再把所有的一切从1024×768的分辨率重新缩放到当前的屏幕分辨率。这或许听起来很复杂,但它用起来很简单。 接下来本关卡中的所有砖块被渲染,再一次的顺利要归功于砖块的位置已经在游戏的构造器中计算好了。看一看代码,那是关于如何在屏幕上部初始化砖块位置的: // Init all blocks, set positions and bounding boxes for (int y = 0; y < NumOfRows; y++) for (int x = 0; x < NumOfColumns; x++) { blockPositions[x, y] = new Vector2( 0.05f + 0.9f * x / (float)(NumOfColumns - 1), 0.066f + 0.5f * y / (float)(NumOfRows - 1)); Vector3 pos = new Vector3(blockPositions[x, y], 0); Vector3 blockSize = new Vector3( GameBlockRect.X/1024.0f, GameBlockRect.Y/768, 0); blockBoxes[x, y] = new BoundingBox( pos - blockSize/2, pos + blockSize/2); } // for for 边界盒变量blockBoxes用于碰撞检测,这个稍后讨论。位置计算也不是大事;x坐标范围从0.05到0.95按照你拥有的列数步进(如果你记得正确,是14列)。也可以试着把常量NumOfColumns的值改成20,场景就会有更多的砖块。 最后,如果玩家升级了或者输了,就会在屏幕上渲染对应的消息。然后,调用SpriteHelper类的DrawSprites方法渲染所有的游戏元素输出到屏幕上。看看对应的单元测试中是如何渲染砖块、球板和游戏信息的,我就是从单元测试开始的,然后才去实现游戏。 碰撞检测 Breakout游戏的碰撞检测比只要检测球拍和屏幕边界的Pong游戏更复杂一点。最复杂的部分就是,球撞击砖块的时候能正确地反弹回来。完整的检测代码请查看本章的源代码。 像上一个游戏一样,也有一个带有边界盒(bounding box)的球、屏幕边界和球板。砖块是新元素,并且为了检测每一个碰撞,每一帧你都要检测所有的砖块。如图3-16是游戏中砖块发生的的一个碰撞示例: 图 3-16 细看一下砖块的基本碰撞代码。屏幕边界和球板的碰撞检测与Pong游戏中的非常相似,并且可以借助TestBallCollisions单元测试来检查。为了检测砖块的碰撞,你要反复遍历所有的砖块,并且检查是否球的边界盒碰撞了这些砖块的边界盒。实际的游戏代码稍微更复杂一些,因为要检测撞上了边界盒的哪一边,以及球必须向哪个方向反弹,不过其余代码和主体思想还是相同的。 // Ball hits any block? for (int y = 0; y < NumOfRows; y++) for (int x = 0; x < NumOfColumns; x++) if (blocks[x, y]) { // Collision check if (ballBox.Intersects(blockBoxes[x, y])) { // Kill block blocks[x, y] = false; // Add score score++; // Update title Window.Title = "XnaBreakout - Level " + (level + 1) + " - Score " + score; // Play sound soundBank.PlayCue("BreakoutBlockKill"); // Bounce ball back ballSpeedVector = -ballSpeedVector; // Go outta here, only handle 1 block at a time break; } // if } // for for if

辅助类——其他辅助类

clock 七月 28, 2010 10:54 by author alex
其他辅助类 Helpers命名空间中包括了更多的辅助类;大多数辅助类就像RandomHelper类一样简单。把它们遍历一遍非常沉闷,所以请你们自己审阅本章没有提及的那些辅助类,如果想进一步了解它们可以使用其中内含的单元测试进行检测。 在本章结尾进入Breakout游戏之前,浏览几个余下的辅助类,它们在下面几章中会被频繁地使用:SpriteHelper、EnumHelper和ColorHelper。 SpriteHelper类 在上一章,你使用了多次sprite渲染,因为单元测试你被迫用一种简单的方式来处理XNA中的sprite。这种解决方案,以及事实上你可能把要用到的代码不止一次地放进可复用的类中,这导致我们使用SpriteHelper类(如图3-10所示)。它主要提供了一个构造器来创建新的SpriteHelper实例,存储texture纹理和Graphic Rectangle图像矩形数据,还提供了一些渲染方法更方便地把sprite绘制到屏幕上,就像你在上一章中使用一系列SpriteToRender类所做的那样。 图 3-10 这里的大多数方法都没有做很多操作;构造器只是初始化数值,Render方法只是向sprite列表添加一个新的SpriteToRender实例,RenderCentered方法在指定位置居中显示sprite,最后DrawSprites方法把所有sprite绘制到屏幕上。看一看DrawSprites方法,它和前一章中的DrawSprites方法很类似,不过有一些改进: public static void DrawSprites(int width, int height) { // No need to render if we got no sprites this frame if (sprites.Count == 0) return; // Create sprite batch if we have not done it yet. // Use device from texture to create the sprite batch. if (spriteBatch == null) spriteBatch = new SpriteBatch(sprites[0].texture.GraphicsDevice); // Start rendering sprites spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); // Render all sprites foreach (SpriteToRender sprite in sprites) spriteBatch.Draw(sprite.texture, // Rescale to fit resolution new Rectangle( sprite.rect.X * width / 1024, sprite.rect.Y * height / 768, sprite.rect.Width * width / 1024, sprite.rect.Height * height / 768), sprite.sourceRect, sprite.color); // We are done, draw everything on screen. spriteBatch.End(); // Kill list of remembered sprites sprites.Clear(); } // DrawSprites(width, height) 以渲染窗口分辨率的当前宽度和高度为参数调用该方法,并根据分辨率对所有sprite进行比例缩放,这对于支持Xbox 360的所有分辨率非常重要。在DrawSprites 方法中首先检查是否有东西要渲染。然后确保静态的SpriteBatch对象被创建,它将被所有需要绘制的sprite使用到。调用SpriteBatch的Begin方法之后,在当前帧中遍历所有的sprite并重新调整它们的尺寸以适应当前屏幕,最后通过调用End方法让每一个sprite绘制到屏幕上。sprite列表也被清空,开始刷新下一帧。作为该类如何工作的范例,可以参看本章最后的Breakout游戏。 EnumHelper类 当你需要遍历枚举项,或者快速获取枚举值序号的时候,EnumHelper类(如图3-11所示)非常有用。在Pong和Breakout游戏中没有用到任何枚举类型,但下一章的游戏在遍历block类型的时候使用Enum类(System.Enum)变得非常有用。也请注意到,EnumHelper类用到了Enum类的几个方法,这些方法在.Net Compact Framework中并没有被实现。为了避免任何编译错误,在Xbox 360项目中通常排除整个EnumHelper类,不过如果你喜欢,在Windows平台上还是可以使用它。 图 3-11 单元测试用的TestGetAllEnumNames方法如下所示,也说明了GetAllEnumNames方法是如何工作的。它借助EnumHelper类内部定义的EnumEnumerator辅助类来遍历每一个枚举值。 [Test] public void TestGetAllEnumNames() { Assert.AreEqual( "Missions, Highscore, Credits, Help, Options, Exit, Back", EnumHelper.GetAllEnumNames(typeof(MenuButton))); } // TestGetAllEnumNames() GetAllEnumNames方法则使用了之前讨论的StringHelper类中的WriteArrayData辅助方法: public static string GetAllEnumNames(Type type) { return StringHelper.WriteArrayData(GetEnumerator(type)); } // GetAllEnumNames(type) ColorHelper类 ColorHelper类(如图3-12所示)原本长得多,并且具有许多的方法,但是因为XNA中新的Color类比Managed DirectX中使用的System.Drawings中的Color类强大得多,所以有很多方法就不再需要了。不过它还是包含了一些对颜色操作有用的方法。 图 3-12 例如,ColorHelper.Empty字段可以用来把shader特效参数设置为空值——0,0,0,0通常不是一个合法颜色值;它是完全透明的。而黑色的Alpha值是255。 /// <summary> /// Empty color, used to mark unused color values. /// </summary> public static readonly Color Empty = new Color(0, 0, 0, 0);

辅助类——StringHelper类

clock 七月 28, 2010 10:49 by author alex
StringHelper类 StringHelper类是最大的辅助类之一,估计它是我曾写的第一个辅助类,因为处理字符串会卷入如此之多的问题,很容易就可以想到许多方式来改善性能,更容易地处理字符串列表,输出字符串数据等等。 如果看一看StringHelper类(如图3-9所示),您会立刻发现许多方法,并且所有重载的方法支持很多不同的参数类型。它还包含相当多的单元测试;几分钟前,您就看见过一个来自于StringHelper类的单元测试。 图 3-9 您可能自言自语,为什么这个类中的单元测试那么少,而方法却如此之多。这是因为很多年前我就开始写这个类了,远在我开始使用单元测试之前。其中有些方法在.Net 2.0 中没有太大的意义了,因为现在Framework实现了它们,不过我还是习惯于自己的方法。我只是希望你能在这个类中找到某些有用的方法。可能要花一些时间来习惯这么多方法,不过当您需要进行一项复杂的字符串操作时,您将会因为这个有用的方法感谢我(如果您有自己的辅助类,就是您自己)。 提取文件名 在System.IO命名空间的Path类中,也包含许多诸如GetDirectory、CutExtension等等的方法,不过在StringHelper类中用来处理文件名的最有用的方法之一就是ExtractFilename方法,它把路径和扩展名都去掉了,只得到文件名,再无其他。 Path.GetFileNameWithoutExtension方法做类似的一件事,不过出于某些原因我更喜欢自己的方法。如果您想实现自己的方法,并且需要一些能着手工作的代码,也可能很有趣。再强调一次:您不是必须写自己的Path方法,不过有时您不知道Framwork提供了这些,或者您只是想自己去研究一下。 自从我测试这些方法的性能以来已经过了很久,不过我还是认为大多数StringHelper类的方法比某些Path类的方法更快。 /// <summary> /// Extracts filename from full path+filename, cuts of extension /// if cutExtension is true. Can be also used to cut of directories /// from a path (only last one will remain). /// </summary> static public string ExtractFilename(string pathFile, bool cutExtension) { if (pathFile == null) return ""; // Support windows and unix slashes string[] fileName = pathFile.Split(new char[] { '\\', '/' }); if (fileName.Length == 0) { if (cutExtension) return CutExtension(pathFile); return pathFile; } // if (fileName.Length) if (cutExtension) return CutExtension(fileName[fileName.Length - 1]); return fileName[fileName.Length - 1]; } // ExtractFilename(pathFile, cutExtension) Writing a unit test for a method like this is also very simple. Just check if the expected result is returned: Assert.AreEqual("SomeFile", StringHelper.ExtractFilename("SomeDir\\SomeFile.bmp")); 输出列表 在StringHelper类中另一个比较独特的是WriteArrayData方法,它把任何类型的列表、数组或者IEnumerable数据书写为文本字符串,这些字符串可以被写入日志文件中。实现还是非常简单: /// <summary> /// Returns a string with the array data, ArrayList version. /// </summary> static public string WriteArrayData(ArrayList array) { StringBuilder ret = new StringBuilder(); if (array != null) foreach (object obj in array) ret.Append((ret.Length == 0 ? "" : ", ") + obj.ToString()); return ret.ToString(); } // WriteArrayData(array) 列表,甚至泛型列表都是从ArrayList类继承来的,所以能够给这个方法传递任何动态列表。对于存在特殊重载版本的Array数组、特殊的集合、byte和integer数组,这些以IEnumerable接口工作的类型也都存在对应的重载版本,不过使用非object类型的重载速度会更快。 可以编写下列代码来测试WriteArrayData方法: /// <summary> /// Test WriteArrayData /// </summary> [Test] public void TestWriteArrayData() { Assert.AreEqual("3, 5, 10", WriteArrayData(new int[] { 3, 5, 10 })); Assert.AreEqual("one, after, another", WriteArrayData(new string[] { "one", "after", "another" })); List<string> genericList = new List<string>(); genericList.Add("whats"); genericList.AddRange(new string[] { "going", "on" }); Assert.AreEqual("whats, going, on", WriteArrayData(genericList)); } // TestWriteArray()

辅助类——随机函数(RandomHelper)辅助类

clock 七月 28, 2010 10:47 by author alex
  随机函数(RandomHelper)辅助类 看一下助手类之一的RandomHelper类。在单一项目中它不被经常使用,但几乎任何一个游戏都使用一些随机数生成,使得游戏内容看上去没有太多的周期性,并且给游戏带来更多变化。 在Breakout游戏中,您将编写一些随机数,这里的砖块(block)就是随机生成的。level 1 的随机概率是10%,level 2使用的随机率是20%,以此类推。这样随着级别提升,游戏变得更难。您可以仅仅使用Random类,调用Next方法来生成一个新的随机数,但万一您想生成一个随机标准化向量,你就得编写如下的代码行: Random randomGenerator = new Random((int)DateTime.Now.Ticks); Vector3 randomNormalVector = new Vector3( (float)randomGenerator.NextDouble() * 2.0f - 1.0f, (float)randomGenerator.NextDouble() * 2.0f - 1.0f, (float)randomGenerator.NextDouble() * 2.0f - 1.0f); randomNormalVector.Normalize(); 取代再三重复这段代码,诸如RandomHelper的助手类会非常有用。图3-8显示了RandomHelper类的基本结构。 图 3-8 如你所见,这些方法都很简单,只需花几分钟就可以写出整个类。可是,这个类非常有用,归功于Random类的内部globalRandomGenerator实例,RandomHelper类在产生随机数上比你每次需要随机数时就创建一个新的Random类要快得多。 生成随机向量 这里你看到的是RandomHelper类的一个方法: /// <summary> /// Get random Vector2 /// </summary> /// <param name="min">Minimum for each component</param> /// <param name="max">Maximum for each component</param> /// <returns>Vector2</returns> public static Vector2 GetRandomVector2(float min, float max) { return new Vector2( GetRandomFloat(min, max), GetRandomFloat(min, max)); } // GetRandomVector2(min, max) 测试RandomHelper类中的任何方法没有太大意义,因为所有的返回值都是随机数,你没有必要检查GetRandomVector2方法是否返回一个Vector2;它就是返回Vector2的。基本上不会出错。

辅助类——XNA 中的单元测试

clock 七月 28, 2010 10:45 by author alex
XNA 中的单元测试 在本书即将带来的工程项目进一步深入助手类的细节之前,本节简单讨论一下单元测试。在上一章您已经学习了静态单元测试(static unit test)。静态单元测试非常适用于快速检测可视的结果、测试物理特性和控制器,以及快速构建游戏。但是助手类和组件不需要用户输入,而需要你考虑它们的接口。这是没有意义的,因为单元测试主要是为了改善你的应用程序的可维护性,并确保一切运行尽可能无错。例如,可以调用下面的代码行来测试Log类是否工作: FileHelper.DeleteFile(Log.LogFilename); Log.Write("New log entry"); 提示: 这段代码只能在Log类内部执行,因为Log.LogFilename是私有的。 现在,您可以进入应用程序的文件夹中查看是否有日志文件存在,并且有一个“New log entry”字样的条目在其中。不过,自己一再地检查这个文件内容是有点儿麻烦的。取代于记录下这里的每一条错误信息,你应该只把不重要的警告信息(比如,用户没有连接到Internet)放置到日志,当致命的的错误发生要抛出异常(比如,找不到纹理、shader不可用等等)。当问题变得越来越多,测试变得更加复杂并且卷入漫长的检测过程时,这么做尤其正确。通过让单元测试自我检测,你就避免了亲自检查错误,并且让它们自动地被执行,而不是像使用静态单元测试那样要你亲自从Program类中调用。 NUnit和TestDriven.Net 要做到上述这些,您可以使用流行的NUnit Framework,你可以在http://www.nunit.org/下载它。 另一个选择,也可以使用来自于http://www.testdriven.net/的TestDrive.Net,假设你使用的是Visual Studio 2005 Professional或更高版本。它支持很多非常酷的特性,你可以直接使用热键或者弹出菜单开始测试,这真是又酷又简单。TestDriven.Net不能工作在VC# Express或者XNA Studio Express(一年前倒是能工作的,但是开发者不得不删除这个支持Express的插件,这是因为Microsoft希望开发者在大型程序使用Professional Edition)。关于如何让XNA在Visual Studio 2005中工作,参见第一章;可以使用在XNA Studio Express中的一个虚拟项目来处理内容素材,这是在Visual Studio 2005中不可能实现的。 无论你安装哪个版本,只要从安装文件夹把NUnit.Framework.dll添加到你的工程项目中(右击你的项目,添加一个新引用;如果你在全局程序集缓存——Global Assembly Cache中找不到NUnit.Framework.dll,可以使用“浏览”,它是第一个被呈现的标签页。)。现在您可以添加下面的using指令: #if DEBUG using NUnit.Framework; #endif 我通常在using指令区域的顶部添加这段代码,它之所以只在debug模式下使用,是因为实际上你只在debug模式下编译才使用单元测试;对于最终的游戏您也不想要这个额外的NUnit.Framework.dll以及所有的测试代码,因为你的游戏不需要它。 作为一个例子,看一看StringHelper类中使用的第一个单元测试,这个类检测IsInList辅助方法是否按预期工作: [TestFixture] public class StringHelperTests { /// <summary> /// Test IsInList /// </summary> [Test] public void TestIsInList() { Assert.IsTrue(IsInList("whats", new string[] { "hi", "whats", "up?" }, false)); Assert.IsFalse(IsInList("no way", new string[] { "omg", "no no", "there is no way!" }, false)); } // TestIsInList() ... Assert是NUnit框架中的一个辅助类,它包含一些检查返回值是否符合预期的方法。如果返回值不符合预期,一个异常将被抛出,您可以立即看到你的测试失败在哪一行。例如,Assert.IsTrue检查IsInList方法的返回值是否是true。如果返回值是false,一个异常将被抛出。幸运的是字符串数组中包含了“whats”,这个测试应该通过。下一个测试检查“no way”,而该字符串不在第二个字符串数组中,因此第二个测试行应该依照法则返回false。注意:“there is no way!”包含了“no way”,但此处你测试的不是Contains方法,虽然StringHelper类中也存在这个方法。如果确切的字符串在列表中被找到,IsInList方法才返回true。 开始单元测试 通过点击鼠标右键并选择“Run Test”(如图3-6),您可以在TestDriven.Net中运行测试;如果您没有或者无法使用TestDriven.Net,也可以使用NUnit程序。您也可以借助TestDriven.Net使用相同的方式来进行静态单元测试,但NUnit GUI不支持静态单元测试。所以,我在Program.cs(或者在后面项目中的UnitTesting.cs类)中添加单元测试,来支持所有用户和XNA Studio Express。TestDriven.Net可以被用来起动动态和静态单元测试,但是自version 2.0之后您为了正常工作,必须把[Test]特性从静态单元测试中删除(总之,本书中的静态单元测试都不能使用[Test]特性)。 图 3-6 测试将会没有任何错误地运行,但如果你更改测试,把“whats”改成“whats up”,第一个Assert测试就会失败,并且你会看到来自于TestDriven.Net的下列结果: TestCase 'M:XnaBreakout.Helpers.StringHelper.StringHelperTests.TestIsInList' failed: NUnit.Framework.AssertionException at NUnit.Framework.Assert.DoAssert(IAsserter asserter) at NUnit.Framework.Assert.IsTrue(Boolean condition, String message, Object[] args) at NUnit.Framework.Assert.IsTrue(Boolean condition) C:\code\XnaRacer\Helpers\StringHelper.cs(1387,0): at XnaBreakout.Helpers.StringHelper.StringHelperTests.TestIsInList() 0 passed, 1 failed, 0 skipped, took 0,48 seconds. 它会告诉您要看的确切位置(你甚至可以双击错误信息,跳转到1387行)以及应该修改什么。如果使用NUnit,错误甚至会更直观(如图3-7): Figure 3-7 NUnit GUI是个一次性运行多个单元测试的好工具,并且很快看出哪一个不正常工作,然后深入源代码研究。你可以使用菜单“File→Load”来选择你的程序,或者把任何.Net的.exe或.dll文件拖放到NUnit GUI 程序上。然后就可以看到assembly 里面的所有单元测试,并且点击“Run”就可以测试了。程序是很好,但我通常在编码或者测试的时候不想切换出开发环境,所以我更喜欢TestDriven.Net并一直使用它。要修复这个错误,您只需把“whats up”这行改回“whats”,这样所有的测试将会通过了,你也获得了绿灯。 黄金法则 这里我不准备太深入单元测试,因为第二章已经讨论过了基本规则,它们也适用于动态单元测试。当开始编写你的第一个单元测试的时候,要保持这些方针: 思考您的问题,并且把它们分解成容易管理的小部分。 先写测试,不要考虑具体实现,您认为最终的代码应该是什么样,或者你想让游戏代码像什么样,就怎么写。 试着确保测试尽可能地多。例如,TestIsInList方法不仅测试了成功调用了IsInList方法,还测试了对它的失败调用。单元测试是要花些时间,但别超过50%——用不着为只有两行代码的方法编写30次的测试。 从此刻开始测试要不间断地进行,即使您认为它没有多大意义。这会迫使你看到该做什么,还有离实现过程还有多远。开始的时候,测试甚至不能编译,因为您还没有实现任何东西。当执行了一些空方法之后,测试会失败,因为你还没有做任何事情。最后当所有东西都能正常工作了,您就会感觉好多了。 即使您不非常频繁的进行静态单元测试,每一次您编译代码的时候动态单元测试都被执行(如果它们都运行得足够快)。始终设法运行所有单元测试每天一次或者每周一次,以确保你最新的代码修改不会添加新的错误。

辅助类——记录错误信息

clock 七月 28, 2010 10:41 by author alex
记录错误信息 Debug游戏代码可能是非常复杂的,特别是如果您没有得到任何异常,但某些渲染循环却出错。只设置几个断点并不够,尤其是如果游戏在运行一段时间之后遇到错误,Debug并不是正确的选择。您想知道每一帧都运行了什么,但又不想逐步贯穿500帧去发现它。对于这类问题,您可以仅仅抛出一些文本到控制台,不过这只能在Visual Studio中使用,而且当你下次启动项目时将会丢失所有的控制台内容。 在我做过的所有比较大型的项目中,一个最重要的类就是Log类,它只是给一个简单的文本文件写入消息、警告、错误或者Debug文本。这个类本身很简短,也很简单,但如果你以正确的方式使用它,将会给您的debug调试和测试会话更令人愉快。另外,还有更加高级的日志记录类和框架(logging classes and frameworks)可利用,诸如Log4Net,你可以在http://logging.apache.org/log4net/找到。日志不仅仅只是给文本文件写入几行。来自于应用程序的日志数据常常用来远程获取用户错误,借助一个WebService,你可以激活Windows错误事件,还可以做很多其他事情。这些不是本书能涵盖的,因为这是一个非常复杂的话题。对于本书中的简单游戏,使用Log类应该足够了。 先看一看Log类(在Breakout游戏中能找到一个更复杂的版本): public class Log { #region Variables private static StreamWriter writer = null; private const string LogFilename = "Log.txt"; #endregion 它使用一个Log.txt文件来存储所有消息,并使用一个静态的StreamWriter对象,以便可以方便地在静态方法中访问。 #region Static constructor to create log file static Log() { // Open file FileStream file = new FileStream( LogFilename, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite); writer = new StreamWriter(file); // Go to end of file writer.BaseStream.Seek(0, SeekOrigin.End); // Enable auto flush (always be up to date when reading!) writer.AutoFlush = true; // Add some info about this session writer.WriteLine("/// Session started at: "+ StringHelper.WriteIsoDateAndTime(DateTime.Now)); } // Log() #endregion 在游戏运行的时候,枚举值FileShare.ReadWrite确保你总是可以从外部读写文件。除此之外,要把writer设置到文件的末尾,AutoFlush属性能够确保写入新数据会被立即存储到日志文件中,最后再添加一点儿文本指示这次会话已经开始。对于时间戳你将使用StringHelper类的一个辅助方法,你立刻就会学到这个类。 最后,这是该类的最重要的一个方法、也是您将一直调用的唯一方法: #region Write log entry static public void Write(string message) { DateTime ct = DateTime.Now; string s = "[" + ct.Hour.ToString("00") + ":" + ct.Minute.ToString("00") + ":" + ct.Second.ToString("00") + "] " + message; writer.WriteLine(s); #if DEBUG // In debug mode write that message to the console as well! System.Console.WriteLine(s); #endif } // Write(message) #endregion 首先,在消息的前面加上一个简单的时间戳。然后消息被写入Log.txt文件中,最后如果项目在debug模式,也把消息输出到控制台。现在,只是通过添加下列代码行,当你每一次完成来自第二章的Breakout游戏的一个关卡,你就给Log.txt文件添加一个新行: Log.Write("Level " + level + " completed.");

辅助类——掌握内容管道

clock 七月 28, 2010 10:37 by author alex
  掌握内容管道 就像你在第一章学到的,内容管道通常用来导入游戏资源,诸如纹理、shaders、阴影和声音文件。不像任何其他非Visual Studio(或 XNA Studio)支持的文件,你只要把添加它们到工程项目,内容文件会被处理,并且然后会被编译为二进制内容文件,这些文件能被你的游戏加载(见图3-1)。 图 3-1 在过去游戏编程不得不编写自己的导入器来加载游戏内容数据或者使用一种可利用的格式,诸如在DirectX中.x 文件格式的模型文件。但是常常,这种可利用的格式是不能胜任的,太慢,或者当试着给游戏添加新特性时就是不可更改。这是为什么几乎每一款商业游戏都有自己的文件格式的原因,并且在其后台自定义逻辑编程。只有游戏开发者知道内部格式的规划是有益处的,并且他们能所心所欲地经常扩展或改变它。但是,在游戏中要用这种途径得到内容,通常有许多工作要做。加载纹理通常不是那么复杂,因为有许多库存在,并且即使当你编写自己的文件格式,它基本上也只包含被作为24bit或32bit色彩值储存的像素。如果你试图使用压缩或者硬件压缩纹理,诸如DXT格式,就变得有点儿难了,但是DirectX 有一整套的有用方法来协助你处理。 另一方面,加载3D模型数据要复杂得多,特别是在XNA,在这里你不仅必须要有几何数据,而且渲染一个3D对象需要数个shader,然后当然,材质数据将告诉着色器shader它的颜色、纹理以及一些要用到的参数。在DirectX中,大多数指南和范例只是使用.x文件格式,但是.x格式可能对于许多工程项目还不够胜任。尤其是,你如果使用normal napping(法线圆锥曲面叶)和需要包含切线的几何数据,.x文件将不是非常有用了。你将不得不在你的应用程序中产生切线,并且这样做还可能引入运行问题。例如,我的一个老游戏,Rocket Commander,就有这个问题,并且它需要一个复杂的模型加载过程和切线再生过程。 其他游戏数据像加载声音文件(.wav),shader(.fx),或者自定义数据(例如.xml)可能是简单的,因为你的游戏或者你正在使用的框架提供了充分的帮助类来快速加载这一切,但是然后,在另一个平台运行同样内容的游戏,你可能遇到问题。例如,你可能在Windows 平台使用ACPCM声音文件,并且使用编译过的Pixel Shader 1.1 文件,或者只是加载一对 .jpg 文件作为纹理,但是在Xbox 360上不支持ACPCM ;声音要么是PCM,要么是Xbox专有的自定义XMA格式。Shader代码必须是Xbox 360接受的格式,加载纹理的工作也可能有差别。如果更多的平台在未来被支持,这个问题甚至将变得更复杂。 为了简单化游戏内容的加载,XNA现在允许你直接把原始的内容文件放到XNA Studio工程项目,并且它们将被处理和编译为当前所选平台的正确输出格式。例如,你的声音文件能基于你的XACT项目设定来进行处理——你将拥有不同的输出格式和压缩格式。但是所有的原始声音文件在wave bank 上都是同样的,仅仅需要在某一位置被更新。这个主意很好,但是它需要所有的原始内容文件格式都被支持,这是不切合实际的因为可利用的文件格式如此之多,而你不知道哪一些将被使用。例如,你的一个图像艺术师可能使用Photoshop并且储存.psd文件,而其他团队可能使用 Gimp或Paint-Shop或仅仅是Windows绘图板。大约有数以千计的其他图形工具和程序。另外,你实际上不知道要提取哪一个数据;很多格式有“多层”结构,并且可能艺术家想要每一层都可利用,或者干脆合并一切图层。 受支持的文件格式 取代干脆放下一切,使用一个可利用的处理程序,和被支持的格式,或者如果你认为你需要,试着写你自己的内容处理程序(见第七章): 纹理格式:: .dds, .png, .jpg, .bmp, .tga -基本上每一个你都能用.NET Framework或DirectX加载。输入格式通常为了最佳质量,应该是不被压缩的。被高度压缩的.jpg文件是坏的,尤其是在游戏中你使用DXT再次压缩它们。换句话说,对你的输入文件可以使用合适的输出压缩(如使用DXT压缩的.dds文件),并且再次为内容属性进行同样的设定,离开它们那个途径(在我的所有项目中,我用这个方式处理了大多数内容)。 声音和音乐格式:: .xap (XACT Audio Project) -在XACT中,你只能导入.wav文件,但是你能设置许多效果和参数,以及选择windows平台上的ACPCM压缩或者Xbox 360 平台上的XMA。阅读更多的相关内容在第九章。 3D模型格式: .fbx and .x model files - .x文件以来自DirectX SDK的许多范例和指南而知名。DirectX 提供了一些易于加载.x 文件的类。 大多数.x 文件在XNA下也应该工作得很好;主要的区别是DirectX 的.x文件通常不使用shader,而XNA总是使用shader。为了从3D Studio Max导出模型,使用Panda DirectX Exporter。你可以在http://www.andytather.co.uk/Panda/directxmax.aspx找到Panda Exporter 插件。 .fbx是一种较新的文件,最初由Alias开发,Alias是Maya的制作商,Maya也是一个3D建模工具。Alias被Autodesk -- 3D Studio Max 和许多 CAT 程序的制造商收购了。.fbx意思是“Universal 3D Asset Exchange”,并且是 Autodesk免费的跨平台内容交换格式。在新版本的3D Studio Max 9 它默认被包含,并且Maya也支持它。还有许多其他的3D 内容建造程序支持导入和导出.fbx 格式。在XNA,它对于活动的模型、骨骼和蒙皮特别有用。为此它支持更多选项,但它对shader特别糟糕,因为没有材质或任何shader 设置能被导出。 .fbx 格式的另一个问题是缺少格式的规格说明书,并且为了访问SDK你必须加入 Autodesk Developer Network 支付一年一度的会员资格金,真是吸血。如果你看看其它交换格式像Collada,你能看到它们要开放得多并且可扩展,因为它们不只是被一个公司开发,许多新附件和特色时常会被添加。在过去Collada 不支持shader设定,但是当前版本对3D数据支持很好;你能输出tangent切线,shader设定,和其他一切你在游戏中需要的东西。不幸的是,Collada不被XNA支持,并且我不能说服Microsoft包含它。XNARacer 最初所有的模型、赛道、地形数据都使用 Collada文件,但是后来全更改了以支持内容管道content pipeline。 其它格式 -你能导入自定义文件格式,例如xml文件、二进制文件,甚至写你自定义的处理程序。如果你有一个较大的项目,并且它值得你努力;或者你需要一个特殊的模型格式,并且它尚不被XNA支持,自定义格式就可能很有用。例如,Quake3/Doom3 使用 md3/md5文件,你如果只是用来导入一些模型测试和随便玩玩,一个md5 导入器会很棒。 万一你的游戏有较多的内容文件或者一些自定义数据,你要么决定写一个自定义处理程序,并且在工程项目中使用被导入的以及被编译的数据,要么就按照旧方式自己加载内容文件。例如,在本书最后一章你将编写一个竞速游戏,它会使用一个从位图文件导入的,带有地形高度值的地形图。处理位图文件,以及为游戏输出地形高度数据将是可能的,但是这有太多的工作——仅仅加载高度数据则要简单得多,并且只需要一次。 内容管道的优缺点 内容管道的另一个缺点是,被编译过的内容不能再一次被修改。一旦你启动游戏,或者把你的游戏部署于客户端电脑或Xbox 360,所有的内容文件只包含被编译过的数据。比如说你已经写了一个粒子编辑器,它使用了支持所有粒子的shader。当编辑器在运行的时候,如果你想要动态的改变纹理、shader,以及其他粒子设置,你将不得不重新加载纹理、shader等等。但是因为你首先需要在XNA Studio 中有内容被编译,你必须停止你的应用程序,添加所有的文件到你的XNA Studio 工程项目,重编译并且等待,直到所有内容被重新构建(build),然后再次启动。尤其是测试、调整特效或粒子的情形,可能非常恼人并且会严重拖慢你的工作进程。只是动态的加载纹理、shader和粒子设置,以及像那样的程序不使用内容管道可能会更好。 最后,这里有一个诀窍,我会用在大多数有许多内容文件的工程项目中:编译所有的内容文件,并且确定你不会非常频繁地修改它们(仅仅好几天改一次)。现在,你可以使用一个虚拟工程项目来编译你所有的游戏内容,并且拷贝所有编译过的内容文件到你真正的工程项目中。尤其当使用单元测试和章节最后谈论的敏捷方法学的时候,你将每天成百次的启动应用程序,并且每一次运行都要尽可能地快。 内容管道也有优点,那就是编译过的数据(.xnb文件)无法被除了XNA引擎之外的其它任何程序读取,并且其加载过程也通常快得多,因为所有数据都已经是游戏所需要的确切的格式。例如,纹理总是被储存为DXT文件,并且使用了假设你在内容属性中指定的mip-maps。这样,游戏就会在一次快速调用中加载texture纹理数据,然后把它发送给图像卡渲染,这又是一个快速处理过程。这对于3D模型数据更加重要。如果您看一看游戏Rocket Commander并分析一下,你会发现加载3D模型和产生所有的附加数据以及切线,会花掉大部分的初始化时间(超过了90%);然而XNA游戏有10倍的模型数据大小,加载快得多。能尽可能快地加载所有数据对Xbox 360控制台来说,也是一件好事;控制台游戏通常拥有很短的加载时间。 处理内容目录 OK,您已经学习了很多使用内容管道的优势和不足;此时要聚焦在游戏编程和日常问题上了。如果您看一看Rocket Commander游戏和Racing Game游戏的内容文件夹(如图3-2),会发现Rocket Commander有一大堆文件夹,然而XNARacer只使用了两个简单的文件夹。 图 3-2 看图的样子,你可能会以为Rocket Commander的内容文件要多很多,但实际上XNARacer使用的3D模型文件数量几乎是Rocket Commander的10倍,并且还包含更多的纹理、音乐和声音文件。 您或许要问为什么Rocket Commander使用这么多的文件夹。因为在这个游戏中没有使用内容管道,为了更有组织地维护这些内容,并且容易查找,游戏的每一部分都使用了文件夹。例如,“Textures”文件夹包含了菜单和游戏界面使用的所有2D 纹理,它的“Models”子文件夹包含了3D模型纹理,“Effects”子文件夹则包含了特效纹理,等等诸如此类。 在XNA中,您不能使用这样的目录结构,因为大多数内容文件,尤其是3D模型文件,可能需要递归地加载许多其它的内容文件(如图3-3)。 图 3-3 正如您所见,Apple模型是从Apple.x文件加载的,它又递归地加载Apple.dds、AppleNormal.dds和NormalMapping.fx。内容处理器希望所有这些文件都放在同一个文件夹中,这迫使您使用一个文件夹来存放所有的3D模型文件、纹理以及它们使用的shader。另外,大多数shader还会被其它3D数据使用,复制shader可能非常令人糊涂,把他们放到另一个文件夹中也是。有时候您还需要为自定义3D数据加载纹理(比如,在游戏XNARacer中,guard rail holder模型和generated guard rail对象使用相同的纹理)。 无论如何,记住每一块内容必须有唯一的名称是很重要的。您不能拥有一个名为“Apple”的模型和一个名为“Apple”的纹理。正如您在图3-3中看到的,在“Input File”那一行只添加了一个Apple.x文件;所有的其它文件都是由模型处理器自动添加的。另外,XNA足够聪明的会重命名所有的递归文件,因为它们通常会使用和模型文件名称相同的纹理。递归文件的名称以“~0”结尾。您也无法设置这些递归文件的内容属性,因为你不能把它们添加到工程项目中,所以要确保输入文件已经使用了正确的格式(在上述的例子中是DXT1和DXT5)。 导入并访问内容数据 现在您已经足够了解如何导入某些内容并在游戏中访问它们。在前几章,您已经访问了一些内容文件,并快速地了解了内容管道。现在,您将更进一步地注视实际的处理过程,以及如何使用内容文件。在第七章,您会通过扩展X模型文件处理器(X Model File Processor)为您的图像引擎编写自己的内容处理器,并添加一些有用的特性。 回顾第一章您学习了如何添加纹理;只要选择一个纹理文件(.dds、.jpg、.bmp或.png)然后把它放到XNA Studio项目中。现在你可以点击纹理,并且配置其 Content Processor 属性(如图3-4): 图 3-4 给texture设置正确的Content Processor模式很重要。对于2D数据,像sprite、文本以及游戏使用的所有UI图像,通常最好使用32bpp Sprite格式(未压缩的,这意味着1024×1024分辨率及32bpp的纹理,需要4MB空间)。 在一个3D游戏中,3D纹理数据的使用要比2D UI 纹理多得多,而且每一款游戏的纹理和级别会逐渐增大。因为这个原因,保持纹理的小尺寸非常重要。要使用硬件纹理压缩(hardware texture compression),而不要降低纹理的分辨率以及使游戏看上去非常糟糕。您可以为色彩纹理选择1:6压缩率的DXT1格式,以及为包含Alpha信息(或compressed normal maps)的纹理选择1:4压缩率的DXT5格式。这意味着在游戏消耗相同显存容量的情况下,DXT1压缩的纹理数量是未压缩纹理的6倍,而且还不会有太多的质量损失。另一个技巧是在shader里合并纹理或者甚至生成纹理;例如,细节纹理可以改进地形的细节而几乎不消耗额外的显存。 对于模型文件,当前你只能使用X Model Importer或者FBX Model Importer(如图3-5)。将来可能会有更多的格式可供使用。如果你编写自定义的模型处理器,就像你将要在第七章做的那样,你能以你选择纹理处理器同样的方式选择模型处理器。对于normal mapping 你要选择来自于第七章的自定义XNARacer Tangent Model Processor。在下面几章中只要许可默认值。 图 3-5 如果您按照前两章的方式来加载纹理,那么您或许已经知道了如何在XNA中加载内容。纹理加载如下: backgroundTexture = content.Load<Texture2D>("CityGroundSmall"); 3D模型也以相同的方式加载----只要改变Load方法的泛型参数类型: appleModel = content.Load<Model>("apple"); 显示模型稍微有点复杂;没有简单的绘制方法,你必须遍历所有的模型mesh并更新所有shader特效,然后再渲染每一部分。更多细节参看第五章和第六章。本书在第七章后面,您将看到一个新类专门加载和渲染模型,它使3D模型显示在3D世界中甚至简单到只需使用一行代码就实现: appleModel.Render(Vector3.Zero); 现在您已经了解所有的关于内容管道的基础知识。在即将到来的几章中,当您在图形引擎中使用自定义的Tangent Model Processor添加3D模型的时候,你将学习有关内容管道的更多内容,并且在第九章中你会深入学习XACT。

辅助类——概览

clock 七月 28, 2010 10:31 by author alex
概览 在Helper命名空间,我通常放许多有用的小工具和类,这些类随着时间的过去变得相当有用。对于一个单一的工程项目,它可能不是最重要或最有价值的代码,但是因为它能被一遍又一遍的使用,无论引擎的其他部分是否改变,助手类将是你的引擎和工程项目里最稳固的部分。大多数助手类不处理太多的游戏程序,并且对其他工程项目甚至网站都有用。 你正好看过前面的章节,你不借助任何助手类就能写一个完整的游戏,甚至根本不用任何额外的文件或类。但是当工程项目变得越来越大,你将会看到有许多重复的样式和相似的问题发生,这些问题过去已经被解决过了。通常,在XNA中大多数重用的功能是图形组件。在下一章节,特别是本书的第二部分,你学到更多关于游戏组件和图像引擎。因为现在这个章节保持聚焦在非常基本的功能,如记录信息,访问游戏内容,做有效的单元测试,怎样产生随机数,以及许多其他较小的问题。 为了让这一章有一点让人更兴奋,你也创建下一个小游戏,只是为了娱乐。不是所有的助手类将被使用,但是build一个游戏引擎不是一个简单的任务。因为这个原因,本章和下一章从助手类和游戏组件开始,这样在本书第二部分中开发你的图像引擎就会容易一点儿。请注意我没有用这种方式编写本章呈现的辅助类;它们是过去几年进化过程中的产物。如论何时需要它们,只要添加功能或者你自己的助手类。如果你一旦解决一个问题,并且认为它可能不会再次有用,只要移除它的位置。但是如果你发现自己多次拷贝方案到一个新的工程项目,或者也许是另一个类,你应该认真考虑提取逻辑并且把它们放到一个特殊的类。 另外,您将学习到更多有关前几章中使用过的内容管道的知识,来支持Xbox 360。直接加载纹理或者shader,而不使用内容管道也可行,但是所有这些方法只能工作在windows平台。如果你想要创建一个跨平台游戏,你应该确保一切编译和工作在windows平台和Xbox 360平台都正常。贯穿本书你将持续这么做。 本章节末尾,你将快速开发一个有趣的Breakout克隆游戏。它比前面章节简单得多,因为你将首先编写所有的辅助类。例如,借助SpriteHelper 类渲染精灵是非常容易的。在下一章节,你能找到更多的辅助类和游戏组件来改进Pong游戏和Breakout游戏。

创建你的第一个游戏Pong——总结

clock 七月 26, 2010 15:02 by author alex
总结 我希望您能喜欢本章中的内容,并掌握创建游戏的整个过程。Pong可能并不是最好玩的游戏,但它涵盖了对于任何一个游戏项目来说都可能包含的从头至尾的所有东西。对于大一些的游戏项目,只用一章的篇幅是讲不完的,但在这里您至少已经学习了一些概念、单元测试以及一些敏捷方法学的基本术语。下一章要学习辅助类的设计,然后再深入研究Game类和游戏组件,这之后,您就可以准备写一个包含很多非常酷的特效的3D引擎,它将在本书最后一部分使用XNA写一个大的、好玩的游戏时用到。同时,您还将学习一些其他的小的、但好玩的游戏项目。 本章最重要的一点是敏捷开发给游戏编程带来的好处,虽然您可以不断地做一些修改,并且很轻松的以自顶而下的方式解决问题,但是做一个游戏构思,即使只有一页纸那么多,仍然很重要。单元测试也很有帮助,不过在下一章中您将看到单元测试还可以自动化地完成,并且它们对于助手类来说也很有用,这些助手类遍及本书中后面的项目。 看一看在本章中您学到的内容: 写下您的想法,并有一个小的游戏构思非常重要 正好一页纸的构思相当有用 为了解决问题你使用了自顶而下的方式,并且站在最高层面来使用单元测试,而不用去考虑细节实现。通过这种方式您能够快速地提取出游戏概念的构想,并且在单元测试中写下它们,这时候就会很容易地发现您必须具体实现什么样的方法。 创建一些纹理素材,把它们加载到项目中,然后把它们作为Sprite渲染出来。 一次性渲染大量Sprite可以带来较高的效能。 使用一个精巧的计算公式可以帮助您创建分辨率独立(resolution-independent)的游戏。这样,游戏就可以在Xbox 360上很好地运行,并且是在任何可能的分辨率下。 你使用XACT添加声音,并且学习了一些基础;在第9章可以查到更多的细节。 使用两个Gamepad控制器处理游戏输入,以及使用键盘处理多玩家输入。 借助BoundingBox结构体进行2D碰撞检测。 如果始终把z值设为0,您可以使用Vector3来代替Vector2 正常工作。 使用单元测试来进行碰撞检测非常重要,并可以显著减少您必须进行的测试工作量。即使您当前正在处理游戏中完全不同于碰撞的部分,仍然可以切换到单元测试中,再次检测碰撞。

创建你的第一个游戏Pong——疑难解答

clock 七月 26, 2010 14:59 by author alex
疑难解答 希望本章不会有太多的难题。本章只涉及了两个主要问题:单元测试和Sprites。或许在Xbox 360上运行游戏您还有些问题,我已经在第一章的结尾处写了一些提示和技巧;如果您仍然有Xbox 360设定的问题,可以去查看。 另一个问题大多数人都不会提出,但对于少数对异常处理,尤其是对XNA异常没经验的人来说却会提出,那就是要理解为什么会抛出异常,并且要如何正确解决它。要随时紧盯着异常,试着回顾完整的堆栈跟踪,查看是否有带有详细描述信息的内部异常。 就像Managed DirectX,有时您会得到来自底层DirectX Framework的异常,有时候它会令人困惑。试着到Internet和MSDN查找对应错误代码的帮助信息。在这些情况下,上下文背景通常要比错误信息本身更重要。比如,在编译一个Shader失败之后,您可能会得到奇怪的错误编码,此时应该确定Shader文件是否存在,是否能够被正确编译,您的硬件能否处理它,而不是试图查明这些错误编码什么意思。有一个用来检查Shader文件是否有效的好工具,它就是来自于Nvidia的FX Composer。 如果您在使用单元测试的时候陷入麻烦,也请阅读下一章的“疑难解答”部分,因为它也讨论了很多单元测试。几乎始终要记住,此处您使用的单元测试和标准程序没有什么区别;同种方式对待它们。要像对待最终的应用程序一样对它们进行Debug 并一步步地跟踪。 最后,是一些在XNA中使用Sprites的提示: 如果您在其他精灵的上面渲染很多Sprites,会出现很多XNA按随机排序显示的后果,以及使用不同Texture时的问题,而且背景Sprite有时会掩盖被该在它前面的一切。就像您在XNA Pong游戏中看到的,通过额外的SpriteBatch方法调用来渲染背景。这样它就被独立出来,不会干扰游戏或者菜单中的其他Sprite。 试着一次性渲染所有的Sprite,尤其是当您可以使用混合模式对它们进行排序的时候。如果你高效调用一次SpriteBatch,渲染所有的Sprite会使速度快很多。查看作为范例的DrawSprites方法。如果您使用不同的混合模式,比如附加光源效果,并且其他所有的Sprite使用Alpha混合,试图分两个过程来渲染,先渲染Alpha混合物体,然后在顶部添加光源。这样做您的游戏效能会提升200%-300%。 要考虑分辨率问题,以及如何处理不同的宽度和高度;您可以在Initialize方法中借助下面两行代码来获取当前使用的分辨率: width = graphics.GraphicsDevice.Viewport.Width; height = graphics.GraphicsDevice.Viewport.Height; 不能强制使用某个特定的分辨率;因为在Xbox 360上它可能根本不工作,在Windows平台上也只能使用一个建议分辨率。例如,您想在PC上测试720p 16:9(1024×768)分辨率,您必须要有一台支持这个分辨率的显示器,可以是窗口模式(windowed mode)或者支持测试的全屏模式(fullscreen mode)。在游戏主类(在本章中是PongGame类)的构造器中添加下面两行代码,把分辨率预设为720p。通过检查先前描述的宽度和高度,你能判断。你可以通过中使用先前方式获取的宽度和高度,来检查Initialize方法是否正确工作。 graphics.PreferredBackBufferWidth = 1280; graphics.PreferredBackBufferHeight = 720; 在XNA中不支持字体——要显示文本,您既可以像本游戏那样建立已经包含文本的纹理,也可以像第五章那样编写自己的字体支持。对于这两种解决方案您都要在屏幕上使用Sprite来渲染文本或者每次渲染一个字母。 在XNA中Sprite并不是唯一显示2D数据的方式;您可以编写自己的Shader,甚至可以实现比Sprite类更多的高级特效。作为一个例子,可以查看第五章,看到如何借助Shaders渲染2D线条。

创建你的第一个游戏Pong——挑战:改进您的Pong游戏

clock 七月 26, 2010 14:58 by author alex
挑战:改进您的Pong游戏 现在轮到您来对游戏进行一些改进了;也许您能换一换图片或声音,或者调整游戏的玩法。如果您有Gamepad控制器,您还可以试着给它添加Rumble支持。而且如果您阅读本书的话,的确应该有一个Gamepad,即使您没有Xbox 360;很多XNA范例都需要Xbox 360控制器,而且有些游戏使用Xbox 360控制器会比用键盘更好玩。 这里是为特制Gamepad添加Rumble支持的方法: GamePad.SetVibration(PlayerIndex.One, leftRumble, rightRumble); 当球撞倒球拍时,使用上述代码会让控制器震动一会(left Rumble和right Rumble使用相同的值,这样整个控制器就会震动)。当玩家失去一条命时也可以震动一下,这次震动时间可以长一些,并且强烈一些。 您可以查看本章的工程项目,看看将要的挑战方案。您的方案不必和这相同,不过看一看可能有帮助,甚至可能是一种解决某个特定问题的捷径。 好好享受XNA Pong游戏吧!

创建你的第一个游戏Pong——在Xbox 360看上去如何?

clock 七月 26, 2010 14:56 by author alex
  在Xbox 360看上去如何? 好了,现在游戏已经完成了,并且你也在Windows平台上做了很多测试。正如我在第一章中解释过的,要让Xbox 360上启动并以XNA Framework运行需要几个步骤。如果Xbox 360上的XNA Game Launcher已经启动,您可以试着编译您的XNA Pong游戏并把它部署到控制台。 对于Xbox 360,有一些特别重要的事要记住: 如非必要不要使用任何外部DLL -虽然XNA Game library DLL是被支持的,但大多数Windows平台上的DLL将会调用Windows的系统DLL文件,而这些DLL文件在Xbox 360平台是不存在的,而且更糟的是,由于安全原因在Xbox 360平台上使用P-Invoke调用非托管DLL文件是不被支持的。也不要使用非安全代码或者进行任何调用外部代码、访问设备,或者使用不支持的特性的尝试。这样做是浪费时间,您最好按照规则行事。 时时进行测试 -在项目测试中,如果您把输出设置更改为Xbox 360,就要测试代码能否编译。您习惯使用的很多方法在Xbox 360上是不存在的。例如,在Xbox 360上动态库System.dll中没有TryParse方法,还有一些XNA方法和类,例如MouseState类甚至缺省也不被支持。 不要直接加载内容-在Windows平台上,借助于Texture2D或CompiledEffect构造器直接加载textures和shaders是可行的。而在Xbox 360上这些方法完全不存在;您只能使用内容管道来加载内容。如果您使用动态加载或重新加载textures/shaders的代码,它们就只能在Windows平台上工作,所以为Xbox 360构建程序要使用#if !XBOX 360…#endif 标记把代码包含起来。 保存游戏并加载其他内容 -要保存游戏您可以使用Content命名空间以及很多有用的助手类。StorageContaine.TitleLocation在Windows平台也可以使用,并且只使用输出文件夹,借助它加载内容您要始终确保使用了正确的路径。从Xbox 360其他位置加载会导致IO操作的拒绝访问(Access Denied)异常。 测试分辨率 -Xbox 360支持很多不同于PC的分辨率,所以您应该至少要测试2-3种不同分辨率来保证游戏正常运行。 允许使用Gamepad操作一切-关于这一点,我所见过的大多数XNA游戏都犯了一个错误,它们要么只支持键盘输入,要么只支持Gamepad输入,这也就意味着它们要么只能运行在Windows上,要么只能运行在Xbox 360上。考虑到支持尽可能多的输入设备对您的游戏来说是非常有意义的。 在Xbox 360上进行Debug和单元测试-在Xbox 360控制台上进行Debug调试和单元测试并没有什么不同;可以应用相同的规则。或许跟踪错误和记录信息有点难,不过使用XNA可以很好进行逐步调试和检测单元测试。要好好利用这一点。顺便说一句:在Windows平台上,调试的时候如果编辑代码仍然可以继续执行,但是对于Xbox 360游戏却不行。这一点真讨厌。不过您多半是在Windows平台上进行主要的开发。 这个XNA Pong游戏在编码时始终考虑到了Xbox 360;所有您使用的方法和特性在Xbox 360上都受支持。所以在Xbox 360上测试这个游戏没什么大问题,您可以直接部署它,启动它,并且运行正常,如图2-11所示。 图 2-11

创建你的第一个游戏Pong——让我们编写Pong

clock 七月 26, 2010 14:53 by author alex
让我们编写Pong 游戏构思都解决了,并且所有你需要着手的文件也有了。是做实际编码的时候了。首先,注视SpriteBatch类,并且看看如何不费力地管理所有的sprite精灵。SpriteBatch类不仅能按照图像保存时的格式来渲染sprites,还能把它们放大或缩小,给它们重新着色,甚至能旋转它们。 把菜单一起放进来之后,您将会添加球拍,并且在你第一章就看到过的输入功能的帮助下,来移动它们。球自身的移动主要靠一些简单的变量,而且每次你撞击到球拍之一都会把球反弹回来,并且播放声音文件PongBallHit.wav的声音。如果球出了左边或右边的屏幕边界,将播放文件PongBallLost.wav的声音,同时玩家失去一条生命。 一些单元测试被用来确保菜单和基本的游戏工作。然后再添加另外的单元测试来处理复杂的部分,像球拍的侧边撞击球以及微调游戏的玩法。为了支持多玩家,您还将使用一个单元测试来考验操作控制,然后把它们添加到主菜单选项来支持多人模式。 在本章的下一部分,您将在Xbox 360上测试整个游戏,并思考如何对游戏做更多的改进。 Sprites 正如你在第一章看到的,SpriteBatch类被用来直接在屏幕上渲染您的纹理。因为您还没有任何的辅助类,您仍将使用相同的方式来渲染所有的东西。更多关于辅助类的信息可以让您每天的游戏编程生活更容易,可以参考第三章。在您的Pong游戏中,您会使用两个层:空间背景,它通过加载PongBackground.dds纹理文件来实现;用菜单和游戏的纹理来呈现菜单文本和游戏组成(球拍和球)。 要加载所有纹理,使用下列代码行。首当你必须定义这里要使用的纹理: Texture2D backgroundTexture, menuTexture, gameTexture; 然后在Initialize方法加载一切: // Load all our content backgroundTexture = content.Load<Texture2D>("PongBackground"); menuTexture = content.Load<Texture2D>("PongMenu"); gameTexture = content.Load<Texture2D>("PongGame"); 最后,您可以使用在第一章中学到的SpriteBatch的方法来渲染背景: // Draw background texture in a separate pass, else it gets messed up // with our other sprites, the ordering does not really work great. spriteBatch.Begin(); spriteBatch.Draw(backgroundTexture, new Rectangle(0, 0, width, height), Color.LightGray); spriteBatch.End(); 使用LightGray这个颜色,意味着你让背景稍微变暗了些,以至于和前景项(菜单文本内容和游戏元素)形成较鲜明的对比。如您所见,渲染一个sprite到屏幕上并非只使用一行代码,并且只渲染sprite纹理的一些部分还要更复杂得多。看一下您在游戏中使用的矩形(rectangle): static readonly Rectangle XnaPongLogoRect = new Rectangle(0, 0, 512, 110), MenuSingleplayerRect = new Rectangle(0, 110, 512, 38), MenuMultiplayerRect = new Rectangle(0, 148, 512, 38), MenuExitRect = new Rectangle(0, 185, 512, 38), GameLifesRect = new Rectangle(0, 222, 100, 34), GameRedWonRect = new Rectangle(151, 222, 155, 34), GameBlueWonRect = new Rectangle(338, 222, 165, 34), GameRedPaddleRect = new Rectangle(23, 0, 22, 92), GameBluePaddleRect = new Rectangle(0, 0, 22, 92), GameBallRect = new Rectangle(1, 94, 33, 33), GameSmallBallRect = new Rectangle(37, 108, 19, 19); 真是相当多的矩形对象,不过比起诸如导入一些XML数据这样的操作,仅仅使用这些常量值还是简单的了。这里使用静态只读(static readonly)变量取代常量是因为常量不能声明为结构类型的,而且static read-only变量和常量的使用方式一样。您或许会问怎样得到这些值,以及如何确保这些值正确呢。 游戏中的单元测试 从这里我们开始引入单元测试。对于游戏编程来说,单元测试主要是把您的问题分解成易于管理的小问题。即使为这样一个非常简单的游戏编写单元测试也是一种好想法。单元测试可以很好地在屏幕上排列您的纹理,测试音效,以及添加碰撞检测。起初,在我计划写这一章以及Pong游戏的时候没有使用单元测试,但当我一开始编写游戏我就不能自已,等我意识到,我已经写了六个单元测试。 比如,彻底检查菜单图像矩形,可以使用下面的单元测试: public static void TestMenuSprites() { StartTest( delegate { testGame.RenderSprite(testGame.menuTexture, 512-XnaPongLogoRect.Width/2, 150, XnaPongLogoRect); testGame.RenderSprite(testGame.menuTexture, 512-MenuSingleplayerRect.Width/2, 300, MenuSingleplayerRect); testGame.RenderSprite(testGame.menuTexture, 512-MenuMultiplayerRect.Width/2, 350, MenuMultiplayerRect, Color.Orange); testGame.RenderSprite(testGame.menuTexture, 512-MenuExitRect.Width/2, 400, MenuExitRect); }); } // TestMenuSprites() 请注意:这并不是本书最终的单元测试代码。这里您只需要使用它的基本思想。这个委托(delegate)包含在Draw方法中每一帧都要执行的代码。 您可能会问自己:StartTest是什么?testGame和RenderSprite方法又是什么?它们都从哪儿来?好,它是传统的游戏编码方式和使用单元测试的敏捷开发之间主要的不同点之一。所有这些方法目前还不存在。类似于您怎样计划游戏,您也可以通过写下您想怎样进行测试,来计划你的单元测试。在这个例子中,就是显示游戏Logo和三个菜单选项(单人模式、多人模式和退出)。 写好一个单元测试之后,修正所有的语法错误,您就可以立即开始编译代码开始测试——只要按下F5,您会发现一连串的错误,这些错误必须一步一步地修正,然后才可以开始进行单元测试。静态单元测试通常不使用Assert方法,但可以添加一些代码,当某些值不是预期的值时以便抛出异常。对于您的单元测试,您只需要查看屏幕的输出结果,然后修改RenderSprite方法,直到一切方式工作都按您所想。 下一章将在主要细节上讨论单元测试。对于这个Pong游戏,您只需要继承PongGame类,然后在您的单元测试中添加一个简单的委托来渲染自定义代码: delegate void TestDelegate(); class TestPongGame : PongGame { TestDelegate testLoop; public TestPongGame(TestDelegate setTestLoop) { testLoop = setTestLoop; } // TestPongGame(setTestLoop) protected override void Draw(GameTime gameTime) { base.Draw(gameTime); testLoop(); } // Draw(gameTime) } // class TestPongGame 现在您就可以写这个非常简单的StartTest方法来创建一个TestPongGame类的实例,然后调用TestPongGame的Run方法执行Draw方法里的自定义testLoop代码: static TestPongGame testGame; static void StartTest(TestDelegate testLoop) { using (testGame = new TestPongGame(testLoop)) { testGame.Run(); } // using } // StartTest(testLoop) 这里使用静态实例testGame是为了让编写单元测试更简单,但如果您在其他地方使用容易引起混乱,因为只有调用StartTest之后它才有意义,在后面的章节中您会看到更好的实现方式。 现在单元测试第一版中的的两个错误已被修正,现在只缺少RenderSprite方法了。这里只是让单元测试能执行,添加一个空方法即可: public void RenderSprite(Texture2D texture, int x, int y, Rectangle sourceRect, Color color) { //TODO } // RenderSprite(texture, rect, sourceRect) public void RenderSprite(Texture2D texture, int x, int y, Rectangle sourceRect) { //TODO } // RenderSprite(texture, rect, sourceRect) 添加上述的这两个方法之后,您就可以执行TestMenuSprites方法了。那要怎样做呢?如果使用TestDriven.Net,您只要点击右键,然后选择“Start Test”就可以了。不过,XNA Game Studio Express不支持插件,所以您必须修改Program.cs文件中的Main方法来自行编写单元测试: static void Main(string[] args) { //PongGame.StartGame(); PongGame.TestMenuSprites(); } // Main(args) 如您所见,我把StartGame方法提取了出来,以便于让Main方法更容易阅读,而且容易变换单元测试。StartGame正是用了标准代码: public static void StartGame() { using (PongGame game = new PongGame()) { game.Run(); } // using } // StartGame() 现在如果你按下F5,单元测试的代码将取代标准的游戏代码而执行。因为RenderSprite方法还不包含任何代码,所以您只能看到PongGame的Draw方法画出的宇宙背景。现在添加代码让菜单工作。虽然您已经知道了如何渲染Sprite,但是每一次单独调用RenderSprite方法都要重新启动并结束SpriteBatch,这样的效率非常低。您可以创建一个简单的sprite列表,列出在每一帧您想渲染的sprite,每次调用RenderSprite的时候就添加一个新登录。然后在每一帧的最后绘制所有的sprite精灵: class SpriteToRender { public Texture2D texture; public Rectangle rect; public Rectangle? sourceRect; public Color color; public SpriteToRender(Texture2D setTexture, Rectangle setRect, Rectangle? setSourceRect, Color setColor) { texture = setTexture; rect = setRect; sourceRect = setSourceRect; color = setColor; } // SpriteToRender(setTexture, setRect, setColor) } // SpriteToRender List<SpriteToRender> sprites = new List<SpriteToRender>(); 顺便说一句:所有这些代码,包括单元测试,都放在PongGame类中的。通常您想重用代码,并且以后扩展游戏,那么把这些分解在多个类中更好。为了让事情简单些,并且因为以后您不会过多使用这些代码,这一切就可以用尽可能最快的方式来编写。虽然很明显这不是最简洁最优雅的编码方式,不过它通常是最快地、最有效率地让您的单元测试运行的方式。接下来的要点,您能重构代码,让它更优雅以及可重用。多亏了单元测试,您始终有一个强大的工具确保这一切在变更代码设计之后功能依旧。 在前面的代码中,您也许注意到“Rectangle?”被用来取代“Rectangle”,来定义sourceRect变量。“Rectangle?”的意思是参数类型允许NULL(空)值,并且您可以给这个参数只传递NULL,这样可以创建RenderSprite方法的不使用参数sourceRect的重载版本,来渲染整幅纹理: public void RenderSprite(Texture2D texture, Rectangle rect, Rectangle? sourceRect, Color color) { sprites.Add(new SpriteToRender(texture, rect, sourceRect, color)); } // RenderSprite(texture, rect, sourceRect, color) 非常直接了当,在Draw方法最后调用的DrawSprites方法也不是很复杂: public void DrawSprites() { // No need to render if we got no sprites this frame if (sprites.Count == 0) return; // Start rendering sprites spriteBatch.Begin(SpriteBlendMode.AlphaBlend, SpriteSortMode.BackToFront, SaveStateMode.None); // Render all sprites foreach (SpriteToRender sprite in sprites) spriteBatch.Draw(sprite.texture, // Rescale to fit resolution new Rectangle( sprite.rect.X * width / 1024, sprite.rect.Y * height / 768, sprite.rect.Width * width / 1024, sprite.rect.Height * height / 768), sprite.sourceRect, sprite.color); // We are done, draw everything on screen with help of the end method. spriteBatch.End(); // Kill list of remembered sprites sprites.Clear(); } // DrawSprites() 虽然对于这个游戏来说并不是非常重要,总之在Windows平台至少得使用1024×768的默认分辨率,您可以把所有Sprites从1024×768重新调整到当前的分辨率。请注意,本游戏的所有纹理以及本书中即将到来的游戏通常都使用1024×768的分辨率。上面的DrawSprites的代码就确保了所有的Sprites可以被正确调整到当前使用的分辨率。比如,在Xbox 360上有多种可能的分辨率,并且强制游戏运行在这些预先您并不知道的分辨率上。因为这个缘故,Xbox 360应该是独立处理分辨率的,如果可能的话它还允许类似HDTV 1080p(1920×1080)这样的格式。基本上,DrawSprites方法先检查是否有任何Sprite要渲染,如果没有就退出函数。如果有,就使用默认的Alpha混合(AlphaBlend)模式以及从后到前的排序方式,渲染所有的Sprites,同时不保存渲染状态。这就是说,如果您改变了任何XNA的渲染状态,当End方法被调用的时候状态都不会被保存。通常,您总是想使用SaveStateMode.None这种方式,因为它是最快的,而且通过单元测试你能确保调用这个方法渲染出来的一切都能正常工作,并且在某种程度上不会被改变。 您也许会思考“所有这些是为了渲染主菜单图像”?如果您按下F5,您将看到如图2-5中所示的屏幕。因为您已经实现了游戏的基本代码,所有Sprite渲染的代码,以及单元测试所需要的一切,您几乎已经完成50% 的工作了。现在您只需要添加游戏图片、操作控制以及简单球体碰撞的代码,这样就完成了。 图 2-5 添加球和挡板 要添加球、球拍和其他游戏组件您需要使用另一个叫TestGameSprites的单元测试: public static void TestGameSprites() { StartTest( delegate { // Show lives testGame.ShowLives(); // Ball in center testGame.RenderBall(); // Render both paddles testGame.RenderPaddles(); }); } // TestGameSprites() 这个单元测试比上一个更好地体现了敏捷开发方法学的处理过程。正如您将看到的,您只是看一眼设计概念就高水准的实现了一切。在顶部你看到每个玩家的生命数量,中间是球和每个玩家的球拍,屏幕边缘没有使用任何特殊图像,还有您已经实现了背景。 你只要确定理解这些单元测试的处理方式并为。添加它们,按F5之后把如下代码加入到Main方法中,并注释旧的单元测试的代码: //PongGame.StartGame(); //PongGame.TestMenuSprites(); PongGame.TestGameSprites(); 您会得到三条错误信息,因为TestGameSprites中三个新的方法还没有实现。当您看到这些错误之后,您就确切地知道接下来要做的三步要,如果它们都被实现了,并测试过了,那这个单元测试就完成了,您就可以继续游戏的下一个部分。我要提醒一点:这种方式确实使整个处理过程更直截了当,而且看起来好像您从头到尾都是事先设计好的,但正如您之前所知的,您仅仅写下了一页纸的游戏构思。其他一切东西是随着您从最顶层的设计到最底层的实现过程中逐步设计并创建出来的。看看三个新方法: public void ShowLives() { // Left players lives RenderSprite(menuTexture, 2, 2, GameLivesRect); for (int num = 0; num < leftPlayerLives; num++) RenderSprite(gameTexture, 2+GameLivesRect.Width+ GameSmallBallRect.Width*num-2, 9, GameSmallBallRect); // Right players lives int rightX = 1024-GameLivesRect.Width-GameSmallBallRect.Width*3-4; RenderSprite(menuTexture, rightX, 2, GameLivesRect); for (int num = 0; num < rightPlayerLives; num++) RenderSprite(gameTexture, rightX+GameLivesRect.Width+ GameSmallBallRect.Width*num-2, 9, GameSmallBallRect); } // ShowLives() ShowLives方法只是为两个玩家显示“Lives:”文本内容,并添加使用来自于游戏纹理的小球的数量作为玩家的生命数量。RenderBall方法甚至更简单: public void RenderBall() { RenderSprite(gameTexture, (int)((0.05f+0.9f*ballPosition.X)*1024) GameBallRect.Width/2, (int)((0.02f+0.96f*ballPosition.Y)*768) GameBallRect.Height/2, GameBallRect); } // RenderBall() 最后使用RenderPaddles方法在当前位置显示左右球拍: public void RenderPaddles() { RenderSprite(gameTexture, (int)(0.05f*1024)-GameRedPaddleRect.Width/2, (int)((0.06f+0.88f*leftPaddlePosition)*768) GameRedPaddleRect.Height/2, GameRedPaddleRect); RenderSprite(gameTexture, (int)(0.95f*1024)-GameBluePaddleRect.Width/2, (int)((0.06f+0.88f*rightPaddlePosition)*768) GameBluePaddleRect.Height/2, GameBluePaddleRect); } // RenderPaddle(leftPaddle) 之前,您可能想知道RenderBall和RenderPaddles方法中的那些浮点数(floating-point numbers)是什么,其实这些游戏中需要的新变量是用来跟踪记录当前球和球拍的位置: /// <summary> /// Current paddle positions, 0 means top, 1 means bottom. /// </summary> float leftPaddlePosition = 0.5f, rightPaddlePosition = 0.5f; /// <summary> /// Current ball position, again from 0 to 1, 0 is left and top, /// 1 is bottom and right. /// </summary> Vector2 ballPosition = new Vector2(0.5f, 0.5f); /// <summary> /// Ball speed vector, randomized for every new ball. /// Will be set to Zero if we are in menu or game is over. /// </summary> Vector2 ballSpeedVector = new Vector2(0, 0); 现在或许您对为什么在这些渲染方法中使用浮点数更加清楚了。使用这种方式您不必处理屏幕坐标、多种分辨率以及检查屏幕边界。球和球拍的位置只在0到1之间,对于x坐标,0意味着在屏幕左边界,而1意味着在屏幕的右边界。y坐标和球拍也是同理:0在屏幕的最顶部,1在屏幕的最底部。您还使用了一个速度向量(speed vector)来更新每一帧球的位置,这将稍后讨论。 球拍渲染在屏幕上。把左球拍(红色的)放在左边并向右偏移5%的距离,这样看得更清晰,并且多出来的这一点儿区域是让球能运动到这里,从而让该玩家就死了一次。同样发生在右球拍(蓝色的)放在屏幕右边95%宽度(也就是0.95f)的位置。按F5之后,看一下输出效果(见图2-6): 图 2-6 看起来好像游戏几乎立刻做好了。虽然单元测试很不错,并且能很快地给您一个美好的结果,但并不意味着这就已经做好了。还必须处理用户输入以及碰撞检测。 处理用户输入 正如您在第一章所看到的,在XNA中捕捉键盘和Gamepad游戏手柄的输入是相当简单的。单单为它编写一个额外的单元测试有些过份。您已经知道它是如何工作,而且这里也只是想测试一下球拍的控制。所以还不需要写一个新的单元测试。你完全可以使用TestGameSprites测试,或许可以把它重命名为TestSingleplayerGame。单元测试的内容还是一样的,您只需在PongGame的Update方法中修改对输入的处理,并更新球拍的位置: // Get current gamepad and keyboard states gamePad = GamePad.GetState(PlayerIndex.One); gamePad2 = GamePad.GetState(PlayerIndex.Two); keyboard = Keyboard.GetState(); gamePadUp = gamePad.DPad.Up == ButtonState.Pressed || gamePad.ThumbSticks.Left.Y > 0.5f; gamePadDown = gamePad.DPad.Down == ButtonState.Pressed || gamePad.ThumbSticks.Left.Y < -0.5f; gamePad2Up = gamePad2.DPad.Up == ButtonState.Pressed || gamePad2.ThumbSticks.Left.Y > 0.5f; gamePad2Down = gamePad2.DPad.Down == ButtonState.Pressed || gamePad2.ThumbSticks.Left.Y < -0.5f; // Move half way across the screen each second float moveFactorPerSecond = 0.5f * (float)gameTime.ElapsedRealTime.TotalMilliseconds / 1000.0f; // Move up and down if we press the cursor or gamepad keys. if (gamePadUp || keyboard.IsKeyDown(Keys.Up)) rightPaddlePosition -= moveFactorPerSecond; if (gamePadDown || keyboard.IsKeyDown(Keys.Down)) rightPaddlePosition += moveFactorPerSecond; // Second player is either controlled by player 2 or by the computer if (multiplayer) { // Move up and down if we press the cursor or gamepad keys. if (gamePad2Up || keyboard.IsKeyDown(Keys.W)) leftPaddlePosition -= moveFactorPerSecond; if (gamePad2Down || keyboard.IsKeyDown(Keys.S)) leftPaddlePosition += moveFactorPerSecond; } // if else { // Just let the computer follow the ball position float computerChange = ComputerPaddleSpeed * moveFactorPerSecond; if (leftPaddlePosition > ballPosition.Y + computerChange) leftPaddlePosition -= computerChange; else if (leftPaddlePosition < ballPosition.Y - computerChange) leftPaddlePosition += computerChange; } // else // Make sure paddles stay between 0 and 1 if (leftPaddlePosition < 0) leftPaddlePosition = 0; if (leftPaddlePosition > 1) leftPaddlePosition = 1; if (rightPaddlePosition < 0) rightPaddlePosition = 0; if (rightPaddlePosition > 1) rightPaddlePosition = 1; 您可能注意到这里又有些新变量(multiplayer、gamePad、 gamePad2、keyboard和 ComputerPaddleSpeed),但目前要关心的是改变球拍位置的代码。变量moveFactorPerSecond用来确保单人模式中,球和球拍总是以相同的速度运动,而无论帧的渲染速度。如果是1fps(frame per second),那么moveFactorPerSecond就等于1,如果是10fps,它就等于0.1,如果是100fps,它就是0.01,以此类推。 接下来,当“Up”、“Down”键或者游标按键被按下的时候,您将改变右球拍的位置。而左球拍由另一个玩家player 2控制,无论是使用第二个可获得的Gampad还是“W”、“S”键。如果不是多人而是单人游戏模式,那么左球拍就由计算机控制,并以取值0.5f的ComputerPaddleSpeed变量限速,跟着球移动。起初,球的移动比较慢,但每次碰撞都会提升一点速度,并且您也可以使用球拍边缘撞击球来加速。这样计算机就接不到球,您就赢了。 为了让新的Update方法工作,需要添加新的变量和常量: /// <summary> /// Ball speed multiplicator, this is how much screen space the ball /// will travel each second. /// </summary> const float BallSpeedMultiplicator = 0.5f; /// <summary> /// Computer paddle speed. If the ball moves faster up or down than /// this, the computer paddle can't keep up and finally we will win. /// </summary> const float ComputerPaddleSpeed = 0.5f; /// <summary> /// Game modes /// </summary> enum GameMode { Menu, Game, GameOver, } // enum GameMode GamePadState gamePad, gamePad2; KeyboardState keyboard; bool gamePadUp = false, gamePadDown = false, gamePad2Up = false, gamePad2Down = false; /// <summary> /// Are we playing a multiplayer game? If this is false, the computer /// controls the left paddle. /// </summary> bool multiplayer = false; /// <summary> /// Game mode we are currently in. Very simple game flow. /// </summary> GameMode gameMode = GameMode.Menu; /// <summary> /// Currently selected menu item. /// </summary> int currentMenuItem = 0; 对于当前的测试,您只需要使用我前面提及的几个变量。但还是要看一下游戏需要的余下变量。变量BallSpeedMultiplicator决定了球有多快,因此也决定了游戏的整体有多快。GameMode用来处理当前您可能在的所有三种游戏模式。您可能是刚开始游戏,正处于菜单界面,或者正在游戏中。当您处在游戏中时,如果一个玩家输了,那么模式将被修改成游戏结束(GameOver)状态,并显示获胜者。 虽然您现在还不需要,但菜单是您必须处理的最后一部分输入操作,所以看一看吧: // Show screen depending on our current screen mode if (gameMode == GameMode.Menu) { // Show menu RenderSprite(menuTexture, 512-XnaPongLogoRect.Width/2, 150, XnaPongLogoRect); RenderSprite(menuTexture, 512-MenuSingleplayerRect.Width/2, 300, MenuSingleplayerRect, currentMenuItem == 0 ? Color.Orange : Color.White); RenderSprite(menuTexture, 512-MenuMultiplayerRect.Width/2, 350, MenuMultiplayerRect, currentMenuItem == 1 ? Color.Orange : Color.White); RenderSprite(menuTexture, 512-MenuExitRect.Width/2, 400, MenuExitRect, currentMenuItem == 2 ? Color.Orange : Color.White); if ((keyboard.IsKeyDown(Keys.Down) || gamePadDown) && remDownPressed == false) { currentMenuItem = (currentMenuItem + 1)%3; } // else if else if ((keyboard.IsKeyDown(Keys.Up) || gamePadUp) && remUpPressed == false) { currentMenuItem = (currentMenuItem + 2)%3; } // else if else if ((keyboard.IsKeyDown(Keys.Space) || keyboard.IsKeyDown(Keys.LeftControl) || keyboard.IsKeyDown(Keys.RightControl) || keyboard.IsKeyDown(Keys.Enter) || gamePad.Buttons.A == ButtonState.Pressed || gamePad.Buttons.Start == ButtonState.Pressed || // Back or Escape exits our game keyboard.IsKeyDown(Keys.Escape) || gamePad.Buttons.Back == ButtonState.Pressed) && remSpaceOrStartPressed == false && remEscOrBackPressed == false) { // Quit app. if (currentMenuItem == 2 || keyboard.IsKeyDown(Keys.Escape) || gamePad.Buttons.Back == ButtonState.Pressed) { this.Exit(); } // if else { // Start game .. handle game, etc. .. 这里有一些新的变量,像remDownPressed或者gamePadUp,它们用来让输入处理更容易些。额外的细节可以查看本章的源代码。下一章将详细讨论输入辅助类(Input helper class),它将进一步简化该过程。 这些就是所有您在Pong游戏中需要知道的关于输入处理的内容。如果您再次执行单元测试,您能看到和前一个测试相同的输出屏幕,不过现在您可以控制球拍了。 碰撞检测 为了从中间移动球,单元测试TestSingleplayerGame要调用下面的方法。球的移动到随机位置(这里你定义了至少四种随机方向): /// <summary> /// Start new ball at the beginning of each game and when a ball is /// lost. /// </summary> public void StartNewBall() { ballPosition = new Vector2(0.5f, 0.5f); Random rnd = new Random((int)DateTime.Now.Ticks); int direction = rnd.Next(4); ballSpeedVector = direction == 0 ? new Vector2(1, 0.8f) : direction == 1 ? new Vector2(1, -0.8f) : direction == 2 ? new Vector2(-1, 0.8f) : new Vector2(-1, -0.8f); } // StartNewBall() 在Update方法中,球位置的更新基于变量ballSpeedVector: // Update ball position ballPosition += ballSpeedVector * moveFactorPerSecond * BallSpeedMultiplicator; 如果现在使用这个单元测试开始游戏,球将从中心移动出屏幕,这可不够酷。这就是为什么您需要碰撞检测。看一下图2-7所示的构思,并且为碰撞代码添加一些改进。回过头去看之前设计的构思图,并且基于新想法和知识进行改进,有时是很有意义的,这次就是其中之一。这里有三种碰撞可能发生: 图 2-7 碰撞屏幕边界在屏幕顶边和底边 碰撞球拍把球弹回给对手 碰撞球拍后面的屏幕边界,失去一条命。 该状况发生,玩家丢掉一条命,并用StartNewBall方法重新设置球 您可以继续使用TestSingleplayerGame来检测碰撞,不过创建一些新的测试很容易,并且可以对每一个问题来测试。面对这种问题,单元测试再一次显示出它的长处。现在您已经有了一个清晰的概念要做什么,只是还不知道要怎么去做。那就写一个单元测试来实现它吧: public static void TestBallCollisions() { StartTest( delegate { // Make sure we are in the game and in singleplayer mode testGame.gameMode = GameMode.Game; testGame.multiplayer = false; testGame.Window.Title = "Xna Pong - Press 1-5 to start collision tests"; // Start specific collision scene based on the user input. if (testGame.keyboard.IsKeyDown(Keys.D1)) { // First test, just collide with screen border testGame.ballPosition = new Vector2(0.6f, 0.9f); testGame.ballSpeedVector = new Vector2(1, 1); } // if else if (testGame.keyboard.IsKeyDown(Keys.D2)) { // Second test, straight on collision with right paddle testGame.ballPosition = new Vector2(0.9f, 0.6f); testGame.ballSpeedVector = new Vector2(1, 1); testGame.rightPaddlePosition = 0.7f; } // if else if (testGame.keyboard.IsKeyDown(Keys.D3)) { // Thrid test, straight on collision with left paddle testGame.ballPosition = new Vector2(0.1f, 0.4f); testGame.ballSpeedVector = new Vector2(-1, -0.5f); testGame.leftPaddlePosition = 0.35f; } // if else if (testGame.keyboard.IsKeyDown(Keys.D4)) { // Advanced test to check if we hit the edge of the right paddle testGame.ballPosition = new Vector2(0.9f, 0.4f); testGame.ballSpeedVector = new Vector2(1, -0.5f); testGame.rightPaddlePosition = 0.29f; } // if else if (testGame.keyboard.IsKeyDown(Keys.D5)) { // Advanced test to check if we hit the edge of the right paddle testGame.ballPosition = new Vector2(0.9f, 0.4f); testGame.ballSpeedVector = new Vector2(1, -0.5f); testGame.rightPaddlePosition = 0.42f; } // if // Show lifes testGame.ShowLives(); // Ball in center testGame.RenderBall(); // Render both paddles testGame.RenderPaddles(); }); } // TestBallCollisions () 此处的想法是通过按下1-5数字键来建立自定义碰撞检测场景。比如,如果1被按下,球将移动到(0.6, 0.9)位置,它靠近屏幕的底部中心。球的速度向量被设置成(1, 1)以确保它向屏幕边界移动,在这个地方球应该像构思中描述的那样反弹。如果按下4或5,高级球拍测试被启动,以检测是否撞到了右球拍的边缘,此处比起其他简单的碰撞检测需要更多的微调。碰撞检测在PongGame的Update方法中进行。 现在您可以开始测试。显然,如果现在开始的话,它还不能正常工作,因为您还没有实现任何的碰撞检测。 球是否碰撞了屏幕的顶边和底边是最简单的测试。所有下列代码添加到Update方法中,位置在您为下一帧更新球的之前: // Check top and bottom screen border if (ballPosition.Y < 0 || ballPosition.Y > 1) { ballSpeedVector.Y = -ballSpeedVector.Y; // Move ball back into screen space if (ballPosition.Y < 0) ballPosition.Y = 0; if (ballPosition.Y > 1) ballPosition.Y = 1; } // if 这里重点部分就是颠倒球速度向量的y分量。有时候变量moveFactorPerSecond在下一帧中的值比在当前帧中的值要小一些。这样球就会跑出屏幕边界,您就得在每一帧中颠倒速度向量的y分量。要修正这个问题,您就得确保球的位置始终处于屏幕范围内而不跑出去。对于球拍也要做同样的调整。 球拍的碰撞检测稍微更复杂一点。如果您只想检测屏幕顶边和底边的碰撞,现在只要按下F5进行测试。为了测试球拍碰撞,就得构建边界盒(bounding box)以执行相交检测(intersection test),它可以用到XNA中的BoundingBox类。BoundingBox使用Vector3结构体,并且工作于3D空间,不过在Pong游戏的2D空间中,您恰好可以忽略z坐标值而始终把它设置为0: // Check for collisions with the paddles. // Construct bounding boxes to use the intersection helper method. Vector2 ballSize = new Vector2( GameBallRect.Width / 1024.0f, GameBallRect.Height / 768.0f); BoundingBox ballBox = new BoundingBox( new Vector3(ballPosition.X - ballSize.X / 2, ballPosition.Y - ballSize.Y / 2, 0), new Vector3(ballPosition.X + ballSize.X / 2, ballPosition.Y + ballSize.Y / 2, 0)); Vector2 paddleSize = new Vector2( GameRedPaddleRect.Width / 1024.0f, GameRedPaddleRect.Height / 768.0f); BoundingBox leftPaddleBox = new BoundingBox( new Vector3(-paddleSize.X/2, leftPaddlePosition-paddleSize.Y/2, 0), new Vector3(+paddleSize.X/2, leftPaddlePosition+paddleSize.Y/2, 0)); BoundingBox rightPaddleBox = new BoundingBox( new Vector3(1-paddleSize.X/2, rightPaddlePosition-paddleSize.Y/2, 0), new Vector3(1+paddleSize.X/2, rightPaddlePosition+paddleSize.Y/2, 0)); // Ball hit left paddle? if (ballBox.Intersects(leftPaddleBox)) { // Bounce of the paddle ballSpeedVector.X = -ballSpeedVector.X; // Increase speed a little ballSpeedVector *= 1.05f; // Did we hit the edges of the paddle? if (ballBox.Intersects(new BoundingBox( new Vector3(leftPaddleBox.Min.X - 0.01f, leftPaddleBox.Min.Y - 0.01f, 0), new Vector3(leftPaddleBox.Min.X + 0.01f, leftPaddleBox.Min.Y + 0.01f, 0)))) // Bounce of at a more difficult angle for the other player ballSpeedVector.Y = -2; else if (ballBox.Intersects(new BoundingBox( new Vector3(leftPaddleBox.Min.X - 0.01f, leftPaddleBox.Max.Y - 0.01f, 0), new Vector3(leftPaddleBox.Min.X + 0.01f, leftPaddleBox.Max.Y + 0.01f, 0)))) // Bounce of at a more difficult angle for the other player ballSpeedVector.Y = +2; // Move away from the paddle ballPosition.X += moveFactorPerSecond * BallSpeedMultiplicator; } // if 边界盒的构建方式相类于在RenderBall和RenderPaddles方法中被处理的渲染代码。不过这里的边界检测代码有点更复杂,并且当球撞倒球拍边缘时正好以一种快速的卑劣的方式来提高球速。不过这些让游戏更加有趣了。 用于左球拍的的代码和右球拍的完全一样;您只需用右球拍的变量替换掉所有左球拍的变量,并把移动方向代码取反。 您必须为游戏做的最后一件事就是处理所有的游戏进行,以及最终的碰撞测试是为了检测当球运动到玩家球拍后面的时候,玩家会失去一条命。这个代码非常简单,而且您还可以直接处理某个玩家是否已经失去了所有生命,这个时候游戏就结束了。显示“红方胜”或“蓝方胜”的消息在Draw方法处理的。如果用户按下Space或Escape键,他将返回主菜单,并且游戏重新开始: // Ball lost? if (ballPosition.X < -0.065f) { // Play sound soundBank.PlayCue("PongBallLost"); // Reduce number of lives leftPlayerLives -- ; // Start new ball StartNewBall(); } // if else if (ballPosition.X > 1.065f) { // Play sound soundBank.PlayCue("PongBallLost"); // Reduce number of lives rightPlayerLives -- ; // Start new ball StartNewBall(); } // if // If either player has no more lives, the other player has won! if (gameMode == GameMode.Game && (leftPlayerLives == 0 || rightPlayerLives == 0)) { gameMode = GameMode.GameOver; StopBall(); } // if 好了,这就是游戏最难的部分;除了边界盒的碰撞检测稍微有点复杂,游戏余下的部分都很简单,可以很容易就实现。您还学习了一些单元测试,以及如何以更有效率的方式处理Sprite。现在您可以按下F5来测试游戏,并且对碰撞做些微调(如图2-8所示)。 图 2-8 添加声音 要给您的游戏添加声音,通常只要放下几个.wav文件到项目中,然后回放它们。在XNA中,不支持加载.wav文件,原因是Xbox 360和Windows平台使用不同的声音和音乐格式。为了解决这个问题,微软发明了一个叫做XACT的工具,它在DirectX SDK和Xbox 360SDK中早就可以得到了。XACT是Microsoft Cross-Platform Audio Creation Tool的缩写。XNA也使用这个工具,并且微软决定让它成为播放声音文件的唯一方式。 虽然对于添加特效、做大量调整以及在一个地方管理所有声音文件,XACT是一个非常好的工具,但对于这样一个游戏项目来说,它就有点把事情过于复杂化了。本书有完整一章内容关于XACT;可以参考第9章。对于Pong游戏,您只需要两个简单的声音文件: PongBallHit.wav每次碰撞边界或球拍的时候都会播放这个文件,也用作更换菜单选项的音效 PongBallLost.wav is used when a player lost a life because he didn’t catch the ball in time. 要给您的游戏添加这些文件,您必须创建一个新的XACT项目。可以在“开始→所有程序→Microsoft XNA Game StudioExpress→Tools”找到XACT。在新的XACT项目中,点击菜单“Wave Banks→New Wave Bank”添加一个Wave Bank,再点击菜单“Sound Banks→New Sound Bank”添加一个Sound Bank。然后把这两个.wav文件拖放到新的XACT Wave Bank窗口中,接下来把两个新的Wave Bank项拖放到Sound Bank窗口,然后再拖放到Cues窗口。如果对你有困惑或者出现问题了,可以参考第9章的更多细节。 图 2-9 下面这些是XACT用来处理声音的主要组件: Wave Bank存储所有的.wav文件,这里除了.wav文件以外,不能添加其他任何文件。不支持.mp3、.wma或者其他.wav以外的任何格式。导入压缩文件也不是可选项。Windows的ACPCM和Xbox 360的XMA压缩是可以的,但必须遵循一些规则,这些规则在第9章被描述。 Sound Bank 主要用来在cue的帮助下在游戏中回放声音。这里您可以通过改变音量和音高(pitch),添加分类,附加音效(RPC)来修改声音设置。您还可以在这里定义多声道。不过通常只是在这里设置音量; Sound Cues用来播放声音。一个Sound Cue至少要有一个Sound Bank和它对应,但可以把多个声音文件分配给一个cue里,并设置一些规则——是否要随机播放某个声音,是否一次只能播放一个此类的声音,以及声音替换的规则。这里的cue名称非常重要,它被用来在游戏中访问cue,并最终播放声音。 你要把PongBallHit的音量设置成-8,PongBallLost设置成-4,默认值-12太小了,而撞击声响了点儿,这样在游戏中降低音量听起来就更好一些。余下部分可以使用默认值,并且把这个项目保存成PongSound.xap文件。再把该文件添加到 XNA Studio 项目中,它就可以利用XNA内容管道自动地编译和构建所有文件,而且可同时应用于Windows平台和Xbox 360平台。另外,您还要确保这两个wav文件要和PongSound.xap文件放在相同的文件夹内,否则内容管道可能找不到这些文件,也就无法构建您的XACT项目。 图 2-10 回放声音的代码相当简单,只要确保声音能正常播放,音量也合适即可。这里是用于声音的简单单元测试: public static void TestSounds() { StartTest( delegate { if (testGame.keyboard.IsKeyDown(Keys.Space)) testGame.soundBank.PlayCue("PongBallHit"); if (testGame.keyboard.IsKeyDown(Keys.LeftControl)) testGame.soundBank.PlayCue("PongBallLost"); }); } // TestSounds() 现在,你所有必须要做的是给游戏实现声音效果,只要在您想播放声音的地方添加几行代码即可。首先,给菜单和每一次球和屏幕边界或球拍碰撞的时候添加音效(查看前面的碰撞检测)。 然后,在玩家失去一条命的时候像这样添加PongBallLost音效: // Ball lost? if (ballPosition.X < -0.065f) { // Play sound soundBank.PlayCue("PongBallLost"); // Reduce life leftPlayerLives -- ; // Start new ball StartNewBall(); } // if else if (ballPosition.X > 1.065f) { // Play sound soundBank.PlayCue("PongBallLost"); // Reduce life rightPlayerLives -- ; // Start new ball StartNewBall(); } // if 添加这些代码行之后,您可以重新启用单元测试TestSingleplayerGame来检查一下声音能否被正确地播放。对于更复杂的游戏需要一个更好的系统在播放声音的时候进行检测,而对于大多数的简单游戏,直接使用PlayCue方法就可以工作很好了,这个方法只是在需要的时候播放声音并且保存Cue。您也可以自己创建并管理Sound Cue;这样的好处是您可以停止播放或者继续开始等等。

友情链接赞助