一、设计思路
贪吃蛇游戏的基本游戏规则如下:
- 蛇在固定游戏框架区域内移动;
- 蛇身体行径轨迹会沿着蛇头行径轨迹移动;
- 框架内随机出现食物,蛇吃掉食物后身体变长,食物被吃掉后系统随机生成下一个食物;
- 当蛇头碰到蛇身体或障壁时游戏结束。
不难看出,在贪吃蛇游戏代码设计中,难点在于如何实现蛇身体移动轨迹与蛇头保持一致。如图所示:
从上图不难看出,蛇的移动方式基本遵循“加头去尾”的规则,即蛇头每次朝指定方向前进一个单位,蛇尾朝蛇头行径轨迹跟进一个单位;同时,蛇头每次“吃下”一个食物时,蛇尾“保持不动”而蛇头“前进一个单位”。
在学习了C++的标准模板库(STL)之后,笔者发现:通过设计一个贪吃蛇的类,该类中包含记录蛇头坐标和蛇尾坐标的成员,以及记录蛇身移动方式数据的vector向量成员。当蛇头每次移动一个单位,vector尾部插入一个记录蛇头移动方向的数据,同时vector首部删除一个数据,蛇尾移动前通过读取vector首部数据从而可以按照蛇头移动方向进行移动。而蛇头“吃下”食物时,只需在vector尾部插入记录蛇头移动方向的数据,vector首部不删除数据。
如此一来,仅使用vector向量记录的方式,即可用较少的代码实现贪吃蛇移动。
二、代码实现
下面通过实例代码来描述贪吃蛇游戏设计的基本思路。
程序设计平台:Visual Studio 2022 (需添加easyX插件)
打开Visual Studio 2022,选择添加C++控制台空白项目即可。
下面展示程序代码(附注释说明):
1、类定义
项目中添加.h类文件,添加如下代码:
#include <graphics.h>
#include <vector>
#include "Var.cpp"
using namespace std;
using namespace SnakeVar;
#pragma once
class Snake //贪吃蛇基类
{
public:
int HeadX; //蛇头部坐标
int HeadY;
int TailX; //蛇尾部坐标
int TailY;
vector<int> snake; //设置向量snake表示贪吃蛇蛇身方向数据,每个单位数值范围为[1,4]
int map[GameWidth][GameHeight] = { 0 }; //游戏区域
void SetHead(int cx, int cy, int length); //初始化蛇头位置
};
在相应.cpp 文件中对Snake::SetHead()函数进行定义:
#include "Snake.h"
void Snake::SetHead(int cx, int cy, int length)
{
HeadX = cx; //设置蛇头坐标
HeadY = cy;
for (int i=0;i<length;i++) //蛇头朝下length-1个单位均赋值为1代表蛇身
map[cx][cy+i] = 1;
TailX = cx; TailY = cy+length-1; //最后一个单位所在坐标为蛇尾坐标
snake.resize(length, 1); //初始化向量snake容量,数据均赋值为1
}
2、头文件引用及变量声明
#include <graphics.h> //使用easyX画图及窗口创建功能
#include <conio.h> //代码中使用getchar(),_getch()及_kbhit()函数需引用
#include <vector> //使用vector向量
#include <time.h> //时间函数为随机数提供种子
#include <stdlib.h> //使用随机函数rand()功能
using namespace std;
using namespace SnakeVar;
//变量声明区
unsigned int snakelength = InitSnakeLen; //蛇身长度
Snake newsnake;
static Direction Movingflag = DIR_UP; //初始默认运动方向为向上
3、定义常量及宏
新建一个Var.cpp文件用于定义常量及宏:
namespace SnakeVar
{
const unsigned int GameWidth = 15; //游戏区域长宽值
const unsigned int GameHeight = 15;
typedef enum { DIR_UP = 1, DIR_DOWN = 2,
DIR_LEFT = 3, DIR_RIGHT = 4 } Direction; //枚举移动方向类型
#define BLOCK 30 //单个方块像素单位
#define InitSnakeLen 2 //初始蛇长度(包括头部)
}
4、主程序函数声明及定义
在主程序所在Main.cpp文件中对所用函数声明如下:
//函数声明
int Crand(int MinValue, int MaxValue); //设置范围随机数
void DrawMap(); //绘制贪吃蛇运动范围
void InitGame(); //窗口初始化
void MoveSnake(Direction direction); //蛇身移动
void SetFood(); //在贪吃蛇运动范围内生成食物
void SetWindowCentered(HWND hwnd); //窗口显示中心化
下面对程序中使用的函数逐一说明:
(1)随机函数Crand()
int Crand(int MinValue, int MaxValue)
{
srand((unsigned)time(NULL) + (unsigned)rand());
int result = (rand() % (MaxValue - MinValue + 1)) + MinValue;
return result;
}
总所周知,C/C++语言产生随机数是需要“种子”(srand())来辅助的,然而系统默认种子数值为1,因此单纯使用rand()函数生成的随机数具有很强规律性,随机性很差。因此,在实践中通常使用时间函数time()作为种子以提高随机数的随机性。这里通过Crand()函数可以随机在最小值MinValue和最大值MaxValue之间生成随机数,为贪吃蛇的“食物”提供随机坐标位置。
(2)DrawMap()绘制界面
上文在Snake类中定义了二维数组map用于提供界面坐标,map数组中各单位不同数值代表不同含义:数值为0表示空白区域,用黑色方块表示;数值大于0表示该区域为贪吃蛇身体占据部分,用蓝色方块表示;数值小于0表示该点位为“食物”所在位置,用黄色方块表示。最后,根据Snake类中蛇头位置坐标HeadX及HeadY数值定位蛇头位置,用红色方块表示。
程序中每次map数组中发生数值变动,使用DrawMap()函数遍历map数组并重新绘制界面:
void DrawMap()
{
setlinestyle(PS_DOT); //单元格边框
for (int x = 0; x < GameHeight; x++)
{
for (int y=0;y< GameWidth;y++)
if (newsnake.map[y][x] == 0) //数值为0区域为空白区
{
setfillcolor(BLACK);
fillrectangle(y*BLOCK, x*BLOCK, (y+1)*BLOCK, (x+1)*BLOCK);
}
else if (newsnake.map[y][x] > 0) //数值大于0区域为蛇身部分
{
setfillcolor(BLUE);
fillrectangle(y * BLOCK, x * BLOCK, (y + 1) * BLOCK, (x + 1) * BLOCK);
}
else if (newsnake.map[y][x] < 0) //负数部分为食物
{
setfillcolor(YELLOW);
fillrectangle(y * BLOCK, x * BLOCK, (y + 1) * BLOCK, (x + 1) * BLOCK);
}
}
setfillcolor(RED); //蛇头绘制为红色
fillrectangle(newsnake.HeadX * BLOCK, newsnake.HeadY * BLOCK,
(newsnake.HeadX + 1) * BLOCK, (newsnake.HeadY + 1) * BLOCK);
}
(3)窗口初始化InitGame()
依据初始高度及初始宽度绘制窗口,并居中显示:
void InitGame()
{
initgraph(GameWidth * BLOCK, GameHeight * BLOCK, 0);
HWND hwnd = GetHWnd(); //获取窗口句柄
SetWindowCentered(hwnd); //窗口居中显示
}
(4)移动蛇头MoveSnake()
void MoveSnake(Direction direction)
{
vector<int>::iterator sp = newsnake.snake.begin(); //迭代器指向向量首部
unsigned int number; //传递移动方式数据
switch (direction)
{
case DIR_UP:
number = 1; //方向数据:向上移动为1,向下移动为2,向左移动为3,向右移动为4
if (newsnake.HeadY <= 0) exit(0); //蛇头碰到最顶端时
if (newsnake.map[newsnake.HeadX][newsnake.HeadY-1]<0) //当蛇头下一次移动位置上有食物
{
snakelength++; //蛇身长度增加
newsnake.snake.push_back(number); //向量尾部插入记录蛇头移动方式的数据
newsnake.map[newsnake.HeadX][--newsnake.HeadY] = number; //蛇尾坐标不变,蛇头坐标向当前方向增加一个单位,新坐标位置赋值大于0表示位置被蛇身占据
SetFood(); //生成下一个食物
}
else if (newsnake.map[newsnake.HeadX][newsnake.HeadY - 1]==0) //蛇头下一次移动位置为空白区域时
{
newsnake.snake.erase(sp); //蛇头每次移动一个单位,向量snake删除向量首部数据并在向量尾部添加蛇头读取的新方向数据
newsnake.snake.push_back(number);
newsnake.map[newsnake.HeadX][--newsnake.HeadY] = number;
switch (newsnake.snake.front()) //蛇尾移动方式需从向量snake首部读取数据
{
case 1:
newsnake.map[newsnake.TailX][newsnake.TailY--] = 0; //读取数值为1,蛇尾向上移动,同时原坐标位置设置为0
break;
case 2:
newsnake.map[newsnake.TailX][newsnake.TailY++] = 0; //读取数值为2,蛇尾向下移动,同时原坐标位置设置为0
break;
case 3:
newsnake.map[newsnake.TailX--][newsnake.TailY] = 0; //读取数值为3,蛇尾向左移动,同时原坐标位置设置为0
break;
case 4:
newsnake.map[newsnake.TailX++][newsnake.TailY] = 0; //读取数值为4,蛇尾向右移动,同时原坐标位置设置为0
break;
}
}
else //当蛇头下一次移动位置上数值大于0,表明蛇头碰到蛇身
{
exit(0); //程序退出
}
break;
case DIR_DOWN: //其他方向控制代码参考上述代码
number = 2;
if (newsnake.HeadY+1 >= GameHeight) exit(0);
if (newsnake.map[newsnake.HeadX][newsnake.HeadY + 1] < 0)
{
snakelength++;
newsnake.snake.push_back(number);
newsnake.map[newsnake.HeadX][++newsnake.HeadY] = number;
SetFood();
}
else if (newsnake.map[newsnake.HeadX][newsnake.HeadY + 1] == 0)
{
newsnake.snake.erase(sp);
newsnake.snake.push_back(number);
newsnake.map[newsnake.HeadX][++newsnake.HeadY] = number;
switch (newsnake.snake.front())
{
case 1:
newsnake.map[newsnake.TailX][newsnake.TailY--] = 0;
break;
case 2:
newsnake.map[newsnake.TailX][newsnake.TailY++] = 0;
break;
case 3:
newsnake.map[newsnake.TailX--][newsnake.TailY] = 0;
break;
case 4:
newsnake.map[newsnake.TailX++][newsnake.TailY] = 0;
break;
}
}
else
{
exit(0);
}
break;
case DIR_LEFT:
number = 3;
if (newsnake.HeadX <= 0) exit(0);
if (newsnake.map[newsnake.HeadX-1][newsnake.HeadY] < 0)
{
snakelength++;
newsnake.snake.push_back(number);
newsnake.map[--newsnake.HeadX][newsnake.HeadY] = number;
SetFood();
}
else if (newsnake.map[newsnake.HeadX-1][newsnake.HeadY] == 0)
{
newsnake.snake.erase(sp);
newsnake.snake.push_back(number);
newsnake.map[--newsnake.HeadX][newsnake.HeadY] = number;
switch (newsnake.snake.front())
{
case 1:
newsnake.map[newsnake.TailX][newsnake.TailY--] = 0;
break;
case 2:
newsnake.map[newsnake.TailX][newsnake.TailY++] = 0;
break;
case 3:
newsnake.map[newsnake.TailX--][newsnake.TailY] = 0;
break;
case 4:
newsnake.map[newsnake.TailX++][newsnake.TailY] = 0;
break;
}
}
else
{
exit(0);
}
break;
case DIR_RIGHT:
number = 4;
if (newsnake.HeadX + 1 >= GameWidth) exit(0);
if (newsnake.map[newsnake.HeadX+1][newsnake.HeadY] < 0)
{
snakelength++;
newsnake.snake.push_back(number);
newsnake.map[++newsnake.HeadX][newsnake.HeadY] = number;
SetFood();
}
else if (newsnake.map[newsnake.HeadX+1][newsnake.HeadY] == 0)
{
newsnake.snake.erase(sp);
newsnake.snake.push_back(number);
newsnake.map[++newsnake.HeadX][newsnake.HeadY] = number;
switch (newsnake.snake.front())
{
case 1:
newsnake.map[newsnake.TailX][newsnake.TailY--] = 0;
break;
case 2:
newsnake.map[newsnake.TailX][newsnake.TailY++] = 0;
break;
case 3:
newsnake.map[newsnake.TailX--][newsnake.TailY] = 0;
break;
case 4:
newsnake.map[newsnake.TailX++][newsnake.TailY] = 0;
break;
}
}
else
{
exit(0);
}
break;
}
DrawMap(); //每完成一次移动过程,界面绘制一次
Sleep(1000-(snakelength-InitSnakeLen)*10); //移动后延迟时间,蛇身越长,延迟时间越短
}
上文提到,贪吃蛇移动方式简化为蛇头坐标及蛇尾坐标数值变动以及snake向量增、删数据的算法。本代码中,对蛇头下一次移动后动作作如下判定:
- 当蛇头下一次移动位置为空白区域时,向snake向量尾部添加数据,同时删除snake向量首部数据;
- 当蛇头下一次移动位置上有食物时,仅完成向量尾部添加数据的动作,不删除向量首部数据;
- 当蛇头移动到游戏边界时,游戏结束。
具体移动方式处理可参考上述代码注释。
(5)随机生成食物SetFood()
void SetFood()
{
re: int cx = 0, cy = 0;
cx = Crand(0, GameWidth-1);
Sleep(150); //人为制造时间差,降低X与Y数值相同的几率
cy = Crand(0, GameHeight-1);
if (newsnake.map[cx][cy] > 0) //随机坐标位于蛇身时重新生成
goto re;
else newsnake.map[cx][cy] = -1;
}
该段代码中为方便,在判定随机坐标位是否位于蛇身的部分使用了goto语句,不喜欢goto语句的可用do-while循序方式改写。
(6)设置窗口居中SetWindowCentered()
void SetWindowCentered(HWND hwnd)
{
RECT rect; INT windowX, windowY;
GetWindowRect(hwnd, &rect);
windowX = (GetSystemMetrics(SM_CXFULLSCREEN) - (rect.right - rect.left)) / 2; //设置窗口居中显示位置
windowY = (GetSystemMetrics(SM_CYFULLSCREEN) - (rect.bottom - rect.top)) / 2;
SetWindowPos(hwnd, HWND_TOPMOST, windowX, windowY, -1, -1, SWP_NOSIZE | SWP_NOZORDER);
5、main函数部分
主程序段代码如下:
int main(int argc, char* argv[])
{
InitGame(); //初始化窗口
newsnake.SetHead(GameWidth/2, GameHeight/2, snakelength); //初始化蛇头位置
SetFood(); //设置食物位置
DrawMap(); //游戏界面绘制
while (true) //通过循环代码实现蛇身连续不停运动
{
if (_kbhit())
{
char ch = _getch(); //使用wsad按键实现上下左右方向控制
switch (ch)
{
case 'w':
if (Movingflag !=DIR_DOWN) Movingflag = DIR_UP; //当蛇头向下运动时向上运动按键失效,避免触碰蛇身
break;
case 's':
if (Movingflag != DIR_UP) Movingflag = DIR_DOWN; //当蛇头向上运动时向下运动按键失效
break;
case 'a':
if (Movingflag != DIR_RIGHT) Movingflag = DIR_LEFT; //当蛇头向右运动时向左运动按键失效
break;
case 'd':
if (Movingflag != DIR_LEFT) Movingflag = DIR_RIGHT; //当蛇头向左运动时向右运动按键失效
break;
}
}
MoveSnake(Movingflag); //完成一次蛇头运动,然后循环
}
getchar();
return 0;
}
代码编译后,调试效果如下:
内容如有错误,欢迎随时批评指正,谢谢各位。