Re0:从零开始的C++游戏开发 (中)
这是蒟蒻观看B站upVoidmatrix的课程从零开始的提瓦特幸存者的个人笔记【自用】
前言:采用适用于小白的easyx图形库。
第三集 提瓦特の幸存者
3.1 程序动画实现及角色移动
在开始之前,我们应该认识到,尽管我们可以通过点线面绘制简单的画面,但是想要只用这种矢量绘图的方式完成游戏内全部素材是远远不够的。想要绘制一个简单的人物就要洋洋洒洒300+行代码,那更不用提什么画质精美的3A大作了。
所以,使用经过专业绘图软件(如:PS等)处理的位图素材是必不可少的。位图素材也就是我们常说的图片资源素材。
那么,我们如何在Easyx中加载并渲染图片资源呢?我们查看文档就可发现,Easyx使用了一个叫做**IMAGE
的类来表示图片对象**;而加载图片使用一个叫做**loadimage
的函数**,这个函数负责将图片文件数据加载到IMAGE对象
中、或者直接将图片加载到绘图窗口中,同时这个函数还有一个重载,用以从资源文件中加载图像。
加载图片完成后,就是如何渲染图片,这里使用**putimage
函数**。putimage
函数同样有两个重载。
所以整套图片绘制的流程就是:
IMAGE img;
loadimage(&img,"test.jpg");
putimage(100,200,&img);
掌握这两个函数后,我们就可以开始编写代码了。
在一切开始之前按照先前所讲述的,将游戏框架写出来。
#include <graphics.h>
int main()
{
initgraph(1280, 720);
bool running = true;
ExMessage msg;
BeginBatchDraw();
while (running)
{
DWORD start_time = GetTickCount();
while (peekmessage(&msg))
{
}
cleardevice();
FlushBatchDraw();
DWORD end_time = GetTickCount();
DWORD delta_time = start_time - end_time;
if (delta_time < 1000 / 144)
{
Sleep(1000 / 144 - delta_time);
}
}
EndBatchDraw();
return 0;
}
现在就可以将背景绘制在窗口中了:首先,将素材文件copy到工程目录下。需要注意的是,VS在调试时使用的相对路径、根目录和新建代码的默认位置相同。
在加载渲染好背景图片后,就到了我们的重点——如何让画面”动“起来?
在游戏开发技术中,角色动画的常见实现可以笼统的分为两类:序列帧动画和关键帧动画。序列帧动画通常由一组图片素材组成,我们在程序中随着时间的推移不断切换显示这一序列的图片,借助视觉暂留效应,便有了动画效果;而关键帧动画如骨骼动画等往往涉及到更复杂的图形学技术,在此暂不作讨论。
现在我们使用一组二次元人物图片作为游戏素材,要想实现每个一段时间切换一张图片显示,该如何处理呢?
3.1.1 动画实现
我们或许会想到Sleep()
函数,例如:我们希望在一秒钟切换10次图片,那么只需要写下Sleep(100);
这样的代码就可以了,吗?但是,我们在之前提及过,当调用Sleep()
函数时,程序会卡在这里等待对应的时间,这是一个”阻塞式“的行为;而在我们的游戏框架设计中,所有的画面渲染等操作,都应该在一次又一次的循环中进行,每次循环的时间都应该控制在1/60秒内,也就是说,我们切换动画轮播的任务,应该分摊在多帧之间进行,而不是在单次循环内一次性解决。
这就触及到我们游戏编程的一个核心思想:主循环内应尽量避免阻塞式的行为或过于繁重且耗时过长的任务。具体可以进入**“高性能”编程领域**深入学习。
为了确保动画序列帧的能够间隔固定的时间进行切换,我们这里类比定时器的概念实现一个计数器。
首先,定义idx_cur_anim
变量来存储当前动画的帧索引;再定义一个counter
用来记录当前动画帧一共播放了几个游戏帧,这里使用staic
修饰计数器,保证计数器只在第一个游戏帧时被初始化为0,我们不妨每5个游戏帧切换动画帧。
随后,我们还要考虑到动画帧序列播放结束后的行为,我们希望动画是循环播放的,也就是当动画的帧索引到大帧总数时,将索引重置为0。
const int PLAYER_ANIM_NUM = 4;
int main()
{
/* ...
...*/
static int counter = 0;
if(++counter % 5 == 0)
{
idx_cur_anim ++;
}
idx_cur_anim = idx_cur_anim % PLAYER_ANIM_NUM;
/* ...
...*/
}
这样,我们就完成了动画的数据逻辑部分,接下来就是动画的渲染部分。
在这之前,我们首先应该像加载背景图片那样将动画的每一帧图片都加载到程序中。定义LoadAnimation()
函数。我们将图片规律命名,这样就可以使用循环加载图片。在使用**Unicode
字符集**的情况下,我们可以使用wstring
来拼凑出文件路径,进而传递给loadimage()
函数,将图片加载到数组中。
现在来到游戏框架中的画面渲染部分,之前定义的动画帧索引这时便可以当作IMAGE数组的索引来使用。
但运行程序我们会发现,虽然人物动画轮播功能是正常的,但人物的周围套上了黑黑的边框。看起来图片的透明区域并未发生作用,这是因为putimage()
函数在渲染过程中,并没有使用IMAGE对象的透明度信息,所以我们想要绘制类似这种带有透明度的图片素材,就要自己处理这部分逻辑。这里,我们类比putimage()
函数封装一个putimage_alpha()
函数。
// 实现透明通道混叠 借助系统绘图函数的比较轻巧的实现
#pragma comment(lib,"MSIMG32.LIB")
inline void putimage_alpha(int x, int y, IMAGE* img)
{
int w = img->getwidth();
int h = img->getheight();
AlphaBlend(GetImageHDC(NULL), x, y, w, h,
GetImageHDC(img), 0, 0, w, h, {AC_SRC_OVER, 0, 255, AC_SRC_ALPHA});
}
再次运行程序,就可以发现动画被正常渲染了。
3.1.2 角色移动
接着,我们来实现键盘控制角色移动的功能。
我们首先定义POINT
类型的player_pos
变量用来存储玩家的位置,记得将玩家坐标初始化。随后将动画渲染的位置更改为player_pos
变量的位置。
这时,只需要在事件处理部分根据按键修改player_pos
的值,就可以实现角色的移动。
我们只需要对键盘按下的消息进行处理,定义PLAYER_SPEED
常量表示玩家速度,并约定使用方向键控制玩家移动。
/*...
...*/
while(peekmessage(&msg))
{
if(msg.message = WM_KEYDOWN)
{
switch(msg.vkcode)
{
case VK_UP:
player_pos.y -= PLAYER_SPEED;
break;
case VK_DOWN:
player_pos.y += PLAYER_SPEED;
break;
case VK_LEFT:
player_pos.x -= PLAYER_SPEED;
break;
case VK_RIGHT:
player_pos.x += PLAYER_SPEED;
break;
}
}
}
/*...
...*/
关于键码对照表可以查看微软官方文档。
运行程序,我们可以发现角色可以移动了,但人物的移动“手感”有些奇怪。当我们按下方向键,角色向着对应的方向抽搐了一下,一段时间后才进行较为连贯的移动,在连续移动的过程中顿挫感也十分明显。
出现此等原因主要有二:1.首先是持续按下一小段时间后才开始连贯移动的问题。这是因为当我们按下方向键时,会首先有一个WM_KEYDOWN
消息进入消息事件队列中,随后,当我们我们保持按键按下状态一段时间后,才会有接连不断的WM_KEYDOWN
消息被触发;2.然后是移动过程中的卡顿问题。这是因为WM_KEYDOWN
消息的产生是与我们的主循环异步进行的,且触发的频率与操作系统和硬件设备相关,这就导致在有些游戏帧中事件处理部分对多个WM_KEYDOWN
消息进行了处理,而在其余游戏帧中WM_KEYDOWN
消息较少或没有,这就导致角色在某些游戏帧中前进的距离较远/近一些,在宏观上展现为移动过程中的卡顿感。
解决问题就要理清思路,我们抽象地总结实际的功能需求:当按键按下时,我们要确保在每一个游戏帧中都连贯的移动相同的距离;从玩家的行为角度讲,也就是玩家按下按键时,WM_KEYDOWN
消息触发,标志角色开始移动;而当玩家按键抬起时,WM_KEYUP
消息触发,标志移动结束。
那么我们的解决方案就明晰了。我们首先定义4个bool
变量分别标志玩家是否向对应方向移动。在事件处理部分,不直接对玩家的位置数据进行操作,而是设置这些布尔变量的值,按键按下设为true
、按键抬起设为false
。在数据处理部分,我们再根据这些布尔变量的状态确定是否对玩家的位置进行处理。
/*...
...*/
bool is_move_up = false;
bool is_move_down = false;
bool is_move_left = false;
bool is_move_right = false;
/*...
...*/
while(running)
{
/*...
...*/
while(peekmessage(&msg))
{
if(msg.message = WM_KEYDOWN)
{
switch(msg.vkcode)
{
case VK_UP:
is_move_up = true;
break;
case VK_DOWN:
is_move_down = true;
break;
case VK_LEFT:
is_move_left = true;
break;
case VK_RIGHT:
is_move_right = true;
break;
}
}
else if(msg.message = WM_KEYUP)
{
switch(msg.vkcode)
{
case VK_UP:
is_move_up = false;
break;
case VK_DOWN:
is_move_down = false;
break;
case VK_LEFT:
is_move_left = false;
break;
case VK_RIGHT:
is_move_right = false;
break;
}
}
}
if(is_move_up) plaayer_pos.y -= PLAYER_SPEED;
if(is_move_down) plaayer_pos.y += PLAYER_SPEED;
if(is_move_left) plaayer_pos.x -= PLAYER_SPEED;
if(is_move_right) plaayer_pos.x += PLAYER_SPEED;
/*...
...*/
}
/*...
...*/
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
容器中,即:TCHAR 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
关键字来开辟内存。