C++基于EasyX制作贪吃蛇游戏(五)第三版文档

本文最后更新于:2020年8月5日 中午

继续完善贪吃蛇,改用面向对象的思想完成代码,引入界面UI以及排行榜。

上接 C++基于EasyX制作贪吃蛇游戏(三)第二版文档 继续更新制作贪吃蛇游戏的一些相关设计。

程序展示

以下是B站视频

上面视频不能播放请移步:https://www.bilibili.com/video/BV1fZ4y1T7xo/

改用面向对象

原先两版程序都是使用的面向过程方式编写的,函数以及全局变量在整个文件之中飘……,本次决定改用面向对象的方式重写代码,毕竟挺缺少面向对象的练习,可能写出来的代码不是很好,但是我会尽量去完善的。

改用面向对象之后,我会尽力将绘制与数据计算这两者分开,不让两者混杂在一个函数内。所以重写的代码会改变以前两个版本的代码,不过核心流程还是一样的。

公共数据 common.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//蛇的节点半径
#define SNAKE_RADIU 9
//食物的半径
#define FOOD_RADIU 8
//蛇的节点宽度
#define SNAKE_WIDTH 20
//背景颜色,黑色
#define BG_COLOR 0

//方向的枚举
enum class Dir { DIR_UP = 1, DIR_RIGHT = 2, DIR_DOWN = 3, DIR_LEFT = 4 };

//点的结构体
struct Point {
int x;
int y;

Point() :x(-1), y(-1) {}
Point(int dx, int dy) :x(dx), y(dy) {}
Point(const Point& point) :x(point.x), y(point.y) {}

bool operator==(const Point& point)
{
return (this->x == point.x) && (this->y == point.y);
}

};

//记录游玩信息
struct PlayerMsg
{
int id;
int score;
int len;
std::string r_time; //记录时间

PlayerMsg()
{
id = 99;
score = 0;
len = 0;
r_time = "";
}
};

struct SortPlayerMsg
{
bool operator()(const PlayerMsg &msg1, const PlayerMsg &msg2)
{
if (msg1.score == msg2.score)
{
return msg1.r_time > msg2.r_time;
}
else return msg1.score > msg2.score;
}
};

公共数据头文件,定义以及存储一些常用的数据结构。

Dir是枚举方向类。

Point是点的结构体,重载了==操作符, 便于两个点集的比较。

PlayerMes是用来存储游玩信息。SortPlayerMsg重载了()操作符, 便于两个PlayerMessort排序。详情请看:STL专题-sort、reverse

Snake类的设计 —— 贪吃蛇类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Snake
{
public:
const int MinSpeed = 1; //蛇的最小速度
const int MaxSpeed = 25; //蛇的最大速度
const int OrgSpeed = 15; //蛇的原始速度

private:
int m_len; //蛇的长度
int m_speed; //蛇的速度
Dir m_direction; //蛇的方向
std::list<Point> m_snakelist; //蛇的链表
Point m_tail; //蛇移动过后的尾部节点,主要用于吃食物

public:
Snake();
~Snake();

int getLen(); //获取长度
int getSpeed(); //获取速度
Dir getDirection(); //获取方向
bool setSpeed(int speed); //设置速度,设置成功返回true

void Move(); //移动一节
void EatFood(); //吃食物
void ChangeDir(Dir dir); //改变方向
void Dead(); //死亡

bool ColideWall(int left,int top,int right,int bottom); //碰撞到墙
bool ColideSnake(); //碰撞到了自身
bool ColideFood(Point point); //碰到了食物

void DrawSnake(); //绘制蛇
void DrawSnakeHead(Point pos); //绘制蛇头
void DrawSnakeNode(Point pos); //绘制蛇的身体结点

std::list<Point> GetSnakeAllNode();

};

贪吃蛇类,开始使用STL中的list作为蛇的链表,不再使用自定义的链表。链表中存储Point类型的值,及节点的横纵坐标。

额外还需要蛇的方向、长度以及速度这几个参数。Point m_tail;参数在EatFood()函数那里进行说明。

三个 public const int 的速度是预先设置好的速度等级,方便之后使用。

  • bool setSpeed(int speed);函数用于改变蛇的速度,如若改变的蛇的速度超过最大值,那就将蛇的速度设置为最大值;最小值同理。如果修改速度成功就返回true
  • void Move();函数向蛇的方向移动一格,蛇的除蛇头以外的全部节点均向前复制一格。对应链表的操作可以用去除链表末尾的节点,复制链表头部的节点再插入头部,然后额外改变头部的值
  • void EatFood(); 函数主要描述蛇吃到食物之后的动作。在本游戏中,我设定蛇吃到食物后,尾部增长一格。因此需要一个变量来保存蛇刚刚走过的尾部节点,即Point m_tail;。蛇吃到食物后,将这个尾部节点加入链表即可。
  • void ChangeDir(Dir dir);改变方向,本来想起函数名为setDir(Dir dir)的,但是名字不太直观就换了。改变方向时,不是同方向或者不是反方向才能改变。
  • void Dead();死亡效果,因为蛇碰撞死后效果不太直观,就用随机函数改变一下各个节点的位置。但是效果很难看。
  • ColideWallColideSnake以及ColideFood来检测蛇的头部有没有碰撞到什么。
  • std::list<Point> GetSnakeAllNode();用于获取蛇的全部结点,主要用于食物生成检测时使用。

Food类的设计 —— 食物类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Food
{
private:
Point m_pos;
bool m_state;

public:
Food();

bool getState();
void setState(bool state);
Point getPos(); //获取食物坐标

void Generate(Snake *snake);//产生新的食物

void DrawFood();

};
  • 两个数据成员:食物位置以及食物状态。
  • Food();构造参数,其内设定了初始的食物位置,之后的位置需要使用Generate函数生成
  • void Generate(Snake *snake);生成食物函数,因为生成食物不能与蛇的节点重合,所以需要蛇的节点信息。

RankList类的设计 —— 排行榜类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class RankList
{
private:
std::vector<PlayerMsg> m_msg;
const std::string m_rankfile = "retro";
const int MAX_RANK = 10;
public:
RankList();

void SaveMsg(PlayerMsg msg);
std::vector<PlayerMsg> getRankList();
void SaveToRank();

private:
void WriteTime(PlayerMsg &msg);
void ReadFile();
void WriteFile();
};
  • 排行榜类主要作用是存储管理用户游玩结束之后的游戏数据,涉及了读写文件操作。
  • 使用vector来存储用户的游玩数据,上限是10条,即MAX_RANK。也就是排行榜只保存前10名的数据。固定的读写文件名为retro
  • 私有函数中void WriteTime(PlayerMsg &msg);来写入用户达成成绩的时间。ReadFile()读取配置文件数据,存入到vector中。WriteFile()vector中的数据写回配置文件中。
  • 构造函数RankList();中调用ReadFile()来初始化vector
  • void SaveMsg(PlayerMsg msg);是保存用户数据到vector中,如果其排名在10名之外,则不会保存成功。
  • void SaveToRank();是将vector中的数据写回文件,实际调用的是WriteFile()函数。

Game类的设计 —— 游戏控制类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Game
{
private:
int m_GameState; //游戏状态,0在主UI,1在游戏中,2在排行榜,3在游戏规则中
PlayerMsg m_msg; //游玩数据
Snake *m_snake; //蛇
Food *m_food; //食物
RankList *m_ranklist; //排行榜

public:
Game();

void Init(); //初始化
void Run(); //控制程序
void Close(); //关闭程序,释放资源

private:
void InitData(); //初始化数据

void PlayGame(); //开始游戏

void ShowMainUI(); //展示主UI
void ShowRank(); //排行榜展示
void ShowRule(); //展示规则界面

void DrawGamePlay(); //绘制初始游戏界面
void DrawScore(); //绘制分数
void DrawSnakeLen(); //绘制长度
void DrawSpeed(); //绘制速度
void DrawRunning(); //绘制正在运行
void DrawPause(); //绘制暂停提示
void DrawRebegin(); //绘制重新开始
void DrawGameOver(); //绘制游戏结束

void ChangeChooseUI(int left, int top, int right, int bottom, int kind);//修改选中的选项颜色
void ClearRegion(int left, int top, int right, int bottom); //使用背景色清除指定区域
};

Game类是游戏的控制类,也是游戏的主体,所以融合了上述全部的类。

Game主要被用于主函数调用,所以只有构造函数以及三个函数是public,其余全部private

  • 程序状态m_GameState,标识程序的运行状态,是在主界面?在游戏中?在排行榜中?还是在游戏帮助中,方便控制程序。

  • void Init();初始化,主要是进行图形库的初始化。

  • void Close();结束,主要是图形库释放资源。

  • void Run();用来运行程序,展示UI,等待用户操作。

  • 构造函数Game();主要是初始化一些数据,最主要的是设置程序状态m_GameState为0,以及初始化RankList,便于访问排行榜时可以看到数据。

  • InitData()初始化一些在开始游戏时才需要用到的数据,比如Snake以及Food,重置PlayMsg,防止原来的数据对新开一局的数据产生干扰。

  • PlayGame()则是游戏的控制函数,主要完成游戏中的全部控制,留在下面细说。

  • ChangeChooseUI这个函数主要就是改变选中选项的效果,重新绘制这个按钮的样式,增加程序与用户的交互。

UI设计

相较于之前的两版程序增加了UI,更加方便用户的控制,同时增加了鼠标的点选,更加直接。

例如上图左上角的返回键可以点击。

鼠标点击操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if ((m_GameState == 2 || m_GameState == 3) && MouseHit()) //在排行榜或者游戏帮助中点击
{
MOUSEMSG mouse = GetMouseMsg();//获取鼠标点击消息
if (mouse.mkLButton) //左键按下
{
if (mouse.x >= 20 && mouse.x <= 63 && mouse.y >= 20 && mouse.y <= 43)
{
//点击返回选项
ChangeChooseUI(20, 20, 63, 43, 5);
Sleep(500);

FlushMouseMsgBuffer();//清空鼠标消息缓冲区。

m_GameState = 0;
ShowMainUI();
}
}
}
  • MouseHit()来检测有没有鼠标点击事件,有的话为true。
  • GetMouseMsg()来获取鼠标点击消息,返回一个MOUSEMSG类型的数据。
  • FlushMouseMsgBuffer()来清空鼠标消息缓冲区,防止残存的消息对其他函数产生干扰。

游戏控制 - PlayGame()

相较于前两版程序,我换用了重绘机制。原版程序使用的是仅消除蛇的尾端,局部擦除与重绘的方式。

但是由于数据运算与绘制的分离,原版的方式不容易实现,于是现在使用的是每一次循环就重新绘制一次游戏界面的方式,也就是最常规的方式。

以下是伪流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
while(true)
{
if(检测食物是否存在)
{
不存在生成
}

if(按键检测)
{
改变方向或者暂停程序
}

Move();//移动

if(吃到食物)
{
长度增加
分数增加
食物状态改变
}

if(碰撞检测)
{
碰撞则死亡
...
}

清空区域
重绘蛇

sleep(200);

}

具体的内容可以在函数实现里看到

批量绘图

上述循环完成之后,界面每一次重新绘制都有些不太稳定,有闪烁的情况,这时就需要使用批量绘图。

  • BeginBatchDraw();开始批量绘图,其后的任何绘图操作暂时都不会进行绘制,直到执行 FlushBatchDraw()EndBatchDraw() 才将之前的绘图输出。

  • FlushBatchDraw() 用于执行绘制任务。

  • EndBatchDraw()结束批量绘图模式,并将还没有绘制的图完成绘制。

这三者加入到PlayGame()函数中,保证画面的流畅性。

结束语

至此,面向对象版贪吃蛇程序完成。这版程序主要做了一些事情:

  • 改用面向对象方式编写程序
  • 换用蛇的数据结构为STL的list,操作更加方便。
  • 将数据运算与绘制操作分离
  • 增加UI与用户交互效果
  • 增加排行榜机制,使用了文件读写操作。

一些不足:

  • 食物类的生成算法需要检测蛇的节点保证不覆盖,因此效率可能比较差,实际运行时会有卡顿现象。考虑后续引入多线程解决。
  • UI还是挺难看的……
  • 等待补充……