适用于 Windows Mobile 的 Pong 游戏
使用 VC++/Visual Studio 2005 编写的 Pong 克隆游戏。
引言
你喜欢电脑游戏吗?是的?我也是。你有没有想过写一个?我也是!
我认为玩游戏很有趣,而写游戏更有趣。但正如有人明智地说过的,游戏是一件复杂的事情。事实上,拼凑出一个可玩的游戏可能相当有挑战性。在这篇文章中,我想介绍一个我为 Windows Mobile 编写的 Pong 游戏。它使用 VC++ 和 Visual Studio 2005 编写。最多可以支持五颗球,具体取决于技能水平。球的速度会随着时间增加,碰撞会让球变得疯狂。有道具可以缓解这种情况,但仍然很难击败电脑。
通过这篇文章,我想分享我关于如何将一个复杂的任务分解成更小、更易于管理的部分的想法。
什么构成一个 Pong 游戏?
两个挡板和一个球?如果它那么简单就好了。下面是对挑战的概述
- 程序应该处理不同类型的资源,例如游戏图形和音乐
- 它应该被正确计时,以便动画在慢速和快速设备上看起来几乎相同
- 它应该提供一个自定义游戏菜单,包含选项和设置,以及一种正确开始或退出游戏的方式
- 最后但同样重要的是,应该有一个游戏逻辑来移动球,检查碰撞,响应用户输入,计算分数等。
让我们来看看这些挑战。
游戏资源
游戏通常有很多资源:图形、声音、文本等等。您可能知道,Windows 系统支持将各种类型的资源包含到可执行文件中;大多数情况下,图标、光标、对话框、菜单等数据就是这样存储的。我认为游戏资源应该存储在文件中,而不是存储在 EXE 本身中。它们可以塞进 EXE,但如果您不受内置资源 API 的限制,您会拥有更多自由。
资源应该以某种方式组织。我的意思是,将所有类型的资源都放在同一个文件夹中不是一个好主意;相反,应该有一些分组。图形和声音是不同的类别,所以如果有很多,至少它们应该放在两个不同的文件夹中,例如 *Gfx* 和 *Sfx*。
一个有趣的问题是,如何存储游戏图形。在这个 Pong 游戏中,有挡板、球、背景、道具等。它们应该存储在 BMP 或 JPG 文件中吗?Windows 支持处理位图吗?是的,但它不像应该的那样灵活或有用。我应该编写位图文件头的解析例程吗?我看了一下不同的位图头格式,然后宣称我宁愿走 500 英里。或者,在游戏中包含一个通用的图像库来处理这些格式是个好主意吗?一个好的库,例如 CXImage,提供了很多功能,这样您就可以围绕它编写一个完整的绘图应用程序。但游戏不需要它的 99% 的功能;显示图像几乎就足够了。
我决定不使用以上任何一种;相反,我创建了一个简单的图像格式。不如 BMP 或 JPG 文件复杂或花哨,但足以满足目的。每个图像都有一个头块
struct ImageHeader { DWORD ID; //image identifier char Name[32]; //internal name, useful while debugging unsigned char Type; //image type unsigned char Format; //pixel format unsigned short Width; unsigned short Height; };
游戏中的每个图像都有一个唯一的 ID;这些 ID 可以在 *game.h* 中找到(enum InGameImages
)。`Name` 在调试时可能很有用,`Format` 和 `Type` 字段稍后会很有趣。这个头块之后紧跟着图像的 16 位像素数据:(`Width * Height`)WORD
,以 RGB-565 格式存储,如这篇 文章中所述。我选择了黑色作为透明色,所以任何值为零的像素都将被绘图例程跳过。
为了从 BMP 文件构建图像(IMG)文件,我编写了一个名为 **bmp2img** 的小型 Windows 应用程序。此应用程序将文件名作为命令行参数,读取位图,将像素转换为 RGB-565 格式,在前面加上 `ImageHeader` 结构,然后将结果数据写入磁盘。在此应用程序中,我使用 CXImage 库来处理输入 BMP 格式。
好的,我已经将所有游戏图像都转换成了 IMG 文件,但有很多。所以,我决定将所有 IMG 文件打包成一个数据文件。为此,我编写了另一个也在 PC 上运行的辅助应用程序:**imgman**(图像管理器)。尽管有这个名字,这个程序并没有管理太多东西,它只是将文件名作为命令行参数并将它们捆绑到一个文件中。输出文件 *images.dat* 也以一个头开始。但这个头更简单,它包含存储在 DAT 文件中的图像数量,然后是文件偏移/大小对,这样就可以很容易地一个接一个地加载图像。您可以在 `DatLoadGameData()`(参见 *dat.cpp*)中看到加载器循环是如何工作的。对最终的 DAT 文件应用一些压缩会很有用,也许我以后会包含它。
当图像被加载时,它们会被放入一个结构中并添加到容器中。下面是 `Image` 结构的样子
struct Image { ImageHeader Header; void *Data; void *Background; unsigned char Status; };
我不仅想显示图像,还想擦除它们!擦除图像意味着显示图像显示之前的像素。如果图像类型是可擦除的(*img.h* 中的 `enum ImageTypes`),则必须为背景像素分配内存。这就是背景指针在那里。
为了处理与游戏图像相关的任务,我编写了以下辅助例程(参见 *img.cpp*)
ImgAdd()
将图像添加到内部图像容器,一个 `std::map`。仅当图像格式为 RGB-565 时才接受图像。此外,如果图像类型是可擦除的,它将为保存的背景分配适当的内存块。ImgRemove()
从容器中删除图像。ImgGet()
返回具有给定 ID 的图像结构。ImgBlit()
在给定的 x、y 坐标处显示具有给定 ID 的图像。它可以先保存背景再显示,以便可以正确擦除图像。ImgErase()
通过显示保存的背景来擦除图像。ImgBlit2()
使用颜色覆盖来显示图像。主菜单徽标的等离子效果就是这样应用的。ImgBlitPart()
仅显示图像的一个较小的矩形部分。这用于滚动效果。ImgErasePart()
擦除使用 `ImgBlitPart()` 显示的图像。
所有这些例程都使用 ZGfx
类来负责绘制到设备显示器。
关于我使用的“图像”还有几件事。基本上,游戏中的一切都是“图像”。徽标、菜单项、滚动条、球、挡板和分数 - 都是图像。我想保持简单,所以我没有使用字体和复杂的文本输出例程。例如,主菜单滚动条是以下图像
代码只是显示此图像的一部分,并带有可变的偏移量。`ImgBlitPart()` 函数负责这一点。游戏分数是从此图像动态创建的
您可以看到数字是如何在 *print.cpp* 中的 `PrintNumber()` 函数中逐个字符打印的。这个简单的函数无法进行复杂的文本输出,但实际上,游戏只需要打印数字。
这应该可以处理游戏图形,但声音怎么样?
如果您想在程序中播放单个 Wave 文件,您可能需要考虑 Windows 的 `PlaySound()` 函数。我认为 Windows 甚至有一种播放 MIDI 文件的方法,但我没有测试过。还有专门的音频库,我认为使用它们是个好主意。通常,它们带有一个方便的 API 并且文档齐全。所以,如果您体验过使用 `waveOutPrepareHeader()` 等 Windows 音频函数的乐趣,您一定会喜欢音频库。我认为 PocketPC 上两个最优秀的音频库是 Hekkus 和 FMOD。它们都可供个人免费使用;但 FMOD 支持更多文件格式(尽管在我看来,也需要更多内存)。我为这个项目选择了 FMOD,因为 Horace 为我创作的曲子最初是 MP3 格式的。所以,我编写了音频例程,并开始测试。然后,我意识到,MP3 在加载和解码时需要大量内存。实际上,它太多了。所以,我已经有了为 FMOD 编写的例程,但内存不足了。我将 MP3 转换为 MIDI 文件。它仍然听起来不错,但无论我怎么转换,FMOD 都无法识别它,尽管它应该也支持 MIDI 文件。最后,我尝试了 FMOD 支持的另一种格式,protracker MOD 格式。唉,质量又下降了一些,但我拼命想让它以某种方式工作。终于,MOD 工作了。
我将音频处理例程放在了 *snd.cpp* 中。辅助函数是
SndInit()
初始化 FMOD 库并加载曲目SndSetVolume()
设置音量SndPlayTune()
播放曲目SndStop()
停止音频输出
我没有打包这个游戏中使用的唯一声音文件。但如果有一组文件,将它们打包成一个,和/或对大的(例如 WAV)文件进行压缩是个好主意。
计时与帧
我认为游戏和普通应用程序之间有一个基本但重要的区别。让我解释一下。
应用程序,比如说 Microsoft Word,如果您不打字或不移动鼠标,会做什么?除了定期的自动保存,没什么特别的。除非您键入某些内容或单击某个地方,否则它不会更新其视图。应用程序通常响应事件(例如用户输入)。然后,它们执行特定任务(计算、绘图等)并等待下一个事件。但是,游戏必须一直更新显示,即使您什么都不做。这是一个很大的区别。游戏应该如何更新显示?想象一下老式胶片电影。胶片上有静态图像,它们是帧。当帧快速连续显示时,您会看到电影。这就是重点。游戏将通过每秒渲染 n 次静态图像(n 是帧率)来更新显示。这款游戏通过 `GameDrawFrame()` 函数(*game.cpp*)渲染一帧。要看到电影,必须定期调用此函数。有几种方法可以实现这一点
如果您想要一个固定的帧率,比如说 25,那么您会调用 `DrawFrame()`,然后等待 40ms(1000/25),然后再次调用它,在一个循环中,只要游戏运行。如果游戏中的一个对象每秒需要移动 100 像素,那么它将在每一帧中移动 4 像素。但在快速机器上,您甚至可以绘制 50 帧而不是 25 帧,这意味着您可以在每帧中移动相同的对象 2 像素,这将导致动画稍微平滑一些。另一个问题是,在较慢的机器上,帧率可能会低于固定值,这会导致游戏变慢。在我看来,使用固定帧率的缺点是它没有真正的优点 :-) 所以不要使用固定的帧率,而是让它可变。如果是可变的,它在快速设备上会更高,在慢速设备上会更低。而且,当需要绘制很多东西时,它可能会暂时下降,但这不会对游戏玩法造成太大影响。我将帧率逻辑放在了 `WinMain` 的应用程序消息循环中(参见 *zpong.cpp*)
lastupdate=0; //init variable g_running=true; //running flag, will be cleared when the user requests exit // Main message loop: PeekMessage(&msg, NULL, 0, 0, PM_NOREMOVE); while(msg.message!=WM_QUIT && g_running) { if(PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); } else { //if focus got if(g_focus>0) { passed=TimerGetDouble(); passed-=lastupdate; //time passed since last update if(passed>1.0/25) //limit framerate to max 25 FPS { lastupdate=TimerGetDouble(); //set to "now" GameDrawFrame(passed); } } } }
代码的工作原理如下:如果应用程序的消息队列中有消息,则获取并处理它。否则,如果游戏仍然具有输入焦点,它会检查系统时间,如果自上次更新以来已经过去了足够的时间,它会调用 `GameDrawFrame()`。`passed` 和 `lastupdate` 是 `double` 变量。`TimerGetDouble()` 函数返回高分辨率系统计时器(性能计数器)的值,转换为秒(参见 *timer.cpp*)。请注意 `GameDrawFrame()` 有一个参数
void GameDrawFrame(double frametime);
这样,绘图代码就知道自上一帧以来过去了多少时间,并且它可以根据当前的帧率以更慢或更快的速度移动对象。从左到右以每秒 100 像素的速度移动对象非常简单
object.x += 100 * frametime;
后续的 `frametime` 值将累加到每秒 1.0,对象将移动 100 像素。就这样!
计时很重要,不只是在功夫格斗中,如果您还记得 Carl Douglas 的一曲成名。对于“完美计时”,不应使用 `Sleep()` 或 `GetTickCount()` 等函数,因为它们不够精确。相反,请使用性能计数器函数 `QueryPerformanceFrequency()` 和 `QueryPerformanceCounter()`。
游戏状态和菜单
状态的概念是您在“普通”的、事件驱动的应用程序中不一定需要的东西。现在,我们知道游戏应该有一个 `DrawFrame()` 函数,它将每秒调用 n 次来处理一个时间步长。看看下面这块代码
void DoWork() //this function handles one step { switch(g_state) { case 0: //perform tasks for state 0 ... //check if there is a transition if(some_condition == true) g_state=1; //change state break; case 1: //perform tasks for state 1 ... //check if there is a transition if(some_condition == true) g_state=0; //change state break; } }
`DoWork()` 函数将定期调用(就像 `DrawFrame()` 一样!)。它以一个 `switch` 语句开始。根据当前状态,它执行不同的、特定于状态的任务。然后,它检查所谓的转换条件是否为真,如果为真,则更新状态。显然,如果发生转换并且状态发生变化,那么下次调用该函数时,它将执行另一个状态的代码。这就是想法。因为游戏只有一个函数来完成所有的绘图工作(即 `GameDrawFrame()`),并且因为游戏有不同、易于区分的任务,所以使用“状态机”并熟悉“以状态思考”是个好主意。(如果您对计算理论感兴趣,我建议您在 Google 上搜索确定性自动机和状态机。)
我已将程序任务划分为以下状态(*game.h* 中的 `enum GameStates`)
gsMenu
绘制主菜单gsGame
处理游戏玩法gsInGameMenu
处理游戏过程中点击屏幕时弹出的菜单gsLifeLost
绘制生命损失动画gsGameOver
是游戏结束序列
游戏开始时,初始状态为 `gsMenu`。此状态绘制并制作主菜单动画。菜单提供技能选择,可以选择开启/关闭音乐等。在此状态下,它还会使屏幕上的多个球弹跳。看看 `GameDrawFrame()` 中的 `case gsMenu` 处理程序。首先,它擦除屏幕上的一切,然后更新每个对象的位置(徽标、菜单项、球、滚动条),然后重新绘制一切。重要的是要知道擦除是按相反顺序完成的,所以先显示的对象最后被擦除。菜单项的正弦波效果使用一个预先计算的正弦值数组。这样,就不需要一直调用缓慢的 `sin()` 函数了。参见 `GameUpdateMenuItemPositions()`:正弦数组索引只是使用 `frametime` 值更新。ZPONG 徽标上的等离子效果使用了这篇 文章中的代码。
在此状态下唯一可能的转换是通过“开始游戏”菜单项进入 `gsGame` 状态。如果玩家选择“退出”,则清除全局 `g_running` 标志,结果是消息循环退出。
`gsGame` 状态是最复杂的,因为它实现了整个游戏逻辑。稍后我们将详细了解它。此状态下可能发生的转换
- 如果用户点击屏幕,状态将更改为 `gsInGameMenu` 并弹出游戏内菜单
- 如果玩家错过了所有球,则生命将丢失。为了处理这种情况,状态将更改为 `gsLifeLost`。
`gsInGameMenu` 状态绘制一个简单的菜单,提供两个选择:继续游戏或退出到主菜单。因此,可能发生的转换是进入 `gsMenu` 或返回 `gsGame`。
在 `gsLifeLost` 状态中,一个动画消息弹出,告知玩家出了问题。然后,如果所有生命都已丢失,则进入 `gsGameOver` 状态。否则,返回 `gsGame`。
此图提供了状态和转换的概述
现在,是时候详细了解游戏逻辑了。
游戏逻辑
`GameDrawFrame()` 中的 `case gsGame` 处理程序以一个 `if` 语句开始。它检查一个布尔变量 `gameplay_inited` 以查看游戏玩法是否已初始化。如果为 `false`,它会显示游戏背景图像,清除玩家分数,将生命数设置为 3 ... 即执行初始化函数,然后将 `gameplay_inited` 设置为 `true`。我更喜欢这种 `bool` 控制的方法来进行一次性初始化任务。如果游戏初始化已完成,处理程序会采样输入(键盘和屏幕点击),以查看是否应弹出游戏内菜单。如果是这种情况,它会将状态更改为 `gsInGameMenu`。否则,它会调用 `GameUpdateGame()` 来完成实际的绘图工作。此函数使用与主菜单代码相同的方法:擦除除背景外的所有内容,更新对象,然后重新绘制所有内容。第一步是通过以下调用完成的
GameEraseLives(); GameEraseScores(); GameErasePaddles(); GameEraseBalls(); GameErasePowerUp();
擦除球、挡板和道具很容易,因为 `ImgErase()` 函数可以处理静态图像。但正如我之前解释过的,“生命”和“分数”是动态创建的,来自包含数字的静态图像。因此,无法擦除分数,因为根本没有分数图像。嗯,听起来很复杂。为了解决这个问题并使动态构建的图形可擦除,我添加了特殊的图像,称为占位符(`igiLivesPlaceHolder`、`igiPlayerScorePlaceHolder` 等)。这些图像是空白的(所有像素为黑色),并且尺寸与生命、分数等图形相同。当在它们上调用 `ImgBlit()` 时,不会显示任何内容,但背景会被保存,因此任何绘制在那里内容都将变得可擦除。看看 `GameDrawLives()` 函数
void GameDrawLives() { char str[32]; ImgBlit(igiLivesPlaceHolder, GAME_W-33, GAME_H-50); sprintf(str, "%d", lives); PrintNumber(str, GAME_W-33, GAME_H-50, RGB_TO_565(255,255,255)); }
首先,它显示占位符(它是空白的),然后以白色打印生命数量。很明显,为了从屏幕上擦除生命数量,代码只需擦除占位符
void GameEraseLives() { ImgErase(igiLivesPlaceHolder, GAME_W-33, GAME_H-50); }
很简单,不是吗?现在是更新游戏对象的例程。挡板没什么复杂之处,它们有一个水平位置属性(`player_x` 和 `comp_x` 变量)。游戏使用 `GetAsyncKeyState()` API 函数(参见 *input.cpp*)采样键盘输入,并在 `GameUpdatePlayerPaddle()` 函数中相应地更新玩家挡板。计算机挡板有一个简单的机制:如果有一些球向上移动,代码会选择最近的一个并向其移动挡板。否则,它会将挡板移回中心。`GameUpdateCompPaddle()` 函数负责这一点。
球和道具具有特殊的属性,因此有必要围绕它们进行封装。有两种结构可以处理这个问题:`ball` 和 `powerup`(参见 *ball.h* 和 *powerup.h*)。
球具有 x、y 位置和 x、y 速度,以及与之关联的图像 ID。它们还有一个状态值和一个移动样式。状态可以是 `bsStillInGame`、`bsLeftUp` 或 `bsLeftDown`。这些状态值由球位置更新函数设置:`Update()`、`UpdateSine()` 和 `UpdateCircle()`。如果这些函数将状态设置为 `bsStillInGame` 以外的值,则意味着有人得分了,因为对方错过了球。碰撞(修改球速度向量)不由 `ball` 对象本身处理,因为球不应该访问其他球的属性。球也有自己的 `Blit()` 和 `Erase()` 函数。
道具的属性较少,只有 x、y 位置和 y 速度,外加一个图像 ID。支持函数是 `Blit()`、`Erase()` 和 `Update()`。道具效果由游戏本身处理,因为此对象也不应干扰其他游戏结构。
球的碰撞由 `GameCheckBallCollisions()` 函数处理。它在 `for` 循环中使用经典的 `OVERLAPS` 宏来检查球与球之间的位置。为了避免重复检查,它在处理时为球设置 `collision_tested` 标志。如果球发生碰撞,它们可能改变移动样式为正弦波或圆形。
游戏根据技能水平和玩家挡板击回的球数量生成球和道具。我添加了三个道具:红色的将所有球移回电脑挡板,绿色的临时提高玩家速度,黄色的通过在球靠近玩家时将其推开来提供力场效果。它们是这样的
黄色和绿色的有超时值,而红色的效果是一次性的。使用 `frametime` 值处理超时非常简单。我们所需要做的就是从道具的 TTL(剩余寿命)中减去当前的 `frametime` 值,如果 TTL 达到零,则超时。道具效果由 `GameDoPowerUpStuff()` 函数处理。我有个好消息。只剩下几件事了!
代码通过在 `for` 循环中调用 `GameUpdateBall()` 来更新球的位置。此循环还处理其他几个事项:它检查球的状态值并移除离开屏幕的球,如果球被错过则得分,并在球碰到挡板时使其反弹。
完成所有处理后,代码会检查游戏状态是否应更改。也就是说,如果所有球都消失了,并且最后一个球被玩家错过了,状态将更改为 `gsLifeLost`。
最后要做的是通过重新绘制所有内容来更新显示
GameDrawPowerUp(); GameDrawBalls(); GameDrawPaddles(); GameDrawScores(frametime); GameDrawLives();
请注意,绘图例程是按相反顺序调用的。第一个被擦除的是最后一个被绘制的。否则,彼此靠近的对象将显示不正确。
结束语
游戏确实很复杂!所以,在您开始实际编码之前,肯定值得仔细规划每一件小事。如果您有可重用的、特定任务的代码,例如图像或音频处理,也可以节省大量时间和精力。演示项目包含游戏运行所需的一切:EXE 文件、FMOD DLL 文件和数据文件。为了使源代码包更小,我没有包含 FMOD 链接库(*fmodce.lib*)和 CXImage LIB 文件。如果您想编译项目,请遵循 *readme* 文件中的说明。
玩得开心!