目录
2.1.1、首先解释一下为什么会有Atlas类,直接使用Animation类来管理从外部文件导入的动画资源不就好了吗?
2.2、Animation类:动画类 —— 本质上是一个简单的动画管理器类
2.6.1、StartButton类:开始按钮类 —— 继承于Button类
2.6.2、QuitButton类:退出按钮类 —— 同样继承于Button类
3.1、子弹更新逻辑:void UpdateBullet(vector & bullet_list, const Player& player)
写在最前面:
我写这篇文章的动机是为了我个人总结学习经验,如果里面的内容(如:代码、图片等)有侵权行为,烦请各位好心人们可以提醒一下我,我受到消息后就立马删除该篇文章。
1、项目介绍:
《提瓦特幸存者》这个项目是B站up主Voidmatrix大神发布的教学项目,视频内容清晰明了地介绍了如何使用C++以及EasyX来完成一个类似于《吸血鬼幸存者》的2D游戏,视频链接放在下面,如果有兴趣的读者可以去看看。
【【从零开始的C++游戏开发】全宇宙最简单的类吸血鬼幸存者游戏开发 | EasyX制作提瓦特幸存者】 https://www.bilibili.com/video/BV1eM4m1S74K/?share_source=copy_web&vd_source=c8afb69f3b738831ad4f696c6007c985
2、项目内容中各种类的实现:
该项目主要有以下几个类组成:
1)Atlas类:图集类 —— 用于存放导入的资源的类
2)Animation类:动画类 —— 本质上是一个简单的动画管理器类
3)Player类:玩家类
4)Bullet类:子弹类
5)Enemy类:敌人类
6)Button类:按钮类 —— 让其作为父类
7)StartButton类:开始按钮类 —— 继承于Button类
8)QuitButton类:退出按钮类 —— 同样继承于Button类
接下来,我将会依次介绍上面所提到的类:
2.1、Atlas类:图集类 —— 用于存放导入的资源的类
2.1.1、首先解释一下为什么会有Atlas类,直接使用Animation类来管理从外部文件导入的动画资源不就好了吗?
答:在本项目中,对于玩家类确实可以直接使用Animation类来管理,因为只有一位玩家。但是对于敌人就不可以了。因为在地图中每刷新一个敌人,就需要初始化一个敌人,而每次初始化一个敌人就需要重新从外部资源中导入动画资源,这显然是不合理的。因此,要使用Atlas类将需要使用的动画资源先导入,再由Animation类管理Atlas类中的动画资源,这样就不需要重复从外部资源导入动画。这就是游戏设计中的其中一种游戏设计模式:享元模式 —— 即让对应的对象共享对应的图集。
2.1.2、如何设计Atlas类:
1) 在Atlas类中,使用一个类型为vector<IMAGE*>的公有成员变量来管理从外部文件导入的动画序列资源。
注:vector是C++的STL的一个容器,从逻辑上看是一个可变长的数组,使用前需要引头文件:<vector>。而IMAGE是EasyX程序库中的一种类型,表示动画类,使用前需要引头文件:<graphics.h>。
2)Atlas的构造函数:在Atlas的有参构造函数中,其参数为“资源路径”和“动画序列的动画帧数量”。在该函数中,使用for循环来将一个动画序列的每一个动画帧初始化并放在堆区中(使用了new关键字),再将初始化好的一个动画帧放入vector中,直至for循环遍历完整个该动画序列的所有动画帧。
3)Atlas的析构函数:由于在Atlas的构造函数中使用了关键字new,所有要在Atlas的析构函数调用delete,来依次释放放在vector中的并且开辟在堆区的动画帧,避免内存泄露。
Atlas类的源代码如下:
class Atlas//使用Atlas类表示图集
{
public:
Atlas(LPCTSTR path, int num)
{
//TCHAR 就是当你的字符设置为什么就是什么
//例如:程序编译为 ANSI, TCHAR 就是相当于 CHAR
//当程序编译为 UNICODE, TCHAR 就相当于 WCHAR
TCHAR path_file[256];//使用该数组里保存文件路径
for (int i = 0; i < num; i++)
{
_stprintf_s(path_file, path, i);//将格式字符串path和i连接后的结果放到格式字符串path_file中
IMAGE* frame = new IMAGE();
loadimage(frame, path_file);//从对应的路径中加载图片
frame_list.push_back(frame);
}
}
~Atlas()
{
for (size_t i = 0; i < frame_list.size(); i++)
{
delete frame_list[i];//依次释放frame_list中的所有路径
}
}
public:
vector<IMAGE*>frame_list;//动画帧序列
};
//享元模式:让玩家和敌人共享以下对应的图集
Atlas* atlas_player_left;//Atlas(图集)——每一个玩家朝向左边的动画都使用这个图集
Atlas* atlas_player_right;
Atlas* atlas_enemy_left;//每一个敌人朝向左边的动画都使用这个图集
Atlas* atlas_enemy_right;
2.2、Animation类:动画类 —— 本质上是一个简单的动画管理器类
2.2.1、如何设计Animation类:
1)Animation类的私有成员变量有:
动画计时器timer,动画帧索引idx_frame,动画帧间隔interval_ms,指向当前动画所使用的图集指针anim_atlas。根据这几个成员变量,就可以实现一个简单的动画管理器类。
2)Animation类的构造函数:Animation(Atlas* atlas, int interval)
该函数参数的参数为:
参数1:保存图片的路径 参数2:播放动画的间隔
通过传入的参数来初始化对应的成员变量。
由于在构造函数和其它成员函数中没有使用到new关键字,因此可以不用设计Animation类的析构函数,或是直接使用它的默认版本:~Animation() = default;
3)播放动画的成员函数:void Play(int x, int y, int delta)
前两个参数:在指定位置播放动画 参数3:距离上一次播放动画帧的时间间隔
让动画计时器加上播放上一个动画帧的时间间隔,如果这时动画计时器所经过的时间大于动画帧间隔,则可以播放下一个动画帧(让帧索引+1即可),如果播放到最后一个动画帧时,则将动画帧索引归零,同时也将计时器归零。
Animation类的源代码如下:
class Animation//动画类
{
public:
//构造函数的参数:
//参数1:保存图片的路径 参数2:播放动画的间隔
Animation(Atlas* atlas, int interval)
{
anim_atlas = atlas;//注意:anim_atlas是Animation中共享的对象,所以万万不可在Animation的析构函数中delete掉anim_atlas
interval_ms = interval;
}
~Animation() = default;
//前两个参数:在指定位置播放动画 参数3:距离上一次播放动画帧的时间间隔
void Play(int x, int y, int delta)//播放动画
{
timer += delta;
if (timer >= interval_ms)//如果计时器timer超过了动画帧间隔的时间,则可以播放下一个动画帧
{
idx_frame = (idx_frame + 1) % anim_atlas->frame_list.size();//播放到最后一个动画帧时,重置动画帧索引
timer = 0;//计时器归零
}
putimage_alpha(x, y, anim_atlas->frame_list[idx_frame]);//播放动画帧
}
private:
int timer = 0;//动画计时器
int idx_frame = 0;//动画帧索引
int interval_ms = 0;//动画帧间隔
Atlas* anim_atlas;//指向当前动画所使用的图集指针
};
2.3、Player类:玩家类
在这个项目中,玩家类对象就是玩家可以在游戏中控制的对象,对于玩家类,它具有以下成员属性:
const int FRAME_WIDTH = 80;//玩家动画宽度
const int FRAME_HEIGHT = 80;//玩家动画高度
const int SPEED = 6;//设置玩家移动速度
const int SHADOW_WIDTH = 32;//玩家阴影宽度
POINT pos{ 200,200 };//记录玩家位置 —— 默认位置为x = 200,y = 200
注:POINT表示二维坐标,POINT pos{ 200,200 }中的前一个200表示x,后一个200表示y。
//使用bool变量来设置玩家移动方向,以提升操作手感
bool is_move_up = false;//玩家是否向上移动
bool is_move_down = false;
bool is_move_left = false;
bool is_move_right = false;
bool facing_left = true;//使用facing_left表示玩家是否向左
IMAGE img_shadow;//玩家阴影图片
Animation* anim_left;//玩家朝向左边的动画
Animation* anim_right;
根据上面所给出的成员属性,就可以设计出一个简单的玩家类。
2.3.1、玩家类的设计:
1)Player类的构造函数:
在该构造函数中,只需要初始化玩家的阴影图片、以及玩家朝向左和向右的动画即可。由于玩家朝向左和向右的动画类对象是开辟在堆区的(使用了new),所以要注意在玩家类的析构函数中要是否掉,以免发生内存泄漏。
2)Player类的析构函数:
delete掉玩家朝向左和向右的动画类对象即可。
3)处理玩家类的信息的成员函数:void ProcessEvent(const ExMessage& msg)
在该成员函数中,根据玩家按下的按键来判断玩家是否进行移动逻辑。如果玩家按下了对应的移动键,则将对应移动方向的bool成员变量设为true,反之,如果松开了对应的移动键,则设为false。
注意:使用bool成员变量来判断玩家的移动方向的目的是为了提升操作手感。由于在EasyX中,按键信息的产生与游戏主循环是异步进行的(这里提到所谓的异步进行,就是当玩家按下移动按键后,就会有一个按键信息进入事件队列中,当玩家按住不放时,游戏主循环此时仍然在进行,但此时却没有新的按键信息进入事件队列,只有玩家一直按住按键并且过了一小段时间之后,才会有接连不断的按键信息进入事件队列),所以如果直接在处理玩家信息的成员函数中改变玩家的位置,则会造成手感不佳以及画面卡顿的问题。
4)玩家移动逻辑:
首先通过玩家移动方向的bool变量来计算得出玩家此时在x轴、y轴的方向,并计算出此时玩家在移动方向上的向量,以确保玩家斜向移动时的速度和纵向移动时的速度是一样的:
int dir_x = is_move_right - is_move_left;//玩家x轴的方向
int dir_y = is_move_down - is_move_up;
double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);//获取玩家在移动方向上的向量
如果len_dir不为0,则说明玩家此时正在移动,那么就需要改变玩家的位置:
先通过len_dir来计算出玩家在x、y轴的单位向量,将对应的单位向量乘以速度再加上对应的玩家位置,即可修改玩家的位置,同时也可以避免玩家斜向速度更快的问题。
最后还要加上玩家位置的判断,避免玩家走出屏幕范围之外。
5)绘制玩家:
先绘制玩家阴影,这么做是为了防止阴影图片覆盖了玩家动画。
将游戏主循环中一次循环过去的时间作为参数传入该成员函数中,以此来判断是否要绘制玩家类动画的下一个动画帧。
再根据玩家此时的位置以及玩家水平移动方向的bool变量来计算得出玩家此时在x轴的方向,以此来绘制出对应的朝向动画。
Player类的代码如下:
class Player
{
public:
Player()
{
loadimage(&img_shadow, _T("img/shadow_player.png"));//加载玩家阴影动画
anim_left = new Animation(atlas_player_left, 45);//从全局函数中已初始化好的atlas中加载玩家朝向左边的动画,播放动画帧间隔为45ms
anim_right = new Animation(atlas_player_right, 45);
}
~Player()
{
delete anim_left;
delete anim_right;
}
void ProcessEvent(const ExMessage& msg)//处理玩家类的信息
{
switch(msg.message)//判断获取的信息是什么
{
case WM_KEYDOWN:
{
//如果直接将修改玩家坐标的代码放在switch中,则会造成画面卡顿和操作手感不佳的问题
//因为WM_KEYDOWN消息的产生与游戏主循环是异步进行的
switch (msg.vkcode)//判断获取到的是哪个键盘的信息
{
case VK_UP:
case 'W':
is_move_up = true;
break;
case VK_DOWN:
case 'S':
is_move_down = true;
break;
case VK_LEFT:
case 'A':
is_move_left = true;
break;
case VK_RIGHT:
case 'D':
is_move_right = true;
break;
default:
break;
}
break;
}
case WM_KEYUP:
{
switch (msg.vkcode)//判断获取到的是哪个键盘的信息
{
case VK_UP:
case 'W':
is_move_up = false;
break;
case VK_DOWN:
case 'S':
is_move_down = false;
break;
case VK_LEFT:
case 'A':
is_move_left = false;
break;
case VK_RIGHT:
case 'D':
is_move_right = false;
break;
default:
break;
}
break;
}
}
}
void Move()//玩家移动
{
//保持玩家在各个方向上的速度是一样的(这么做的主要原因是,防止玩家斜向移动是比在纵向移动时速度快):
int dir_x = is_move_right - is_move_left;//玩家x轴的方向
int dir_y = is_move_down - is_move_up;
double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);//获取玩家在移动方向上的向量
if (len_dir != 0)
{
//使用double来尽可能降低因为类型转换而造成的精度丢失
double normalized_x = dir_x / len_dir;//normalized_x是在获取x轴的单位向量
double normalized_y = dir_y / len_dir;
pos.x += static_cast<int>(SPEED * normalized_x);
pos.y += static_cast<int>(SPEED * normalized_y);
}
//让玩家只能在屏幕范围内移动:
if (pos.x < 0)pos.x = 0;
if (pos.y < 0)pos.y = 0;
if (pos.x + FRAME_WIDTH > WINDOW_WIDTH)pos.x = WINDOW_WIDTH - FRAME_WIDTH;
if (pos.y + FRAME_HEIGHT > WINDOW_HEIGHT)pos.y = WINDOW_HEIGHT - FRAME_HEIGHT;
}
void Draw(int delta)//绘制玩家
{
int player_shadow_x = pos.x + (FRAME_WIDTH - SHADOW_WIDTH) / 2;//计算阴影图片应该所在的位置
int player_shadow_y = pos.y + FRAME_HEIGHT - 8;
putimage_alpha(player_shadow_x, player_shadow_y, &img_shadow);
int dir_x = is_move_right - is_move_left;//玩家在x轴的方向
if (dir_x < 0)
facing_left = true;
else if (dir_x > 0)
facing_left = false;
if (facing_left)
anim_left->Play(pos.x, pos.y, delta);
else
anim_right->Play(pos.x, pos.y, delta);
}
const POINT GetPosition()const//获取玩家的坐标
{
return this->pos;
}
public:
const int FRAME_WIDTH = 80;//玩家动画宽度
const int FRAME_HEIGHT = 80;//玩家动画高度
private:
const int SPEED = 6;//设置玩家移动速度
const int SHADOW_WIDTH = 32;//玩家阴影宽度
private:
POINT pos{ 200,200 };//记录玩家位置
//使用bool变量来设置玩家移动方向,以提升操作手感
bool is_move_up = false;//玩家是否向上移动
bool is_move_down = false;
bool is_move_left = false;
bool is_move_right = false;
bool facing_left = true;//使用facing_left表示玩家是否向左
IMAGE img_shadow;//玩家阴影图片
Animation* anim_left;//玩家朝向左边的动画
Animation* anim_right;
};
2.4、Bullet类:子弹类
在该项目中,子弹数量一共有三颗,每颗子弹都是围绕着玩家做圆周运动,并且子弹在做圆周运动时会离玩家时远时近。子弹类的实现比较简单,在该类中只需要记录子弹的半径和位置,以及绘制子弹即可,至于围绕玩家做圆周运动的功能则是由一个全局函数完成(全局函数的内容将会在第三部分提及)。
子弹类的设计:
POINT pos{ 0,0 };//子弹坐标
const int RADIUS = 10;//子弹(圆形)半径
子弹并没有使用任何图片或者动画,因此只需要使用EasyX程序库所提供的绘图函数来绘制实心圆即可,同时,由于不需要通过子弹类的构造函数来初始化任何成员变量,因此可以不设计该类的构造函数和析构函数,或者直接使用默认的即可。
Bullet类的代码如下:
class Bullet//子弹类
{
public:
POINT pos{ 0,0 };//子弹坐标
public:
//显式指定要默认的构造函数和析构函数
Bullet() = default;
~Bullet() = default;
void Draw() const//绘制子弹
{
setlinecolor(RGB(255, 155, 50));//设置线条颜色
setfillcolor(RGB(200, 75, 10));//设置填充颜色
fillcircle(pos.x, pos.y, RADIUS);
}
private:
const int RADIUS = 10;//子弹(圆形)半径
};
2.5、Enemy类:敌人类
在本项目中,敌人类的设计与玩家类的设计有些相似,但与玩家类不同的是,敌人类会判断敌人是否被玩家击杀,或者击杀了玩家,所以在敌人类中,我们要将敌人、玩家以及子弹抽象成对应的形状,并根据这些形状来判断是否玩家与敌人、或者敌人与子弹是否发生了碰撞。
敌人类的成员变量如下:
const int SPEED = 3;//设置敌人移动速度
const int FRAME_WIDTH = 96;//敌人动画宽度
const int FRAME_HEIGHT = 96;//敌人动画高度
const int SHADOW_WIDTH = 48;//敌人阴影宽度
POINT pos{ 0,0 };//记录敌人位置
//使用bool变量来设置敌人移动方向
bool is_move_up = false;
bool is_move_down = false;
bool is_move_left = false;
bool is_move_right = false;
bool facing_left = true;//使用facing_left表示敌人是否向左
bool alive = true;//判断敌人是否存活
IMAGE img_shadow;//敌人阴影图片
Animation* anim_left;//敌人朝向左边的动画
Animation* anim_right;
1)Enemy类的构造函数:
与玩家类一样,需要加载阴影图片、以及初始化敌人动画。与玩家类不同的是,敌人类的构造函数还需要初始化敌人的生成位置,这个位置最好是随机的、同时也是恰好位于屏幕边界之外的。因此,我们需要通过rand() % 4来随机获取敌人的生成位置,并将这个随机位置赋给敌人的位置。
2)Enemy类的析构函数:
由于敌人类的动画开辟在堆区,所以需要在该析构函数中delete,防止内存泄漏。
3)敌人移动逻辑:
在该项目中,敌人只需要一直朝着玩家位置移动即可,所以在该敌人类中,敌人的移动逻辑设计的比较简单。
首先通过玩家位置和敌人位置来计算出敌人在x、y轴的方向以及移动方向的向量,之后与玩家类的移动逻辑相似,如果敌人的移动方向向量不为0,则说明敌人在移动。通过敌人的移动方向向量来计算出敌人在x、y轴的单位向量,再根据该单位向量乘以速度来修改敌人的位置。最后再根据敌人在x轴的方向来修改敌人的朝向。
4)绘制敌人:
与玩家类相似,先根据敌人位置绘制出敌人阴影图片,再根据敌人的朝向来绘制出敌人对应朝向的动画。
5)检测敌人是否与子弹发生了碰撞:bool checkBulletCollision(const Bullet& bullet)
将传入的子弹对象视为一个点,当子弹位于敌人的动画帧大小范围内,则子弹碰撞到了敌人。注意:检测碰撞时,要水平碰撞和竖直碰撞同时发生了,才可以视为发生了碰撞。
6)检测敌人是否与玩家发生了碰撞:bool checkPlayerCollision(const Player& player)
将敌人抽象成为一个点,当这个点进入了玩家的动画帧范围内,则敌人攻击到了玩家。
注意:检测碰撞时,要水平碰撞和竖直碰撞同时发生了,才可以视为发生了碰撞。
Enemy类的代码如下:
class Enemy//敌人类
{
public:
Enemy()
{
loadimage(&img_shadow, _T("img/shadow_enemy.png"));//加载敌人阴影图片
anim_left = new Animation(atlas_enemy_left, 45);//从全局函数中已初始化好的atlas中加载敌人朝向左边的动画,播放动画帧间隔为45ms
anim_right = new Animation(atlas_enemy_right, 45);
//敌人可以生成所在的边界:
enum class SpawnEdge { Up = 0, Down, Left, Right };
SpawnEdge edge = (SpawnEdge)(rand() % 4);//敌人随机在一条边界生成
switch (edge)//随机敌人的生成位置:
{
case SpawnEdge::Up:
pos.x = rand() % WINDOW_WIDTH;//敌人生成位置的x轴为在上边界宽度随机出来一个点
pos.y = -FRAME_HEIGHT;//敌人的生成位置的y轴刚好在上边界的边缘
break;
case SpawnEdge::Down:
pos.x = rand() % WINDOW_WIDTH;
pos.y = WINDOW_HEIGHT;
break;
case SpawnEdge::Left:
pos.x = -FRAME_WIDTH;
pos.y = rand() % WINDOW_HEIGHT;
break;
case SpawnEdge::Right:
pos.x = WINDOW_WIDTH;
pos.y = rand() % WINDOW_HEIGHT;
break;
default:
break;
}
}
~Enemy()
{
delete anim_left;
delete anim_right;
}
bool checkBulletCollision(const Bullet& bullet)
{
//将子弹视为一个点,当子弹位于敌人的动画帧大小范围内,则子弹碰撞到了敌人
bool is_overlap_x = (bullet.pos.x >= this->pos.x && bullet.pos.x <= this->pos.x + this->FRAME_WIDTH);//子弹在动画帧的x轴范围内
bool is_overlap_y = (bullet.pos.y >= this->pos.y && bullet.pos.y <= this->pos.y + this->FRAME_HEIGHT);
return is_overlap_x && is_overlap_y;//返回子弹是否碰撞到了敌人
}
bool checkPlayerCollision(const Player& player)
{
//将敌人抽象成为一个点,当这个点进入了玩家的动画帧范围内,则敌人攻击到了玩家
POINT check_pos = { this->pos.x + this->FRAME_WIDTH / 2,this->pos.y + this->FRAME_HEIGHT / 2 };//敌人的碰撞检测范围
bool is_overlap_x = (check_pos.x >= player.GetPosition().x && check_pos.x <= player.GetPosition().x + player.FRAME_WIDTH);//敌人在玩家动画帧的x轴范围内
bool is_overlap_y = (check_pos.y >= player.GetPosition().y && check_pos.y <= player.GetPosition().y + player.FRAME_HEIGHT);
return is_overlap_x && is_overlap_y;//返回敌人是否碰撞到了玩家
}
void Move(const Player& player)//敌人移动
{
const POINT& player_pos = player.GetPosition();//player是常对象,所以只能调用常成员函数
int dir_x = player_pos.x - this->pos.x;//敌人x轴的方向
int dir_y = player_pos.y - this->pos.y;
double len_dir = sqrt(dir_x * dir_x + dir_y * dir_y);//获取玩家移动方向上的向量
if (len_dir != 0)
{
//使用double来尽可能降低因为类型转换而造成的精度丢失
double normalized_x = dir_x / len_dir;//normalized_x是在获取x轴的单位向量
double normalized_y = dir_y / len_dir;
pos.x += static_cast<int>(SPEED * normalized_x);
pos.y += static_cast<int>(SPEED * normalized_y);
}
if (dir_x < 0)//改变敌人在x轴的方向
facing_left = true;
else if (dir_x > 0)
facing_left = false;
}
void Draw(int delta)//绘制敌人
{
int shadow_x = pos.x + (FRAME_WIDTH - SHADOW_WIDTH) / 2;//计算阴影图片应该所在的位置
int shadow_y = pos.y + FRAME_HEIGHT - 35;
putimage_alpha(shadow_x, shadow_y, &img_shadow);
//由于敌人始终保持移动,所以不需要根据敌人的方向来改变敌人静止时的朝向
if (facing_left)
anim_left->Play(pos.x, pos.y, delta);
else
anim_right->Play(pos.x, pos.y, delta);
}
void Hurt()//敌人受伤逻辑
{
alive = false;
}
bool GetAlive()//获取敌人是否存活
{
return alive;
}
private:
const int SPEED = 3;//设置敌人移动速度
const int FRAME_WIDTH = 96;//敌人动画宽度
const int FRAME_HEIGHT = 96;//敌人动画高度
const int SHADOW_WIDTH = 48;//敌人阴影宽度
private:
POINT pos{ 0,0 };//记录敌人位置
//使用bool变量来设置敌人移动方向
bool is_move_up = false;
bool is_move_down = false;
bool is_move_left = false;
bool is_move_right = false;
bool facing_left = true;//使用facing_left表示敌人是否向左
bool alive = true;//判断敌人是否存活
IMAGE img_shadow;//敌人阴影图片
Animation* anim_left;//敌人朝向左边的动画
Animation* anim_right;
};
2.6、Button类:按钮类 —— 让其作为父类
Button类的设计只要是为了封装一些按钮的通用功能,并让其它更具体的按钮类来继承该类以便实现不同按钮类的不同功能。
1)由于按钮拥有不同的状态,因此使用枚举(enum)来为按钮状态定义一个常量,代码如下:
enum class Status//按钮状态
{
Idle = 0,
Hovered,
Pushed
};
2)按钮的通用成员变量:
RECT region;//按钮的绘制区域
IMAGE img_idle;//按钮的默认状态图片
IMAGE img_hovered;//按钮的悬停状态图片
IMAGE img_pushed;//按钮的按压状态图片
Status status = Status::Idle;//按钮的状态
注意:RECT表示矩形类型。
1)Button类的构造函数:
由于成员变量中有按钮的绘制区域,以及按钮不同状态的图片,所以需要通过有参构造函数传入的参数来为这些成员变量赋初值.
2)Button类的析构函数:
由于在构造函数中没有使用new关键字,所以不需要再析构函数中使用delete,可以直接使用默认的析构函数。
3)按钮的点击事件:virtual void OnClick() = 0;
由于不同的按钮被点击后会有不同的点击事件,所以将此成员函数设为纯虚函数,有子类来实现具体逻辑。
4)按钮信息处理逻辑: void ProcessEvent(const ExMessage& msg)
通过获取鼠标的信息来判断此时按钮应该处于何种状态,并修改成对应的状态,同时,如果在对应的按钮区域内捕获到了鼠标点击事件,则执行在子类中实现的逻辑功能。
5)绘制按钮:
根据按钮的状态来绘制对应的按钮图片。
6)判断鼠标是否位于按钮区域内:
如果鼠标位于按钮区域内,则返回真,反之返回假。
Button类的代码如下:
class Button//按钮类
{
public:
Button(RECT reg, LPCTSTR img_path_idle, LPCTSTR img_path_hovered, LPCTSTR img_path_pushed)
{
region = reg;
loadimage(&img_idle, img_path_idle);//加载图片
loadimage(&img_hovered, img_path_hovered);
loadimage(&img_pushed, img_path_pushed);
}
~Button() = default;
enum class Status//按钮状态
{
Idle = 0,
Hovered,
Pushed
};
void ProcessEvent(const ExMessage& msg)//按钮处理信息——通过信息来改变按钮状态
{
switch (msg.message)
{
case WM_MOUSEMOVE://获取鼠标移动事件
if (status == Status::Idle && checkCursor(msg.x, msg.y))//如果按钮目前处于默认状态并且鼠标在按钮区域内,则按钮改为悬停状态
status = Status::Hovered;
else if (status == Status::Hovered && !checkCursor(msg.x, msg.y))//如果按钮目前处于悬停状态,并且鼠标不在按钮区域内,则按钮改为默认状态
status = Status::Idle;
break;
case WM_LBUTTONDOWN://获取鼠标左键点击事件
if (checkCursor(msg.x, msg.y))//如果鼠标在按钮区域内,并且点击了鼠标左键——则按钮改为按压状态
status = Status::Pushed;
break;
case WM_LBUTTONUP://获取鼠标左键松开事件
if (status == Status::Pushed && checkCursor(msg.x, msg.y))//如果按钮处于按压状态,并且在按钮区域内松开了鼠标左键——则触发按钮点击事件
OnClick();
else if (status == Status::Pushed && !checkCursor(msg.x, msg.y))//如果按钮目前处于按下状态,并且鼠标不在按钮区域内还松开了鼠标左键,则按钮改为默认状态
status = Status::Idle;
break;
default:
break;
}
}
void Draw()//绘制按钮
{
switch (status)
{
case Status::Idle:
putimage(region.left, region.top, &img_idle);//left:矩形左部 x 坐标。 top:矩形顶部 y 坐标。
break;
case Status::Hovered:
putimage(region.left, region.top, &img_hovered);
break;
case Status::Pushed:
putimage(region.left, region.top, &img_pushed);
break;
}
}
bool checkCursor(int x, int y)//判断鼠标是否在按钮区域内
{
return x >= region.left && x <= region.right && y >= region.top && y <= region.bottom;
}
protected:
virtual void OnClick() = 0;//鼠标点击按钮事件——因不同按钮而已,所以将其设为纯虚函数
private:
RECT region;//按钮的绘制区域
IMAGE img_idle;//按钮的默认状态
IMAGE img_hovered;//按钮的悬停状态
IMAGE img_pushed;//按钮的按压状态
Status status = Status::Idle;//按钮的状态
};
2.6.1、StartButton类:开始按钮类 —— 继承于Button类
StartButton类由于继承了Button类,所以实现起来十分简单。对于其构造函数,只需要调用父类的构造函数以及传入父类构造函数所需要的参数,并且具体实现父类的纯虚函数即可。
由于开始按钮决定了是否开始游戏,所以在全局区域中设置一个bool变量来指定是否开始游戏,并在开始按钮类的点击事件中将其设为true。
开始按钮的代码如下:
class StartButton :public Button//游戏开始按钮
{
public:
StartButton(RECT reg, LPCTSTR img_path_idle, LPCTSTR img_path_hovered, LPCTSTR img_path_pushed)
:Button(reg, img_path_idle, img_path_hovered, img_path_pushed) {}
~StartButton() = default;
protected:
void OnClick()
{
is_game_start = true;//开始游戏
//进入游玩界面之后在播放背景音乐:
mciSendString(_T("play bgm repeat from 0"), nullptr, 0, nullptr);//加上repeat告诉编译器,我不仅要播放此bgm,并且还是要重复播放
}
};
2.6.2、QuitButton类:退出按钮类 —— 同样继承于Button类
QuitButton类与StartButton类类似,同样也是在其构造函数中调用父类的构造函数以及传入对于的参数,并且具体实现父类的纯虚函数即可。
由于退出按钮类决定了游戏是否运行,所以在全局区域中设置一个bool变量来指定是否进行游戏主循环,若执行了退出按钮的点击事件,则将其设为false,游戏主循环也将不会继续进行。
QuitButton类的代码如下:
class QuitButton :public Button//游戏退出按钮
{
public:
QuitButton(RECT reg, LPCTSTR img_path_idle, LPCTSTR img_path_hovered, LPCTSTR img_path_pushed)
:Button(reg, img_path_idle, img_path_hovered, img_path_pushed) {}
~QuitButton() = default;
protected:
void OnClick()
{
is_running = false;//结束运行游戏
}
};
3、项目内容中全局函数的实现:
在第二部分内容中主要讲解了任何实现各种类的实现,而在第三部分,将会讲解本项目中全局函数的实现,并通过这些全局函数来完善项目中的各种功能。
3.1、子弹更新逻辑:void UpdateBullet(vector<Bullet>& bullet_list, const Player& player)
早在第二部分的Bullet类提到过,要是有全局函数来实现子弹更新逻辑,因为玩家同时拥有三颗子弹(一颗子弹需要更新一次则需要调用一次成员函数,如果有很多子弹则需要同时调用很多个成员函数,这样的内存开销比较大),而每颗子弹又相对独立,所以就不在Bullet类中的成员函数来实现更新子弹的逻辑,转而使用全局函数并且将所有子弹封装为数组传入该全局函数,这样可以减少内存的开销,同时也要传入玩家类对象,因为子弹位置的更新需要玩家的位置。
实现子弹围绕玩家做圆周运动并且距离玩家时远时近的算法:
想要做到上面提到的要求,则需要提供子弹的切向速度以及径向速度。
1)让子弹距离玩家时远时近:通过给出的径向速度,并且使用GetTickCount()函数和三角函数sin()来改变子弹做圆周运动的运动半径。
注意:GetTickCount()是 Windows API 中的一个函数,用于返回自操作系统启动以来经过的时间(以毫秒为单位)。这样子弹的运动半径就会随着时间而改变。
算法如下:
const double RADIAL_SPEED = 0.0045;//径向波动速度——让子弹距离玩家时远时近的速度
double radius = 100 + 25 * sin(GetTickCount() * RADIAL_SPEED);//获取运动半径
2)让子弹围绕玩家做圆周运动:通过给出的切向速度,并且计算出每颗子弹之间的弧度(三颗子弹,每一颗子弹隔120度),再使用for循环和GetTickCount()函数来让每一颗子弹随着时间而改变其本身的弧度,最后再通过玩家位置与当前子弹的弧度来计算得出子弹更新后的位置。
算法如下:
double radian_interval = 2 * 3.14159 / bullet_list.size();//计算出子弹之间的弧度
for (size_t i = 0; i < bullet_list.size(); i++)
{//依次改变当前子弹的弧度——让子弹围绕玩家旋转
double radian = GetTickCount() * TANGENT_SPEED + radian_interval * i;
bullet_list[i].pos.x = player_pos.x + player.FRAME_WIDTH / 2 + (int)(radius * sin(radian));
bullet_list[i].pos.y = player_pos.y + player.FRAME_HEIGHT / 2 + (int)(radius * cos(radian));
}
子弹更新逻辑的全局函数代码如下:
void UpdateBullet(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_pos = 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].pos.x = player_pos.x + player.FRAME_WIDTH / 2 + (int)(radius * sin(radian));
bullet_list[i].pos.y = player_pos.y + player.FRAME_HEIGHT / 2 + (int)(radius * cos(radian));
}
}
3.2、生成敌人逻辑:
在本项目中,敌人是每隔一段时间就刷出来一只,因此在该函数中,可以使用计时器来判断何时需要刷新敌人。但为了简单起见,这里改用了计数器,因为该函数是放在游戏主循环中调用的,所以该函数的调用频率是十分高的,因此声明一个静态int类型变量来作为计数器,当该函数的调用次数到了一定数量,则可以刷新出来一只敌人。
注意:静态类型的变量(由static关键字声明)不会因为函数执行完毕而被销毁。
生成敌人逻辑的全局函数代码如下:
void TryGenerateEnemy(vector<Enemy*>& enemy_list)//生成敌人
{
const int INTERVAL = 100;//生成敌人的间隔
static int counter = 0;//计数器
if ((++counter) % INTERVAL == 0)//当计数器到达生成敌人的间隔时,就生成敌人
enemy_list.push_back(new Enemy());
}
3.3、绘制带透明度的图片的绘图函数:
由于EasyX自带的绘图函数无法绘制出带有透明度的图片,因此需要手动写一个可以绘制出带有透明度图片的函数,而在该函数想要使用一些相应的程序库函数,则需要链接对应的库。
代码如下:
#pragma comment(lib,"MSIMG32.LIB")//链接对应的库——这样才能使用AlphaBlend()函数
inline void putimage_alpha(int x, int y, IMAGE* img)//类比putimage函数,程序员自行封装一个putimage_alpha函数,这样就不会绘制出黑框
{
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.4、绘制玩家得分:
此函数用于在屏幕上绘制玩家获得的得分,每击杀一个敌人就可以获得一分。
代码如下:
void DrawPlayerScore(int score)//绘制玩家得分
{
static TCHAR text[64];
_stprintf_s(text, _T("当前玩家得分为:%d"), score);
setbkmode(TRANSPARENT);//这个函数用于设置当前设备图案填充和文字输出时的背景模式。TRANSPARENT——这个背景是透明的
settextcolor(RGB(255, 85, 185));
outtextxy(10, 10, text);
}
4、游戏主循环:
游戏主循环主要的事情有三件:读取操作、处理数据、绘制画面。
1)资源加载操作:
在游戏主循环中应当注意,要尽量避免在游戏主循环中进行资源加载的动作,以及还应该尽量避免阻塞式的行为或者过于繁重且耗时过长的任务。因为对于玩家来说,他们会更加愿意在启动游戏前多等待一会,也不愿意在进行游玩时发生卡顿,因此资源加载的动作要尽量放在主循环的外面。
2)将游戏维持在60帧:
在游戏主循环中,为了减少内存的开销,一般将主循环控制在60帧的时间内(1000/60,单位为毫秒)。使用GetTickCount()获取主循环开始时的时间start_time,并在主循环即将结束时再次调用GetTickCount()来获取主循环结束时的时间end_time,让end_time减去start_time,如果结果小于1000/60毫秒,则说明此时游戏的帧数是大于60帧的,让程序休眠这多出来的时间即可让游戏维持在60帧之内。
3)在主循环中绘图:
为了防止游戏中绘制出来的图片有闪烁的效果,因此需要调用BeginBatchDraw()函数和FlushBatchDraw()函数,同时为了避免上一次绘制的图片出现在屏幕上,还需要调用cleardevice()函数来进行清屏。
注意:BeginBatchDraw()函数表示停止绘图,并且准备批量绘图,直到遇到FlushBatchDraw()或者EndBatchDraw()就开始批量绘图。
游戏主循环的代码如下:
BeginBatchDraw();//停止绘图,准备批量绘图,直到遇到FlushBatchDraw()或者EndBatchDraw()就开始批量绘图
while (is_running)//游戏主循环
{
DWORD start_time = GetTickCount();
while (peekmessage(&msg))//获取信息——事件处理部分
{
if (is_game_start)//如果开始游玩,则处理玩家类消息
player.ProcessEvent(msg);//玩家处理获取到的信息
else//如果游戏还没有开始游玩,则处理按钮消息
{
btn_start_game.ProcessEvent(msg);
btn_quit_game.ProcessEvent(msg);
}
}
//使用全局函数和常量来实现播放动画:
//static int counter = 0;//counter为游戏帧计数器
//if (++counter % 5 == 0)//让每5个游戏帧切换一个动画帧
// idx_current_anim++;//播放下一个动画帧
实现循环播放动画:
//idx_current_anim = idx_current_anim % PLAYER_ANIM_NUM;//当播放到最后一个动画帧时,将当前动画索引重置为0
//putimage_alpha(player_pos.x, player_pos.y, &img_player_left[idx_current_anim]);//这里不能使用putimage函数,否则会有一次黑框
if (is_game_start)//如果游戏开始游玩,则处理玩家和敌人的相关逻辑——数据处理部分
{
player.Move();//玩家移动不能放到获取信息的循环里面,否则玩家移动会卡顿
TryGenerateEnemy(enemy_list);//每隔移动时间生成敌人
UpdateBullet(bullet_list, player);//更新子弹位置——让子弹围绕玩家旋转
for (Enemy* enemy : enemy_list)//C++11
enemy->Move(player);//批量移动敌人
for (Enemy* enemy : enemy_list)//检测敌人是否碰撞到了玩家
{
if (enemy->checkPlayerCollision(player))
{
mciSendString(_T("play hurt from 0"), nullptr, 0, nullptr);//播放玩家死亡音效
static TCHAR text[64];
_stprintf_s(text, _T("你的得分为:%d"), score);
//GetHWnd()——这个函数用于获取绘图窗口句柄。
//在 Windows 下,句柄是一个窗口的标识,得到句柄后,可以使用 Windows API 中的函数实现对窗口的控制。
//参数2:窗口显示的文本 参数3:窗口标题 参数4:显示ok按钮
MessageBox(GetHWnd(), text, _T("游戏结束"), MB_OK);//弹窗
is_running = false;//游戏结束
break;
}
}
for (Enemy* enemy : enemy_list)//依次判断敌人是否碰撞到了子弹
{
for (const Bullet& bullet : bullet_list)
{
if (enemy->checkBulletCollision(bullet))
{
mciSendString(_T("play hit from 0"), nullptr, 0, nullptr);//播放敌人死亡音效
enemy->Hurt();
score++;//玩家每杀一个敌人,则+1分
}
}
}
for (size_t i = 0; i < enemy_list.size(); i++)//移除已死亡的敌人
{
Enemy* enemy = enemy_list[i];
if (!enemy->GetAlive())//如果敌人已死亡——则delete
{
//使用swap算法来提高效率
swap(enemy_list[i], enemy_list.back());//让已死亡的敌人与敌人列表中最后一个敌人交换在vector中的位置
enemy_list.pop_back();//将放置在vector尾部已死亡的敌人弹出vector
delete enemy;//最后delete已死亡的敌人——防止内存泄漏
}
}
}
cleardevice();//清屏
if (is_game_start)//如果游戏开始游玩,则绘制玩家和敌人还有游戏背景
{
//绘图操作要放在清屏和批量绘图之间:
putimage(0, 0, &img_background);//绘制背景
DrawPlayerScore(score);//绘制玩家得分
player.Draw(1000 / 144);//绘制玩家
for (Enemy* enemy : enemy_list)
enemy->Draw(1000 / 144);//批量绘制敌人
for (const Bullet& bullet : bullet_list)//绘制子弹
bullet.Draw();
}
else//如果还没有开始游玩,则绘制主菜单和按钮
{
putimage(0, 0, &img_menu);
btn_start_game.Draw();
btn_quit_game.Draw();
}
FlushBatchDraw();//批量绘图
DWORD end_time = GetTickCount();
DWORD delta_time = end_time - start_time;
if (delta_time < 1000 / 60)//如果画面帧数大于60帧,则让程序休眠超过60帧的那部分时间
Sleep(1000 / 60 - delta_time);
}
EndBatchDraw();
5、项目总结与个人感悟:
至此,《提瓦特幸存者》项目可以说已经完成了。通过完成此项目,可以学习到有关EasyX程序库的函数和类型,以及学习到了制作游戏时应当具备的思想,还有游戏运行时的底层逻辑。完成此项目给我最大的感触是,原来可以不使用业内主流的游戏引擎也可以通过一些代码来制作一些小游戏。
我曾经使用过虚幻5来尝试制作一些小游戏,那时的我还认为脱离了业内主流的游戏引擎就基本无法制作游戏了,但通过完成此项目,让我初步了解到了有关游戏以及游戏引擎更加底层的知识。
写在最后:
作者本人只不过是一名涉世未深的大学生,只是因为爱玩游戏从而萌生了想要去制作游戏的想法。如果身为读者的你读完了这篇我心血来潮而写的文章并且还对这篇文章还对你有帮助,那我会感到万分荣幸。这篇文章的内容写得比较粗糙,如有大神路过并看到了这篇文章,还望高抬贵手、嘴下留情,如果要是能给我这位迷茫的初学者一些宝贵的建议或是经验分享,那就万分感激了。
最后,在说一遍,如果里面的内容(如:代码、图片等)有侵权行为,烦请各位好心人们可以提醒一下我,我受到消息后就立马删除该篇文章。