一、前言
本文主要介绍如何使用C/C++实现一个控制台版本的2048小游戏。
如果你想要实现带图形界面的,可以参考本站:推箱子小游戏,使用EGE图形库,贴图片即可
当然,网上已经有了很多现成的代码,但新手应该也很难看懂他们的代码逻辑
但我会详尽介绍每一步的来由,力争让每个人都能轻易看懂。
如果你还没有玩过这个游戏,那建议你先通过下面这个网站在线玩玩看:2048
思考一下它的实现原理是什么?
二、思考游戏逻辑
玩了这个游戏之后,你应该能明显感受到它本质就是一个二维数组
- 最开始随机在两个位置生成两个数字
- 然后通过响应用户的按键信息,做出4个方向相同数字合并的动作,并再随机产生一个数字
- 最后再将处理好的游戏数据显示出来即可
首先是二维数组,这个很简单:
int map[4][4]{}; //存储游戏数据
注意最后的{}
,意思是将这个二维数组清零。
然后是生成随机数。
在程序中生成一个完全随机的随机数是一个非常困难的问题,因为程序本质就是计算,或者抽象成一个函数。
也就是说,一个函数在没有任何输入的情况下,基本不可能生成每次都不同的结果。
明白了这个道理,我们就可以来看看C++
中如何生成随机数
我们产生随机数的函数为:rand()
它会直接返回一个0
到RAND_MAX
之间的整数值,而RAND_MAX
这个宏在我这里定义的值是32767
但正如前面所说,一个函数如果没有任何输入,那么它输出的值就不可能完全随机
比如我这里直接运行4次下面这段代码:
cout << rand() << endl;
cout << rand() << endl;
cout << rand() << endl;
cout << rand() << endl;
每次运行的结果都是固定的值:
41
18467
6334
26500
所以我们需要给它设定一个数值,在这里叫做初始化种子
即这个函数会通过我们这个初始化数值来随机出后面的数值,只要我们每次传入的种子值不同,那么其随机出的数值就不会相同
为了保证每次的种子都设置的不一样,一般我们就用时间数值来作为种子
//设置一个随机数种子
srand(time(NULL));
srand
函数就是用来设置种子的,它唯一一个参数就是这个种子的数值- 而这个数值我们通过
time
函数来获取,给它传入NULL
,就会返回当前的时间数值
这就能够保证每次的种子都不同,那么计算出来的随机数也就不会相同了
至此我们就完成了基本的知识点讲解,下面正式开始写代码
三、游戏主循环
所谓游戏,便是一个不断循环的程序而已
所以第一步我们就得写好我们的游戏主循环
首先是头文件、枚举变量以及地图数据的全局定义:
#include <iostream> //主要输入输出头文件,还包括常用c函数,比如这里用到的rand,srand函数等等
#include<conio.h> //获取用户输入键值函数_getchar函数的头文件
#include <ctime> //time这个函数头文件
using namespace std;
int map[4][4]{}; //存储游戏数据
//上下左右
enum {
UP, //上
DOWN, //下
LEFT, //左
RIGHT //右
};
//游戏所处的状态
enum {
GAME_OVER, //游戏结束
GAME_WIN, //游戏获胜
GAME_CONTINUE //游戏继续
};
代码我注释的非常详细,关于枚举更加详细的介绍,可以参照本站的另一篇文章:枚举与枚举量。
接下来就是我们需要写的函数:
//根据数据打印游戏界面
void Show();
//在地图中随机产生一个随机数
bool CreateNumber();
//根据用户输入的键值进行移动数据
void Move(int direction);
//处理输入输出,返回上下左右
int Input();
//判断游戏状态
int IsWin();
那么我们如何知道要写上面这些函数呢?那当然是从main
函数开始思考游戏逻辑
main
函数先写好:
int main()
{
//设置一个随机数种子
srand(time(NULL));
//初始化,先随机产生两个随机数
CreateNumber();
CreateNumber();
//显示地图
Show();
//进入游戏循环
while (true)
{
//用户输入,然后游戏
}
}
- 这里首先设置好随机数的种子
srand(time(NULL));
- 然后
2048
这个游戏需要在最开始生成两个随机数,所以我们就需要写一个生成随机数的函数CreateNumber
,并调用两次 - 然后就可以显示游戏界面了,所以我们得写一个显示界面的函数
Show
- 最后就正式进入游戏循环
然后接下来我们再思考进入游戏循环后,应该做些什么
//进入游戏循环
while (true)
{
//获取用户输入的移动方向
int direction = Input();
//移动数据
Move(direction);
//判断目前游戏是否已经赢了或结束了
int gameState = IsWin();
if (gameState == GAME_CONTINUE) //游戏继续
{
CreateNumber(); //在地图中产生一个随机数
Show(); //然后刷新游戏界面
}
else if (gameState == GAME_WIN) //游戏已经胜利
{
Show(); //显示数据
cout << "You Win!" << endl;
break;
}
else if (gameState == GAME_OVER) //游戏结束
{
Show();
cout << "You lose!" << endl;
break;
}
}
-
首先我们应该等待用户的输入,即用户按了哪个方向的键,所以我们需要一个函数
Input
等待用户输入,并返回方向 -
得到用户输入的方向后,我们就可以根据这个方向来移动游戏数据了,所以就得写一个根据方向来移动数据的函数
Move(int direction)
-
移动完成后,我们就得判断当前游戏的状态,是赢了,输了还是进行下一次循环?所以就有了函数
IsWin
,用它来获取当前游戏的状态并返回 -
最后我们根据这个返回的状态,来判断我们需要做哪些操作
- 如果游戏继续,那么就在地图上随机产生一个随机数,然后进入下一次循环
- 如果赢了或输了,那就根据情况输出相应的提示信息即可
至此,我们就思考好了游戏的整体逻辑,那么下一步就是写好各个函数的具体内容即可
四、函数详解
1.CreateNumber
首先是在地图上产生随机数的函数:
bool CreateNumber()
{
bool f = false; //记录当前地图中是否存在空位置,假设没有
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++)
{
if (map[i][j] == 0) {
f = true; //有空位置,则置true
}
}
}
if (!f) return false; //没有空位置,则不产生随机数,直接返回
int x = -1;
int y = -1;
//三分之二的概率生成2,三分之一的概率生成4
int whitch = rand() % 3 ? 2 : 4;
//循环找到一个随机坐标,放入随机数
do
{
x = rand() % 4; //随机x坐标
y = rand() % 4; //随机y坐标
} while (map[x][y] != 0);
map[x][y] = whitch;
return true;
}
这个函数的主要逻辑就是:
- 先判断当前地图有没有空位置,如果没有,则直接返回
false
- 先产生一个随机数,
2/3
的概率为2
,1/3
的概率为4
- 然后再在这个地图数据(二维数组)中随机找到一个空位置(即为0)
- 让这个空位置等于上面产生的随机数,最后返回即可
这里讲解一下三元运算符的用法:
ret= bool ? res1 : res2
的意思是,如果bool
为true
,即不为0,那么ret
就等于res1
,否则ret
就等于res2
懂了这个语句,我们再来看这里就会简单很多
rand() % 3
代表产生一个随机数然后对3
取模,那么结果只能为0 ,1 ,2
- 而
0
代表false
,1
和2
就代表true
,这就恰好是1/3
与2/3
的关系,所以2
写在了:
前,即为1,2
的时候,就取2
,为0
的时候就取4
然后接下来再来看如何产生一个随机坐标的
因为我们这里本质就是一个二维数组,且下标只能取0,1,2,3
,所以就可以通过产生一个随机数,对4
取模,就能得到0,1,2,3
中的随机值
do
{
x = rand() % 4; //随机x坐标
y = rand() % 4; //随机y坐标
} while (map[x][y] != 0);
这里写入do while
语句的目的就是,为了确保取得的这个坐标为空,如果不为空,那就重新随机,直到取得一个为空的坐标
2.Show
创建完两个随机数,我们就得显示界面了
void Show()
{
system("cls"); //清空屏幕
cout << "***************** 2048 控 制 台 版 ******************" << endl;
cout << "***************** 作者:cadn 余识 ******************" << endl << endl;
//双循环按map变量中的数据生成地图
for (int i = 0; i < 4; ++i)
{
cout << "---------------------------------" << endl; //上下行数据的分割线
for (int j = 0; j < 4; ++j) //遍历显示数据
{
if (map[i][j] == 0) //等于0说明为空
{
cout << "| \t";
}
else //不等于0则输出数据
{
cout << "| " << map[i][j] << "\t";
}
}
cout << "|" << endl;
}
cout << "---------------------------------" << endl; //最底下的横线
}
由于这个游戏界面就是许多横竖线组成的框,而控制台想要在指定位置输出字符并不太好控制
所以这里采取的就是直接一行一行的输出
首先我们需要清空屏幕:
system("cls"); //清空屏幕
cls
是windows
批处理指令,它应该是clear screen
的缩写,system
就是c
提供给我们在代码中执行批处理指令的函数, 它的作用就是清空屏幕
然后下面就是一个双循环,用于输出游戏界面,输出后长下面这样:
这是比较基础的东西了,就是循环输出而已,只是有些繁琐,需要注意输出的各种格式
这里就不再过多讲解了
3.Input
然后就是获取用户的输入了!
int Input()
{
while (true) {
char c = _getch();
switch (c) {
case 'w':
case 'W':
case 72: //测试得到的上箭头对应的值
return UP;
case 's':
case 'S':
case 80: //下箭头对应的键码值
return DOWN;
case 'a':
case 'A':
case 75: //左箭头对应的键码值
return LEFT;
case 'd':
case 'D':
case 77: //右箭头对应的键码值
return RIGHT;
default:
break; //不是指定的键,继续下一次循环
}
}
}
这里主要用到的函数是_getch
,这个函数的作用是直接接收一个用户输入的字符就返回,不会显示在屏幕上,也不需要你按enter
键
根据一般习惯,w s a d
这四个键一般用于上下左右,所以上面就通过多个case
语句,进行判断返回
但也有人更习惯于键盘上的上下左右箭头,所以就可以自己测试一下当你按键盘上的下上下左右箭头时,这个函数返回的是什么值,上面的各个数值就是我测试出来的
如果用户输入的其它值,就不返回,进入下一次循环,否则直接返回对应的方向
4.Move
然后是移动数据,这个函数是我们游戏的核心,内容也稍微多一些:
void Move(int direction)
{
switch (direction)
{
case UP:
//最上面一行不动
for (int row = 1; row < 4; row++)
{
for (int crow = row; crow >= 1; crow--)
{
for (int col = 0; col < 4; col++)
{
//上一个格子为空
if (map[crow - 1][col] == 0)
{
map[crow - 1][col] = map[crow][col];
map[crow][col] = 0;
}
else
{
//合并
if (map[crow - 1][col] == map[crow][col])
{
map[crow - 1][col] *= 2;
map[crow][col] = 0;
}
}
}
}
}
break;
case DOWN:
//最下面一行不动
for (int row = 4 - 2; row >= 0; --row)
{
for (int crow = row; crow < 4 - 1; ++crow)
{
for (int col = 0; col < 4; ++col)
{
//上一个格子为空
if (map[crow + 1][col] == 0)
{
map[crow + 1][col] = map[crow][col];
map[crow][col] = 0;
}
else
{
//合并
if (map[crow + 1][col] == map[crow][col])
{
map[crow + 1][col] *= 2;
map[crow][col] = 0;
}
}
}
}
}
break;
case LEFT:
//最左边一列不动
for (int col = 1; col < 4; ++col)
{
for (int ccol = col; ccol >= 1; --ccol)
{
for (int row = 0; row < 4; ++row)
{
//上一个格子为空
if (map[row][ccol - 1] == 0)
{
map[row][ccol - 1] = map[row][ccol];
map[row][ccol] = 0;
}
else
{
//合并
if (map[row][ccol - 1] == map[row][ccol])
{
map[row][ccol - 1] *= 2;
map[row][ccol] = 0;
}
}
}
}
}
break;
case RIGHT:
//最右边一列不动
for (int col = 4 - 2; col >= 0; --col)
{
for (int ccol = col; ccol <= 4 - 2; ++ccol)
{
for (int row = 0; row < 4; ++row)
{
//上一个格子为空
if (map[row][ccol + 1] == 0)
{
map[row][ccol + 1] = map[row][ccol];
map[row][ccol] = 0;
}
else
{
//合并
if (map[row][ccol + 1] == map[row][ccol])
{
map[row][ccol + 1] *= 2;
map[row][ccol] = 0;
}
}
}
}
}
break;
}
}
它的主体逻辑就是通过具体的方向,执行对应的数据合并移动等操作
四个方向的逻辑是相同的,所以我这里就只讲解其中一个方向的逻辑,其它的类似即可
比如当用户按了向上的键
那么第一行的内容就不用动了,我们从第二行开始,即从下标1开始循环
//最上面一行不动
for (int row = 1; row < 4; row++)
{
//遍历
}
我们要做的就是从第二行开始,遍历查找同列的前一行,是空位置,还是相同的数值,或是不同的数值?
每一行,我们都需要将当前行以前的所有行进行遍历,并向前合并移动,考虑下面这个例子:
初始列 | 第一次向上移动 | 第二次向上移动 |
---|---|---|
0 | 1 | 1 |
1 | 0 | 1 |
1 | 1 | 0 |
上面只是单纯的后面一行往前一行进行合并,但明显不合情理,第二次移动后,最前面两个1还应该合并一下,所以我们我们在代码中就需要向前遍历循环,来解决这种情况
所以里面还有两个循环:
for (int crow = row; crow >= 1; crow--)
{
for (int col = 0; col < 4; col++)
{
//上一个格子为空
if (map[crow - 1][col] == 0)
{
map[crow - 1][col] = map[crow][col];
map[crow][col] = 0;
}
else
{
//合并
if (map[crow - 1][col] == map[crow][col])
{
map[crow - 1][col] *= 2;
map[crow][col] = 0;
}
}
}
}
外层循环就是从当前行向前面所有行进行遍历,并向前合并
内层就是遍历该行所有的列,然后进行判断,如果与前面一行相同,则合并,为空,则向前移动,否则不处理
5.IsWin
然后我们还需要判断当前的游戏状态:
int IsWin()
{
//出现2048则赢得游戏
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
if (map[i][j] == 2048)
{
return GAME_WIN;
break;
}
}
}
//判断是否游戏已经结束
//横向检查
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4 - 1; ++j)
{
if (!map[i][j] || (map[i][j] == map[i][j + 1])) //判断是否有空格,或者有两个相邻元素等值,有着直接返回,游戏继续
{
return GAME_CONTINUE;
break;
}
}
}
//纵向检查
for (int j = 0; j < 4; ++j)
{
for (int i = 0; i < 4 - 1; ++i)
{
if (!map[i][j] || (map[i][j] == map[i + 1][j]))//判断是否有空格,或者有两个相邻元素等值,有着直接返回,游戏继续
{
return GAME_CONTINUE;
break;
}
}
}
//如果运行到这里,说明不符合上述两种状况,游戏结束
return GAME_OVER;
}
判断方法也很简单,就是进行全遍历
- 如果出现
2048
则判为胜利,直接return
GAME_WIN
- 如果没有胜利,那就判断是否存在空格,以及相邻两个数字相等的清空,有者直接
return GAME_CONTINUE
,游戏继续 - 如果上面两种情况都不存在,那就是游戏结束了,直接
return GAME_OVER;
三、完整代码
#include <iostream> //主要输入输出头文件,还包括常用c函数,比如这里用到的rand,srand函数等等
#include<conio.h> //获取用户输入键值函数_getchar函数的头文件
#include <ctime> //time这个函数头文件
using namespace std;
int map[4][4]{}; //存储游戏数据
//上下左右
enum {
UP, //上
DOWN, //下
LEFT, //左
RIGHT //右
};
//游戏所处的状态
enum {
GAME_OVER, //游戏结束
GAME_WIN, //游戏获胜
GAME_CONTINUE //游戏继续
};
//根据数据打印游戏界面
void Show();
//在地图中随机产生一个随机数
bool CreateNumber();
//根据用户输入的键值进行移动数据
void Move(int direction);
//处理输入输出,返回上下左右
int Input();
//判断游戏状态
int IsWin();
int main()
{
//设置一个随机数种子
srand(time(NULL));
//初始化,先随机产生两个随机数
CreateNumber();
CreateNumber();
//显示地图
Show();
//进入游戏循环
while (true)
{
//获取用户输入的移动方向
int direction = Input();
//移动数据
Move(direction);
//判断目前游戏是否已经赢了或结束了
int gameState = IsWin();
if (gameState == GAME_CONTINUE) //游戏继续
{
CreateNumber(); //在地图中产生一个随机数
Show(); //然后刷新游戏界面
}
else if (gameState == GAME_WIN) //游戏已经胜利
{
Show(); //显示数据
cout << "You Win!" << endl;
break;
}
else if (gameState == GAME_OVER) //游戏结束
{
Show();
cout << "You lose!" << endl;
break;
}
}
}
void Show()
{
system("cls"); //清空屏幕
cout << "***************** 2048 控 制 台 版 ******************" << endl;
cout << "***************** 作者:cadn 余识 ******************" << endl << endl;
//双循环按map变量中的数据生成地图
for (int i = 0; i < 4; ++i)
{
cout << "---------------------------------" << endl; //上下行数据的分割线
for (int j = 0; j < 4; ++j) //遍历显示数据
{
if (map[i][j] == 0) //等于0说明为空
{
cout << "| \t";
}
else //不等于0则输出数据
{
cout << "| " << map[i][j] << "\t";
}
}
cout << "|" << endl;
}
cout << "---------------------------------" << endl; //最底下的横线
}
bool CreateNumber()
{
bool f = false; //记录当前地图中是否存在空位置,假设没有
for (int i = 0; i < 4; i++) {
for (int j = 0; j < 4; j++)
{
if (map[i][j] == 0) {
f = true; //有空位置,则置true
}
}
}
if (!f) return false; //没有空位置,则不产生随机数,直接返回
int x = -1;
int y = -1;
//三分之二的概率生成2,三分之一的概率生成4
int whitch = rand() % 3 ? 2 : 4;
//循环找到一个随机坐标,放入随机数
do
{
x = rand() % 4; //随机x坐标
y = rand() % 4; //随机y坐标
} while (map[x][y] != 0);
map[x][y] = whitch;
return true;
}
int IsWin()
{
//出现2048则赢得游戏
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4; ++j)
{
if (map[i][j] == 2048)
{
return GAME_WIN;
break;
}
}
}
//判断是否游戏已经结束
//横向检查
for (int i = 0; i < 4; ++i)
{
for (int j = 0; j < 4 - 1; ++j)
{
if (!map[i][j] || (map[i][j] == map[i][j + 1])) //判断是否有空格,或者有两个相邻元素等值,有着直接返回,游戏继续
{
return GAME_CONTINUE;
break;
}
}
}
//纵向检查
for (int j = 0; j < 4; ++j)
{
for (int i = 0; i < 4 - 1; ++i)
{
if (!map[i][j] || (map[i][j] == map[i + 1][j]))//判断是否有空格,或者有两个相邻元素等值,有着直接返回,游戏继续
{
return GAME_CONTINUE;
break;
}
}
}
//如果运行到这里,说明不符合上述两种状况,游戏结束
return GAME_OVER;
}
int Input()
{
while (true) {
char c = _getch();
switch (c) {
case 'w':
case 'W':
case 72: //测试得到的上箭头对应的值
return UP;
case 's':
case 'S':
case 80: //下箭头对应的键码值
return DOWN;
case 'a':
case 'A':
case 75: //左箭头对应的键码值
return LEFT;
case 'd':
case 'D':
case 77: //右箭头对应的键码值
return RIGHT;
default:
break; //不是指定的键,继续下一次循环
}
}
}
void Move(int direction)
{
switch (direction)
{
case UP:
//最上面一行不动
for (int row = 1; row < 4; row++)
{
for (int crow = row; crow >= 1; crow--)
{
for (int col = 0; col < 4; col++)
{
//上一个格子为空
if (map[crow - 1][col] == 0)
{
map[crow - 1][col] = map[crow][col];
map[crow][col] = 0;
}
else
{
//合并
if (map[crow - 1][col] == map[crow][col])
{
map[crow - 1][col] *= 2;
map[crow][col] = 0;
}
}
}
}
}
break;
case DOWN:
//最下面一行不动
for (int row = 4 - 2; row >= 0; --row)
{
for (int crow = row; crow < 4 - 1; ++crow)
{
for (int col = 0; col < 4; ++col)
{
//上一个格子为空
if (map[crow + 1][col] == 0)
{
map[crow + 1][col] = map[crow][col];
map[crow][col] = 0;
}
else
{
//合并
if (map[crow + 1][col] == map[crow][col])
{
map[crow + 1][col] *= 2;
map[crow][col] = 0;
}
}
}
}
}
break;
case LEFT:
//最左边一列不动
for (int col = 1; col < 4; ++col)
{
for (int ccol = col; ccol >= 1; --ccol)
{
for (int row = 0; row < 4; ++row)
{
//上一个格子为空
if (map[row][ccol - 1] == 0)
{
map[row][ccol - 1] = map[row][ccol];
map[row][ccol] = 0;
}
else
{
//合并
if (map[row][ccol - 1] == map[row][ccol])
{
map[row][ccol - 1] *= 2;
map[row][ccol] = 0;
}
}
}
}
}
break;
case RIGHT:
//最右边一列不动
for (int col = 4 - 2; col >= 0; --col)
{
for (int ccol = col; ccol <= 4 - 2; ++ccol)
{
for (int row = 0; row < 4; ++row)
{
//上一个格子为空
if (map[row][ccol + 1] == 0)
{
map[row][ccol + 1] = map[row][ccol];
map[row][ccol] = 0;
}
else
{
//合并
if (map[row][ccol + 1] == map[row][ccol])
{
map[row][ccol + 1] *= 2;
map[row][ccol] = 0;
}
}
}
}
}
break;
}
}