当前位置: 首页 > news >正文

Re0:从零开始的C++游戏开发【中】

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是一个根据元素数量动态增长的容器,而不需要像数组那样一开始便固定其容量大小。

  1. 我们将动画帧序列的vector容器定义为私有成员;

  2. 加载图片的部分自然就需要放在构造函数里面。这里抽象一下加载动画所需要的参数Animation(LPCTSTR path,int num,int inteval)分别是:图片文件包含的路径、当前动画所使用的图片数量和帧间隔(由于在目前的动画中,帧与帧之间的时间间隔是固定的);

  3. 循环加载图片。由于我们使用的图片素材命名都十分规律,所以可以直接将路径参数当作字符串格式化的模板;最后,我们将图片对象的指针添加到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关键字来开辟内存。

🔺很多人在初学时会忽略掉有关内存管理的问题,所以会养成内存泄漏的坏习惯。我们在这里使用了new关键字,那么我们就要马上警惕起来,在哪里使用delete释放掉内存?

就像在C语言中每个malloc对应一个free一样,在CPP中,我们要养成习惯去检查每一个new也要对应一个delete

所以在析构函数中,我们需要遍历vector的每一个元素,一次将它delete掉。

class Animation
{
public:Animation(LPCTSTR path,int num,int inteval){interval_ms = interval;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);frame_list.push_back(frame);}}~Animation(){// 注意这里使用vector存储原始指针似乎是危险的事情,可以使用智能指针。具体还请移步看相关资料文献。for(size_t i = 0;i < frame_list.size();i ++)delete frame_list[i];}
private:std::vector<IMAGE*> frame_list;
};
3.2.2 角色移动优化

然后是动画播放的部分。我们定义**Play函数**,暂时将帧索引更新的逻辑和渲染的代码都放置到函数中,这样我们就需要传入xy两个参数来表示动画当前渲染的位置,最后我们还定义一个参数delta,用来表示距离上一次调用Play函数过去了多久时间。这其实已经将我们之前的动画计数器的思路转变为了计时器的思路。

那这两种思路之间有什么区别呢?

一般来说,一个动画的播放速度也就是帧间隔,应该是与实际时间有关的,而不是与游戏的帧率有关,我们希望的是无论游戏帧的频率有多快,动画的播放速度是一致的,而不是画面刷新越快,动画播放越快,这样整个游戏画面就如同开了“倍速”一样。所以使用与实际时间有关的定时器,会比每一下调用都累加一次的计数器更能满足这种需求。

我们只需要在每次调用Play函数时,对timer计时器变量增加对应的时间,如果定时器到达帧间隔,那么就切换动画图片到下一帧,同时重置定时器的值。最后通过我们之前定义好的putimage_alpha函数绘制当前动画帧,这是我们就可以使用简洁的代码来加载玩家角色向左向右的动画了。

Animation anim_left_player(_T("img/player_left_%d.png"),6,45);
Animation anim_right_player(_T("img/player_right_%d.png"),6,45);

我们定义DrawPlayer函数用以绘制玩家动画。由于需要左右翻转还需要传入玩家当前在x轴上的移动方向;动画实现左右翻转的逻辑也很简单:我们定义facing_left静态布尔变量,表示玩家动画是否面向左侧。在绘制动画时,只需要根据facing_left的值判断当前绘制的是向左还是向右的动画即可。

void DrawPlayer(int delta,int dir_x)
{static bool facing_left = false;if(dir_x < 0)facing_left = true;else if(dir_x > 0)facing_left = false;if(facing_left)anim_left_player.Player(player_pos.x,player_pos.y,delta);elseanim_right_player.Player(player_pos.x,player_pos.y,delta);
}

为了让玩家再游戏画面中更加醒目,我们考虑在玩家的脚底添加阴影效果。阴影的实现同样也是使用图片素材IMAGE img_shadow,将它添加进来,并绘制在玩家的脚底。我们定义三个常量用来存储玩家的图片尺寸和阴影宽度。

const int PLAYER_WIDTH = 80;
const int PLAYER_HEIGHT = 80;
const int SHADOW_WIDTH = 32;

在计算阴影的水平位置时,我们考虑将阴影居中,int pos_shadow_x = player_pos_x + (PLAYER_WIDTH / 2 - SHADOW_WIDTH / 2);;在计算竖直位置时,我们将它放到玩家脚底偏移一小段的地方。然后在绘制玩家动画之前绘制阴影图片。

void DrawPlayer(int delta,int dir_x)
{int pos_shadow_x = player_pos_x + (PLAYER_WIDTH / 2 - SHADOW_WIDTH / 2);int pos_shadow_y = player_pos_y + PLAYER_HEIGHT - 8;putimage_alpha(pos_shadow_x,pos_shadow_y,&img_shadow);static bool facing_left = false;if(dir_x < 0)facing_left = true;else if(dir_x > 0)facing_left = false;if(facing_left)anim_left_player.Player(player_pos.x,player_pos.y,delta);elseanim_right_player.Player(player_pos.x,player_pos.y,delta);
}

现在玩家所有的移动功能都已经完成了。但是,移动手感似乎有些奇怪,玩家在斜向移动的时候速度快一些。

在处理玩家移动的代码中,我们发现,当我们同时按下处于x和y轴两个方向的按键时,玩家的位置坐标就在这一帧内向着两个方向移动了一个单位距离,由勾股定理,这就导致这一帧玩家的位移距离是根号二倍的速度,于是就有了斜向移动更快的现象。我们这里可以通过if else来判断当玩家按下处于x和y轴两个方向的按键时,x和y坐标改变的数值变为PALYER_SPPED/根号2,但是要处理多种组合的情况,使用if else有些过于冗长,所以我们这里只需要借用一点点向量运算的知识,来确保玩家每次位移的大小都是相同的。

只需要确保运算时的速度方向向量是单位向量即可。

这里使用doouble来尽可能避免浮点数和整形互相转换的精度丢失问题,可是依旧会有小问题。

int dir_x = is_move_right - is_move_left;
int dir_y = is_move_down - is_move_up;
double len_dir = sqrt(die_x * die_x + dir_y + dir_y);
if(len_dir != 0)
{double nomalized_x = dir_x / len_dir;double nomalized_y = dir_y / len_dir;player_pos.x += (int)(PLAYER_SPEED * normalized_x);player_pos.y += (int)(PLAYER_SPEED * normalized_y);
}cleardevice();putimage(0,0,&img_backguond);
DrawPlayer(1000 / 144,dir_x);FlushBatchDraw();

再次运行程序,玩家的移动速度在各个方向上都确定了。

在玩家移动上,我们还有一个细节仍待处理:我们需要玩家始终处于画面内,也就是说玩家动画所在矩形必须位于1280X720尺寸的窗口内部。

const int WINDOW_WIDTH = 1280;
const int WINDOW_HEIGHT = 720;

再根据按键输入更新玩家的位置后,我们就还需要对玩家的位置进行校准

if(player_pos.x < 0) player_pos.x = 0;
if(player_pos.y < 0) player_pos.y = 0;
if(play_pos.x + PLAYER_WIDTH > WINDOW_WIDTH) player_pos.x = WINDOW_WIDTH - PLAYER_WIDTH;
if(play_pos.y + PLAYER_HEIGHT > WINDOW_HEIGHT) player_pos.y = WINDOW_HEIGHT - PLAYER_HEIGHT;
3.2.3 玩家类和敌人类实现

我们使用野猪🐗表示敌人,野猪同样也有面向左和右两套动画,从代码设计角度考虑讲,让这些野猪Animation对象与玩家的Animation对象混杂在一起显然不好。

所以我们再次使用封装这一特性,将玩家的逻辑封装到Player类中,而与敌人相关的逻辑就封装在Enemy类中。虽然我们可以将玩家和敌人共同的逻辑抽象出来,定义更基础的类,如Character角色或GameObject游戏对象,这些设计会涉及到面向对象中另两大特性——继承和多态

但,目前我们不使用继承和多态进行实践,而仅使用封装来确保数据和逻辑。

首先,玩家类Player的实现。

我们首先将和玩家类相关的散落在外部的成员变量和常量放在Player类中;然后定义ProcessEvent函数来处理玩家的操作消息、定义Move函数来处理玩家的移动、定义Draw函数来绘制玩家;然后将对应逻辑的代码移动到函数内部,注意一些微调整。最终玩家类的代码就完成了。

class Player
{
public:Player(){loadimage(&img_shadow, _T("img/shadow_player.png"));anim_left = new Animation(_T("img/player_left_%d.png"), 6, 45);anim_right = new Animation(_T("img/player_right_%d.png"), 6, 45);}~Player(){delete anim_left;delete anim_right;}void ProcessEvent(const ExMessage& msg){}void Move(){}void Draw(int delta){}private:const int SPEED = 3;const int FRAME_WIDTH = 80; // 玩家宽度const int FRAME_HEIGHT = 80; // 玩家高度const int SHADOW_WIDTH = 32; // 阴影宽度private:IMAGE img_shadow;Animation* anim_left;Animation* anim_right;POINT position = { 500,500 }; // 玩家位置bool is_move_up = false;bool is_move_down = false;bool is_move_left = false;bool is_move_right = false;
};

然后,是子弹类Bullet的实现。

其中并没有太多复杂的数据和逻辑。成员变量只需要有位置信息,而在渲染方法Draw里面我们使用橙红色填充圆来进行绘制。

class Bullet
{
public:POINT position = { 0,0 };
public:Bullet() = default;~Bullet() = default;void Draw() const{setlinecolor(RGB(255, 155, 50));setfillcolor(RGB(200, 75, 10));fillcircle(position.x, position.y, RADIUS);}
private:const int RADIUS = 10;
};

随后,是敌人类Enemy的实现。

我们仿照着Player类定义Enemy类。

接下来便是设计敌人类中的成员方法,不过在此之前,我们要先想清楚敌人的行动逻辑:我们希望敌人从地图外的随机位置刷新出来,并向着玩家移动,敌人触碰玩家时会对玩家造成伤害游戏结束;敌人触碰到玩家周围的子弹时会消失

首先是敌人的刷新机制,即敌人在生成时随机初始化自己位置。所以此逻辑要放在构造函数中。地图有四条边,这里定义SpawnEdge枚举用以标识敌人出生的边界,使用随机数%4后便可实现随机地图边界的效果。接下来是地图的坐标值,分类讨论,对于上边界它的x位置坐标应该是随机的,其他同理推出。

//敌人生出边界
enum class SpawnEdge
{Up = 0,Down,Left,Right
};
// 将敌人放置在地图边界外的随机处
SpawnEdge edge = (SpawnEdge)(rand() % 4);
switch (edge)
{
case SpawnEdge::Up:position.x = rand() % WINDOW_WIDTH;position.y = -FRAME_HEIGHT;break;
case SpawnEdge::Down:position.x = rand() % WINDOW_WIDTH;position.y = WINDOW_HEIGHT;break;
case SpawnEdge::Left:position.x = -FRAME_WIDTH;position.y = rand() % WINDOW_HEIGHT;break;
case SpawnEdge::Right:position.x = WINDOW_WIDTH;position.y = rand() % WINDOW_HEIGHT;break;
default:break;
}

然后是碰撞向相关函数,检测与子弹发生的碰撞传入Bullet参数,检测与玩家发生的碰撞传入Player参数。

bool CheckBulletCollision(const Bullet& bullet)
{return false;
}bool CheckPlayerCollision(const Player& player)
{return false;
}

Move函数需要始终追寻玩家移动,所以传入Player参数。

值得注意的是我们传入的参数都应添加引用这与使用指针进行参数传递类似,都是为了避免在传入参数过程中对传入的对象进行了不必要的拷贝构造;同时,又添加const限定符来避免在函数内部不小心对参数进行了修改,这是一个好习惯。

Move函数实现逻辑与玩家类十分相似,我们只需要将玩家的位置与敌人的位置进行作差,即可得到敌人需要移动的向量。

void Move(const Player& player)
{const POINT& player_position = player.GetPosition();int dir_x = player_position.x - position.x;int dir_y = player_position.y - position.y;double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);if (len_dir != 0){double nomalized_x = dir_x / len_dir;double nomalized_y = dir_y / len_dir;position.x += (int)(SPEED * nomalized_x);position.y += (int)(SPEED * nomalized_y);}if (dir_x < 0) facing_left = true;else facing_left = false;
}

随后是绘制敌人的Draw函数,也与玩家类中的实现十分相似。由于敌人始终处于移动状态,所以我们不需要使用static变量静态保存无移动时的动画翻转状态。

void Draw(int delta)
{int pos_shadow_x = position.x + (FRAME_WIDTH / 2 - SHADOW_WIDTH / 2);int pos_shadow_y = position.y + FRAME_HEIGHT - 35;putimage_alpha(pos_shadow_x, pos_shadow_y, &img_shadow);if (facing_left)anim_left->Play(position.x, position.y, delta);elseanim_right->Play(position.x, position.y, delta);
}

游戏中的面向对象的设计已初具雏形,现在我们要考虑的是:如何在主循环实例化这些对象。

玩家对象全局只有一个,所以我们定义在主循环外部;在消息处理部分,调用Player类的ProcessEvent方法处理玩家在操作事件;在数据处理部分调用Move进行移动;在渲染部分调用Draw来绘制画面。

由于游戏中敌人数量也是动态的,所以我们这里同样使用vector来存储Enemy的对象指针。在主循环中,我们使用一个定义TryGenerateEnemy函数来生成敌人,为了简单起见,函数内置了一个计数器,当到达指定时间间隔便向容器中添加新的敌人。在数据如理部分,我们遍历vector中的每一个敌人依次调用Move方法,在渲染部分也依次调用Draw方法。

(PS :最后,我们会发现Move()函数中,角色位置改变运用了浮点型强转为整形所造成精度丢失,是玩家和敌人的SPEED范围有限,这是一个问题。)

3.3 2D碰撞检测和音效播控

3.3.1 子弹碰撞逻辑

在前面的代码中,我们已经定义了子弹类,也定义了敌人与子弹和玩家的碰撞方法。但实际的碰撞逻辑仍未实现

首先是敌人和子弹的碰撞。在CheckBulletCollision方法中,我们将子弹等效为一个点。如果想要检定这两者的碰撞,那么只要检测这个点是否在敌人所处矩形内。而判断二维平面内点在矩形内十分简单。

bool CheckBulletCollision(const Bullet& bullet)
{// 将子弹等效为点bool is_overlap_x = bullet.position.x >= position.x && bullet.position.x <= position.x + FRAME_WIDTH;bool is_overlap_y = bullet.position.y >= position.y && bullet.position.y <= position.y + FRAME_HEIGHT;return is_overlap_x && is_overlap_y;
}

然后是敌人和玩家的碰撞检测,这就涉及到两个矩形之间的相交检测。但是,如果我们将二者的碰撞模型抽象成两个矩形的相交,我们考虑这样一种极端情景:敌人的位置处于玩家的对角线方向处,此时二者并未重合,但是数据逻辑上却判定为碰撞,这会让玩家非常困惑。所以,在大部分2D游戏作品中的程序设计中,对于这类受击碰撞箱,其实是要小于玩家所在的碰撞箱。考虑到我们的游戏敌人的尺寸并不大,以及这类割草游戏中对于碰撞的检定不应过于严格,所以我们以敌人的中心点位置作为其碰撞坐标,只有当敌人的中心点在玩家矩形箱内,二者才发生碰撞,敌人才能对玩家造成伤害。所以在CheckPlayerCollision方法中,我们首先应该计算出敌人的判定点位置,随后再判断这个点是否在玩家当前所在矩形内。注意,由于我们这里需要获取玩家的实时位置,所以我们扩展了玩家类的方法,为它提供了一个GetPosition方法来返回玩家当前坐标。

const POINT& GetPosition() const
{return position;
}

最后,我们还需要在主循环中遍历敌人列表,依次检测他们是否与玩家发生了碰撞,当二者发生碰撞时,我们弹出提示信息,结束游戏。

接下来,我们要让子弹显示在画面中了。

首先定义子弹vector并初始化它的长度为3。随后,我们定义UpdateBullets函数用以在主循环实现子弹实时跟随玩家的逻辑。

三颗子弹只是均匀做圆周运动太过死板,所以我们让子弹有除去圆周运动的切向速度外,还有一个不断波动的径向速度;在视觉效果上,这些子弹会围绕着玩家进行时近时远的圆周运动。

我们首先定义子弹的径向速度和切向速度。切向速度决定了圆周运动的快慢,而径向速度决定了子弹距离玩家时近时远波动速度。计算子弹之间的角度间隔也十分简单。最后我们遍历子弹列表中的每一个子弹,根据玩家当前的位置依次修改它们的位置。

void UpdateBullets(std::vector<Bullet>& bullet_list, const Player& player)
{const double RADIAL_SPEED = 0.0045; // 径向const double TANGENT_SPEED = 0.0055; // 切向double radian_interval = 2 * 3.14159 / bullet_list.size(); // 子弹之间的弧度间隔POINT player_position = player.GetPosition();double radius = 100 + 25 * sin(GetTickCount() * RADIAL_SPEED);for (size_t i = 0; i < bullet_list.size(); i++){double radian = GetTickCount() * TANGENT_SPEED + radian_interval * i;bullet_list[i].position.x = player_position.x + player.FRAME_WIDTH / 2 + (int)(radius * sin(radian));bullet_list[i].position.y = player_position.y + player.FRAME_HEIGHT / 2 + (int)(radius * cos(radian));}
}

随后,我们要编写敌人受击消失的逻辑了。

为了更通用的设计,我们给Enemy类中新增了两个方法HurtCheckAlive,同时,新增alive布尔变量标识敌人当前是否是存活状态。

Hurt方法为受击方法,当敌人收到攻击时便会调用;CheckAlive方法为敌人存活检测方法,函数直接返回alive成员的值,用来在类外获取当前敌人存活状态。

Hurt方法中,常见的思路是递减敌人血量,这里我们使用最简单的方法,让敌人一被击必杀。

那么在主循环中,当CheckBulletCollision成功时,便要调用敌人类的Hurt方法,而在碰撞检测结束后,我们还需要遍历现存的敌人列表,依次检查已经被击杀的敌人,并将它们从游戏中删掉。

for (Enemy* enemy : enemy_list)
{for (const Bullet& bullet : bullet_list){if (enemy->CheckBulletCollision(bullet)){enemy->Hurt();}}
}

这里,我们使用swappop_back组合技来实现从vector中删除元素,这是一种在元素次序无关时性能较好的删除方法,原理十分简单。

for (size_t i = 0; i < enemy_list.size(); i++)
{Enemy* enemy = enemy_list[i];if (!enemy->CheckAlive()){std::swap(enemy_list[i], enemy_list.back());enemy_list.pop_back();delete enemy;}
}

仿照其他游戏,得分机制是必不可少的。

所以,我们定义score变量记录玩家得分,并定义DrawPlayerScore()绘制得分。

// 绘制玩家得分
void DrawPlayerScore(int score)
{static TCHAR text[64];_stprintf_s(text, _T("当前玩家得分:%d"), score);setbkmode(TRANSPARENT);settextcolor(RGB(255, 85, 185));outtextxy(10, 10, text);
}

这样,我们的游戏基本上已经完成,但总感觉不得劲,哎?没错,音效和音乐。

3.3.2 音效音乐播控

这里提供一种较为简单轻巧的音乐播放实现方式:mciSendString

我们首先需要将mus文件素材放置对应目录下,随后在代码种来链接对应的库#pragma comment(lib,"Winnm.lib")

这个函数的前三个字母mci代表了Media Control Interface即:媒体控制接口。它的作用更像是我们对Windows系统发号施令,我们把字符串形式的命令告诉系统,让它们来帮我们播放声音。

使用文档

我们只需要关注第一个参数,也就是我们要对Windows系统发出的“命令”,当我们加载背景音乐的时候,我们可以编写这样的代码:

mciSendString(_T("open mus/bgm.mp3 alias bgm"), NULL, 0, NULL);

这样便是告诉系统:你要把mus目录下bgm.mp3文件加载到程序中,并且在后续的命令中,我们给这个音乐取名为”bgm“。

当我们需要播放背景音乐时,我们呢=只需要写下这样的代码:

mciSendString(_T("play bgm repeat from 0"), NULL, 0, NULL);

这样就是在和系统说:现在我要播放先前已经追备好的名为bgm的音乐,并让它从头循环播放。于是,背景音乐便添加完成。

那么子弹击中敌人的音效也十分简单,我们编写相似的代码加载hit.wav文件并取名为hit,并在子弹碰撞时播放,注意此时并未添加repeat命令,来确保受击音效不会出发后永无休止的循环播放下去。

至此,游戏内部数据逻辑已全部完成,除去一些卡顿的bug,会在后续课程中优化。

相关文章:

  • 计网期末复习指南(六):应用层(DNS、FTP、URL、HTTP、SMTP、POP3)
  • Java学习19-List、set容器
  • 【云原生 | 60】Docker中通过docker-compose部署kafka集群
  • python-web应用程序-Django-From组件
  • jeecg dictText字典值
  • C++:栈(stack)、队列(queue)、优先级队列(priority_queue)
  • 【计算机毕设】基于SpringBoot的民宿在线预定平台设计与实现 - 源码免费(私信领取)
  • Java算法篇之二分查找模板
  • C++ Thread多线程并发记录(3)线程创建总结
  • 基础—SQL—DML(数据操作语言)修改和删除
  • 力扣----轮转数组
  • 重学java 61.IO流 ② 字节输出流
  • 【面试宝藏】Redis 常见面试题解析
  • 如何通过PHP语言实现远程控制多路照明
  • 利用BeanFactoryPostProcessor让Bean提前被创建
  • JavaScript 如何正确处理 Unicode 编码问题!
  • 「面试题」如何实现一个圣杯布局?
  • Angular 响应式表单 基础例子
  • Docker下部署自己的LNMP工作环境
  • download使用浅析
  • hadoop集群管理系统搭建规划说明
  • iOS高仿微信项目、阴影圆角渐变色效果、卡片动画、波浪动画、路由框架等源码...
  • Java的Interrupt与线程中断
  • js正则,这点儿就够用了
  • SpingCloudBus整合RabbitMQ
  • springboot_database项目介绍
  • 记一次用 NodeJs 实现模拟登录的思路
  • 聊一聊前端的监控
  • 使用iElevator.js模拟segmentfault的文章标题导航
  • 一起来学SpringBoot | 第三篇:SpringBoot日志配置
  • 因为阿里,他们成了“杭漂”
  • 3月27日云栖精选夜读 | 从 “城市大脑”实践,瞭望未来城市源起 ...
  • $forceUpdate()函数
  • (4)通过调用hadoop的java api实现本地文件上传到hadoop文件系统上
  • (阿里云万网)-域名注册购买实名流程
  • (差分)胡桃爱原石
  • (二)Optional
  • (剑指Offer)面试题34:丑数
  • (六)c52学习之旅-独立按键
  • (免费领源码)Java#Springboot#mysql农产品销售管理系统47627-计算机毕业设计项目选题推荐
  • (十六)一篇文章学会Java的常用API
  • (实战篇)如何缓存数据
  • (四)Controller接口控制器详解(三)
  • (转)iOS字体
  • *p=a是把a的值赋给p,p=a是把a的地址赋给p。
  • ..回顾17,展望18
  • .net core使用RPC方式进行高效的HTTP服务访问
  • .NET 应用启用与禁用自动生成绑定重定向 (bindingRedirect),解决不同版本 dll 的依赖问题
  • .NET/C# 推荐一个我设计的缓存类型(适合缓存反射等耗性能的操作,附用法)
  • .NET是什么
  • @ 代码随想录算法训练营第8周(C语言)|Day57(动态规划)
  • @RequestMapping 和 @GetMapping等子注解的区别及其用法
  • [ 2222 ]http://e.eqxiu.com/s/wJMf15Ku
  • [.net 面向对象程序设计进阶] (19) 异步(Asynchronous) 使用异步创建快速响应和可伸缩性的应用程序...
  • [AIGC] HashMap的扩容与缩容:动态调整容量以提高性能