我们先分析游戏的功能需求:有一块区域,区域内有一条蛇和一个食物,蛇通过上下左右移动到达食物所在的点,蛇吃到食物,蛇长度增加,重新生成食物,分数增加,如果蛇头碰到自己的身体或者区域边界,游戏结束。否则,游戏继续。
我们用MVC的框架来实现它,即 Model——View——Controller:
Model
:定义和实现相关的结构体类型以及功能函数
View
:定义和实现所有输出相关的函数
Controller
:定义和实现游戏逻辑相关的函数,将Model和View中的函数联系起来
从Model中先开始看
首先,我们需要知道在贪吃蛇的游戏中,我们会用到哪些结构体,通过这些结构体,我们基本可以构造出一个贪吃蛇的游戏。这里先附上我贪吃蛇的游戏界面,方便构思:
通过这幅图,我们先来分析一波:在界面中出现的元素都有 蛇
食物
墙
分数
操作提示
分数排行榜
。
现在,我们就需要将他们一一表示出来:
蛇
:
1. 蛇的属性可以简单的分解为:蛇头
,蛇尾
,蛇前进的方向
,蛇前进的速度
。
2. 由于蛇是动态增长的,所以用链表的形式来保存,链表的每个节点都由一个数据域和一
个指针域组成。因此需要一个节点类型的变量。然后将这些变量连接起来形成链表。当贪吃
蛇吃食物变长时,为了减少时间复杂度,我们用链表的头当蛇的尾巴,链表的尾当蛇的头。
当蛇前进时,增加蛇头就等于链表的尾插,删除蛇尾就等于链表的头删,因此它的时间复杂
度都是为O(1)。
3. 在这里数据域是为了表示坐标,因此我们需要一个新的类型来表示坐标,这个类型包含
两个元素,即 X
和 Y
。
4. 蛇前进的方向只有四种,即:上
下
左
右
因此,我们用枚举的方式来定义。
结合上面的四点,我们需要定义的结构体就有:
typedef enum Direction{ //方向
UP, DOWN, LEFT, RIGHT
}Direction;
typedef struct Position{ //节点的数据域
int x;
int y;
}Position;
typedef struct Node{ //节点
Position pos;
struct Node *next;
}Node;
typedef struct Snake{ //蛇的结构体
Node *head;
Node *tail;
int speed; //我们通过Sleep函数来控制蛇的前进速度
Direction direction;
}Snake;
食物
分数
分数排行榜
:
1. 食物的属性只有一种,即 食物的坐标
,而坐标实质即为之前定义的Position
类型的一
个变量。
2. 分数,只需要用一个整型的变量记录当前分数,并在每一次移动后刷新输出即可。
3. 分数排行榜,每次游戏结束后,将本次的分数与已存储的分数比较,将前十再次存储
即可。
墙
操作提示
:
1. 这两种元素,只需要给定一个坐标,将需要输出的内容输出即可
我们对上面的所有元素进行一次封装,将它们封装在一个结构体内:
typedef struct Game {
Snake snake; //蛇
Position food; //食物坐标
int width; //宽
int height; //高
int score; //分数
}Game;
到这里,贪吃蛇中用到的结构体基本就构建完成了,下面我们来分析执行的操作:
1. 首先,我们需要对游戏进行初始化:即 :初始化蛇的各项属性
初始化食物的位置
初始化
分数
初始化游戏界面
上面这些功能我们可以封装到一个函数内,即初始化游戏
,在初始化蛇的属性时,我们
需要给定蛇头的位置
和蛇前进的方向
蛇的长度
这样我们就能将几个连续的坐标连接成一条蛇
最后还需要给定蛇的速度
。
#define DEFAULT_HEAD 8
void SnakeInitialize(Snake *pSnake)
{
int i = 0;
assert(pSnake != NULL);
pSnake->direction = LEFT;
pSnake->speed = 300;
pSnake->head = NULL;
pSnake->tail = NULL;
//链表 尾 头
// 这里给出的蛇坐标为 : (8,8) <- (9, 8) <-(10, 8)
//pSnake head tail
//这里默认蛇的长度为3
for(i = DEFAULT_HEAD; i < DEFAULT_HEAD + 3; i++) {
int x = DEFAULT_HEAD + i;
int y = DEFAULT_HEAD;
Node *newnode = (Node *)malloc(sizeof(Node));
assert(newnode != NULL);
newnode->pos.x = x;
newnode->pos.y = y;
newnode->next = pSnake->tail;
pSnake->tail = newnode;
if (pSnake->head == NULL) {
pSnake->head = pSnake->tail;
}
}
}
初始化食物的位置时,我们用随机生成的方式,在游戏的范围内生成即可,同时,食物
也不能出现在蛇的身体上。
void GenerateFood(Game *pgame)
{
int x = 0, y = 0;
assert(pgame != NULL);
do {
x = rand()%(pgame->width);
y = rand()%(pgame->height);
}while(IsInSnake(x, y, &(pgame->snake)));
//IsInSnake(x, y, &(pgame->snake) 用来判断是否在蛇的身体上。
//如果在 返回 1 循环继续 如果不在 返回 0 跳出循环
(pgame->food).x = x;
(pgame->food).y = y;
}
最后我们将它们封装在 初始化游戏
中:
void GameInitialize(Game *pgame)
{
assert(pgame != NULL);
pgame->height = 28;
pgame->width = 28;
SnakeInitialize(&(pgame->snake));
GenerateFood(pgame);
pgame->score = 0;
}
2. 而当游戏进行时,我们需要执行的操作有:蛇前进
判断蛇是否吃到食物
判断游戏是否结
束判断分数是否增加
。Model中我们只实现操作相关的函数,判断相关的函数在Controller中
实现。
关于蛇前进的操作其实只有两步,即:① 将下一个到达的坐标置为蛇头 ② 将当前的蛇
尾去掉。而因为我们的蛇头是链表的尾,所以只需要让当前的头的next
指向新的节点即
可。删除蛇尾也就是进行一次链表的头删。
//蛇增加头 链表的尾插
void SnakeAddHead(Snake *pSnake, Position *nextpos)
{
assert(pSnake != NULL);
Node *newnode = (Node *)malloc (sizeof(Node));
assert(newnode != NULL);
newnode->pos.x = nextpos->x;
newnode->pos.y = nextpos->y;
newnode->next = NULL;
PrintSnakeBlock(&(pSnake->head->pos), nextpos);
// PrintSnakeBlock在View 中实现
pSnake->head->next = newnode;
pSnake->head = newnode;
}
//蛇删除尾 链表的头删
void SnakeRemoveTail(Snake *pSnake)
{
Node *del = NULL;
assert(pSnake != NULL);
del = pSnake->tail;
pSnake->tail = pSnake->tail->next;
CleanSnakeBlock(&(del->pos));
//CleanSnakeBlock在View 中实现
free(del);
del = NULL;
}
3. 最后游戏结束时,我们需要执行的操作有:刷新排行榜
销毁游戏
游戏开始时,我们先将排行吧中的数据取出来,游戏结束时,判断新的成绩能否加入排
行榜,(判断的方式是对排行榜个数 + 1
个数进行排序,等到保存和输出的时候,只保存
排行榜个数
个数据。比如:排行榜保存前10名
,当取出10
个成绩后,将它们保存在一个大
小为11
的数组中,将本局分数存入最后一个
,进行排序,前10
个就为新的排行榜)并将排
行榜数据再次保存。最后再将蛇的身体链表free掉即可。
//读取排行榜
void SnakeRankRead(int size, int *prank)
{
int i = 0, k = 0;
assert(prank != NULL);
FILE *pf = NULL;
pf = fopen("Rank.dat", "a");
pf = fopen("Rank.dat", "r");
assert(pf != NULL);
while ((i < size) && ((k = fgetc(pf)) != EOF)) {
(*(prank + i++)) = k;
}
}
//保存排行榜
void SnakeRankSave(int size, int *prank)
{
int i = 0;
FILE *pf = NULL;
assert(prank != NULL);
pf = fopen("Rank.dat", "w");
assert(pf != NULL);
for (i = 0; i < size; i++) {
fputc(*(prank + i), pf);
}
}
//销毁蛇身体
void SnakeDestroy(Snake *pSnake)
{
Node *del, *cur = NULL;
assert(pSnake != NULL);
for (cur = pSnake->tail; cur != NULL;) {
del = cur;
cur = cur->next;
free(del);
}
pSnake->head = NULL;
pSnake->tail = NULL;
pSnake->speed = 0;
}
再看View
我们说过View 中实现的是与输出相关的,游戏中的输出则共有下面几种:输出蛇
输出食物
输出边界
输出提示
输出分数
输出排行榜
开始界面
View中的输出我们而已采用移动光标的方式来做,这里用SetConsoleCursorPosition
函数来实现。
//X 为横 Y 为纵
static void SetCurPos(int X, int Y)
{
HANDLE hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
COORD coord = { X, Y };
SetConsoleCursorPosition(hStdOutput, coord);
}
因为我们在前面定义的边界为28*28,我们是从左上角,也就是(0,0)的位置开始输出,又因为中文字符占两个坐标位置,因此,在输出时需要对输出的界面进行转换。
static void NewCurPos(int X, int Y)
{
X = 2 * (X + 1);
Y = Y + 1;
SetCurPos(X, Y);
}
为了界面的美化,我们还需要一个隐藏光标的函数:
void ViewInit(int width, int height)
{
HANDLE hStdOutput = GetStdHandle(STD_OUTPUT_HANDLE);
CONSOLE_CURSOR_INFO info;
GetConsoleCursorInfo(hStdOutput, &info);
info.bVisible = 0;
SetConsoleCursorInfo(hStdOutput, &info);
}
输出蛇
: 输出蛇分为两种:一种是最开始的时候我们需要将整个蛇输出。另外一种是蛇在前进过
程中的输出。在这里我们采用局部刷新的方法,即:当蛇前进一步时,我们在新的蛇头的位
置输出一块蛇的身体,在原来蛇尾的部分输出两个空格(蛇的身体为中文字符,占两个空格
)。这样我们就可以根据光标的移动输出,来显示蛇的前进。
//输出蛇
void PrintSnake(const Snake *pSnake)
{
Node *cur = NULL;
assert(pSnake != NULL);
for (cur = pSnake->tail; cur != NULL; cur = cur->next)
{
NewCurPos((cur->pos).x, (cur->pos).y);
if (cur == pSnake->head) {
printf("⊙");
}
else {
printf("※");
}
}
SetCurPos(65, 16);
}
//新输出的蛇头
void PrintSnakeBlock(const Position *prevpos, const Position *curpos)
{
NewCurPos(prevpos->x, prevpos->y);
printf("※");
NewCurPos(curpos->x, curpos->y);
printf("⊙");
}
//清除掉的蛇尾
void CleanSnakeBlock(const Position *curpos)
{
NewCurPos(curpos->x, curpos->y);
printf(" ");
}
输出食物
输出边界
输出提示
输出分数
输出排行榜
开始界面
:
只需要将光标移动到指定位置输出即可:
//输出界面
void PrintStart0(int width, int height)
{
ViewInit(width, height);
NewCurPos(width / 4, height / 2);
printf("PRESS SPACE TO START GAME !");
}
void PrintStart1(int width, int height)
{
NewCurPos(width / 4, height / 2);
printf(" ");
}
void PrintEnd(int width, int height)
{
NewCurPos(width / 3, height / 2);
printf("GAME OVER!");
NewCurPos(width + 2, height);
}
void PrintWall(int width, int height)
{
int i = 0;
//上
SetCurPos(0, 0);
for (i = 0; i < width + 2; i++) {
printf("█");
}
//左
for (i = 0; i < height + 2; i++) {
SetCurPos(0, i);
printf("█");
}
//下
SetCurPos(0, height + 1);
for (i = 0; i < width + 2; i++) {
printf("█");
}
//右
for (i = 0; i < height + 2; i++) {
SetCurPos(2 * (width + 1), i);
printf("█");
}
}
//输出食物
void PrintFood(const Position *pFood)
{
NewCurPos(pFood->x, pFood->y);
printf("●");
}
//输出分数
void PrintScore(int width, int height, int score)
{
NewCurPos(width + 2, height / 3);
printf("当前分数:%d", score);
}
//输出排行榜
void PrintRank(int width, int height, int size, const int *pscore)
{
int i = 0;
assert(pscore != NULL);
NewCurPos(width + 15, 0);
printf("Rank:");
for (i = 1; i <= size; i++) {
NewCurPos(width + 15, i);
printf("第%2d名:%5d分", i, *(pscore + i - 1));
}
}
//输出提示
void PrintFunction(int width, int height)
{
NewCurPos(width + 2, height / 2);
printf("空格键暂停");
NewCurPos(width + 2, (height / 2) + 5);
printf("长按方向键加速");
}
因为上面这些函数在游戏的一开始就需要整体进行一次输出,因此我们将它们封装在一个函数内部,方便调用,在需要单独输出时,再单独调用即可。
void PrintGame(const Game *pGame)
{
PrintWall(pGame->width, pGame->height);
PrintSnake(&(pGame->snake));
PrintScore(pGame->width, pGame->height, pGame->score);
PrintFunction(pGame->width, pGame->height);
}
最后是Controller的部分
在这里我们将Model中的函数以及View中的函数联系起来,最后组成我们的贪吃蛇。
- 游戏开始:
a. 进行界面输出PrintGame
b. 游戏的初始化GameInitialize
c. 从排行榜文件中读取数据
将以前的排行榜中的数据取出来,存放在一个比排行榜容量大1
的数组中。
#define DEFAULT_RANKNUM 10
int *prank = NULL;
prank = (int *)malloc(sizeof(int) * (DEFAULT_RANKNUM + 1));
SnakeRankRead(DEFAULT_RANKNUM, prank);
- 游戏进行:
a. 输出食物PrintFood
b. 输出当前分数PrintScore
c. 进行按键操作
这里我们用到了GetAsyncKeyState
函数来检测我们按下的键,当我们按下一个键,并且目前蛇的方向与我们按下键的方向不是反方向时,蛇的方向更改为我们按下的键的方向。如果本次按下的键的方向与上次的相同,那么蛇的前进速度增加。也就是speed
的值减小,在这里我使用了一个固定值50,这并不会改变蛇自身的速度的值,只是通过isspeed
这个标记的值来决定,使用当前的速度,还是固定值50,作为本次循环中Sleep
函数的值。如果我们按下了空格,那么游戏会进入暂停状态,直下一次按下空格。
if (GetAsyncKeyState(VK_UP) && game.snake.direction != DOWN) {
if (game.snake.direction == UP) {
isspeed = 1;
}
game.snake.direction = UP;
}
else if (GetAsyncKeyState(VK_DOWN) && game.snake.direction != UP) {
if (game.snake.direction == DOWN) {
isspeed = 1;
}
game.snake.direction = DOWN;
}
else if (GetAsyncKeyState(VK_LEFT) && game.snake.direction != RIGHT) {
if (game.snake.direction == LEFT) {
isspeed = 1;
}
game.snake.direction = LEFT;
}
else if (GetAsyncKeyState(VK_RIGHT) && game.snake.direction != LEFT) {
if (game.snake.direction == RIGHT) {
isspeed = 1;
}
game.snake.direction = RIGHT;
}
else if (GetAsyncKeyState(VK_SPACE)) {
Pause();
}
d. 判断食物是否被吃。
只需要判断蛇头的位置是否在食物的坐标处即可。当走到这一步的时候,已经检测过一次按键
了,所以蛇头已经变为了新的方向上的蛇头,因此在这一步需要获取下一个坐标,GetNextPos
也
就是新的蛇头的位置。用新的蛇头的位置和食物的位置做比较。
i. 如果被吃:增加蛇头,更新分数,生成新的食物。
ii. 如果没有被吃:增加蛇头,删除蛇尾。
//获取新的蛇头的坐标
static Position GetNextPos(const Snake *pSnake)
{
assert(pSnake != NULL);
Position nextpos = pSnake->head->pos;
switch(pSnake->direction) {
case UP:
nextpos.x = (pSnake->head->pos).x;
nextpos.y = (pSnake->head->pos).y - 1;
break;
case DOWN:
nextpos.x = (pSnake->head->pos).x;
nextpos.y = (pSnake->head->pos).y + 1;
break;
case LEFT:
nextpos.x = (pSnake->head->pos).x - 1;
nextpos.y = (pSnake->head->pos).y;
break;
case RIGHT:
nextpos.x = (pSnake->head->pos).x + 1;
nextpos.y = (pSnake->head->pos).y;
break;
}
return nextpos;
}
//判断食物是否被吃
static int IsEat(const Position *pFood, const Position *nextpos)
{
return (pFood->x == nextpos->x) && (pFood->y == nextpos->y);
}
//判断按完之后的情况
Position nextpos = GetNextPos(&(game.snake));
//走的这里的时候,蛇的头部已经到达了下一个坐标处,只差赋值
// 吃到食物 没吃食物
if (IsEat(&(game.food), &nextpos)) {
//吃到食物 尾巴不变 头变长一个 重新生成食物 分数增加
GenerateFood(&game);
if (game.snake.speed > 80) {
game.snake.speed -= 5;
}
game.score += 10;
}
e. 判断游戏是否结束。
判断游戏是否结束,只需要判断蛇头与边界是否重合、蛇头与自身除蛇头以外的位置是否重合即可。
static int GameOver(const Game *pGame)
{
Node *cur = NULL;
assert(pGame != NULL);
//左边
if (pGame->snake.head->pos.x < 0) {
return 1;
}
//右边
if (pGame->snake.head->pos.x >= pGame->width) {
return 1;
}
//上边
if (pGame->snake.head->pos.y < 0) {
return 1;
}
//下边
if (pGame->snake.head->pos.y >= pGame->height) {
return 1;
}
//自己
for (cur = pGame->snake.tail; cur != pGame->snake.head; cur = cur->next) {
if (cur->pos.x == pGame->snake.head->pos.x && cur->pos.y == pGame->snake.head->pos.y) {
return 1;
}
}
return 0;
}
i. 如果结束,跳出循环。
ii. 如果没有结束,继续重复游戏进行的动作。
3. 游戏结束:
a. 输出结束界面PrintEnd
b. 更新排行榜
将本局的分数存入存放排行榜分数数组的最后一位,从最后一位往前排序,将排行榜个数的数
据从前到后输出,并保存。
(*(prank + DEFAULT_RANKNUM)) = game.score;
SnakeScoreSort(DEFAULT_RANKNUM + 1, prank);
SnakeRankSave(DEFAULT_RANKNUM, prank);
PrintRank(game.width, game.height, DEFAULT_RANKNUM, prank);
c. 游戏销毁 GameDestroy