2048游戏之——C++与控制台
2048游戏之——C++与控制台
- 概要
-
-
需求
-
设计
-
- 一、Map类
- 二、Event类
- 三、GameController类
- 四、total.h
- 五、gameloop.cpp
-
实现
-
- 一、实现Map
-
- 1、构造Map——RAII
-
2、随机生成——两种方法
-
3、Map自身渲染——Draw()
-
4、Map更新——Update()
-
5、操作和判断 Mapper矩阵
-
未完 ...
-
概要
我是Kpurek,首次在写blog,请多指教。
本文讲解我如何根据既定规则,设计2048游戏的框架结构,包括类设计、游戏流程设计、算法设计。因为游戏比较简单,设计起来思路容易理清,用控制台实现的话效果也不会很差(能玩)
最初版的效果:(还没有优化随机生成方法)

本项目开源,GitHub地址。
需求
1、显示4*4数字矩阵,左对齐
2、键盘输入,检测方向键,上下左右对应2048界面的上向左右滑动。
3、能够控制帧率,并尽量使界面流畅、舒适
4、游戏结束时暂停游戏
因此根据这些简单的需求,我构思出了以下的游戏框架:
1、核心部分:
存储游戏的信息,包括方块的值等信息;
实现游戏的核心算法,包括移动、合并方块以及判断游戏状态。
用于将矩阵对象渲染到屏幕上;
能够接受消息,例如键盘输入信息;
发送消息,例如游戏结束;
2、控制部分:
控制游戏流程,包括接收消息、处理消息等功能。
3、消息部分:
用于核心组件和控制组件的通信;
检测键盘事件;
处理游戏状态信息;
这个部分主要起到解耦作用,方便更改游戏的输入方式、状态信息。
4、还需要一个游戏循环状态机
我将它放在了main函数中。
因此,根据以上的框架,我设计出以下几个类:
设计
一、Map类
class Map
{
public:
构造
Map();
//析构,用于RAII的自动回收
~Map();
随机生成方块
void RandomCreate();
//渲染矩阵
void Draw()const;
//检测、更新事件
void Update(Event& ev);
//返回矩阵大小
size_t size()const;
private:
//向上操作
void Move_UP();
//向下
void Move_DOWN();
//向左
void Move_LEFT();
//向右
void Move_RIGHT();
//是否游戏结束
bool isOver();
private:
//Map的矩阵
int** Mapper;
//每次随机生成的方块数量不能超过2:
const int AdditionNumber = 2;
//矩阵大小
size_t m_size;
};
AI写代码
二、Event类
class Event //
{
public:
// 由键盘事件获取消息
void CheckKeyEvent();
//原本应该有的push和get方法(公有),游戏对象能够发送消息
//但本游戏几乎不需要,因此省略
/*
void pushEvent();
vector<STATE> getEvent()const;
*/
public:
使用vector容器搭建的数组,作为消息队列:
vector<STATE>state;
};
AI写代码
三、GameController类
class GameController
{
public:
//构造
GameController();
public:
//清空画面
void clear();
//渲染
void draw(Map const& map);
//控制
void display();
//设置帧率
void SetFPS(int const& fps);
//获取事件
void PullEvent(Event const& ev);
//窗口是否为打开状态
bool isOpen()const;
private:
//退出游戏
void exit();
//游戏结束
void GameOver();
private:
//是否打开
bool isOpening;
//是否结束
bool isGameOver;
//是否操作了矩阵
bool isMoved;
//每一帧的开始事件
time_t StartTime;
//帧率
int FPS;
//帧间间隔
time_t FrameTime;
};
AI写代码
各个类的成员、方法,都已经做了详细的注释;
可能有一点容易混淆:
Map类和GameController类都有一个名为draw的方法,一个是Draw一个是draw。要注意,这两个方法不止是名字有区别:Map的Draw是被GameController内部调用的,不会在我们的游戏循环内被直接调用。
也就是这样一个过程:
游戏循环(状态机)—调用—>GameController.draw()—调用—>Map.Draw()
那么说到状态,我使用了一个非常简单但使用的方法来定义游戏的状态——枚举。
枚举定义在了以下的总头文件中:
四、total.h
#pragma once
#include<iostream>
#include<conio.h>
#include<ctime>
#include<cassert>
#include<vector>
using namespace std;
各种宏
//ESC的ASCII
#define ESC_ASC 27
//方向键的前字符ASCII
#define DIRECTION_ASC 224
//方向键后字符ASCII
#define UP_ASC 72
#define DOWN_ASC 80
#define LEFT_ASC 75
#define RIGHT_ASC 77
//游戏公共事件的枚举类
enum class STATE
{
//游戏结束事件
GAME_OVER = 0,
//方向操作事件
UP, DOWN, LEFT, RIGHT,
//游戏退出事件
ESCAPE
};
AI写代码
定义了游戏对象的基础类,以及游戏的状态、信息,那么现在,终于可以写出游戏的状态机了!
五、gameloop.cpp
int main()
{
GameController App;
App.SetFPS(100);
Map map;
Event ev;
//游戏循环
while (App.isOpen())//是否退出
{
//事件检测
ev.CheckKeyEvent();
//主窗口消息处理
App.PullEvent(ev);
//根据消息,更新矩阵
map.Update(ev);
//刷新
App.clear();
//渲染
App.draw(map);
//控制帧率
App.display();
}
}
AI写代码
(PS:用过SFML的朋友,可能会发现我这个状态机的写法和它非常相似;没错,这些命名以及类的设计,我是参考了部分SFML的设计)
到目前为止,我已经将所有类的成员、提供的方法,以及游戏所需要的各类数据结构,都罗列出来了。
剩下的,就是实现他们了。
不过在这之前,为了确保我在实现时仍然能够保持清晰的思路,避免不必要的耦合情况,先画一张图来表示一下我搭出的框架。

用OneNote 画的,有点抽象(丑),不过能大致体现这几个类的功能与关系,就足够了。
sa,就开始一步步实现自己的2048吧。
实现
一、实现Map
1、构造Map——RAII
我使用简单的RAII机制(Resource Acquisition Is Initialization),使得Mapper在Map构造时获取内存,Map析构时被回收。
这样写一是安全,二是对称、简单。
Map的构造函数
Map()
:Mapper(nullptr), m_size(4)
{
//申请内存
Mapper = new int* [4]; assert(Mapper);
for (size_t i = 0; i < 4; i++)
{
Mapper[i] = new int[4]; assert(Mapper[i]);
}
//初始化
for (size_t i = 0; i < 4; i++)
{
for (size_t j = 0; j < 4; j++)
{
Mapper[i][j] = 0;
}
}
RandomCreate();
//RandomCreate_New();
}
//析构,RAII的依赖
~Map()
{
if (Mapper != NULL)
{
for (size_t i = 0; i < 4; i++)
{
delete[] Mapper[i];
}
delete[] Mapper;
}
}
AI写代码
基础的矩阵数据结构,已经构造完成,还需要在初始化时调用一次RandomCreate 方法,来随机生成两个方块。
2、随机生成——两种方法
有多种方法可以实现随机生成,我给出两种。
第一种,是我最初使用的方法,也是最无脑的方法:随缘法。
话不说多先上代码:
随机生成方块
void RandomCreate()
{
srand((unsigned int)time(NULL));
//记录生成个数,不能超过两个
int createcount = 0;
//是否已经满了,无法生成
bool isFull;
//这个循环用于随机生成,直到满足要求才退出
while (1)
{
//先假设矩阵已经满了
isFull = true;
//如果已经生成2个,退出
if (createcount >= AdditionNumber)
{
return;
}
//遍历矩阵
for (size_t i = 0; i < this->m_size; i++)
{
for (size_t j = 0; j < this->m_size; j++)
{
//已经生成2个
if (createcount >= AdditionNumber)
{
return;
}
//如果元素为0,则可以生成:
if (Mapper[i][j] == 0)
{
//有百分之十的概率会在此处生成
//这是为了减少生成位置不均匀分布程度(代价是增加了迭代次数)
int randnum = rand() % 10;
if (randnum == 0)
{
//有百分之十的概率生成4
randnum = rand() % 10;
if (randnum == 0)
Mapper[i][j] = 4;
else
Mapper[i][j] = 2;
//生成数加一
createcount++;
}
//找到了0元素,矩阵没有满,假设错误
isFull = false;
}
}
}
//如果的确满了,假设正确,退出
if (isFull)return;
}
}
AI写代码
可以看到,我采用试错的方法,随缘地在空位中生成数字,直到生成了目标个数的新数字方格,才退出循环。这样写代码比较繁琐,跑起来也比较耗时——但它无脑容易写啊~
不过,对概率有着比较敏感认识的朋友,应该马上能够反应过来:你这生成的并不够“随机”啊!
没错,(概率推导我就不在此列出来了)每个空位的生成概率并不相同。从我的代码中可以看到,有这么一段:
if(Mapper[i][j] == 0)
{
int randnum = rand() % 10;
if (randnum == 0)
{
/**生成语句**/
}
}
AI写代码
为什么要套一个随机数在生成数字的外面呢?
我称这个随机数为“生成率 ”,即在这个空位生成新数字的概率。假设去掉这个生成率会发生什么?
一旦检测到了一个Mapper的空位,就会执行生成数字的语句。而检测空位,在我的代码中依赖于遍历——也就是说,先被检测到的那些空位,会优先生成数字。这样问题就严重了,在游戏之初,几乎每次仅会在矩阵的开头几个位置生成2或4,后面的位置根本无暇光顾。这不符合2048的游戏规则 (不过也许这能成为一种新的Game Play呢~) 因此,我当时简单地套了一个生成率在生成语句的外面。这样“检测,100%生成”,就成为了“检测,可能生成”。如果生成率小一点,则通过多次迭代,能够作到各个空位的生成概率大致相等。
不过这并不能从根本上解决问题——每个空位的生成概率仍然不一致(在此就不列出概率的计算式了)并且随着生成概率变小,迭代的次数会大大增加。也就是产生了这样一种关系:
想要生成随机程度更高?那就减小生成率,多迭代几次吧!
所以,我想出了第二种方法(当时没有实现,写这篇blog的时候顺手实现的)
第二种,其实也挺容易想到,并且整个方法只需要对Mapper 遍历两次就能够完成随机生成。
先上代码:
void RandomCreate_New()
{
//记录空位个数
int emptyCount = 0;
for (size_t i = 0; i < 4; i++)
{
for (size_t j = 0; j < 4; j++)
{
//遇到空位
if (Mapper[i][j] == 0)emptyCount++;
}
}
//没有空位直接返回
if (emptyCount == 0)return;
//矩阵还有空位
//第一个插入的位置,范围 1~emptyCount
int locate1 = rand() % emptyCount + 1;
//第二个插入的位置,范围 1~emptyCount
int locate2 = -1;
//如果有两个以上的空位
if (emptyCount >= 2)
{
//尝试locate2的值,直到与locate1不相等
//最坏情况的错误概率都小于等于50%,效率没问题
do
{
locate2 = rand() % emptyCount + 1;
} while (locate2 == locate1);
}
//param:
//realCount,实际空位个数
//newNumRate,新数字的生成控制(0~9),为0时生成4,1~9生成2
for (size_t i = 0, realCount = 0, newNumRate = 0; i < 4; i++)
{
for (size_t j = 0; j < 4; j++)
{
//如果元素为0,空位计数器加一
if (Mapper[i][j] == 0)
{
realCount++;
//如果空位计数器等于任意已标志位置
if (realCount == locate1 || realCount == locate2)
{
newNumRate = rand() % 10;
//10%的概率为4,90%的概率为2
newNumRate == 0 ? Mapper[i][j] = 4 : Mapper[i][j] = 2;
}
}
}
}
}
AI写代码
代码中,先遍历一次Mapper ,计算出空位的个数,保存在变量emptyCount 中。接着,根据空位个数,再生成随机两个不相同的位置数据locate1 、locate2 ,也保存起来。然后,再遍历一次Mapper ,遍历过程中,只要找到空位,就目前正处于第几个空位(realCount)与之前保存的locate1 、locate2 作比较,如果相等则将现在这个空位随机分配一个值。分配2的概率为0.9,4则为0.1。这个是我随便取的值。应该是思路较清晰、速度较快的一种随机生成方法了。
3、Map自身渲染——Draw()
Map.Draw(),说得好听点叫做渲染,其实在控制台中只需要使用printf以及格式控制即可。
按照自己想要的格式去设计完全没有问题,只要考虑到数字的长度问题,以及性能问题,都可以做出整齐、美观的界面。
我的方法:
//Map::Draw()
void Draw()const
{
printf("\n");
for (size_t i = 0; i < m_size; i++)
{
for (size_t j = 0; j < m_size; j++)
if (Mapper[i][j] == 0)
printf("--- ");
else
printf("%-5d ", Mapper[i][j]);
printf("\n\n\n");
}
}
AI写代码
这个代码实在没什么好说的。用printf只是因为控制格式的代码很精简,执行效率也高。
4、Map更新——Update()
Map类中还有一个public的方法——那就是Update。
这个方法,在我之前的状态机(以及那个抽象图片中)都有体现——那就是获取来自Event对象收集的消息,并且反馈游戏是否结束的消息给Event对象。所有的矩阵操作、数值判断,都会汇总到Update里来最终执行。
先看代码:
void Update(Event& ev)
{
//判断是否操作了矩阵
bool isMoved = false;
//遍历消息队列
for (auto it = ev.state.begin(); it != ev.state.end(); it++)
{
//如果有操作,isMoved为真
switch (*it)
{
case STATE::UP:
isMoved = true; Move_UP();
break;
case STATE::DOWN:
isMoved = true; Move_DOWN();
break;
case STATE::LEFT:
isMoved = true; Move_LEFT();
break;
case STATE::RIGHT:
isMoved = true; Move_RIGHT();
break;
}
}
//如果操作过了
if (isMoved == true)
{
//在空位(有的话)随机生成
//RandomCreate();
RandomCreate_New();
//如果游戏已经结束
if (isOver() == true)
{
//压入游戏结束事件
ev.state.push_back(STATE::GAME_OVER);
}
}
}
AI写代码
如注释所示,思路清晰。
首先,我们添加一个局部flag,isMoved 。这个flag标志本次Update是否操作了矩阵,从而决定是否需要调用随机生成方法。(上下左右这种成为操作)
要注意,Update是每一帧调用一次的,而某一帧(可以说大部分帧),玩家都不会操作矩阵——这是因为,玩家不会在一秒内按下多达数十次的操作按键,因此大多数时候我们只是等待。
并且还有一个非常重要的原因:
在不执行操作的每一帧,整个游戏画面都是静止的——这一特性,对于控制台游戏而言,是再好不过了。 在之后的实际渲染GameController::draw()中我会再次提到这一个性质。
其他部分,不做过多解释,仔细看以上注释就一定能理解。
5、操作和判断 Mapper矩阵
至此,Map的所有公有方法都已经实现了。
不过,Map还仅仅是一个有着逻辑、并无操作的空壳子。我们还无法作到将其做任意的操作,无论上下左右——甚至还无法判断是否已经Game over。
我们还需要实现对矩阵的操作。
在之前设计好的Map类中,我定义了四个基础方法:
//向上
void Move_UP();
//向下
void Move_DOWN();
//向左
void Move_LEFT();
//向右
void Move_RIGHT();
AI写代码
在设计之初,我为了节省代码量,就已经构思好一个非常方便且有效的偷懒方法。
看代码就知道了~
//向上操作
void Move_UP()
{
//转置矩阵,使之与向左等效
ReverseMap();
Move_LEFT();
//转置回来
ReverseMap();
}
//向下
void Move_DOWN()
{
//垂直翻转,使之与向上等效
InverseMap_ver();
Move_UP();
//垂直翻转回来
InverseMap_ver();
}
//向左
void Move_LEFT()
{
/*冗长*/
}
//向右
void Move_RIGHT()
{
//水平翻转,使之与向左等效
InverseMap_hor();
Move_LEFT();
//水平翻转回来
InverseMap_hor();
}
AI写代码
暂且先不看冗长的Move_LEFT方法,只看另外三个——是不是很简洁!
不太理解的朋友,可能是对于矩阵变换(线性代数)不是很熟悉。
没关系!自己动手在纸上画一个2 2(3 3也行)的数字矩阵,按照我的方法进行转置或者翻转变换(转置和翻转请自行查阅百科),看看最终效果是不是等效的。
现在我仔细说明Move_LEFT的算法。
void Move_LEFT()
{
//用于记录遍历过的非零元素,重置时为0
int flag_num = 0;
//非零元素的位置
size_t flag = 0;
//开始按层(行)遍历
for (size_t i = 0; i < this->m_size; i++)
{
flag_num = 0;
flag = 0;
// Union
//合并相同的数字
for (size_t j = 0; j < this->m_size; j++)
{
//如果检测到非零元素
if (Mapper[i][j] != 0)
{
//如果元素与上一个非零元素不同
if (Mapper[i][j] != flag_num)
{
//更新非零元素
flag_num = Mapper[i][j], flag = j;
}
//如果与上一个非零相同
else if (Mapper[i][j] == flag_num)
{
//合并到上一个的位置,相加
Mapper[i][flag] += Mapper[i][j];
//更新位置,重置非零数字
Mapper[i][j] = 0, flag_num = 0, flag = j;
}
}
}
// Move
//用类似冒泡排序的算法
//每次遍历把0提到最后
for (size_t k = 0; k < this->m_size; k++)
{
for (size_t j = 0; j + 1 < this->m_size - k; j++)
{
//如果前一个元素是0 且后一个非零
if (Mapper[i][j] == 0 and Mapper[i][j + 1] != 0)
{
//交换
swap(Mapper[i][j], Mapper[i][j + 1]);
}
}
}
}
}
AI写代码
