【C++/控制台】(附源码)控制台小游戏 —— 电子斗蛐蛐

前言

这个是去年做的一个控制台小游戏,当时只上传了资源,现在再附上一篇实现思路


游戏画面


游戏内容

操控上

        键盘输入按键控制是否自动演算,自动演算速度

        开始时键入存档可选择我预制的存档,结束时键入Y/N进行继续游戏的确认

画面上

        左上角是主画面图,右上角是对应的碰撞显示图(debug查看有无缺失阻挡)

        左下角是一个消息列表,该列表播报所有单位每轮进行的动作

        右下角是一个队伍列表,该列表记录目前所有队伍的KD,最高个人击杀

玩法上

        编辑方法:玩家在指定的函数体内编写生成代码,然后自己编译执行(别问我这么抽象的编辑方法算不算游戏)

        手动生成:提供按直线,按圆心半径进行批量生成游戏单位,可指定其队伍id,在主画面中的样子,属性,攻击范围等的函数,开始时就会放到舞台上。

        自动生成:提供单位生成器,可放入一个单位作为模板进行复制或者按输入的单位属性进行复制,可自己设置生成多少个单位后销毁。

        单位能力:单位会自动寻找距离其最近的敌方单位进攻,生成器会检测自己周围8格是否有空间进行生成。


游戏实现

主函数

游戏整体简单,主函数是一目了然就可以看出做了什么

int main()
{
	Init();//系统初始化

	int input = 1;
	while (input == 1)
	{
		GameInit();//游戏每次重试的初始化

		Render();//渲染第一帧

		while (Flag)
		{
			Input();//接收输入

			Logic();//逻辑运算

			Render();//渲染
		}

		End();//结算

		//输入选择是否终止
		gotoxy(MAPW / 6, MAPH / 2 + 1);
		cout << "是否重试? 1 :同意,2:拒绝  :";
		cin >> input;
	}

	system("color 0F");
	return 0;
}

功能实现

游戏界面

使用简化的代码来保证能够聚焦于逻辑部分,一共四个主要部分

//每帧渲染
void Render()
{
————————————————————————————主地图显示—————————————————————————
	//进行所有Pawn的渲染操作
	for (int i = 0; i < pawns.size(); i++)
	{
		Pawn* p = pawns[i];
		gotoxy(p->x, p->y);
		cout << p->icon;
	}
	//进行所有Spawner的渲染操作
	for (int i = 0; i < spawners.size(); i++)
	{
		PawnSpawner* p = spawners[i];
		gotoxy(p->x, p->y);
		cout << p->icon;
	}
	RenderMap();      //重新渲染地图边框

————————————————————————————碰撞地图显示—————————————————————————
	RenderWorld();    //渲染逻辑地图,用于展示阻挡, 辅助显示,一般不需要

————————————————————————————日志,阵营信息显示—————————————————————————
	InfoList::Instance->displayInfo();    //渲染日志信息
	InfoList::Instance->displayCamp();    //渲染阵营信息

————————————————————————————其他显示—————————————————————————
	RenderOtherInfo();    //操作提示信息等的打印
}

单位

单位是游戏中的最核心的类,拥有攻击行为,寻敌行为,自身信息,战斗属性等

下面是单位的Tick函数中的信息,实现了一个简单的AI寻敌逻辑(去除了不相关的代码)

	//每帧执行逻辑(简易行为)
	void Tick()
	{
		if (isDead) return;//已死亡则不执行操作

		AliveNum++;//存活回合自增

		//Target不存在或已死亡将寻找新的目标
		if (!Target || Target->isDead)
		{
			FindTarget();//寻找目标
		}

		//检查目标是否可用
		if (Target && !Target->isDead)
		{
			//检查目标是否在范围内
			if (isTargetInRange())
			{
				Attack();//进攻函数
			}
			else
			{
				MoveToTarget();//向目标移动函数
			}
		}
	}

单位生成器

单位生成器是游戏中第二重要的东西,该类负责在游戏运行时不断的生产单位,以下是其Tick函数内容

void Tick()
{
	//若未激活则不执行操作
	if (!isValid)return;

	//先检查是否达到最大生成数限制,若最大为-1则为无限制
	if (MaxSpawnCount == -1 || SpawnCount < MaxSpawnCount)
	{
		//检查延时是否达到最大延时
		if (TimeDelay < MaxTimeDelay)
		{
			TimeDelay++;//没达到继续累计
		}
		else
		{
			TimeDelay = 0;//达到后重置延时

			for (int i = x - 1; i <= x + 1; i++)
			{
				for (int j = y - 1; j <= y + 1; j++)
				{
					//利用存储的模板Pawn生成一个Pawn
					Pawn* pawn = spawnPawn(i, j, TempPawn.icon, TempPawn.Team, TempPawn.HP, TempPawn.ATK, TempPawn.DEF, TempPawn.Range);

					//检查是否生成成功
					if (pawn)
					{
						//成功则累加计数器并退出循环
						SpawnCount++;
						return;
					}
				}
			}
		}
	}
	else if (SpawnCount >= MaxSpawnCount)
	{
		//达到最大生成数量时标记自身为不可用(待销毁)
		isValid = false;
	}
}

生命周期管理

游戏中存在大量的单位,而且这些单位随时有可能在任何一帧中死亡,并且一个单位可能被复数个单位视为目标。因此如果死亡即销毁目标,势必造成其他单位内部Target指针内存的访问异常。

在介绍解决方案前,先说一下我的函数的结构,在主函数中的while循环下,我的全局逻辑放在了Logic函数中进行,对所有单位进行Tick的操作也在这里进行

解决思路:延迟一帧删除对象,保证每个将其作为目标的单位都能反应过来对象死亡,并重新选择其他单位

方式:在对对象进行逐Tick操作前和Tick操作后分别放置缓存队列和死亡队列两个队列

Logic函数执行顺序:

1. 将缓存队列中的对象全部推入死亡队列

2. Tick逻辑,死亡对象推入缓存队列,发现目标死亡时更换目标

3. 将死亡队列中的对象全部出队删除

这个操作将保证每个将死亡对象锁定为目标的单位都能及时切换目标

代码部分

//每帧逻辑更新
void Logic()
{
	RandomMoveList();//随机移动顺序表

	//将死亡缓存队列对象推入真正的死亡队列(这个操作让对象死亡延迟到第二帧结束,防止有的对象持有其无效引用)
	while (!Deads.empty())
	{
		FinalDeads.push(Deads.front());
		Deads.pop();
	}

	//执行所有生成器操作
	for (auto it : spawners)
	{
		it->Tick();
	}

	//执行所有的Pawn的Tick操作
	for (auto it : MoveList)
	{
		it->Tick();
	}

	//在所有的逻辑运算完成后,进行死亡Pawn的删除操作
	while (!FinalDeads.empty())
	{
		delete FinalDeads.front();
		FinalDeads.pop();
	}
	
	CheckEnd();
}

信息UI

信息UI是指下半部分的消息列表和派系列表,这个类使用了一个单例,内容较为简单对外暴露的接口提供了增加消息的方法,增加派系的方法,更新派系信息的方法。

游戏内部的地图大小可自由调整,这意味着界面是有有限的相对布局的

左边的消息列表可手动调整显示的长度,右边的派系列表则会自动的根据是否越界而换到下一行,保证了不会因为队伍过多而超出控制台显示范围

class InfoList
{
public:
	void Init();

	//消息队列操作
	void addInfo(string Id, string Info, int Team = -1);//添加消息,最后的队伍表示可以忽略,默认值-1即代表不会显示
	void displayInfo();//显示消息

	//阵营队列操作
	void addToCamp(int id, string icon);//添加到阵营,输入谁要进入阵营以及阵营id
	void UpdateCampKill(int id,int PawnKillCount);//杀敌调用本函数
	void UpdateCampDead(int id);//死亡调用本函数
	void UpdateCampKD(int id);//更新阵营KD信息
	void displayCamp();//显示阵营信息

	void ResetAll();//初始化

	static InfoList* Instance;//单例
	vector<string> Infos;//日志消息队列
	vector<Camp*> Camps;//阵营队列

	int x, y;//坐标信息
	string LevelName;//当前关卡名称
	long long count;//总消息数量
	int MaxLength = 25;//最大显示数量(不限制最大存储数量)
	int MaxWidth = (MAPW * 2 > 50 ? MAPW * 2 : 50);
private:
	InfoList() { count = 0; }
};

派系信息的相对调整布局代码,其中CONW和CONH是定义在Base.h中的宏,用于控制控制台窗口大小

void InfoList::displayCamp()
{
	//这两个变量(FirstX和FirstY)负责记录每行的第一个列的位置
	int Fx = this->x + MaxWidth / 2 + 12;
	int Fy = this->y;
	int num = 0;//循环过程中记录当前行的列数量

	for (int i = 0; i < Camps.size();)
	{
		//过程中将对两个循环体内的变量x和y进行持续修改
		int x = Fx + num * 4;
		int y = Fy;
		num++;

		//如果越界则放置到下一行
		if (x * 2 + 8 > CONW)
		{
			Fx = this->x + MaxWidth / 2 + 2;
			Fy += 10;
			num = 0;

			continue;
		}

		disCampXY(x, y, Camps[i]->ID);
	}
}

随机移动顺序

由于单位每次只能进行移动和进攻中的一个动作,并且都是固定在一个Tick中完成,因此该游戏也可视作一个网格回合制游戏,这意味着单位之间的先后执行顺序决定着最后的结果,为此,需要一个随机化的顺序表增加乐趣

我使用的随机算法比较低效(当然前面的代码看上去也不像是优化过效率的样子)

大体是常驻一个移动顺序表和一个目前的所有实体表,随机时会创建一个临时表用于随机顺序

void RandomMoveList()
{
	vector<Pawn*> pawn(pawns);//复制一份pawns
	MoveList.clear();//初始化顺序表

	//将复制的vector数组中的元素逐个随机获取并插入到MoveList,然后弹出
	srand(time(0));
	for (int i = pawn.size(); i > 0; i--)
	{
		int id = rand() % i;
		MoveList.push_back(pawn[id]);
		pawn.erase(pawn.begin() + id);
	}
}

寻敌与寻路机制

寻敌机制也是游戏的一个重点,由于游戏的规模不大,因此并没有运用到诸如四叉树分区之类的优化,使用的是暴力算法,枚举找到最近的非友方单位,因此整个寻敌机制的复杂度是稳定的不能再稳定的n^2

但是还有一个东西很有意思,就是寻路机制,这个游戏中,一旦前方单位阻挡了后方单位,就会导致堵路,宏观来看就是单位连成了一条线,并且有可能出现追踪的敌人位置到不了的情况

为了防止这些情况,我进行了两个优化手段

第一个优化手段是增加被阻挡的计数,一旦单位被阻隔超过一定次数,则会放弃当前目标

第二个优化手段是如果单位在需要前进的路上被阻挡,则会随机选择一个方向移动

这两个手段叠加在一起后,整个单位群体的表现有了很大改善,更加自然

游玩方式

这个最重要的东西结尾写完了我居然才想起来没有写。

游戏内在主函数中有一个名为GameEditor的函数,所有生成函数都可以在这个里面书写

在其下面还有一个ReadSaveSlot函数,我的所有8个预设存档都是通过添加case并书写生成函数制作出的,风格迥异,用上了所有特性(总共也没几个),可以作为参考进行书写。

下面列一下API(迫真),全部存在于Pawn.h文件当中

值得注意的是,生成器仅支持构造一个Pawn对象,并将其作为参数传入的方式进行生成,但是可以参考我在存档中的写法,直接构造一个匿名对象即可

//单个生成Pawn
Pawn* spawnPawn(int x, int y, string icon, int Team = 0, int HP = 100, int ATK = 25, int DEF = 25, float Range = 1.5f);

//按线生成Pawn
void spawnPawnLine(int x1, int y1, int x2, int y2, string icon, int Team = 0, int HP = 100, int ATK = 25, int DEF = 25, float Range = 1.5f);

//按圆生成Pawn
void spawnPawnCircle(int x, int y, int radius, string icon, int Team = 0, int HP = 100, int ATK = 25, int DEF = 25, float Range = 1.5f);

//生成生成器
PawnSpawner* spawnSpawner(int x, int y, string icon, const Pawn& pawn, int MaxTimeDelay = 1, int MaxSpawnCount = -1);

还有一点小贴士:

所有生成函数都有检查,超出地图的坐标不会生成单位,因此不建议随意在Base文件中修改地图大小的宏,一是界面可能不适配,二是预设存档中的生成坐标一开始就是按照当前的地图大小做的

结尾

总的来说,这段程序难度不大,并且许多机制和算法上都有不小的提升空间(高情商),但在中间仍然有许多小细节是我抠了半天才抠出来的,希望能对各位有些借鉴意义。

  • 20
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值