飞机大战开发
Contents
-
游戏的框架流程
-
如何实现复用和扩展游戏
-
使用数据结构存储对象
-
(工具)使用 CMake 快速编译多文件程序
-
一些 Tips
游戏的框架流程
游戏不同于一般的程序,用户输入资料,程序给出相应。我们玩的游戏大部分都具有如下两个特性:
-
实时性
游戏一般具有动态更新的场景,这些场景可以是预设好的,也可以是随机生成的,然后把这些场景展示给玩家看,提供一个游戏的情景。
-
即时反馈
游戏需要对玩家的操作做出即时的反馈,也就是根据玩家操作对场景做出相应的变化。
这两条要求可以通过高频更新画面来实现,一般的游戏一秒钟检测更新的次数就是我们熟知的帧率。重复的检测任务可以交给循环来实现。
于是最初的游戏框架可以简单地概括为下面的代码:
Initialization(); // 初始化
while(1) {
GetInput(); // 获取玩家的操作
UpdateScreen(); // 更新游戏场景
Sleep(33); // 休眠一会,避免过高频刷新(保持游戏30帧运行)
}
联系一下最后要设计一个什么样的游戏,然后考虑如何实现上面的功能。
以本篇的飞机大战游戏为例子。
-
初始化
首先需要一个游戏界面,然后需要加载游戏中需要的资源,比如背景音乐,等等。一切游戏中需要用到的资源都应该在这个阶段被加载完成,为游戏开始做准备。
-
获取玩家操作
这是很重要的一步,玩家应该主要通过鼠标和键盘控制游戏内场景。
-
更新游戏场景
根据游戏的基本逻辑,结合玩家的输入,计算并更新游戏的场景,并把游戏的场景反馈到显示器上。
现在来结合本次游戏的主题——飞机大战,来思考如何实现这个游戏。
首先是它应该包含哪些元素:
- 一张游戏地图
- 敌人的飞机和玩家的飞机
- 发射子弹
飞机的设计
一个飞机具有如下特征:
- 飞机贴图(形状)
- 飞机位置
- 飞机敌我
- 生命值
- 飞行速度
- 武器类型
- 备弹数量
基于上述特征,可以设计出如下的飞机类:
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 关卡;可以给玩家一些炫酷的技能……
相信通过前面的叙述,对于同学们来说添加上面几种玩法不算难,这里还是再提示一下,当我们往游戏中添加一个新的事件的时候,需要做好以下几点:
- 事件的出现和更新
- 事件的触发条件
- 事件的触发效果
让我们用补给箱来做个例子:
事件的出现:在地图的随机位置生成一个补给箱。
事件的更新:检测补给箱存在时间,存在一定时间没有被玩家拾取则自动销毁。
事件的触发条件:玩家和补给箱位置重合。
事件的触发效果:给玩家一定奖励。
想好这四件事情以后,只需要写一个补给类并做好它和飞机类之间的信息交互,最后在主函数里实现补给对象的更新就可以了。
复用接口的技巧
我们以武器类的扩展为例子探讨复用接口的技巧。
以下是检测击中函数中的核心代码:
假设有一种新的武器,我们考虑新武器和原来的武器有什么不同:
- 构造函数不一样,这可以通过在发射时调用不同的构造函数实现。
- 命中方式不一样。
- 飞行方式不一样。
但我们不可能去为每一种子弹都做一个专属的命中函数和飞行函数,所以可以尝试复用最初版本的这两个函数。
具体做法是,以最开始的子弹类做父类,把飞行和命中函数做成虚函数,让新的子弹类来来继承最初的子弹类,并重写飞行和命中函数。
因为新的子弹类是原来子弹类的子类,所以我们可以把一个原来子弹类的指针指向一个新子弹类(向上造型)。我们使用新子弹类的构造函数 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()
函数的返回值意义也必须是 “子弹是否出界”,执行功能的意义也必须是更新子弹位置。
这就是所谓的“接口”,即强调继承类虚函数必须拥有与被继承类虚函数本质相同(比如更新子弹位置)的功能和相同意义的返回值,只是实现方式有区别。这样做是为了在主程序中的调用不用再改,也就实现了主程序代码的复用。
使用数据结构存储对象
飞机、子弹类有一个共同的特点——他们随时可能需要被删除(比如子弹命中飞机,飞机死了的情况)。假设我们使用数组实现这两种对象的存储,当删除一个对象之后,它原本在的空间却还存在于数组中,我们后续遍历数组的时候就会不可避免访问到这个没有用的空白空间,这既浪费时间又浪费空间。
有人说可以在删除一个对象之后,把它后面的对象依次往前挪一个,但这依旧会把复杂度提升一个量级。而对于高速刷新的屏幕游戏来说,任何一个卡顿都是需要避免的。
所以我们需要一种支持在任意位置 \(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,大功告成!
一些 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
最后的话
我们的源代码中除了上述的功能还实现了很多其他的功能,我们会公布这份源代码,代码里有详细的注释,大家感兴趣的话可以读来看看。当然,这个游戏也有太多不完美的地方(我已经重构过一次了但自己看着还是感觉像屎山)和没完善的功能,如果有同学有兴趣的话也可以继续完善这个游戏。
标签:飞机,子弹,游戏,EasyX,pos,玩家,简单,itAllBullets From: https://www.cnblogs.com/zaza-zt/p/18129887