Re0:提瓦特幸存者
Re0:从零开始的C++游戏开发
前言:本笔记是Chaos观看B站upVoidmatrix的课程从零开始的提瓦特幸存者的个人笔记;所用为适合新手入门的easyx图形库。
第一集 追鼠标の小球
1 |
|
绘图坐标系
easyx中,绘图坐标系相似于二维数组坐标系,即y轴的反转
渲染缓冲区
渲染缓冲区:类似画笔在画布上画画,先绘制的内容就可能被后绘制的内容覆盖掉
cleardevice()
就是使当前填充颜色将画布覆盖,默认填充色为黑色
当我们调用solidcircle()
这类绘图函数时,一个无边框的填充圆被“逐渐地”绘制到这张画布上
当我们不断清屏,不断画圆,逐渐地过程在“宏观上”体现出来了
而当我们调用BeginBatchDraw();
easyx为我们新建一个渲染缓冲区,不同于窗口的渲染缓冲区,它默认是不可见的,随后执行的所有绘制都将在新的画布上进行
而当我们调用FlushBatchDraw();
或EndBatchDraw();
时,easyx会将窗口所显示的缓冲区和新建的缓冲区进行“迅速”交换,这样的交换迅速到我们不会因绘图过程频繁而导致闪烁
游戏框架
*主循环
在上述程序中,我们通过一个while(true)
死循环阻塞程序退出,同时不断执行清屏和绘制的操作,这其实就是游戏框架最核心的部分——主循环
在主循环中,我们不断读取玩家的(鼠标、键盘等)操作,将这些操作翻译成我们的数理逻辑,最后再根据现有的数据将画面内容绘制出来
简而言之,就是读取操作、处理数据、绘制画面这三大要素
1 | 初始化(); |
如上述代码,我们游戏的渲染部分只依赖于当前的数据,依旧是变量x和y的值,而与如何处理得到这些数据的处理逻辑并未有直接关系。这就是软件工程理论中的”解耦耦合“,或者说这就是”数据驱动”,或者说“渲染与逻辑分离”中最朴素的思想。
当然,在主循环开始之前,我们需要把主循环过程中所需要的数据初始化,如:将圆的位置坐标初始化、初始化窗口等。
而在主循环结束后,需要对游戏程序使用的资源进行释放。
第二集 进击の井字棋
三大元素
在代码编写之前,
我们首先根据前面所讲述的游戏框架,思考在井字棋的主循环中三大要素如何设计实现。
1 | 初始化(); |
读取操作:
在本程序中,我们只对鼠标输入进行考虑,所以我们只需对鼠标按键按下的消息进行处理:当鼠标点击在空白的棋盘网格时,便执行落子操作。
数据处理:
我们只需要对游戏的胜负条件进行检测即可,游戏结束的条件是同类型三颗棋子连成一条直线或棋盘被填满。
游戏结束时,使用弹窗告诉玩家游戏结果,然后退出主循环。
绘制画面
网格棋盘:使用
line()
函数绘制直线将窗口等分为3X3
的网格X棋子
:使用line()
函数绘制连接网格对角线的两条直线O棋子
:使用circle()
函数绘制圆心在网格中心的无填充原型除此之外,我们应会在窗口左上角输出一行文字
当前妻子类型:X
,用以告诉玩家当前被放置的棋子类型。
数据结构
接下来,便是考虑如何组织游戏的数据结构。
棋盘:
显而易见,我们可以使用二维数组来表示棋盘。我们将二维数组中每个元素类型设置为
char
类型,再约定'X'字符
表示叉号棋子、'O'字符
表示圆形棋子、'-'字符
默认值表示网格中没有棋子游戏结束条件
2.1 某玩家获胜的情况
我们著需要对
'X'字符
、'O'字符
进行穷举,可能出现的情况一共有8种:分别是横向的三行棋子出现同类型符号、竖向的三行棋子出现同类型符号和两条对角线的棋子出现同类型符号。2.2 两玩家平局的情况
即没有玩家获胜的情况,也就是说数组中的每一个元素均不是
'-'字符
,即可判定玩家平局。
代码编写
到现在,在我们的思路已经十分明晰后,我们着手编写代码。
在代码编写的过程中,我们同样遵循先框架后细化的思路。
我们先把上述思路转变成代码,细节部分先使用注释进行替代,再将每一部分的注释替换为代码。这样可以确保我们在编写代码的过程中不会被突然出现的代码细节打扰。
实现如下:
1 |
|
值得注意的是,在程序运行时的程序占用率过高,通过任务管理器也可以发现一个小小的井字棋游戏,CPU占用率甚至已经超过了电脑中的绝大部分软件。这是因为计算机在执行while循环
时速度较快,我们编写的主循环在顷刻间已经执行完了成千上万次,占用了大量的CPU时间片。对于大部分物理刷新率仅有60Hz
的显示设备来说,这无疑是一种性能浪费。所以我们可以使用Sleep();
函数来让程序在执行完一次循环后休眠一小段时间,从而减少计算资源的浪费。
在大多教程中,这里或许会简答粗暴的写一句Sleep(15)
,来让程序在每一次循环结束后强制等待15毫秒。但是,这种设计是不太合适的,随游戏体量的增大,程序每次执行主循环所执行的计算任务可能是不同的,以及涉及到操作系统CPU计算资源的分配,这就导致每次执行主循环所实际消耗的时间可能是不一样的。所以我们需要根据每一帧执行的实际耗时,动态的计算在这之后要休眠多长时间,这是引入一个新的函数GetTickCount()
。我们可以使用它来获得程序自运行开始以来到现在的毫秒数DWORD time = GetTickCount();
。
1 | 初始化(); |
第三集 提瓦特の幸存者
3.1 程序动画实现及角色移动
在开始之前,我们应该认识到,尽管A我们可以通过点线面绘制简单的画面,但是想要只用这种矢量绘图的方式完成游戏内全部素材是远远不够的。想要绘制一个简单的人物就要洋洋洒洒300+行代码,那更不用提什A么画质精美的3A大作了。
所以,使用经过专业绘图软件(如:PS等)处理的位图素材是必不可少的。位图素材也就是我们常说的图片资源素材。
那么,我们如何在Easyx中加载并渲染图片资源呢?我们查看文档就可发现,Easyx使用了一个叫做**IMAGE
的类来表示图片对象;而加载图片使用一个叫做loadimage
的函数,这个函数负责将图片文件数据加载到IMAGE对象
中**、或者直接将图片加载到绘图窗口中,同时这个函数还有一个重载,用以从资源文件中加载图像。
加载图片完成后,就是如何渲染图片,这里使用**putimage
函数**。putimage
函数同样有两个重载。
所以整套图片绘制的流程就是:
1 | IMAGE img; |
掌握这两个函数后,我们就可以开始编写代码了。
在一切开始之前按照先前所讲述的,将游戏框架写出来。
1 |
|
现在就可以将背景绘制在窗口中了:首先,将素材文件copy到工程目录下。需要注意的是,VS在调试时使用的相对路径、根目录和新建代码的默认位置相同。
在加载渲染好背景图片后,就到了我们的重点——如何让画面”动“起来?
在游戏开发技术中,角色动画的常见实现可以笼统的分为两类:序列帧动画和关键帧动画。序列帧动画通常由一组图片素材组成,我们在程序中随着时间的推移不断切换显示这一序列的图片,借助视觉暂留效应,便有了动画效果;而关键帧动画如骨骼动画等往往涉及到更复杂的图形学技术,在此暂不作讨论。
现在我们使用一组二次元人物图片作为游戏素材,要想实现每个一段时间切换一张图片显示,该如何处理呢?
3.1.1 动画实现
我们或许会想到Sleep()
函数,例如:我们希望在一秒钟切换10次图片,那么只需要写下Sleep(100);
这样的代码就可以了,吗?但是,我们在之前提及过,当调用Sleep()
函数时,程序会卡在这里等待对应的时间,这是一个”阻塞式“的行为;而在我们的游戏框架设计中,所有的画面渲染等操作,都应该在一次又一次的循环中进行,每次循环的时间都应该控制在1/60秒内,也就是说,我们切换动画轮播的任务,应该分摊在多帧之间进行,而不是在单次循环内一次性解决。
这就触及到我们游戏编程的一个核心思想:主循环内应尽量避免阻塞式的行为或过于繁重且耗时过长的任务。具体可以进入“高性能”编程领域深入学习。
为了确保动画序列帧的能够间隔固定的时间进行切换,我们这里类比定时器的概念实现一个计数器。
首先,定义idx_cur_anim
变量来存储当前动画的帧索引;再定义一个counter
用来记录当前动画帧一共播放了几个游戏帧,这里使用staic
修饰计数器,保证计数器只在第一个游戏帧时被初始化为0,我们不妨每5个游戏帧切换动画帧。
随后,我们还要考虑到动画帧序列播放结束后的行为,我们希望动画是循环播放的,也就是当动画的帧索引到大帧总数时,将索引重置为0。
1 | const int PLAYER_ANIM_NUM = 4; |
这样,我们就完成了动画的数据逻辑部分,接下来就是动画的渲染部分。
在这之前,我们首先应该像加载背景图片那样将动画的每一帧图片都加载到程序中。定义LoadAnimation()
函数。我们将图片规律命名,这样就可以使用循环加载图片。在使用**Unicode
字符集**的情况下,我们可以使用wstring
来拼凑出文件路径,进而传递给loadimage()
函数,将图片加载到数组中。
现在来到游戏框架中的画面渲染部分,之前定义的动画帧索引这时便可以当作IMAGE数组的索引来使用。
但运行程序我们会发现,虽然人物动画轮播功能是正常的,但人物的周围套上了黑黑的边框。看起来图片的透明区域并未发生作用,这是因为putimage()
函数在渲染过程中,并没有使用IMAGE对象的透明度信息,所以我们想要绘制类似这种带有透明度的图片素材,就要自己处理这部分逻辑。这里,我们类比putimage()
函数封装一个putimage_alpha()
函数。
1 | // 实现透明通道混叠 借助系统绘图函数的比较轻巧的实现 |
再次运行程序,就可以发现动画被正常渲染了。
3.1.2 角色移动
接着,我们来实现键盘控制角色移动的功能。
我们首先定义POINT
类型的player_pos
变量用来存储玩家的位置,记得将玩家坐标初始化。随后将动画渲染的位置更改为player_pos
变量的位置。
这时,只需要在事件处理部分根据按键修改player_pos
的值,就可以实现角色的移动。
我们只需要对键盘按下的消息进行处理,定义PLAYER_SPEED
常量表示玩家速度,并约定使用方向键控制玩家移动。
1 | /*... |
关于键码对照表可以查看微软官方文档。
运行程序,我们可以发现角色可以移动了,但人物的移动“手感”有些奇怪。当我们按下方向键,角色向着对应的方向抽搐了一下,一段时间后才进行较为连贯的移动,在连续移动的过程中顿挫感也十分明显。
出现此等原因主要有二:1.首先是持续按下一小段时间后才开始连贯移动的问题。这是因为当我们按下方向键时,会首先有一个WM_KEYDOWN
消息进入消息事件队列中,随后,当我们我们保持按键按下状态一段时间后,才会有接连不断的WM_KEYDOWN
消息被触发;2.然后是移动过程中的卡顿问题。这是因为WM_KEYDOWN
消息的产生是与我们的主循环异步进行的,且触发的频率与操作系统和硬件设备相关,这就导致在有些游戏帧中事件处理部分对多个WM_KEYDOWN
消息进行了处理,而在其余游戏帧中WM_KEYDOWN
消息较少或没有,这就导致角色在某些游戏帧中前进的距离较远/近一些,在宏观上展现为移动过程中的卡顿感。
解决问题就要理清思路,我们抽象地总结实际的功能需求:当按键按下时,我们要确保在每一个游戏帧中都连贯的移动相同的距离;从玩家的行为角度讲,也就是玩家按下按键时,WM_KEYDOWN
消息触发,标志角色开始移动;而当玩家按键抬起时,WM_KEYUP
消息触发,标志移动结束。
那么我们的解决方案就明晰了。我们首先定义4个bool
变量分别标志玩家是否向对应方向移动。在事件处理部分,不直接对玩家的位置数据进行操作,而是设置这些布尔变量的值,按键按下设为true
、按键抬起设为false
。在数据处理部分,我们再根据这些布尔变量的状态确定是否对玩家的位置进行处理。
1 | /*... |
3.2 敌人随机生成和索敌逻辑实现
3.2.1 动画类实现
到目前为止,我们已经实现了人物面向左的动画,那么面向右的动画同理:定义IMAGE数组,加载图片到IMAGE数组中,然后在主循环中使用计数器来更新动画的帧索引,最后在绘图阶段将对应帧索引的图片绘制出来。但是这样一来,我们就有两部分能极度相似的动画播控代码了,若后续仍有动画加入到游戏中,我们就还要讲这些代码再写一遍,这就造成了代码冗余。
我们所使用的不同动画之间的区别,无非只是加载和显示的图片不同,而其中更新帧索引和绘制的部分都是完全一样的代码。
于是,我们可以将动画封装成结构体或类,相同的逻辑封装成成员方法,不同的部分使用参数传递。没错,这就是面向对象的3大特性之一的封装。
我们这里定义**Animation
类,用来封装动画相关的数据和逻辑。接下来,我们在填充类的细节的时候,要考虑的就是有哪些数据和功能放在类内部**。
首先是动画的图片加载。考虑到动画所包含的图片帧数量可能是不同的,需要动态的为图片对象序列分配内存,所以这里使用动态数组(向量)vector
容器来代替我们常见的数组。
vector
容器是STL(标准模板库,Standard Template Library)中的内容,STL提供了许多方便我们开发中使用的工具。
为了避免不必要的拷贝构造,我们将vector
内部存储的元素定义为IMAGE类型的指针:vector<IMAGE*> -> IMAGE*[]
。这里,二者的主要区别是,vector
是一个根据元素数量动态增长的容器,而不需要像数组那样一开始便固定其容量大小。
我们将动画帧序列的
vector
容器定义为私有成员;加载图片的部分自然就需要放在构造函数里面。这里抽象一下加载动画所需要的参数
Animation(LPCTSTR path,int num,int inteval)
分别是:图片文件包含的路径、当前动画所使用的图片数量和帧间隔(由于在目前的动画中,帧与帧之间的时间间隔是固定的);循环加载图片。由于我们使用的图片素材命名都十分规律,所以可以直接将路径参数当作字符串格式化的模板;最后,我们将图片对象的指针添加到
vector
容器中,即:1
2
3
4
5
6
7
8TCHAR path_file[256];
for(size_t i = 0;i < num;i ++)
{
_stprintf_s(path_file,path,i);
IMAGE* frame = new IMAGE();
loadimage(frame,path_file);
}注意:由于我们的
vector
内部存储的元素定义为IMAGE类型的指针,所以我们这里使用了new
关键字来开辟内存。
🔺很多人在初学时会忽略掉有关内存管理的问题,所以会养成内存泄漏的坏习惯。我们在这里使用了new
关键字,那么我们就要马上警惕起来,在哪里使用delete
释放掉内存?
就像在C语言中每个malloc
对应一个free
一样,在CPP中,我们要养成习惯去检查每一个new
也要对应一个delete
。
所以在析构函数中,我们需要遍历vector
的每一个元素,一次将它delete
掉。
1 | class Animation |
3.2.2 角色移动优化
然后是动画播放的部分。我们定义**Play
函数**,暂时将帧索引更新的逻辑和渲染的代码都放置到函数中,这样我们就需要传入x
和y
两个参数来表示动画当前渲染的位置,最后我们还定义一个参数delta
,用来表示距离上一次调用Play
函数过去了多久时间。这其实已经将我们之前的动画计数器的思路转变为了计时器的思路。
那这两种思路之间有什么区别呢?
一般来说,一个动画的播放速度也就是帧间隔,应该是与实际时间有关的,而不是与游戏的帧率有关,我们希望的是无论游戏帧的频率有多快,动画的播放速度是一致的,而不是画面刷新越快,动画播放越快,这样整个游戏画面就如同开了“倍速”一样。所以使用与实际时间有关的定时器,会比每一下调用都累加一次的计数器更能满足这种需求。
我们只需要在每次调用Play
函数时,对timer
计时器变量增加对应的时间,如果定时器到达帧间隔,那么就切换动画图片到下一帧,同时重置定时器的值。最后通过我们之前定义好的putimage_alpha
函数绘制当前动画帧,这是我们就可以使用简洁的代码来加载玩家角色向左向右的动画了。
1 | Animation anim_left_player(_T("img/player_left_%d.png"),6,45); |
我们定义DrawPlayer
函数用以绘制玩家动画。由于需要左右翻转还需要传入玩家当前在x轴上的移动方向;动画实现左右翻转的逻辑也很简单:我们定义facing_left
静态布尔变量,表示玩家动画是否面向左侧。在绘制动画时,只需要根据facing_left
的值判断当前绘制的是向左还是向右的动画即可。
1 | void DrawPlayer(int delta,int dir_x) |
为了让玩家再游戏画面中更加醒目,我们考虑在玩家的脚底添加阴影效果。阴影的实现同样也是使用图片素材IMAGE img_shadow
,将它添加进来,并绘制在玩家的脚底。我们定义三个常量用来存储玩家的图片尺寸和阴影宽度。
1 | const int PLAYER_WIDTH = 80; |
在计算阴影的水平位置时,我们考虑将阴影居中,int pos_shadow_x = player_pos_x + (PLAYER_WIDTH / 2 - SHADOW_WIDTH / 2);
;在计算竖直位置时,我们将它放到玩家脚底偏移一小段的地方。然后在绘制玩家动画之前绘制阴影图片。
1 | void DrawPlayer(int delta,int dir_x) |
现在玩家所有的移动功能都已经完成了。但是,移动手感似乎有些奇怪,玩家在斜向移动的时候速度快一些。
在处理玩家移动的代码中,我们发现,当我们同时按下处于x和y轴两个方向的按键时,玩家的位置坐标就在这一帧内向着两个方向都移动了一个单位距离,由勾股定理,这就导致这一帧玩家的位移距离是根号二倍的速度,于是就有了斜向移动更快的现象。我们这里可以通过if else
来判断当玩家按下处于x和y轴两个方向的按键时,x和y坐标改变的数值变为PALYER_SPPED/根号2
,但是要处理多种组合的情况,使用if else
有些过于冗长,所以我们这里只需要借用一点点向量运算的知识,来确保玩家每次位移的大小都是相同的。
只需要确保运算时的速度方向向量是单位向量即可。
这里使用double
来尽可能避免浮点数和整形互相转换的精度丢失问题,可是依旧会有小问题。
1 | int dir_x = is_move_right - is_move_left; |
再次运行程序,玩家的移动速度在各个方向上都确定了。
在玩家移动上,我们还有一个细节仍待处理:我们需要玩家始终处于画面内,也就是说玩家动画所在矩形必须位于1280X720尺寸的窗口内部。
1 | const int WINDOW_WIDTH = 1280; |
再根据按键输入更新玩家的位置后,我们就还需要对玩家的位置进行校准。
1 | if(player_pos.x < 0) player_pos.x = 0; |
3.2.3 玩家类和敌人类实现
我们使用野猪🐗表示敌人,野猪同样也有面向左和右两套动画,从代码设计角度考虑讲,让这些野猪Animation对象与玩家的Animation对象混杂在一起显然不好。
所以我们再次使用封装这一特性,将玩家的逻辑封装到Player
类中,而与敌人相关的逻辑就封装在Enemy
类中。虽然我们可以将玩家和敌人共同的逻辑抽象出来,定义更基础的类,如Character
角色或GameObject
游戏对象,这些设计会涉及到面向对象中另两大特性——继承和多态。
但,目前我们不使用继承和多态进行实践,而仅使用封装来确保数据和逻辑。
首先,玩家类Player
的实现。
我们首先将和玩家类相关的散落在外部的成员变量和常量放在Player类
中;然后定义ProcessEvent
函数来处理玩家的操作消息、定义Move
函数来处理玩家的移动、定义Draw
函数来绘制玩家;然后将对应逻辑的代码移动到函数内部,注意一些微调整。最终玩家类的代码就完成了。
1 | class Player |
然后,是子弹类Bullet
的实现。
其中并没有太多复杂的数据和逻辑。成员变量只需要有位置信息,而在渲染方法Draw
里面我们使用橙红色填充圆来进行绘制。
1 | class Bullet |
随后,是敌人类Enemy
的实现。
我们仿照着Player类定义Enemy类。
接下来便是设计敌人类中的成员方法,不过在此之前,我们要先想清楚敌人的行动逻辑:我们希望敌人从地图外的随机位置刷新出来,并向着玩家移动,敌人触碰玩家时会对玩家造成伤害游戏结束;敌人触碰到玩家周围的子弹时会消失。
首先是敌人的刷新机制,即敌人在生成时随机初始化自己位置。所以此逻辑要放在构造函数中。地图有四条边,这里定义SpawnEdge
枚举用以标识敌人出生的边界,使用随机数%4后便可实现随机地图边界的效果。接下来是地图的坐标值,分类讨论,对于上边界它的x位置坐标应该是随机的,其他同理推出。
1 | //敌人生出边界 |
然后是碰撞向相关函数,检测与子弹发生的碰撞传入Bullet
参数,检测与玩家发生的碰撞传入Player
参数。
1 | bool CheckBulletCollision(const Bullet& bullet) |
Move
函数需要始终追寻玩家移动,所以传入Player
参数。
值得注意的是,我们传入的参数都应添加引用这与使用指针进行参数传递类似,都是为了避免在传入参数过程中对传入的对象进行了不必要的拷贝构造;同时,又添加const
限定符来避免在函数内部不小心对参数进行了修改,这是一个好习惯。
Move
函数实现逻辑与玩家类十分相似,我们只需要将玩家的位置与敌人的位置进行作差,即可得到敌人需要移动的向量。
1 | void Move(const Player& player) |
随后是绘制敌人的Draw
函数,也与玩家类中的实现十分相似。由于敌人始终处于移动状态,所以我们不需要使用static
变量静态保存无移动时的动画翻转状态。
1 | void Draw(int delta) |
游戏中的面向对象的设计已初具雏形,现在我们要考虑的是:如何在主循环实例化这些对象。
玩家对象全局只有一个,所以我们定义在主循环外部;在消息处理部分,调用Player
类的ProcessEvent
方法处理玩家在操作事件;在数据处理部分调用Move
进行移动;在渲染部分调用Draw
来绘制画面。
由于游戏中敌人数量也是动态的,所以我们这里同样使用vector
来存储Enemy
的对象指针。在主循环中,我们使用一个定义TryGenerateEnemy
函数来生成敌人,为了简单起见,函数内置了一个计数器,当到达指定时间间隔便向容器中添加新的敌人。在数据如理部分,我们遍历vector
中的每一个敌人依次调用Move
方法,在渲染部分也依次调用Draw
方法。
(PS :最后,我们会发现Move()
函数中,角色位置改变运用了浮点型强转为整形所造成精度丢失,是玩家和敌人的SPEED范围有限,这是一个问题。)
3.3 2D碰撞检测和音效播控
3.3.1 子弹碰撞逻辑
在前面的代码中,我们已经定义了子弹类,也定义了敌人与子弹和玩家的碰撞方法。但实际的碰撞逻辑仍未实现。
首先是敌人和子弹的碰撞。在CheckBulletCollision
方法中,我们将子弹等效为一个点。如果想要检定这两者的碰撞,那么只要检测这个点是否在敌人所处矩形内。而判断二维平面内点在矩形内十分简单。
1 | bool CheckBulletCollision(const Bullet& bullet) |
然后是敌人和玩家的碰撞检测,这就涉及到两个矩形之间的相交检测。但是,如果我们将二者的碰撞模型抽象成两个矩形的相交,我们考虑这样一种极端情景:敌人的位置处于玩家的对角线方向处,此时二者并未重合,但是数据逻辑上却判定为碰撞,这会让玩家非常困惑。所以,在大部分2D游戏作品中的程序设计中,对于这类受击碰撞箱,其实是要小于玩家所在的碰撞箱。考虑到我们的游戏敌人的尺寸并不大,以及这类割草游戏中对于碰撞的检定不应过于严格,所以我们以敌人的中心点位置作为其碰撞坐标,只有当敌人的中心点在玩家矩形箱内,二者才发生碰撞,敌人才能对玩家造成伤害。所以在CheckPlayerCollision
方法中,我们首先应该计算出敌人的判定点位置,随后再判断这个点是否在玩家当前所在矩形内。注意,由于我们这里需要获取玩家的实时位置,所以我们扩展了玩家类的方法,为它提供了一个GetPosition
方法来返回玩家当前坐标。
1 | const POINT& GetPosition() const |
最后,我们还需要在主循环中遍历敌人列表,依次检测他们是否与玩家发生了碰撞,当二者发生碰撞时,我们弹出提示信息,结束游戏。
接下来,我们要让子弹显示在画面中了。
首先定义子弹vector
并初始化它的长度为3。随后,我们定义UpdateBullets
函数用以在主循环实现子弹实时跟随玩家的逻辑。
三颗子弹只是均匀做圆周运动太过死板,所以我们让子弹有除去圆周运动的切向速度外,还有一个不断波动的径向速度;在视觉效果上,这些子弹会围绕着玩家进行时近时远的圆周运动。
我们首先定义子弹的径向速度和切向速度。切向速度决定了圆周运动的快慢,而径向速度决定了子弹距离玩家时近时远波动速度。计算子弹之间的角度间隔也十分简单。最后我们遍历子弹列表中的每一个子弹,根据玩家当前的位置依次修改它们的位置。
1 | void UpdateBullets(std::vector<Bullet>& bullet_list, const Player& player) |
随后,我们要编写敌人受击消失的逻辑了。
为了更通用的设计,我们给Enemy
类中新增了两个方法Hurt
和CheckAlive
,同时,新增alive
布尔变量标识敌人当前是否是存活状态。
Hurt
方法为受击方法,当敌人收到攻击时便会调用;CheckAlive
方法为敌人存活检测方法,函数直接返回alive
成员的值,用来在类外获取当前敌人存活状态。
在Hurt
方法中,常见的思路是递减敌人血量,这里我们使用最简单的方法,让敌人一被击必杀。
那么在主循环中,当CheckBulletCollision
成功时,便要调用敌人类的Hurt
方法,而在碰撞检测结束后,我们还需要遍历现存的敌人列表,依次检查已经被击杀的敌人,并将它们从游戏中删掉。
1 | for (Enemy* enemy : enemy_list) |
这里,我们使用swap
和pop_back
组合技来实现从vector
中删除元素,这是一种在元素次序无关时性能较好的删除方法,原理十分简单。
1 | for (size_t i = 0; i < enemy_list.size(); i++) |
仿照其他游戏,得分机制是必不可少的。
所以,我们定义score
变量记录玩家得分,并定义DrawPlayerScore()
绘制得分。
1 | // 绘制玩家得分 |
这样,我们的游戏基本上已经完成,但总感觉不得劲,哎?没错,音效和音乐。
3.3.2 音效音乐播控
这里提供一种较为简单轻巧的音乐播放实现方式:mciSendString
。
我们首先需要将mus
文件素材放置对应目录下,随后在代码种来链接对应的库#pragma comment(lib,"Winnm.lib")
。
这个函数的前三个字母mci
代表了Media Control Interface
即:媒体控制接口。它的作用更像是我们对Windows
系统发号施令,我们把字符串形式的命令告诉系统,让它们来帮我们播放声音。
我们只需要关注第一个参数,也就是我们要对Windows
系统发出的“命令”,当我们加载背景音乐的时候,我们可以编写这样的代码:
1 | mciSendString(_T("open mus/bgm.mp3 alias bgm"), NULL, 0, NULL); |
这样便是告诉系统:你要把mus
目录下bgm.mp3
文件加载到程序中,并且在后续的命令中,我们给这个音乐取名为”bgm
“。
当我们需要播放背景音乐时,我们呢=只需要写下这样的代码:
1 | mciSendString(_T("play bgm repeat from 0"), NULL, 0, NULL); |
这样就是在和系统说:现在我要播放先前已经追备好的名为bgm
的音乐,并让它从头循环播放。于是,背景音乐便添加完成。
那么子弹击中敌人的音效也十分简单,我们编写相似的代码加载hit.wav
文件并取名为hit
,并在子弹碰撞时播放,注意此时并未添加repeat
命令,来确保受击音效不会出发后永无休止的循环播放下去。
至此,游戏内部数据逻辑已全部完成,除去一些卡顿的bug,会在后续课程中优化。
3.4 用户界面实现和设计模式基础
3.4.1 导言
假设这样一个场景:在一个游戏中,出现在你的视野中的树木数以千计,虽然我们会惊叹建模师和贴图美术师们逼真的还原水平,但程序并不在乎。它只关心如何从磁盘中加载这些数据,并将其高效地渲染在游戏窗口。
我们随意挑出一棵树,若这棵树是绘制在3D场景中,构成它的资源可以笼统的分为模型和贴图两类。在许多3A大作的游戏资源包中,模型和贴图相关的资源所占的比例是极高的。它们不仅占据了大量的硬盘空间,也占据了游戏启动时加载的大部分时间。如果我们把一棵树在内存中所占用的资源为10MB计算,场景中1000棵树就需要10000MB,也就是说,只是为了把屏幕上把这些树绘制出来就需要占用电脑9.8GB左右的内存。这对于玩家显然是不合理的,况且想要从磁盘上加载1000个模型,也需要十分恐怖的加载时间。
那么我们可能会问,我只需要加载一棵树的模型,然后再游戏里把他绘制1000次不就好了?确实如次,虽然在现代的游戏技术中,对于树木这种大批量出现的渲染任务已有许多成熟的解决方案,但他们都离不开一个设计模式——“享元模式”。
3.4.2 享元模式
“享元”即“共享元素”的意思。”享元模式“是设计模式中使用热度极高的模式之一。(设计模式是一套被反复使用 多数人知晓的 经过分类编码的 代码设计经验的总结),他不像C++
等编程语言的语法那样白纸黑字,但也是一套自成体系的方法论。
若我们把算法比作功夫中的内功,那么设计模式就是外功招式。
就像在引言所讲述的树林场景,我们在设计对应代码结构时,直截了当的思路是:
1 | // 树结构体 |
而在使用享元模式进行重新设计后:
1 | // 树的资产结构体 |
再重新设计的代码中,我们把绘制一棵树所需的数据里面最庞大的部分挑出来。1000个Tree对象中模型和贴图均使用同一个TreeAsset
对象中的数据,这样就可以节省大量的内存空间。
回看我们的代码,这时我们可以看到:在Animation
的设计中,每一个Animation
对象都拥有自己的动画帧列表;而在Enemy
类中,每一个敌人,都拥有两个Animation
对象,这就意味着我们在游戏中每次随机刷新一个野猪,都会从磁盘中加载两套动画的图片到内存中,虽然我们所使用的图片不如3D模型那般恐怖,并不会导致严重的内存爆满问题,但是从磁盘上读取数据的这个I/O操作本身就是十分耗时的工作,尤其是在一些机械硬盘上磁盘速度较慢的情况时,刷新敌人的时候便会有明显的卡顿感。在主循环中动态的从磁盘中加载数据,这本身也违背了我们之前认识到的:“主循环中应尽量避免耗时过长的任务”这一设计准则。加载数据的工作应该放置到我们游戏框架中初始化的部分去做。毕竟从游戏体验角度,对玩家来说,比起在游戏过程中出现卡帧和掉帧等情况,更愿接受在加载时稍微多等一会儿。
所以这里我们要对Animation
类进行重新的拆分和设计。我们思考一下:游戏画面中的野猪们在动画方面可以共享的元素有哪些呢?
那当然是IMAGE
对象构成的vector
了;而动画当前正在播放第几帧等状态信息就各异了,所以就不能放在共享的数据里面。
因此,我们重新定义Atlas
类来表示动画所使用的“图集”,其所需的成员变量,构造和析构函数都是从Animation
中“拆分”下来的。而在整个游戏中,我们只需要用四个共用的Atlas
对象,也就是玩家和敌人分别向左和向右的动画。我们将它们的指针定义为全局变量,稍后进行初始化。
1 | class Atlas |
而将图片序列拆分出去的Animation
类,就需要持有ATlas
类对象的指针了,在初始化时,将它保存在成员变量中。
这里需要注意:由于Atlas
是Animation
之间共享的公共资产,所以千万不能在Animation
的析构函数中使用delete
将Atlas
指针释放掉,Atlas
的生命周期应由更上一层的代码进行控制。
这样敌人刷新时可能的卡顿就一去不复返了。这里蒟蒻注意到在每个对象中阴影图片的绘制也是都要读取、再加载渲染。可以试着用类似的思想实现一下。
3.4.3 用户界面
众所周知,EasyX
作为2D图形库,它与GUI库是有区别的。我们可以十分便利的调用函数绘制点线面各种图形。但是想要在窗口中实现一个带有交互效果的按钮,这就需要我们自己实现了。
Qt作为GUI程序开发框架的定位,决定了它必然会屏蔽太多底层设计。例如我们在目前程序中所使用的“主循环”,这些封装和屏蔽从工程角度讲是再合适不过的,但是我们在探索游戏开发的初期也就是以学习为目的进行实践的过程中,我们更希望有一个功能简单直接容易上手的图形库来让我们选取,而不是直接使用GUI库。当然,在游戏开发中,Qt这些有着明确定位的GUI框架一般也不会直接参与到游戏程序本身的制作中,而是作为游戏开发工具链上的一环。想要在游戏这种即时渲染的框架中渲染更具有通用性的GUI,imGUI
等技术是在合适不过的了。那么想要实现GUI组件,在现有程序中该如何编写呢?
这里,有一句GUI设计哲学“一个按钮之所以是一个按钮,不是因为它长得像一个按钮,而是因为它能够对交互事件做出响应”。无论是文本还是图片,如果能够对玩家的点击事件进行捕获,并修改对应的数据进行响应,那么它就是一个按钮。
这里我们每个按钮提供了3张图片,分别对应了按钮的iale
、hovered
、push
形态。
现在回到代码,来考虑按钮类该如何设计。
按钮必然需要一个RECT变量来描述自己的位置和大小,这在判断鼠标响应时是必须的。然后是3张IMAGE
图片变量。最后我们还应定义按钮当前的状态枚举变量。,这是因为按钮的悬停、按下等状态实在消息处理时进行判断的 ,而在主循环的每一帧画面渲染时,我们都需要根据现有状态选择对应图片进行绘制。
然后就是内部成员函数的编写,绘制函数、事件处理函数。注意在开始编写之前一定要理清代码逻辑。
1 | class Button |
接下来,便可以此为基类编写特殊按钮类了
1 | class QuitGameButton :public Button |
注意我们在这里,将主循环的播放音乐移了过来。
另外还设置了2个全局变量running
和is_game_start
。
再对主函数内代码稍加修改,此次的项目就竣工了。
当然,这只是游戏基本操作的实现,还可以加入其他东西。
请前往B站up**Voidmatrix**那里进一步学习。