Advertisement

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 中。接着,根据空位个数,再生成随机两个不相同的位置数据locate1locate2 ,也保存起来。然后,再遍历一次Mapper ,遍历过程中,只要找到空位,就目前正处于第几个空位(realCount)与之前保存的locate1locate2 作比较,如果相等则将现在这个空位随机分配一个值。分配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写代码

未完 …

全部评论 (0)

还没有任何评论哟~