# airplane **Repository Path**: yngu196/airplane ## Basic Information - **Project Name**: airplane - **Description**: 一个飞机大战小游戏的制作教程 - **Primary Language**: C++ - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-10-15 - **Last Updated**: 2026-10-19 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ### 用 EasyX 写一个飞机大战小游戏 用 C++ 和 EasyX 图形库,从零实现一个可运行的飞机大战:玩家操控飞机躲避敌机、发射子弹得分。代码分两个文件:`main.cpp`(入口)和 `node.h`(核心类),适合作为 C++ 初学者入门练手项目(本项目的源码及图片来自[smallgame-easyx](https://gitee.com/Raindrips/smallgame-easyx)项目中的[airplane_cpp](https://gitee.com/Raindrips/smallgame-easyx/tree/master/airplane_cpp),本项目对其进行了注释和调整,然后进行讲解,感谢[Raindrips](https://gitee.com/Raindrips?utm_source=poper_profile)的开源)。 > **先看效果**:用浏览器打开 [`demo/index.html`](demo/index.html),这是一个演示动画(此演示动画由ai生成,仅用于辅助理解,并非最终效果)。 ### 前言 你是否在为自己学了一段时间的C/C++却还是只能做一些在黑乎乎的控制台操作的程序而困扰;或是想在一些平台上看视频学,却发现学一半少东西,需要私信博主获取完整资源或者是直接让你看一半然后付费购买;或是找到的视频教程要么太老了,要么要用一些复杂的图形库,再者是教程是英文的;或是教程吧啦吧啦分析一大堆,让你没有兴趣听下去。这个教程的目的是帮助你收获一些自信,教你用简单的图形库、图片和简单的语句做出一个不是纯控制台的小游戏。就算你不会EasyX也不要紧,本教程用到的函数自会讲解(虽然还是建议你去学一学),耐心地看下去吧。 ### 环境 - `visual studio2022` - `EasyX`到官网下载对应vs的版本的安装包即可,安装后会自动配置到vs的 include /lib 路径(安装后会有一个叫`EasyX_Help`的文件,里面会有基本的函数使用教程,或者也可以直接访问[EasyX教程](https://docs.easyx.cn/zh-cn/tutorials),EasyX并不复杂,如果你之前没用过,只需要花几分钟看个大概就行)。 - 新建一个空C++项目(控制台项目),项目叫啥都行。把本项目的res文件夹(包含里面的图片)放到你的项目路径下。 ### 项目结构 ``` airplane/ |——main.cpp #入口,创建airplane并启动 |——node.h #核心:Vec2 结构体 + Airplane 类 |-res/ #游戏素材 ``` ### 你需要准备的三张图 代码总共要读 **3 张图**,全都放在项目根目录的 `res` 文件夹里(直接拷贝本项目的 `res` 文件夹最省事;想自己准备图片的话,按下面这张表来,文件名必须一模一样): | 文件名 | 用途 | 代码里的加载尺寸 | 建议准备多大 | |---|---|---|---| | `res/1.png` | 敌机 | 40×40 | 正方形,小飞机朝下 | | `res/2.png` | 玩家飞机 | 40×40 | 正方形,小飞机朝上 | | `res/3.png` | 子弹 | 15×25 | 竖长条形 | > 尺寸不用卡得特别死:代码会把读进来的图**缩放到**上表的尺寸,你给的原图大一点小一点都能跑,只是比例别差太多,不然会变形。实在没有素材,用画图随便涂个小方块存成对应文件名也能跑通全流程。 **还有一个容易漏的项目设置**:教程里用 `_T("...")` 包中文,而且 .vcxproj 要求 `CharacterSet` 为 `Unicode`。所以请确认 `项目属性 -> 高级 -> 字符集` 是 **使用 Unicode 字符集**(VS2022 新建项目默认就是它,一般不用改)。如果这里是"多字节字符集",编译时中文字符串会报错。 ### 正文 #### 1.新建main.cpp 建完之后丢那就行了,或者说,就像你平时那样,写两句(当然,等会再写也不迟,我说白了,谁来了也就这两句): ```cpp #include int main() { return 0; } ``` #### 2.新建node.h 建完之后你会看到一行`#pragma once`,这是什么意思呢?`#pragma once` 写在 `node.h` 第一行,保证这个文件即使被包含多次也只编译一次,防止类 / 结构体重复定义。 #### 3.在`node.h`中添加头文件并添加类 ```cpp #include // EasyX 图形库 #include //处理时间和日期的标准库之一。 #include//提供了各种通用工具函数,包括内存分配、进程控制、环境查询、排序和搜索、数学转换、伪随机数生成等 class Airplane { public: // 构造函数:创建窗口时自动执行 Airplane(int w, int h) { initgraph(w, h);// 打开一个宽 w 像素、高 h 像素的图形窗口。这是 EasyX 最重要的起点函数 —— 没它,后面什么都画不了。 } // 析构函数:程序结束时自动执行 ~Airplane() { closegraph(); // 关掉图形窗口 } }; ``` #### 4.在`main.cpp`中调用构造函数Airplane() ```cpp #include #include "node.h" int main() { Airplane air(360, 640); // 创建一架名叫air的"飞机"(这个飞机是Airplane类型的),同时打开 360×640 尺寸的窗口 while(true){}//让程序空转不退出——不然窗口弹出后就没了 return 0; } ``` 点击运行,你可以看到窗口一直在 #### 5.EasyX的坐标系 可能和你想的不一样,EasyX窗口坐标**原点在窗口左上角,x 轴向右,y 轴向下**。也就是说,`x` 越大越**靠右**,`y` 越大越**往下**。想 "往上走",`y` 要**减小**。这个方向感后面到处都要用。 现在给类添加成员变量。动手之前,先定义一个"点"结构体 `Vec2`——一个点里同时装着横坐标 x 和纵坐标 y。飞机、子弹、敌机的位置,我们都用一个 `Vec2` 来记,比用两个独立变量清爽: ```cpp ··· #include//这是vector容器的头文件 using std::vector; // 定义一个"点":一个点里同时存横、纵两个坐标 struct Vec2 { int x, y; // x 是横坐标,y 是纵坐标 Vec2(int x, int y) : x(x), y(y) {} // 这样就能写 Vec2(a, b) 直接造一个点 }; class Airplane { int wid, hei; // 窗口宽高 Vec2 plane = { 0, 0 }; // 飞机当前位置:一个点,里面同时有 x 和 y。用 plane.x、plane.y 来取 vector vimg; // 一个装图片的"袋子",IMAGE 是 EasyX 的图片类型。这里定义了一个IMAGE类型的vimg,如果你不会vector,你就把vimg当成长度可变的数组就行,你往里面塞多少东西都能塞下 int px = 40; // 飞机图片的边长(正方形),如果是长方形,你也可以定义一个px_wid、px_hei public: ···(友情提示,这里是省略号哦,省略的部分上文写了) }; ``` > `Vec2 plane = { 0, 0 };` 里的 `{ 0, 0 }` 是在初始化时把 x、y 都先设成 0(后面马上会被赋值)。以后凡是要用飞机位置,就写 `plane.x`、`plane.y` 来取它的两个坐标。 如果你不会vector容器的用法,可以看一看[菜鸟教程:STL模板库之vector容器](https://www.runoob.com/cplusplus/cpp-vector.html),也可以顺便学一学像map、list等容器(后面要用到list容器)。 在这里,我只简单的说一下,是这么定义的:`vector<类型> 变量名;`;使用`resize(需要扩容的量)`提前扩容;使用`push_back(数据)`放入数据。 #### 6.在`node.h`的构造函数Airplane()中加载图片并初始化位置 ```cpp Airplane(int w, int h) : wid(w), hei(h) { initgraph(wid, hei); srand((unsigned)time(NULL)); // 给随机数设种子,否则每次运行敌机位置序列一样 vimg.resize(4); // 提前给vimg分 4 个位置 loadimage(&vimg[1], _T("res/2.png"), px, px); // 读"玩家飞机"图 plane = { wid / 2, hei - px * 2 }; // 初始位置:水平居中、在下方。即 plane.x = wid/2,plane.y = hei-px*2 } ``` > 如果你不会Airplane(int w, int h) : wid(w), hei(h){}这种写法也不要紧,`wid(w), hei(h)` 的意思就是:**创建对象时,用 `w` 初始化成员变量 `wid`,用 `h` 初始化 `hei`**,和 >>```cpp >> Airplane(int w, int h) { >> wid = w; // 在函数体里给成员赋值 >> hei = h; >>} >>``` > > 没什么两样(你可以这么认为),两个写法的**结果**相同,区别在于 "成员变量是什么时候拿到值的": >> - 普通赋值:成员变量先被**创建出来(可能是垃圾值 / 默认值)**,然后在函数体里**再赋一次** w、h。等于 "先建房子、再搬家具"。 >> - `Airplane(int w, int h) : wid(w), hei(h){}` 这种写法:成员变量**一出生就直接带着 w、h 的值**。等于 "建房子时家具就装好了"。 > 下面单独讲讲 `loadimage` 这个 EasyX 的**读图函数**,它一共有四个参数,从左到右是:`loadimage(&变量名[数组下标], _T("图片相对路径"), 长, 宽);` - `&vimg[1]`:把图读进哪个变量(`&` 表示 "取它的地址",新手记住这是固定写法即可); - `_T("res/2.png")`:图片路径,相对**程序运行的当前工作目录**(VS 里调试运行默认就是项目目录,所以平时说"相对项目目录"也能跑通)。`_T()` 是 Windows 提供、用来统一编码的宏,EasyX 项目里照着写就行; - `px, px`:读进来后缩放到 40×40。 > 如果你没有把本项目的res文件夹放到你的项目根路径下,那就在你的项目根路径下新建一个`res`文件夹,然后随便放一个名为“2.png”的图片,不然读图会失败(最好也不要放空白图片,你随便画两笔就行) #### 7.在`node.h`的Airplane类中写画图函数 ```cpp ··· class Airplane{ ··· void draw() { cleardevice(); // 清空整个画面 putimage(plane.x, plane.y, &vimg[1]); // 把飞机贴到 plane 的 (x, y) 处 } ··· public: ··· } ``` 游戏不是画一次,而是要**每秒几十次地重画**,才能产生 "动" 的效果。所以我们在调用这个函数时要把这个函数放到循环里,让它一直画 ```cpp ··· class Airplane{ ··· void start() { while (true) { draw(); // 每轮都重画 Sleep(15); // 睡 15 毫秒(约 66 帧/秒),控制速度、避免 CPU 跑满 } } public: ··· } ``` `Sleep(15)` 是让程序 "歇 15 毫秒",这样画面刷新速度稳定。 > 到后面"整体代码"里,`draw()` 的开头会多两行 `BeginBatchDraw()` 和 `EndBatchDraw()`——这是 EasyX 的**双缓冲**:先在后台把一帧画完再一次性显示,能避免画面闪烁。你现在先不写也能跑,最终版里会有。 #### 8.在`main.cpp`中调用`start()` ```cpp #include #include "node.h" int main() { Airplane air(360, 640); air.start(); // 进入主循环,开始游戏 return 0; } ``` #### 9.让飞机能飞 > 检测按键用 `GetAsyncKeyState(键)`。它实时告诉你 "这个键现在是不是按着",而且跟哪个窗口有焦点无关 —— 正好适合游戏。**千万别用 `_kbhit()` / `_getch()`**:它们是给 "控制台黑窗口" 读键的,在我们的图形窗口里根本收不到按键,写了也是白写。 `GetAsyncKeyState` 的参数是**按键代号**:方向键是 `VK_UP / VK_DOWN / VK_LEFT / VK_RIGHT`,字母键直接用字符 `'W' / 'S' / 'A' / 'D'`。 写一个处理按键的函数: ```cpp ··· class Airplane{ ··· void control() { int speed = 3; if ((GetAsyncKeyState(VK_UP) & 0x8000) || (GetAsyncKeyState('W') & 0x8000)) { if (plane.y > px / 2) plane.y -= speed; // 注意:往上走是 y 减小哦~ } if ((GetAsyncKeyState(VK_DOWN) & 0x8000) || (GetAsyncKeyState('S') & 0x8000)) { if (plane.y < hei - px) plane.y += speed; } if ((GetAsyncKeyState(VK_LEFT) & 0x8000) || (GetAsyncKeyState('A') & 0x8000)) { if (plane.x > 0) plane.x -= speed; } if ((GetAsyncKeyState(VK_RIGHT) & 0x8000) || (GetAsyncKeyState('D') & 0x8000)) { if (plane.x < wid - px) plane.x += speed; } } public: ··· ``` 接着,让我们把`control()` 加进start()主循环,放在 `draw()` 之前: ```cpp void start() { while (true) { control(); // 1.处理按键 draw(); // 2.重画画面 Sleep(15); } } ``` 现在你可以操纵飞机上下左右移动了! #### 10.发子弹 首先,你需要一个能记录`现在有哪几颗子弹、又在哪里`的容器——list(前面让你学你学了没——什么?没学?没学就去学啊,留着过年吗?)。其次,按空格时要往容器里面塞一颗子弹。 先加容器。这里用 C++ 的 `list`(链表),它增删最快,适合随时产生和消失的子弹、敌机: ```cpp #include using std::list; ... class Airplane{ list bullet; // 装子弹位置的袋子 ··· } ``` 算了,我也不指望你能去学——当然,如果你能去学的话会更好——这是list的定义方法:`list<类型> 变量名;`。没学的话下不为例。 还记得第 5 步我们定义的 `Vec2` 吗?它就是个"点",里面装着 x、y 两个坐标。子弹、敌机的位置,我们也各用一个 `Vec2` 来记(结构体 `struct` 就是"打包几个数据",`Vec2(a, b)` 是它的构造函数,方便直接造点)。 > 注意区别:子弹、敌机这种会不断增删的,放进 `list` 容器;而玩家飞机只有一架,所以它直接用单个 `Vec2 plane` 就够了,不用装进容器。 接着在`Airplane`类之内写生成子弹的函数: ```cpp void spawnBullet() { if (bullet.size() < 3) { // plane.x 是飞机左上角;+px/2 移动到飞机水平中心 int bulletX = plane.x + px / 2 - BULLET_W / 2; // bulletX 再减去子弹自身宽度一半,让子弹图片自身居中对齐飞机中心 bullet.push_back(Vec2(bulletX, plane.y + 10)); } } ``` - `bullet.size()`:袋子里现有几颗。限制 3 颗,防止无脑刷屏。 - `push_back(...)`:往袋子**尾部**塞一个点。 - `plane.x + px / 2`:得到**玩家飞机的水平中心点 x** - `- BULLET_W / 2`:子弹图片也有宽度(`BULLET_W=15`),把子弹往左偏移自身一半宽度,让子弹的中心对准飞机中心,而不是子弹的左上角对准飞机中心。 > 如果不减去 `BULLET_W/2`:子弹左上角对准飞机中心,子弹整体会偏右一点。 - `plane.y + 10`:子弹的初始位置,在飞机位置往下 10 像素(坐标 y 向下,`+` 是往下)。这里写成偏下其实无所谓,因为子弹一生成就会被 `update()` 里的 `y -= 8` 往上推。想更贴直觉的话写成 `plane.y - 10`(飞机头顶上方),效果一样。 这里有坑:**按键连发。** 如果你直接用 `GetAsyncKeyState` 检测空格,按住不放会每帧疯狂生成。我们只想要 "按一下发一发"。这叫**边沿检测**:记下上一帧按键状态,只在 "刚按下的那一瞬" 触发(修改contrl()函数): ```cpp void control() { // 发射子弹:边沿检测 static bool lastSpace = false; // 记住上一帧空格状态 bool curSpace = (GetAsyncKeyState(VK_SPACE) & 0x8000) != 0; // 现在是否按着 if (curSpace && !lastSpace) spawnBullet(); // 刚按下才发射 lastSpace = curSpace; // 更新存档 // ... 下面继续原有的移动代码 ... } ``` `GetAsyncKeyState` 的返回值比较特殊:它是一个整数,其中**最高位(0x8000)为 1 才代表 "真的按着"**。`& 0x8000` 就是只保留最高位检查,新手先照抄,记住 "判断按键按下要加这个"。第 9 步的移动代码里每个 `GetAsyncKeyState(...)` 也都加了 `& 0x8000`,和这里同理——只认"按下位",避免低位干扰。 > 如果你跟不上,也不要着急,继续看,后面会有完整代码供你复制粘贴 现在子弹生成了,但要让它**显示**且**会动**。先画出来 —— 在 `draw()` 里遍历所有子弹贴图: ```cpp void draw() { cleardevice(); for (auto p : bullet) { // 遍历袋子里的每颗子弹 putimage(p.x, p.y, &vimg[2]); // 用第 3 张图(子弹)画出来 } putimage(plane.x, plane.y, &vimg[1]); } ``` > `for (auto p : bullet)` 是 C++ 的**范围遍历**:依次取出袋子里每个元素,`p` 就是当前那颗子弹。`auto` 让编译器自动猜类型。 再`Airplane`类之内加 "更新" 函数,让子弹每帧上移、飞出屏幕就删掉: ```cpp void update() { for (auto& p : bullet) p.y -= 8; // 每帧 y 减 8,即往上飞 8 像素 // 删掉飞出屏幕顶部的子弹 for (auto it = bullet.begin(); it != bullet.end(); it++) { if (it->y < 0) { bullet.erase(it); break; } } } ``` > - `auto& p`:带 `&` 表示**直接改**袋子里那个点(不带 `&` 的话是复制一份,改了白改)。这是新手常犯的错。 > - 第二段用 "迭代器" `it` 遍历并删除。`it` 可理解为 "指向袋子里某个位置的指针",`it->y` 取它的 y,`bullet.erase(it)` 删掉它。注意 `erase` 会让原来的 `it` 失效,所以正式版里用 `it = bullet.erase(it)` 接住它返回的下一个位置,一次就能把出界的子弹全清掉(这里先写成 `break` 删一个就停,更好懂)。新手照抄即可,理解 "清掉出界的子弹" 这个目的就行。 > - `begin()` 返回一个指向**第一个元素**的迭代器,`end()` 返回的是指向**最后一个元素之后位置**的迭代器 ——`end()` **不指向任何实际元素**,它只是一个 "结束标记",表示 "遍历到这里就该停了"。所以遍历要用 `it != 容器.end()` 作为循环条件,而不是 `it <= end()`。 >> `erase()`是用来删除容器中的元素的,括号里一般填数组元素下标即可 >> >> 迭代器还是挺常用的,如果你不会的话,建议把它学会 把 `update()` 加进`start()`主循环,顺序是 **更新 -> 控制 -> 画**: ```cpp void start() { while (true) { update(); // 1.更新所有物体位置 control(); // 2.处理按键 draw(); // 3.重画 Sleep(15); } } ``` > 记得 `res` 里放一张 `3.png` 当子弹图。 运行:按空格,子弹从飞机上方冒出并往上飞走。子弹搞定。 #### 11.让敌机掉下来 和子弹几乎一样的套路,方向相反:从顶部掉下来、位置随机。 加容器: ``` class Airplane { ··· list enemyPlane; // 装敌机位置 ··· } ``` 生成函数,注意控制节奏,别一窝蜂全出来: ```cpp class Airplane { ··· void spawnEnemy() { static int x = 0; // static:这个计数跨帧保留 if (x >= 20) { // 每 20 帧才尝试生成一次 if (enemyPlane.size() < 5) { // 同屏最多 5 只 enemyPlane.push_back(Vec2(std::rand() % (wid - px) + px / 2, 0)); } x = 0; } x++; } ··· ``` - `static int x = 0;`:`static` 表示这个局部变量**不会因函数结束而消失**,下次调用还记得上次的值。用它当计数器,控制生成频率。 - `std::rand() % (wid - px) + px / 2`:生成一个 0 到 `wid-px` 之间的随机横坐标(`%` 是取余,把随机数限定范围),再偏移一点让敌机别贴边。`std::rand()` 是 C++ 的随机函数。**注意**:只调 `std::rand()` 不设种子的话,每次启动程序生成的随机序列都一样(敌机每次都从相同位置落下)。所以要在构造函数里先调用一次 `srand((unsigned)time(NULL))` 设随机种子(取当前时间当种子)——第 6 步构造函数里已经加好了。 在 `update()` 里让敌机下坠、出界删除、并调用生成: ```cpp class Airplane { ··· void update() { for (auto& p : enemyPlane) p.y += 2; // 敌机每帧下坠 2 像素 for (auto it = enemyPlane.begin(); it != enemyPlane.end(); it++) { if (it->y > hei) { enemyPlane.erase(it); break; } // 掉出屏幕就删 } for (auto& p : bullet) p.y -= 8; for (auto it = bullet.begin(); it != bullet.end(); it++) { if (it->y < 0) { bullet.erase(it); break; } } spawnEnemy(); } ··· } ``` 在`draw()` 里把敌机画出来(放在飞机之前,这样飞机盖在最上层): ```cpp for (auto p : enemyPlane) putimage(p.x, p.y, &vimg[0]); ``` > 记得 `res` 里放一张 `1.png` 当敌机图。 运行:顶部开始不断掉下敌机。 #### 12.打中敌机 + 计分 子弹撞到敌机 -> 两者一起消失、加一分。 先写 "两个矩形是否相撞" 的判断。`v1`、`v2` 是两张图各自的**左上角**,`(w1,h1)`、`(w2,h2)` 分别是它们自己的宽高: ```cpp // v1、v2 是两张图各自的左上角,(w1,h1)、(w2,h2) 分别是它们自己的宽高。 // 判断两个矩形是否重叠:只要某个方向完全错开,就没撞上。 bool Collide(Vec2 v1, int w1, int h1, Vec2 v2, int w2, int h2) { if (v1.x + w1 <= v2.x || v2.x + w2 <= v1.x) return false; // 水平方向错开 if (v1.y + h1 <= v2.y || v2.y + h2 <= v1.y) return false; // 竖直方向错开 return true; // 两个方向都叠上了 = 撞上 } ``` > **为什么每个物体都要带自己的宽高?** 因为不同东西的判定框不一样大:子弹图是 15×25(细长),飞机和敌机是 40×40(`px`)。判断重叠时,"水平方向有没有错开"要用**两边的宽度**分别去比——只给一边的尺寸是不够的(这点很关键,见下面的坑)。 > > **这里有两个很容易写错的坑**: > 1. 不要写成 `abs(v1.x - v2.x) < w`。因为 `v1`、`v2` 存的是**左上角**,图片是从左上角往**右下**铺开的,不是以点为中心。用距离去比,会出现"两张图明明叠在一起、却判定没撞上"。 > 2. 更隐蔽的是**尺寸只给了一半**。如果函数只收一套 `w, h`,然后按 `v1.x + w` 和 `v2.x + w` 去比,那这个 `w` 必须等于**两边宽度之和**;竖直方向同理。实战里很容易只传了子弹的 `15, 25`,漏掉敌机的 40 —— 结果竖直方向 25 < 30,画面上明明压在一起却判不撞,**子弹就从敌机身上穿过去不掉血**。上面的写法让每个物体各自带尺寸,从根上避免了配错。 再写 "子弹打敌机":遍历每一颗子弹 × 每一只敌机,找到相撞的,双双删除并加分: ```cpp void bulletCollide() { for (auto p = bullet.begin(); p != bullet.end(); ) { // 注意:这里不写 p++ bool hit = false; for (auto en = enemyPlane.begin(); en != enemyPlane.end(); en++) { if (Collide(*p, BULLET_W, BULLET_H, *en, px, px)) { // 子弹 15×25,敌机 px×px enemyPlane.erase(en); // 删敌机 hit = true; grade++; // 加分 break; // 这颗子弹打掉一架,换下一颗 } } if (hit) p = bullet.erase(p); // 子弹也消失,p 拿到下一个有效迭代器 else p++; // 没撞到,继续下一颗子弹 } } ``` > `*p` 是把迭代器 "解引用" 成它指向的那个点,因为 `Collide` 要的是点,不是迭代器。 > > **防止子弹"穿过"敌机的一个关键点**:原来的写法一撞到一对就 `return`,导致一帧最多只处理一对碰撞,同帧多对相撞时其余的会被跳过,子弹就从敌机身上"穿过去"了。改成:每颗子弹最多打掉一架敌机,删除时用 `p = bullet.erase(p)` 拿回**下一个有效迭代器**继续遍历,这样一帧内所有该撞的都处理完。 在 `update()` 里调用它。然后显示分数 —— 这里会碰到 EasyX 的**文字 + 编码**问题: ```cpp TCHAR tc[30]; // 一个存文字的数组,TCHAR 是 EasyX 用来兼容编码的类型 ... void draw() { ... wsprintf(tc, _T("score:%d"), grade); // 把 "score:分数" 拼到 tc 里 outtextxy(wid / 2, px, tc); // 在 (宽/2, 40) 处把文字画出来 } ``` - `_T("...")`:`_T()` 是 Windows 提供的宏(来自 `tchar.h`),用来统一宽 / 窄字符写法,EasyX 项目里显示文字都要用它包一下。不包的话中文可能乱码或编译报错,所以所有要显示的文字都包上 `_T()`。 - `wsprintf` 是 "格式化字符串",`%d` 会被 `grade` 的数字替换掉。 - `outtextxy(x, y, 文字)`:在 `(x,y)` 处显示文字。 运行后,现在可以打飞机了! #### 13.游戏结束 + 重开 敌机撞到玩家 -> 游戏结束。写判断: ```cpp bool isGameOver() { for (auto p : enemyPlane) { if (Collide(plane, px, px, p, px, px)) return true; // 两边都是 40×40;plane 本身就是 Vec2,直接传 } return false; } ``` 在主循环里判断,结束就弹窗问是否重开。`MessageBox` 是 Windows 自带的弹窗函数,会**卡住程序直到你点按钮**,正好用来问话: ```cpp void start() { while (true) { update(); control(); draw(); if (isGameOver()) { if (MessageBox(GetForegroundWindow(), _T("是否重新开始游戏"), _T("游戏结束"), MB_YESNO) == IDYES) init(); // 点"是":重置重开 else break; // 点"否":退出程序 } Sleep(15); } } ``` - `MessageBox(窗口, 内容, 标题, 按钮)`:四个参数,前两个好懂。`MB_YESNO` 表示显示 "是 / 否" 两个按钮。返回值 `IDYES` 表示点了 "是"。 - `GetForegroundWindow()`:取当前活动窗口,固定写法。 写 `init()` 重置游戏(也叫初始化): ```cpp void init() { plane = { wid / 2, hei - px * 2 }; bullet.clear(); // 清空子弹 enemyPlane.clear(); // 清空敌机 grade = 0; // 分数归零 } ``` 构造函数里也要调用一次 `init()`,让开局状态一致。 #### 暂停(选做) 按 ESC 暂停。最省事的办法就是弹个 `MessageBox`——**它一弹出,程序卡在这一行,整个游戏就停了**,点确定才继续,天然就是暂停: ```cpp // 放进 control(),同样用边沿检测 static bool lastEsc = false; bool curEsc = (GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0; if (curEsc && !lastEsc) MessageBox(GetForegroundWindow(), _T("已暂停,点击确定继续"), _T("暂停"), MB_OK); lastEsc = curEsc; ``` `MB_OK` 表示只显示一个 "确定" 按钮。 ### 整体代码
点击展开node.h的完整代码 ```cpp #pragma once #include #include #include #include #include using std::list; using std::vector; // 子弹图片的显示尺寸:加载图片和碰撞判定都用它,改一处即可 const int BULLET_W = 15; const int BULLET_H = 25; struct Vec2 { int x; int y; Vec2(int x, int y) :x(x), y(y) {} }; class Airplane { int wid;//宽 int hei;//高 vectorvimg; Vec2 plane = { 0,0 }; list enemyPlane; list bullet; int grade = 0;//得分 int px = 40; TCHAR tc[30]; //生成子弹 void spawnBullet() { if (bullet.size() < 3) { bullet.push_back(Vec2(plane.x, plane.y + 10)); } } //销毁子弹 void destroyBullet() { for (auto it = bullet.begin(); it != bullet.end();) { if (it->y < 0) it = bullet.erase(it); // erase 返回下一个元素,交给循环条件 else ++it; } } //生成敌机 void spawnEnemy() { static int x = 0; if (x >= 20) { if (enemyPlane.size() < 5) { enemyPlane.push_back(Vec2(std::rand() % (wid - px) + px / 2, 0)); } x = 0; } x++; } //销毁敌机 void destroyEnemy() { for (auto it = enemyPlane.begin(); it != enemyPlane.end();) { if (it->y > hei) it = enemyPlane.erase(it); // erase 返回下一个元素,交给循环条件 else ++it; } } //与敌机碰撞 // v1、v2 是两张图各自的左上角,(w1,h1)、(w2,h2) 分别是它们自己的宽高。 // 判断两个矩形是否重叠:只要某个方向完全错开,就没撞上。 bool Collide(Vec2 v1, int w1, int h1, Vec2 v2, int w2, int h2) { if (v1.x + w1 <= v2.x || v2.x + w2 <= v1.x) return false; // 水平方向错开 if (v1.y + h1 <= v2.y || v2.y + h2 <= v1.y) return false; // 竖直方向错开 return true; // 两个方向都叠上了 = 撞上 } //子弹击中敌机 void bulletCollide() { for (auto p = bullet.begin(); p != bullet.end(); ) { bool hit = false; for (auto en = enemyPlane.begin(); en != enemyPlane.end(); en++) { if (Collide(*p, BULLET_W, BULLET_H, *en, px, px)) { enemyPlane.erase(en); hit = true; grade++; break; } } if (hit) p = bullet.erase(p); else p++; } } //实时刷新 void update() { for (auto& p : enemyPlane) { p.y += 2; } for (auto& p : bullet) { p.y -= 8; } bulletCollide(); destroyEnemy(); destroyBullet(); spawnEnemy(); } //控制 void control() { // ESC:弹出暂停窗,阻塞期间游戏完全冻结 static bool lastEsc = false; bool curEsc = (GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0; if (curEsc && !lastEsc) { MessageBox(GetForegroundWindow(), _T("已暂停,点击确定继续"), _T("暂停"), MB_OK); } lastEsc = curEsc; // 空格射击(边沿检测,按一下发一枪) static bool lastSpace = false; bool curSpace = (GetAsyncKeyState(VK_SPACE) & 0x8000) != 0; if (curSpace && !lastSpace) spawnBullet(); lastSpace = curSpace; int speed = 3; if ((GetAsyncKeyState(VK_UP) & 0x8000) || (GetAsyncKeyState('W') & 0x8000)) { if (plane.y > px / 2) plane.y -= speed; } if ((GetAsyncKeyState(VK_DOWN) & 0x8000) || (GetAsyncKeyState('S') & 0x8000)) { if (plane.y < hei - px) plane.y += speed; } if ((GetAsyncKeyState(VK_RIGHT) & 0x8000) || (GetAsyncKeyState('D') & 0x8000)) { if (plane.x < wid - px) plane.x += speed; } if ((GetAsyncKeyState(VK_LEFT) & 0x8000) || (GetAsyncKeyState('A') & 0x8000)) { if (plane.x > 0) plane.x -= speed; } } //判断游戏是否结束 bool isGameOver() { for (auto p : enemyPlane) { if (Collide(plane, px, px, p, px, px)) return true; } return false; } //绘制 void draw() { BeginBatchDraw(); cleardevice(); for (auto p : enemyPlane) { putimage(p.x, p.y, &vimg[0]); } for (auto p : bullet) { putimage(p.x, p.y, &vimg[2]); } putimage(plane.x, plane.y, &vimg[1]); wsprintf(tc, _T("score:%d"), grade); outtextxy(wid / 2, px, tc); EndBatchDraw(); } //初始化 void init() { plane = { wid / 2,hei - px * 2 }; bullet.clear(); enemyPlane.clear(); grade = 0; } public: //开始 void start() { while (true) { update(); control(); draw(); if (isGameOver()) { if (MessageBox(GetForegroundWindow(), _T("是否重新开始游戏"), _T("游戏结束"), MB_YESNO) == IDYES) init(); else break; } Sleep(15); } } //构造函数 Airplane(int wid, int hei) :wid(wid), hei(hei) { initgraph(wid, hei); vimg.resize(4); loadimage(&vimg[0], _T("res/1.png"), px, px); loadimage(&vimg[1], _T("res/2.png"), px, px); loadimage(&vimg[2], _T("res/3.png"), BULLET_W, BULLET_H); init(); } //析构函数 ~Airplane() { closegraph(); } }; ```
`main.cpp` ```cpp #include #include "node.h" int main() { Airplane air(360, 640); air.start(); return 0; } ``` ### 优化 做完上面这些东西,必要功能都基本实现了。但是现在玩起来可能还是不太舒服,所以接下来进行一些优化(装修毛坯房)。 #### 1.添加血量(多条命) 目前我们的逻辑是玩家一旦被敌机撞上,直接游戏结束。如果想做成多条命的效果,不能简单写个变量撞一次扣一滴血就完事了。 > 这里有坑:游戏主循环每 15ms 跑一帧,一秒能跑 60 多帧。如果没有保护,敌机贴在玩家飞机身上时,**同一架敌机一帧帧反复判定碰撞,一瞬间 3 条命直接扣光,游戏瞬间结束**。当然,你也可以画一个护盾给飞机套上,让无敌帧更明显一些,这里就先不做护盾了。 所以要做的就是以下几点: - 1. 发生碰撞后,把撞击你的敌机直接销毁; - 2. 给玩家开启一段**无敌帧**,无敌时间内再次碰撞不会扣血; - 3. 游戏重开的时候,必须把血量、无敌状态全部重置,不然点重新开始上来就是残血甚至 0 血。 ##### 在node.h中添加常量 写在文件最外层,和`BULLET_W`、`BULLET_H`放在同一层级。这两个常量用来配置总血量、无敌持续多少帧,后续想改数值直接改这里就行,不用到处找代码。 ```cpp const int MAX_HP = 3; // 总血量:被撞几次后死亡 const int INVINCIBLE_FRAMES = 60; // 撞机后无敌帧数,60 帧 × 15ms ≈ 0.9 秒 ``` ##### 添加**成员变量** 在类内部,`int grade = 0;` 的后面添加,用来记录当前剩余血量、剩余无敌帧数。 ```cpp int hp = MAX_HP; // 剩余血量 int invincible = 0; // 无敌剩余帧数,>0 期间撞了不扣血 ``` ##### **新增 `planeCollide()`** 把这个函数写在 `bulletCollide()` 的后面,专门处理**玩家飞机和敌机的碰撞逻辑**。 - 遍历所有敌机,如果和玩家相撞,标记发生撞击,并且把这架敌机直接删除; - 就算一帧之内同时撞上多架敌机,也只扣一次血量; - 只有不在无敌状态的时候,才会扣血,扣完血立刻开启无敌保护。 ```cpp //玩家与敌机碰撞:扣血 + 进入无敌 void planeCollide() { bool crashed = false; for (auto it = enemyPlane.begin(); it != enemyPlane.end();) { if (Collide(plane, px, px, *it, px, px)) { crashed = true; it = enemyPlane.erase(it); // 撞上的敌机一起撞毁 } else ++it; } // 一帧内撞几架都只算一次伤害,无敌期间不扣血 if (crashed && invincible == 0) { hp--; invincible = INVINCIBLE_FRAMES; } } ``` ##### 修改 `update()` 更新函数 在`update()`里面调用刚刚写好的碰撞函数,同时每一帧让无敌帧数递减,倒计时归零就解除无敌。 ```cpp bulletCollide(); planeCollide(); // 新增:机体碰撞扣血 destroyEnemy(); destroyBullet(); if (invincible > 0) invincible--; // 新增:无敌计时递减 spawnEnemy(); ``` ##### 修改 `isGameOver()` 现在碰撞扣血逻辑已经全部挪到`update()`里面,这个函数只需要做一件事:判断血量是否归零。 直接替换整个函数: ```cpp bool isGameOver() { return hp <= 0; } ``` ##### *修改 `draw()` 绘制函数 第一处:无敌状态时,让玩家飞机闪烁,给玩家直观提示 “现在我是无敌的,不怕撞”——当然,如果你绘制了一张带护盾的飞机图片的话,拿来用会更明显。 把原来直接绘制玩家飞机的代码,替换成下面这段: ```cpp //原 putimage(plane.x, plane.y, &vimg[1]); 调整为以下: // 无敌期间闪烁:每 4 帧翻转一次显隐,达到闪烁效果 bool blink = (invincible > 0) && ((invincible / 4) % 2 == 0); if (!blink) putimage(plane.x, plane.y, &vimg[1]); ``` 第二处:在屏幕右上角绘制血量图标,用小飞机图标代表剩余血量。 > 说明:我们最开始`vimg.resize(4)`预留了 4 个图片位置,下标`3`一直是空的,正好拿来存血量小图标。 ```cpp wsprintf(tc, _T("score:%d"), grade); outtextxy(wid / 2, px, tc); // 右上角画剩余血量,每一滴血显示一架缩小的小飞机 for (int i = 0; i < hp; i++) putimage(wid - (i + 1) * 24, 4, &vimg[3]); ``` >备选简易方案:如果你不想额外加载图片,也可以直接用文字显示血量。把上面循环删掉,修改 wsprintf 这一行: > ``` > wsprintf(tc, _T("score:%d HP:%d"), grade, hp); > ``` > > `tc[30]`数组空间足够存储这段文字,不会溢出。 回到构造函数的`loadimage`加载图片代码块,补充加载血量图标: ```cpp loadimage(&vimg[3], _T("res/2.png"), 20, 20); ``` 复用玩家飞机的图片,缩放到 20×20 大小当做血量图标。 ##### 修改 `init()` 初始化函数 `init()`会在游戏开局、点击重新开始的时候调用,**必须重置血量和无敌状态**,不然重开游戏会继承上一把的残血。 ```cpp void init() { plane = { wid / 2,hei - px * 2 }; bullet.clear(); enemyPlane.clear(); grade = 0; hp = MAX_HP; // 重置血量为满血 invincible = 0; // 清空无敌状态 } ``` ##### 原有 `start()` 游戏主循环不用改动 当`hp`降到 0,`isGameOver()`返回 true,就会弹出弹窗询问是否重新开始,调用`init()`完成状态重置。 #### 调整玩法 现在你玩了几把发现,游戏难度实在是太低了,也没什么玩法,没有华丽的特效,甚至窗口都很简陋,简称“三无产品”。你也发现,总有可恶的玩家只躲不打或者是只打不躲,这就没意思了啊。所以我们可以做以下调整: - 最大敌机从 **5 -> 10**,设置硬上限,防止敌人爆炸式变多; - 敌机速度最多加快**三分之一**:基础速度 2,加快 1/3 → 最大速度 `2 * (4/3) ≈ 2.66`,不会快到完全没法躲; - 难度同时受**分数**驱动(打得越好分越高,难度上涨); >设计目的: > 1. 只躲不打:敌机数量慢慢变多,屏幕会越来越挤,躲的压力爆炸,不能一味苟; > 2. 只打不躲:速度变快,一旦漏几架敌机冲下来,很容易撞玩家扣血,强迫兼顾躲闪。 ##### 敌机生成调整 ```cpp //生成敌机 void spawnEnemy() { static int x = 0; // 最大敌机数量:基础5,每50分+1,上限10 int maxEnemy = 5 + grade / 50; if (maxEnemy > 10) maxEnemy = 10; // 生成间隔,分数越高刷新越快,最低8帧 int spawnInterval = 20 - grade / 100 * 2; if (spawnInterval < 8) spawnInterval = 8; if (x >= spawnInterval) { if (enemyPlane.size() < maxEnemy) { enemyPlane.push_back(Vec2(std::rand() % (wid - px) + px / 2, 0)); } x = 0; } x++; } ``` ##### update () 里面敌机移动速度 ```cpp //实时刷新 void update() { //敌机速度:基础2,最多加快1/3,上限 8/3 ≈2.66 float enemySpeed = 2.0f + (2.0f / 3.0f) * (grade / 450.0f); if(enemySpeed > 8.0f / 3.0f) enemySpeed = 8.0f / 3.0f; for (auto& p : enemyPlane) { p.y += enemySpeed; } for (auto& p : bullet) { p.y -= 8; } bulletCollide(); planeCollide(); destroyEnemy(); destroyBullet(); if (invincible > 0) invincible--; spawnEnemy(); } ``` > 说明:`grade=450` 的时候速度到达最大值,刚好提升 1/3。 > 使用`float`浮点,速度变化是平滑的,不会一格一格跳。 > > 注意:`Vec2`内部 x,y 是`int`;`p.y += enemySpeed`,`enemySpeed`是浮点数,`p.y`会隐式转 int,会自动截断小数,不影响运行。 #### 优化后的代码
点击展开node.h的完整代码 ```cpp #pragma once #include #include #include #include #include using std::list; using std::vector; // 子弹图片的显示尺寸:加载图片和碰撞判定都用它,改一处即可 const int BULLET_W = 15; const int BULLET_H = 25; const int MAX_HP = 3; // 总血量:被撞几次后死亡 const int INVINCIBLE_FRAMES = 60; // 撞机后无敌帧数,60 帧 × 15ms ≈ 0.9 秒 struct Vec2 { int x; int y; Vec2(int x, int y) :x(x), y(y) {} }; class Airplane { int wid;//宽 int hei;//高 vectorvimg; Vec2 plane = { 0,0 }; list enemyPlane; list bullet; int grade = 0;//得分 int hp = MAX_HP; // 剩余血量 int invincible = 0; // 无敌剩余帧数,>0 期间撞了不扣血 int px = 40; TCHAR tc[30]; //生成子弹 void spawnBullet() { if (bullet.size() < 3) { // plane.x 是飞机左上角;+px/2 移动到飞机水平中心 int bulletX = plane.x + px / 2 - BULLET_W / 2; // bulletX 再减去子弹自身宽度一半,让子弹图片自身居中对齐飞机中心 bullet.push_back(Vec2(bulletX, plane.y + 10)); } } //销毁子弹 void destroyBullet() { for (auto it = bullet.begin(); it != bullet.end();) { if (it->y < 0) it = bullet.erase(it); // erase 返回下一个元素,交给循环条件 else ++it; } } //生成敌机 void spawnEnemy() { static int x = 0; if (x >= 20) { if (enemyPlane.size() < 5) { enemyPlane.push_back(Vec2(std::rand() % (wid - px) + px / 2, 0)); } x = 0; } x++; } //销毁敌机 void destroyEnemy() { for (auto it = enemyPlane.begin(); it != enemyPlane.end();) { if (it->y > hei) it = enemyPlane.erase(it); // erase 返回下一个元素,交给循环条件 else ++it; } } //与敌机碰撞 // v1、v2 是两张图各自的左上角,(w1,h1)、(w2,h2) 分别是它们自己的宽高。 // 判断两个矩形是否重叠:只要某个方向完全错开,就没撞上。 bool Collide(Vec2 v1, int w1, int h1, Vec2 v2, int w2, int h2) { if (v1.x + w1 <= v2.x || v2.x + w2 <= v1.x) return false; // 水平方向错开 if (v1.y + h1 <= v2.y || v2.y + h2 <= v1.y) return false; // 竖直方向错开 return true; // 两个方向都叠上了 = 撞上 } //子弹击中敌机 void bulletCollide() { for (auto p = bullet.begin(); p != bullet.end(); ) { bool hit = false; for (auto en = enemyPlane.begin(); en != enemyPlane.end(); en++) { if (Collide(*p, BULLET_W, BULLET_H, *en, px, px)) { enemyPlane.erase(en); hit = true; grade++; break; } } if (hit) p = bullet.erase(p); else p++; } } //玩家与敌机碰撞:扣血 + 进入无敌 void planeCollide() { bool crashed = false; for (auto it = enemyPlane.begin(); it != enemyPlane.end();) { if (Collide(plane, px, px, *it, px, px)) { crashed = true; it = enemyPlane.erase(it); // 撞上的敌机一起撞毁 } else ++it; } // 一帧内撞几架都只算一次伤害,无敌期间不扣血 if (crashed && invincible == 0) { hp--; invincible = INVINCIBLE_FRAMES; } } //实时刷新 void update() { for (auto& p : enemyPlane) { p.y += 2; } for (auto& p : bullet) { p.y -= 8; } bulletCollide(); planeCollide(); // 新增:机体碰撞扣血 destroyEnemy(); destroyBullet(); if (invincible > 0) invincible--; // 新增:无敌计时递减 spawnEnemy(); } //控制 void control() { // ESC:弹出暂停窗,阻塞期间游戏完全冻结 static bool lastEsc = false; bool curEsc = (GetAsyncKeyState(VK_ESCAPE) & 0x8000) != 0; if (curEsc && !lastEsc) { MessageBox(GetForegroundWindow(), _T("已暂停,点击确定继续"), _T("暂停"), MB_OK); } lastEsc = curEsc; // 空格射击(边沿检测,按一下发一枪) static bool lastSpace = false; bool curSpace = (GetAsyncKeyState(VK_SPACE) & 0x8000) != 0; if (curSpace && !lastSpace) spawnBullet(); lastSpace = curSpace; int speed = 3; if ((GetAsyncKeyState(VK_UP) & 0x8000) || (GetAsyncKeyState('W') & 0x8000)) { if (plane.y > px / 2) plane.y -= speed; } if ((GetAsyncKeyState(VK_DOWN) & 0x8000) || (GetAsyncKeyState('S') & 0x8000)) { if (plane.y < hei - px) plane.y += speed; } if ((GetAsyncKeyState(VK_RIGHT) & 0x8000) || (GetAsyncKeyState('D') & 0x8000)) { if (plane.x < wid - px) plane.x += speed; } if ((GetAsyncKeyState(VK_LEFT) & 0x8000) || (GetAsyncKeyState('A') & 0x8000)) { if (plane.x > 0) plane.x -= speed; } } //判断游戏是否结束 bool isGameOver() { return hp <= 0; } //绘制 void draw() { BeginBatchDraw(); cleardevice(); for (auto p : enemyPlane) { putimage(p.x, p.y, &vimg[0]); } for (auto p : bullet) { putimage(p.x, p.y, &vimg[2]); } // 无敌期间闪烁:每 4 帧翻转一次显隐 bool blink = (invincible > 0) && ((invincible / 4) % 2 == 0); if (!blink) putimage(plane.x, plane.y, &vimg[1]); wsprintf(tc, _T("score:%d"), grade); outtextxy(wid / 2, px, tc); // 右上角画剩余血量,每点血一架小飞机 for (int i = 0; i < hp; i++) putimage(wid - (i + 1) * 24, 4, &vimg[3]); EndBatchDraw(); } //初始化 void init() { plane = { wid / 2,hei - px * 2 }; bullet.clear(); enemyPlane.clear(); grade = 0; hp = MAX_HP; // 新增 invincible = 0; // 新增 } public: //开始 void start() { while (true) { update(); control(); draw(); if (isGameOver()) { if (MessageBox(GetForegroundWindow(), _T("是否重新开始游戏"), _T("游戏结束"), MB_YESNO) == IDYES) init(); else break; } Sleep(15); } } //构造函数 Airplane(int wid, int hei) :wid(wid), hei(hei) { initgraph(wid, hei); vimg.resize(4); loadimage(&vimg[0], _T("res/1.png"), px, px); loadimage(&vimg[1], _T("res/2.png"), px, px); loadimage(&vimg[2], _T("res/3.png"), BULLET_W, BULLET_H); loadimage(&vimg[3], _T("res/2.png"), 20, 20); init(); } //析构函数 ~Airplane() { closegraph(); } }; ```
### 进阶 恭喜!你已经完成了飞机大战的基础玩法,游戏已经可以正常游玩。 **我们当前的代码是为教学而简化的版本**,工程开发里会有另一套设计思路。 > 当你已经完整跑通基础游戏,想进一步提升项目质量,可以尝试下面的改造,属于进阶内容: | 分类 | 拓展项目 | 简要说明 | | --- | --- | --- | | 架构优化 | Delta Time 时间系统 | 替换固定`Sleep(15)`,使用真实流逝时间驱动游戏逻辑,帧率不影响游戏速度 | | 架构优化 | 文件模块化拆分 | 将Vec2、碰撞检测、渲染、游戏循环拆分成独立头文件与源文件,便于维护 | | 架构优化 | 资源管理器 | 统一加载、管理图片资源,消除硬编码路径,资源只加载一次 | | 架构优化 | 游戏状态机 | 使用状态机管理「主菜单/游戏中/暂停/游戏结束」,替代简单bool标记 | | 架构优化 | 独立输入封装 | 将按键检测、边沿检测封装为独立模块,方便后续更换输入方案 | | 架构优化 | 渲染与逻辑解耦 | update只更新数据,draw只负责绘制,不在更新逻辑内调用绘图函数 | | 游戏拓展 | 多类型敌机 | 新增厚血敌机、高速敌机,不同敌机血量、速度、击杀得分不同 | | 游戏拓展 | 道具掉落系统 | 击毁敌机有概率掉落道具,实现回血、子弹强化等增益效果 | | 游戏拓展 | 本地最高分存档 | 将最高分保存到本地txt文件,游戏启动自动读取历史记录 | | 游戏拓展 | 音效系统 | EasyX加载wav音频,添加射击、爆炸、游戏结束音效 | | 游戏拓展 | 敌机AI升级 | 敌机不再只直线下落,增加左右摆动、俯冲等运动模式 | | 游戏拓展 | BOSS战 | 大型BOSS,多段血量,多种攻击方式,专属战斗逻辑 | | 游戏拓展 | 通用粒子系统 | 一套代码复用实现爆炸、飞机尾焰、星空等特效 | | 游戏拓展 | 分段关卡系统 | 划分多关卡,每一关设计独立敌人波次与难度 |