使用 EasyX 开发简单游戏

飞机大战开发

Contents

  • 游戏的框架流程

  • 如何实现复用和扩展游戏

  • 使用数据结构存储对象

  • (工具)使用 CMake 快速编译多文件程序

  • 一些 Tips

游戏的框架流程

游戏不同于一般的程序,用户输入资料,程序给出相应。我们玩的游戏大部分都具有如下两个特性:

  1. 实时性

    游戏一般具有动态更新的场景,这些场景可以是预设好的,也可以是随机生成的,然后把这些场景展示给玩家看,提供一个游戏的情景。

  2. 即时反馈

    游戏需要对玩家的操作做出即时的反馈,也就是根据玩家操作对场景做出相应的变化。

这两条要求可以通过高频更新画面来实现,一般的游戏一秒钟检测更新的次数就是我们熟知的帧率。重复的检测任务可以交给循环来实现。

于是最初的游戏框架可以简单地概括为下面的代码:

Initialization(); // 初始化
while(1) {
  GetInput(); // 获取玩家的操作
  UpdateScreen(); // 更新游戏场景
  Sleep(33); // 休眠一会,避免过高频刷新(保持游戏30帧运行)
}

联系一下最后要设计一个什么样的游戏,然后考虑如何实现上面的功能。

以本篇的飞机大战游戏为例子。

  • 初始化

    首先需要一个游戏界面,然后需要加载游戏中需要的资源,比如背景音乐,等等。一切游戏中需要用到的资源都应该在这个阶段被加载完成,为游戏开始做准备。

  • 获取玩家操作

    这是很重要的一步,玩家应该主要通过鼠标和键盘控制游戏内场景。

  • 更新游戏场景

    根据游戏的基本逻辑,结合玩家的输入,计算并更新游戏的场景,并把游戏的场景反馈到显示器上。

现在来结合本次游戏的主题——飞机大战,来思考如何实现这个游戏。

首先是它应该包含哪些元素:

  1. 一张游戏地图
  2. 敌人的飞机和玩家的飞机
  3. 发射子弹

初始化中要做的工作很简单,只需要把对应的资源使用命令加载进来就可以了,游戏窗口可以直接使用控制台,于是地图和资源的问题就解决了。

有了地图和基本资源之后,我们来设计游戏中出现的其他核心元素。

飞机的设计

一个飞机具有如下特征:

  • 飞机贴图(形状)
  • 飞机位置
  • 飞机敌我
  • 生命值
  • 飞行速度
  • 武器类型
  • 备弹数量

基于上述特征,可以设计出如下的飞机类:

class Plane { // 飞机类
private:
	IMAGE planePNG; // 贴图
	Point pos; // 坐标 (Point 是一个二元坐标类)
	bool flag; // flag为1则为己方,为0则为敌方
	int health; // 生命值
	int v; // 速度
	char* weaponType; // 武器类型
	int ammo; // 子弹数量
/*
  省略了成员函数
*/
};

有了这个框架,需要考虑飞机可以干什么(这部分是游戏的重点,相当于规定了玩家可以做什么,也决定了这个飞机大战游戏该怎么玩)。

首先使用构造函数创建一个飞机对象。

Plane::Plane(
	const int initialX, const int initialY,
	const bool initialFlag, 
	const int initialHealth,
	const char* initialWeaponType, 
	const int initialAmmo
)
  : pos(initialX, initialY), 
		flag(initialFlag), 
		health(initialHealth), 
		v(5),
		ammo(initialAmmo) {
	if (flag)
		loadimage(&planePNG, _T("LAODA.png"));
	else
		loadimage(&planePNG, _T("ENEMY.png"));
	// 初始化玩家和敌人的不同飞机贴图
	weaponType = new char[strlen(initialWeaponType)];
	strcpy(weaponType, initialWeaponType);
}

游戏过程中,需要展示飞机。

void Plane::ShowPlane() {
	putimagePng(pos.x, pos.y, &planePNG);
  // 这里的 putimagePng 是一个绘制贴图的函数。
  // 如果你不想要这么复杂,可以用字符集画一个简单的飞机,比如:
  /*        /=\
  \+/      <<*>>
   |   和   * *
  使用 cout 输出这些字符即可。
  */        
}

然后飞机要可以移动:

void Plane::PlayerMove() { // 玩家使用 WASD 移动
  if (GetAsyncKeyState('W') && pos.y >= 0) // 限制不要走出屏幕
    pos.y -= v;
  if (GetAsyncKeyState('S') && pos.y <= HGRAPH) 
    pos.y += v;
  if (GetAsyncKeyState('A') && pos.x >= 0) 
    pos.x -= v;
  if (GetAsyncKeyState('D') && pos.x <= WGRAPH)
    pos.x += v;
}

void Plane::EnemyMove() { // 敌人移动
	pos.y += v; // 敌人竖直向下移动
}

最重要的,飞机要可以射击,但是完成 “射击” 之前,思考这样两个问题:

  • 射击出去的子弹,属于飞机的一部分吗?
  • 子弹的动作(比如飞行)是否是飞机的特征?

显然答案是否定的,所以说我们不能把子弹做到飞机类里面。

子弹的设计

新建一个子弹类,考虑一颗子弹应该具有如下特征:

  • 子弹图案
  • 子弹威力
  • 飞行速度
  • 子弹位置(坐标)
  • 子弹敌我
  • 子弹飞行角

由此我们写出这样的子弹类:

class NormalBullet {
protected:
  IMAGE bulletPNG; // 子弹贴图
  int power; // 子弹的威力
  int speed; // 飞行速度
  Point pos; // 子弹当前坐标
  const bool flag; // flag 为 1 则为己方,否则为敌方 
  double flyingAngle; // 子弹偏角(右侧,顺时针为正方向)
};

使用构造函数创建一个子弹对象

NormalBullet::NormalBullet(
	const int initialPower, 
	const int initialSpeed,
	const Point initialPos,
	const bool initialFlag,
	const double initialFlyingAngle
)
	: power(initialPower),
		speed(initialSpeed), 
		pos(initialPos), 
		flyingAngle(initialFlyingAngle), 
		flag(initialFlag) {
	AP = 0;
	AOE = 0;
	setfillcolor(RGB(165, 42, 42));
	fillcircle(pos.x, pos.y, r);
	setfillcolor(BLACK);
	// loadimage(&bulletPNG, _T("football.png"));
	// putimagePng(pos.x, pos.y, &bulletPNG);
}

在游戏中更新飞行中的子弹,这里返回值是为了方便外部处理已经出界的子弹,无需在意。

bool NormalBullet::FlyingBullet() {
	// 更新飞行中的子弹
	pos.x += (double)speed * cos(flyingAngle);
	pos.y += (double)speed * sin(flyingAngle);
  // 使用三角函数计算飞行轨迹
	fillcircle(pos.x, pos.y, r); 
  // 这里是 EasyX 库里的一个画圆圈的函数,如果你不想这么麻烦,使用字符图案代替就行
  // 检测子弹是不是出界了
	if (pos.x + r < 0 || 
		pos.x - r > WGRAPH ||
		pos.y + r < 0 || 
		pos.y - r > HGRAPH)
		return 1; // 返回已经出界
	else
		return 0; // 没有出界
}

然后需要检测子弹是不是命中飞机,这里需要代入飞机的坐标作为参数,因为我们的子弹是一个小圆球,就只需要判断飞机坐标到子弹中心是不是小于子弹半径就可以了。

bool NormalBullet::HitPlane(Point planePos) {
	// 检测是否命中飞机
	return planePos.Distance(pos) <= r;
}

好了,这样我们的子弹类就差不多了。现在回到飞机类去完善发射子弹和被子弹击中的部分吧。

ps:这里写的有点问题,当时为了方便,把记录所有子弹的内存做成飞机类里的静态内存了,但是回头一想它其实应该是主程序中的一个全局变量,所以理想中的函数应该是这样的。

NormalBullet* Plane::PlayerShoot() { // 玩家发射
	if (GetAsyncKeyState('J')) { // 检测 J 键发射,也可使用 conio.h 库中的 _kbhit() 检测键盘输入
		NormalBullet* bullet;
		if (strcmp(weaponType, "machine gun") == 0) // 检测武器类型
			bullet = new NormalBullet(NORMALBULLET_POWER, NORMALBULLET_SPEED, pos, flag, PI / 2 * 3);
		return bullet; // 发射了返回新子弹指针
	}
	return nullptr; // 没发射返回空指针
}

// 在调用处把该函数返回的指针插入到记录所有子弹的内存中

这一段是我的源代码,虽然这样不太符合一般编程的习惯,但是我懒得重构代码了:

bool Plane::PlayerShoot() { // 玩家发射
	if (GetAsyncKeyState('J')) {
		NormalBullet* bullet;
		if (strcmp(weaponType, "machine gun") == 0) 
			bullet = new NormalBullet(NORMALBULLET_POWER, NORMALBULLET_SPEED, pos, flag, PI / 2 * 3);
		allBullets.push_back(bullet); // 直接插入飞机类中创建的静态内存池
		return 1; // 发射了返回 1 
	}
	return 0; // 没发射返回 0
}

为了提高难度,我们让敌人也可以发射子弹,并且敌人会根据玩家的位置发射飞向玩家的子弹,算法如下:

void Plane::EnemyShoot(Plane* player) { // 敌人发射(锁定玩家位置)
	Point playerPos = player->GetPlanePos(); // 获取玩家位置
	double initialFlyingAngle = atan((playerPos.y - pos.y) / (playerPos.x - pos.x));
	if (playerPos.x < pos.x) 
		initialFlyingAngle += PI;
	// 根据位置坐标利用反正切函数计算飞行角
  NormalBullet* bullet = new NormalBullet(NORMALBULLET_POWER, NORMALBULLET_SPEED, pos, flag, initialFlyingAngle);
	allBullets.push_back(bullet); // 创建子弹
}

最后需要检测飞行中的子弹是不是命中了飞机,或者检测飞机是不是被飞行中的子弹打中。换句话说,这个检测命中做在子弹类或者飞机类里都可以,这里我选择做在飞机类里:

void Plane::BeingHit() { // 检测击中
	int takeDamage = 0; // 本轮检测中会收到的总伤害
	std::list < NormalBullet* >::iterator itAllBullets;
  // 存储子弹的数据结构是链表,后面会说

  // 使用迭代器遍历所有的子弹
	for (itAllBullets = allBullets.begin(); itAllBullets != allBullets.end();) {
		if (flag != (*itAllBullets)->GetFlag() && (*itAllBullets)->HitPlane(pos)) {
			// 命中并且敌我识别码不一致
			takeDamage += (*itAllBullets)->GetPower(); // 承受伤害+=子弹威力
			itAllBullets = allBullets.erase(itAllBullets); // 删除已经命中的子弹
		}
		else
			itAllBullets++;
	}
	health -= takeDamage; // 扣除生命
}

现在最简单的战斗系统就已经大功告成了,但是距离能玩的游戏还有最后一步:当玩家击杀敌人后,要给玩家计分并且补充敌人,这可以使用一个计分变量和随机算法生成敌人来实现。

我们回到最初的框架,把上面的几个函数组织起来,就形成了一个最简单的飞机大战游戏:

最终的游戏框架

Initialization(); // 初始化游戏,比如创建一个游戏窗口,创建玩家飞机和敌人飞机等工作
while (1) {
  // 控制台程序,使用命令 system("CLS"); 来清除上一次屏幕上的所有东西,准备更新

  // 补充敌机

  // 调用玩家移动和射击函数

  // 遍历所有敌人飞机,调用敌人移动/射击函数

  // 更新子弹位置

  // 遍历所有飞机,调用 BeingHit() 函数检测击中,如果生命值等于 0,表示飞机被击杀了,这里还要对敌人被击杀还是玩家被击杀做区分,比如敌人被击杀玩家得分增加,玩家被击杀游戏结束
  
  // 需要设置一个胜利条件来退出循环,比如击杀几十个敌机

	Sleep(50);
}

使用一张流程图来表示就是这样:

填充这个框架之前,要先确定好上面的类之间的关系:

毕竟面向对象编程的工作方式就是不同类之间通过接口进行通信,现在需要理清各个部分之间是如何工作的。

如何实现复用与扩展游戏

扩展游戏的方法论

先来谈谈扩展,我们的游戏现在只有简单的移动和射击,我们可以考虑给他加点料。

想要扩展飞机大战也很简单,可以加随机的资源箱,当玩家吃到资源箱就会获得随机加成;可以为玩家多添加几种强大的武器;可以设计 boss 关卡;可以给玩家一些炫酷的技能……

相信通过前面的叙述,对于同学们来说添加上面几种玩法不算难,这里还是再提示一下,当我们往游戏中添加一个新的事件的时候,需要做好以下几点:

  • 事件的出现和更新
  • 事件的触发条件
  • 事件的触发效果

让我们用补给箱来做个例子:

事件的出现:在地图的随机位置生成一个补给箱。

事件的更新:检测补给箱存在时间,存在一定时间没有被玩家拾取则自动销毁。

事件的触发条件:玩家和补给箱位置重合。

事件的触发效果:给玩家一定奖励。

想好这四件事情以后,只需要写一个补给类并做好它和飞机类之间的信息交互,最后在主函数里实现补给对象的更新就可以了。

如下是补给类及其调用,具体实现有兴趣的同学可以在源代码中看看。

void GenerateSupplies() { // 随机生成补给
	if (!supply && !supplyTemp) { // 可以生成补给
		Point supplyPos = Point(WGRAPH * Random01(), HGRAPH * Random01()); // 随机坐标
		int supplyClass = rand() % 2;
		if (supplyClass != 0) {// 其他类补给
			supply = new Supply(supplyPos, supplyClass);
			// printf("gnerate successfully!\n");
		}
		else {
			int n = rand() % 3; // 随机武器
			switch (n) {
			case 0: 
				supply = new Supply(supplyPos, supplyClass, "laser", 15);
				break;
			case 1:
				supply = new Supply(supplyPos, supplyClass, "wave", 30);
				break;
			case 3:
				supply = new Supply(supplyPos, supplyClass, "boom", 20);
				break;
			}
		}
		supplyTemp = 350; // 设置补给时钟
	}
}

// 显示补给箱
	if (supply != NULL) {
		if (supply->GetLastTme() > 0) // 没有超出时间限制
			supply->ShowSupply();
		else {
			delete supply;
			supply = NULL;
		}
	}
	// 检测玩家拾取补给箱
	if (supply != NULL && player->GettingSupply(supply)) {
		delete supply;
		supply = NULL;
	}

	if (supplyTemp > 0)
			supplyTemp--;

复用接口的技巧

我们以武器类的扩展为例子探讨复用接口的技巧。

以下是检测击中函数中的核心代码:

假设有一种新的武器,我们考虑新武器和原来的武器有什么不同:

  1. 构造函数不一样,这可以通过在发射时调用不同的构造函数实现。
  2. 命中方式不一样。
  3. 飞行方式不一样。

但我们不可能去为每一种子弹都做一个专属的命中函数和飞行函数,所以可以尝试复用最初版本的这两个函数。

具体做法是,以最开始的子弹类做父类,把飞行和命中函数做成虚函数,让新的子弹类来来继承最初的子弹类,并重写飞行和命中函数。

因为新的子弹类是原来子弹类的子类,所以我们可以把一个原来子弹类的指针指向一个新子弹类(向上造型)。我们使用新子弹类的构造函数 new 一片空间交给原来子弹类的指针并放到存储所有子弹的数据池里。当从数据池里拿出指针并通过指针调用飞行和命中函数的时候,就会动态联编,调用我们重写的适用于新子弹类的飞行和检测命中函数了。

下面是检测飞机被击中以及更新所有子弹位置的核心代码:

// 检测击中
for (itAllBullets = allBullets.begin(); itAllBullets != allBullets.end();) {
  if (flag != (*itAllBullets)->GetFlag() && (*itAllBullets)->HitPlane(pos)) {
    // 这里如果重写了 HitPlane() 函数,就会执行动态联编,执行新子弹类重写的那个函数了!
    takeDamage += (*itAllBullets)->GetPower();
    itAllBullets = allBullets.erase(itAllBullets);
  }
  else
    itAllBullets++;
}

// 更新子弹位置
std::list < NormalBullet* >::iterator itAllBullets;
for (itAllBullets = allBullets.begin(); itAllBullets != allBullets.end();) {
	if ((*itAllBullets)->FlyingBullet()) // 这里会调用重写的函数!
		itAllBullets = allBullets.erase(itAllBullets); // 子弹出界则删除
	else
		itAllBullets++;
}

最后需要强调的是接口的问题,重写的函数其返回值的意义、执行功能的意义必须和重写前的函数一致。比如说 FlyingBullet() 函数,它返回值的意义是 “子弹是否出界”,它执行功能的意义是更新子弹位置;那么我们重写的子弹类中,FlyingBullet() 函数的返回值意义也必须是 “子弹是否出界”,执行功能的意义也必须是更新子弹位置。

这就是所谓的“接口”,即强调继承类虚函数必须拥有与被继承类虚函数本质相同(比如更新子弹位置)的功能和相同意义的返回值,只是实现方式有区别。这样做是为了在主程序中的调用不用再改,也就实现了主程序代码的复用。

但是很可惜的是,因为我想给这个游戏加一种 AOE 伤害,于是专门给一种 Wave 武器做了适配的伤害检测函数,导致最终的代码上又多了很丑很丑的一坨(有兴趣的也可以看看实现),也违背了多态性的初衷,我在此面壁思过。

使用数据结构存储对象

在使用数据结构之前,需要知道使用数据结构和算法的目的是什么。

时间复杂度

引入

在设计算法之前,需要对算法的效率进行评估,即预估这个算法能在可接受的时间能够内解决何种规模的问题。

#include <stdio.h>
#include <chrono>

int main () {
	double sum = 0;
	double fra = 1.0;
	// Start measuring time
	auto begin = std::chrono::high_resolution_clock::now();
	
	int iterations = 1300000000;
	for (int i=0; i<iterations; i++) {
			sum += 1 / fra;
			fra += 1.0;
	}
	
	// Stop measuring time and calculate the elapsed time
	auto end = std::chrono::high_resolution_clock::now();
	auto elapsed = std::chrono::duration_cast<std::chrono::nanoseconds>(end - begin);
	
	printf("Result: %.20f\n", sum);
	
	printf("Time measured: %.3f seconds.\n", elapsed.count() * 1e-9);
	
	return 0;
}

上面这段代码计算了 \(\Sigma^{\infty}_{i = 1} \frac{1}{i}\),而我们常用浮点数计算估算 cpu 速率。

在 13 亿次的计算规模下,耗时大约 900ms,但是不同的 cpu 亦有快慢之分,我们不妨假设一个 cpu 1s 内可以执行 10 亿条 C++ 指令(事实上,对于算法题而言,不建议总运算量超过 1亿)。

而一个算法的执行次数通常可以用一个多项式表示。

比如如下冒泡排序算法:

for(int i = 0; i < N; i ++)
	cin >> a[i];

for (i = 0; i < N - 1; i ++)
	for (j = 0; j < N - 1 - i; j ++) 
		if (a[j] > a[j + 1]) {
			int tmp; //临时变量
			tmp = a[j]; //交换
			a[j] = a[j + 1];
			a[j + 1] = tmp;
		}

读入的 for 循环执行 \(N\) 次两层 for 循环都会执行大约 \(N\) 次,所以总的运算量可以表示为函数 \(f(n) = n ^ 2 + n\)

我们使用渐进符号 \(\Omicron\)(希腊字母 Omicron,才不是什么大写 O,但是因为 O 在电脑上打起来方便,所以我们习惯使用 O 来表示)描述一个函数的阶。它忽略了一个函数中增长较慢的部分以及各项的系数,而保留了可以用来表明该函数增长趋势的重要部分。

换句话说,我们只关心对这个函数影响最大的若干项,也就是增长最快的项。

我们把总运算量函数的渐近 \(O(f(n))\) 定义为一个算法的时间复杂度。(此处指最坏时间复杂度,是狭义且不严谨的定义)

对于上面的冒泡排序函数,它的复杂度为 \(O(n ^ 2)\),现在来预估一下这个算法的效率。

我们上面假定计算机 1s 执行大约 10 亿条 C++ 指令,取数据量 \(n = 30000\),此时 \(O(n ^ 2)\) 接近十亿,所以这个算法一秒钟之内可以完成 30000 个数据的排序。

经典复杂度分析

  • 二分算法

  • 分块算法

我刚参加完蓝桥杯,对于 Problem E 我印象特别深刻,因为我大胆的设计了一个看上去很慢的算法,但经过证明,它的复杂度是可以接受的。

\(n\) 个数 \(a_i\),从前 \(x\) 个数中选 \(k\) 个,要求这 \(k\) 个数的方差不大于指定的数 \(T\),求最小的 \(x\)

下面是我的大致思路,请预估根据这个思路设计的算法的复杂度:

显然决策具有单调性,如果 \(a_1\)\(a_r\) 没有满足条件的,那么 \(a_1\)\(a_l\) 必然没有满足条件的 \((l < r)\),对这 \(n\) 个数二分处理。

取二分区间的中点 \(m\),对 \(a_1\)\(a_m\) 而言,尝试找出 \(k\) 个数满足条件就行了。

数学上对方差的定义是反应数据的集中程度,也就是选取的数差的越小方差就越小。于是需要从这 \(m\) 个数中找相对最接近的 \(k\) 个。

容易想到对这 \(m\) 个数排序,这样 \(a_1\)\(a_k\)\(a_2\)\(a_{k+1}\) ,...... \(a_{m - k + 1}\)\(a_m\),都是相对最接近的 \(k\) 个数。

如果有答案的话,一定就在上面的 \(m - k\) 组数中,对这 \(m - k\) 组数计算方差,看看有没有满足条件的。

如果有,二分右端点 \(r\) 变为 \(m\),否则左端点变为 \(m + 1\)

大家可以猜猜复杂度,我这里给出四个答案:

A. \(O(n ^ 2 \log n)\)

B. \(O(n \log ^ 2 n)\)

C. \(O(nk\log ^ 2 n)\)

D. \(O(n \log n)\)

使用链表存储

飞机、子弹类有一个共同的特点——他们随时可能需要被删除(比如子弹命中飞机,飞机死了的情况)。假设我们使用数组实现这两种对象的存储,当删除一个对象之后,它原本在的空间却还存在于数组中,我们后续遍历数组的时候就会不可避免访问到这个没有用的空白空间,这既浪费时间又浪费空间。

所以我们需要一种支持在任意位置 \(O(1)\) 复杂度插入、删除对象的数据结构,那就是链表。

有人说可以在删除一个对象之后,把它后面的对象依次往前挪一个,但这依旧会把复杂度提升一个量级。而对于高速刷新的屏幕游戏来说,任何一个卡顿都是需要避免的,而且链表可以方便的使用一个指令就删除数据,不存在挪位置的操作,也节省了代码量。

C++ STL 提供模板类 list,其内部实现是一个链表。

关于 STL list 和链表的操作可以百度,本学期也会学习链表的相关知识。

使用 CMake 编译多文件程序

多文件程序编写规范

CMake 是一个很好用的编译工具,但是学习它有点成本,这里只阐述如何使用 CMake 编译一份多文件程序。

首先下载一个 CMake 编译工具(搜索引擎搜 CMake 直接下载即可)。

然后编写一份 CMakeLists.txt 文件,作为编译命令,这里我提供一份编译命令。

cmake_minimum_required(VERSION 3.15)
#最低 CMake 版本

project(PLANE_WAR)
#工程的名字 ( + 当前项目版本 + 当前项目描述 + 网页Homepage + 构建项目语言)

#自动搜索变量
aux_source_directory(${PROJECT_SOURCE_DIR}/. SRC)
#路径名 + 变量名,取出路径中所有的源文件到变量中

include_directories(${PROJECT_SOURCE_DIR}/../include/.)
#设置头文件所在目录

set(CMAKE_CXX_STANDARD 17)
#定义宏编译C++标准,终端输入时后面加 -DCMAKE_CXX_STANDARD=20,在 C++ 20标准下生成可执行文件

set(EXECUTABLE_OUTPUT_PATH .)
#生成可执行文件的目标位置,相对 make 文件的路径或者绝对路径均可
#这里是指定生成到 make 相同的路径下

add_executable(Planewar ${SRC})
#生成可执行程序的名字 + 项目源文件

使用这份编译命令时,文件目录应为(假设 Game/ 是你的工程目录):

/Game/include/xxx.h 这里存放你的头文件, Game/src/xxx.cpp 这里存放你的源代码和 CMakeLists.txt,最终的 .exe 可执行文件会生成到 Game/build 文件夹里。

打开 CMake 编译工具,在 "where is your source code" 一栏填写刚才 src 文件夹的绝对路径(也就是 CMakeLists.txt 的路径),在 "where to build the binaries" 一栏中刚才 build 的绝对路径。

填写好后点击 Generate,大功告成!

ps:如果你的工程路径需要改变的话,需要把 build 文件夹下所有文件删除,(Debug 文件夹可以不删)然后重复上述操作。

一些 Tips

这一节将会介绍一些编写游戏时的技巧和我们开发这个游戏的经验。

应该把常数定义到头文件里

开发游戏时经常会用到窗口大小(长宽)这两个常数,等等,如果每个地方都填数字并不直观,而把这些常数定义在单个文件里,其他文件就没法用,所以常数应该定义在头文件 constants.h 里,需要引用常数的时候,我们去 #include "constants.h"

高频刷新闪屏问题与跨平台优化

闪屏问题可以使用批量绘制得到一定程度上的解决,即把绘制命令先存到缓冲区,在某个时候一起执行。但是我们使用的是 EasyX 库的 BatchDraw() 函数,而且 EasyX 仅仅支持 Windows 下的 MSVC 编译器(我们现在大部分人用的应该都是 Mingw),使用空间太小。如果使用 Qt 的话似乎可以做到跨平台跨编译器编译。可惜我不会

编写代码时留好接口

在编写代码时,除了考虑代码的方便性,也要多多考虑它的可扩展性。建议多使用指针和引用的存储模型,因为指针和引用存储是可以实现继承和扩展的(这样也可以避免类的循环定义)。另外,把主程序和接口编写好,之后可以把所有的子弹类都交给另一个人去实现,这样也可以提升合作开发的效率。

代码风格规范

写代码时,尤其是游戏这种篇幅比较长的代码时,一定要注意代码规范,否则看自己的代码都容易犯高血压。在这份代码中我的代码规范如下:

int planePos; // 变量名采用小驼峰命名法
Point GetPlanePos(Plane* target); //函数名采用大驼峰命名法
int main() {
	// 函数参数表括号后+空格
	// 大括号不换行
}
int a = 9 + 6; // 运算符两侧加空格
for(int i = 0; i < n; i ++) {
	int j, k;
	// 逗号,分号前不加空格,之后加空格
}
#include <iostream> // 引用命令和引用库名称之间加空格

另外同志们一定要注意缩进!!!

代码风格的话,如果几个人合作开发,需要统一一下标准;如果是你自己开发,怎么舒服怎么来。我的这一套算是比较通用的一个标准,如果大家不是觉得我这个太丑的话,建议直接用我这套。

注释与命名规范的问题

对于游戏这种大型代码来说,注释是绝对必不可少的。否则代码量一上来,阅读代码就变得极其困难了。

提醒大家,每个变量名/函数名最好要顾名思义,除了命名,定义/声明完了后面也要加上注释表明它是做什么作用的。这样自己和别人才能快速理解代码。

千万不要用 abcd 这种做变量名!如果这么做最后写出了 bug 又调不出来,那就等着自己重构吧。没有人愿意帮你调依托答辩。

另外,代码里千万不要有迷惑行为,如果这是很必要的,你也一定要加注释说明为什么这么做。

A sad story
之前我和我的同学开发一个程序,他因为输出结果总是比预期大两个数量级,并且他死活调不出来,天才的他遂决定在输出的时候直接除以 100。
但是他没写注释,我拿到代码之后看见最后答案不知道为什么除以了 100 ,给他发消息问结果他睡着了,然后我整晚上又重新研究了一遍为什么除以 100……
希望大家不要在这种事情上产生误会破坏了宝贵的友谊。

当然了,我这次写的代码里也有点小漏洞,比如:

Supply::~Supply() {
	//if (weaponType != NULL)
		// delete [] weaponType;
	//如果执行这个析构,游戏必炸
}

我也一直没调出来,不过也许把注释去掉之后可以恶心后人(bushi

最后的话

我们的源代码中除了上述的功能还实现了很多其他的功能,我们会公布这份源代码,代码里有详细的注释,大家感兴趣的话可以读来看看。当然,这个游戏也有太多不完美的地方(我已经重构过一次了但自己看着还是感觉像屎山)和没完善的功能,如果有同学有兴趣的话也可以继续完善这个游戏。

posted @ 2024-04-11 19:02  ZTer  阅读(366)  评论(0编辑  收藏  举报