简介:这份C++大作业飞机大战源码包面向高校学生与C++初学者,帮助读者通过一个完整可运行的2D游戏项目理解面向对象编程与Qt框架的实际应用。压缩包共78个文件,约54.78MB,以35个png与5个jpg图片、2个wav音频构成游戏素材,12个cpp与10个h文件承载游戏逻辑、碰撞检测、动画与界面模块,另含ui、qrc、pro等Qt工程配置及编译产物,目录结构清晰,便于按模块阅读。项目基于Qt 5.12.0与MSVC2017 32位环境构建,README提供编译运行说明,源码中可学习类的封装、继承与多态、QGraphicsView图形视图框架、信号槽机制以及游戏循环与资源加载等关键知识点。目前已有72人学习下载,适合作为课程设计参考、Qt入门练手或二次开发基础,通过运行与修改源码可切实提升调试与项目组织能力。
1. 飞机大战源码到手之后:先别急着编译,搞清楚它到底能给你什么
很多人拿到「C++大作业飞机大战源码.zip」的第一反应是双击解压、找 .sln 文件、按 F5,然后对着满屏报错发呆。我见过太多这样的情况:压缩包里躺着十几个 .cpp 和 .h,资源目录里一堆 BMP 和 WAV,README 只有两行字,连用的什么图形库都没写清楚。你以为是代码质量不行,其实是你还没搞清楚这份源码的定位——它是一个教学性质的 C++ 大作业项目,不是商业游戏引擎,它的价值在于让你看懂一个完整的小型游戏循环是怎么搭起来的,而不是让你直接拿去上线。
飞机大战这个题材在 C++ 课程设计里出现频率极高,原因很简单:它覆盖了面向对象编程的核心知识点(类与对象、继承、多态、封装),同时涉及游戏开发的基础概念(游戏循环、碰撞检测、帧率控制、资源管理),代码量适中,一个人两三周能写完。但正因为写的人多,质量参差不齐,你拿到的这份源码可能是学长传下来的,也可能是网上随便找的,里面的坑得你自己填。
这篇文章要解决的问题很具体:你手里有一份飞机大战源码,你想把它跑起来、看懂它、改出自己想要的效果,甚至基于它扩展成一个像样的作品。我会从环境配置讲到代码结构,从编译报错讲到玩法扩展,把每一步的参数和踩坑点都写清楚。适合正在做 C++ 大作业的学生,也适合想通过小游戏入门 C++ 项目开发的初学者。如果你已经能熟练用 C++ 写图形程序,这篇文章可能对你偏基础,但里面的调试技巧和扩展思路仍然有参考价值。
2. 把源码跑起来:环境配置与第一次编译
2.1 先判断这份源码用的什么图形库
拿到源码后不要急着打开 IDE,先做一件事:用文本编辑器打开主文件(通常是 main.cpp 或 game.cpp),看开头几行 include 了什么。这一步决定了你后面要装什么环境。常见的飞机大战源码会用以下几种图形库之一:
| 图形库 | 典型 include | 特点 | 适合场景 |
|---|---|---|---|
| EasyX | #include <graphics.h> | 仅 Windows,中文文档多,上手快 | 课程设计、初学者 |
| SDL2 | #include <SDL.h> | 跨平台,C 风格 API,资料丰富 | 想学正规游戏开发 |
| SFML | #include <SFML/Graphics.hpp> | 跨平台,C++ 风格,面向对象 | 想用现代 C++ 写游戏 |
| Qt | #include <QWidget> | 重量级,适合带 UI 的应用 | 大作业要求带界面 |
| Win32 API | #include <windows.h> | 原生,代码量大,不推荐新手 | 老师指定要求 |
如果你打开文件看到的是graphics.h和initgraph,那基本可以确定是 EasyX。这是国内课程设计最常用的方案,因为它足够简单,安装也方便。但要注意,EasyX 只支持 Windows + Visual Studio 的组合,如果你用的是 VS Code 或者 CLion,配置起来会麻烦很多。
提示:如果源码里同时出现了
graphics.h和SDL.h,说明作者可能做了多平台适配,或者是从不同项目拼凑的,这种情况要格外小心,很可能编译不过。
2.2 Visual Studio 配置 EasyX 的完整步骤
假设你确认了源码用的是 EasyX,下面是从零配置的流程。我以 Visual Studio 2022 社区版为例,其他版本操作类似。
第一步,确认你已经安装了「使用 C++ 的桌面开发」工作负载。打开 Visual Studio Installer,点「修改」,勾选这个工作负载,确保右侧的「MSVC v143 生成工具」和「Windows 10/11 SDK」都打上了勾。这一步很多人会漏掉,导致后面找不到windows.h。
第二步,下载 EasyX 库。去 EasyX 官网(easyx.cn)下载最新版,它是一个安装程序,运行后会检测你电脑上安装的 Visual Studio 版本,自动把头文件和库文件复制到对应目录。安装完成后,你可以在 VS 的安装目录下找到EasyX\include\graphics.h和EasyX\lib\VC\amd64\EasyXa.lib这样的文件。
第三步,打开源码的 .sln 文件。如果没有 .sln,就新建一个空项目,把所有 .cpp 和 .h 文件添加进去。然后右键项目 → 属性 → C/C++ → 常规 → 附加包含目录,添加 EasyX 的 include 路径。再点链接器 → 常规 → 附加库目录,添加 EasyX 的 lib 路径。最后在链接器 → 输入 → 附加依赖项里加上EasyXa.lib(Debug x64)或EasyXw.lib(Release x64)。
// 验证 EasyX 是否配置成功的测试代码 #include <graphics.h> #include <conio.h> int main() { // 初始化一个 640x480 的窗口 initgraph(640, 480); // 设置背景色为黑色 setbkcolor(BLACK); cleardevice(); // 画一个白色圆圈,圆心 (320, 240),半径 50 setlinecolor(WHITE); circle(320, 240, 50); // 等待按键,防止窗口一闪而过 _getch(); // 关闭图形窗口 closegraph(); return 0; }这段代码的逻辑很直白:initgraph创建窗口,cleardevice用背景色清屏,circle画圆,_getch暂停程序。如果你能编译运行并看到一个带白圈的黑色窗口,说明 EasyX 环境没问题。参数说明:initgraph(width, height)的两个参数是窗口的像素宽高,常见飞机大战用 480x640 或 640x480,取决于横屏还是竖屏。
2.3 编译报错的三种常见类型和处理方法
即使环境配好了,第一次编译大概率还是会报错。我把常见错误归成三类,你对照着排查。
第一类是「找不到头文件」,报错信息类似cannot open source file "graphics.h"。这说明附加包含目录没设对,或者 EasyX 根本没装上。解决方法是回到 2.2 的第二步重新检查路径,注意路径里不要有中文和空格。
第二类是「无法解析的外部符号」,报错信息里会出现_initgraph、_closegraph这样的函数名。这是链接阶段找不到库文件,说明附加依赖项没写对,或者库文件的位数和项目平台不匹配。比如你的项目是 x64,但链接的是 32 位的库,就会报这个错。检查项目属性 → 链接器 → 高级 → 目标计算机,确保是 x64。
第三类是「C4996 错误」,提示某个函数不安全,比如fopen、strcpy。这是 Visual Studio 的安全检查机制,不是代码本身的问题。最简单的解决办法是在文件最开头加一行#define _CRT_SECURE_NO_WARNINGS,或者在项目属性 → C/C++ → 预处理器 → 预处理器定义里加上这个宏。
// 在 main.cpp 最开头加上这一行,解决大部分 C4996 报错 #define _CRT_SECURE_NO_WARNINGS #include <graphics.h> // ... 后面的代码不变这个宏的作用是告诉编译器跳过安全警告,对于课程设计级别的项目来说完全够用。但如果你以后写正式项目,建议还是用fopen_s、strcpy_s这些安全版本,而不是简单粗暴地屏蔽警告。
3. 读懂代码结构:飞机大战的类设计与游戏循环
3.1 一份典型源码的文件组织方式
飞机大战源码的文件组织通常有两种风格:一种是「一个文件走天下」,所有代码都塞在 main.cpp 里,用几个 struct 或 class 凑合;另一种是「按职责拆分」,每个游戏对象一个类,每个类一对 .h/.cpp。你拿到的源码大概率是前者,因为课程设计赶工期的时候没人有心思做工程化拆分。
先看一个典型的单文件结构长什么样:
// 典型的单文件飞机大战结构(简化示意) #include <graphics.h> #include <vector> #include <ctime> // 全局常量 const int WIDTH = 480; const int HEIGHT = 640; // 玩家飞机 struct Player { int x, y; int speed; bool alive; }; // 子弹 struct Bullet { int x, y; int speed; bool active; }; // 敌机 struct Enemy { int x, y; int speed; bool alive; }; // 全局变量 Player player; std::vector<Bullet> bullets; std::vector<Enemy> enemies; // 初始化函数 void initGame() { player.x = WIDTH / 2; player.y = HEIGHT - 80; player.speed = 5; player.alive = true; } // 游戏主循环 void gameLoop() { while (player.alive) { // 处理输入 // 更新逻辑 // 渲染画面 // 控制帧率 } } int main() { initgraph(WIDTH, HEIGHT); initGame(); gameLoop(); closegraph(); return 0; }这种结构的优点是直观,所有东西都在一个文件里,改起来方便。缺点是随着功能增加,文件会膨胀到上千行,找代码靠 Ctrl+F,改一处可能影响另一处。如果你只是想跑通交作业,这种结构够用;但如果你想把它改成一个像样的项目,建议做一次拆分。
3.2 游戏循环的三个核心阶段
不管源码怎么组织,游戏循环的逻辑都是三段式:输入处理、状态更新、画面渲染。这三段的顺序不能乱,否则会出现「按键没反应」或者「画面撕裂」的问题。
输入处理阶段负责读取键盘状态。EasyX 提供了两种方式:一种是GetAsyncKeyState,可以检测按键是否按下,适合做持续移动;另一种是_getch,会阻塞程序等待按键,适合做菜单选择。飞机大战里移动飞机要用前者。
// 输入处理:检测方向键和空格键 void handleInput() { // 检测左方向键,注意 GetAsyncKeyState 返回值的最高位为 1 表示按下 if (GetAsyncKeyState(VK_LEFT) & 0x8000) { player.x -= player.speed; } if (GetAsyncKeyState(VK_RIGHT) & 0x8000) { player.x += player.speed; } if (GetAsyncKeyState(VK_UP) & 0x8000) { player.y -= player.speed; } if (GetAsyncKeyState(VK_DOWN) & 0x8000) { player.y += player.speed; } // 检测空格键发射子弹 if (GetAsyncKeyState(VK_SPACE) & 0x8000) { // 发射逻辑,后面会讲 } // 边界限制,防止飞机飞出窗口 if (player.x < 0) player.x = 0; if (player.x > WIDTH - 40) player.x = WIDTH - 40; if (player.y < 0) player.y = 0; if (player.y > HEIGHT - 40) player.y = HEIGHT - 40; }这里的关键参数是0x8000,它是一个位掩码,用来提取GetAsyncKeyState返回值的最高位。如果只写if (GetAsyncKeyState(VK_LEFT)),在某些情况下会误判,因为返回值的最低位表示「上次调用后是否按过」,而不是「当前是否按下」。这个坑我踩过,当时调试了半天才发现飞机偶尔会自己动。
状态更新阶段负责移动子弹、移动敌机、检测碰撞、生成新敌人。这部分逻辑最复杂,也最容易出 bug。碰撞检测通常用矩形相交判断,简单但有效。
// 矩形碰撞检测:判断两个矩形是否相交 bool isCollide(int x1, int y1, int w1, int h1, int x2, int y2, int w2, int h2) { // 两个矩形不相交的条件:一个在另一个的左边/右边/上边/下边 if (x1 + w1 < x2) return false; if (x2 + w2 < x1) return false; if (y1 + h1 < y2) return false; if (y2 + h2 < y1) return false; return true; }这个函数的参数是两组 (x, y, width, height),分别代表两个矩形的左上角坐标和宽高。逻辑是:只要有一个方向完全错开,就不碰撞;四个方向都没错开,就碰撞。注意这里的坐标是左上角,不是中心点,如果你的源码用的是中心点坐标,需要先转换。
渲染阶段负责把内存里的状态画到屏幕上。EasyX 的渲染方式是「双缓冲」:先用BeginBatchDraw开启批量绘制,画完所有东西后用FlushBatchDraw一次性刷新,最后用EndBatchDraw结束。如果不这样做,画面会闪烁得厉害。
// 渲染阶段:双缓冲绘制 void render() { BeginBatchDraw(); cleardevice(); // 清屏 // 画玩家飞机(用矩形代替图片,实际项目用 putimage) setfillcolor(BLUE); fillrectangle(player.x, player.y, player.x + 40, player.y + 40); // 画子弹 setfillcolor(YELLOW); for (auto& b : bullets) { if (b.active) { fillrectangle(b.x, b.y, b.x + 4, b.y + 12); } } // 画敌机 setfillcolor(RED); for (auto& e : enemies) { if (e.alive) { fillrectangle(e.x, e.y, e.x + 36, e.y + 36); } } FlushBatchDraw(); EndBatchDraw(); }参数说明:fillrectangle(left, top, right, bottom)的四个参数是矩形的左上角和右下角坐标。setfillcolor设置填充色,颜色常量如BLUE、YELLOW、RED是 EasyX 预定义的。实际项目中你会用putimage加载飞机图片,但原理一样,只是把填充矩形换成贴图。
3.3 帧率控制:为什么你的飞机飞得太快或太慢
游戏循环如果不加帧率控制,会以 CPU 的最大速度运行,导致飞机瞬间飞出屏幕。常见的控制方式是Sleep函数,每帧暂停一段时间。
// 帧率控制:目标 60 帧每秒 const int FRAME_DELAY = 16; // 1000ms / 60 ≈ 16.67ms void gameLoop() { while (player.alive) { DWORD frameStart = GetTickCount(); // 记录帧开始时间 handleInput(); update(); render(); // 计算本帧耗时,补足到 16ms DWORD frameTime = GetTickCount() - frameStart; if (frameTime < FRAME_DELAY) { Sleep(FRAME_DELAY - frameTime); } } }这里用GetTickCount记录帧开始时间,渲染完后计算耗时,如果不到 16ms 就补睡差值。这样做的目的是让每帧的间隔尽量稳定在 16ms 左右,对应约 60 帧每秒。如果你直接Sleep(16),不考虑渲染本身的时间,实际帧率会低于 60,因为渲染也要花时间。
注意:
GetTickCount的精度是毫秒级,对于 60 帧的游戏够用,但如果你要做 144 帧的高刷游戏,需要用QueryPerformanceCounter这种微秒级计时器。课程设计用GetTickCount足够了。
4. 避坑与排查:飞机大战源码最常见的五个翻车点
4.1 现象:编译通过但运行闪退,没有任何报错
原因:最常见的是资源文件路径不对。源码里用loadimage加载图片时,路径可能是相对路径,比如"res/plane.png",但你的工作目录和源码作者的不一样。Visual Studio 默认的工作目录是项目目录,不是 exe 所在目录,所以图片找不到就会加载失败,后续绘制时访问空指针导致闪退。
解决:把资源文件夹复制到项目目录下,或者在项目属性 → 调试 → 工作目录里设置成$(ProjectDir)。更稳妥的做法是用绝对路径,但这样换电脑就失效了。我一般会在代码里加一个检查:
// 加载图片并检查是否成功 IMAGE img; if (loadimage(&img, _T("res/plane.png")) != 0) { // 加载失败,弹出提示并退出 MessageBox(NULL, _T("图片加载失败,请检查 res 目录"), _T("错误"), MB_OK); exit(-1); }loadimage的返回值是 0 表示成功,非 0 表示失败。加上这个检查后,至少你能知道是资源问题,而不是对着闪退干瞪眼。
4.2 现象:飞机移动卡顿,按键响应延迟明显
原因:可能是帧率控制没做好,也可能是输入检测用了阻塞式函数。如果你在游戏循环里用了_getch(),程序会停在那里等按键,整个循环就卡住了。另一个可能是Sleep时间设得太长,比如设了 50ms,帧率只有 20,看起来就卡。
解决:确认输入用的是GetAsyncKeyState而不是_getch。检查Sleep的参数,60 帧对应 16ms,30 帧对应 33ms。如果你想要更流畅的效果,可以不做帧率限制,让循环全速跑,但这样 CPU 占用会很高。折中方案是用Sleep(1)让出 CPU 时间片,同时用计时器控制逻辑更新频率。
4.3 现象:子弹和敌机碰撞检测不准确,有时候穿过去了
原因:这是典型的「高速物体穿透」问题。如果子弹速度很快(比如每帧移动 20 像素),而敌机高度只有 36 像素,子弹可能在两帧之间从敌机上方直接跳到下方,碰撞检测就漏掉了。
解决:有两种方案。第一种是减小子弹速度,但这样游戏手感会变差。第二种是做「连续碰撞检测」,也就是在子弹的移动路径上采样多个点,逐个检测。简单做法是每帧把子弹移动分成若干小步:
// 子弹移动时分步检测碰撞 void moveBullet(Bullet& b) { int steps = b.speed / 5 + 1; // 每步最多移动 5 像素 int stepY = b.speed / steps; for (int i = 0; i < steps; i++) { b.y -= stepY; // 每移动一步就检测一次碰撞 for (auto& e : enemies) { if (e.alive && isCollide(b.x, b.y, 4, 12, e.x, e.y, 36, 36)) { b.active = false; e.alive = false; // 加分逻辑... return; } } } }这个方案的核心思想是把一帧的大位移拆成多个小位移,每个小位移后都做一次碰撞检测。代价是计算量增加,但对于飞机大战这种规模的项目,性能完全不是问题。
4.4 现象:敌机生成位置重叠,一堆飞机叠在一起
原因:随机数生成有问题。很多源码用rand() % WIDTH来生成敌机的 x 坐标,但rand()没有播种,每次运行结果一样。更关键的是,如果生成间隔太短,或者没有检查新敌机和已有敌机的位置关系,就会重叠。
解决:首先用srand(time(NULL))播种,让每次运行的随机序列不同。其次,在生成新敌机时检查是否和现有敌机太近:
// 生成敌机,避免和现有敌机重叠 void spawnEnemy() { int maxAttempts = 10; // 最多尝试 10 次 for (int i = 0; i < maxAttempts; i++) { int x = rand() % (WIDTH - 36); int y = -36; // 从屏幕上方进入 bool overlap = false; for (auto& e : enemies) { if (e.alive && abs(e.x - x) < 40 && abs(e.y - y) < 40) { overlap = true; break; } } if (!overlap) { Enemy e; e.x = x; e.y = y; e.speed = 2 + rand() % 3; // 速度 2~4 e.alive = true; enemies.push_back(e); return; } } // 尝试 10 次都重叠,就放弃这次生成 }参数说明:rand() % (WIDTH - 36)保证敌机 x 坐标在窗口内,36是敌机宽度。abs(e.x - x) < 40判断两个敌机的水平距离是否小于 40 像素,如果是就认为太近。maxAttempts防止死循环,如果尝试多次都找不到合适位置就跳过这次生成。
4.5 现象:程序运行一段时间后越来越卡,最后卡死
原因:内存泄漏或者容器无限增长。最常见的是子弹和敌机被标记为active = false或alive = false后没有从 vector 里删除,导致 vector 越来越大,每帧遍历的时间越来越长。
解决:定期清理不活跃的对象。可以在每帧更新后加一个清理步骤:
// 清理不活跃的子弹和敌机 void cleanup() { // 移除所有 active 为 false 的子弹 bullets.erase( std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b) { return !b.active; }), bullets.end() ); // 移除所有 alive 为 false 的敌机 enemies.erase( std::remove_if(enemies.begin(), enemies.end(), [](const Enemy& e) { return !e.alive; }), enemies.end() ); }这里用了std::remove_if配合 lambda 表达式,把不满足条件的元素移到末尾,然后erase一次性删除。这是 C++ 里删除 vector 元素的标准写法,比手动遍历删除效率高,也不容易出错。注意remove_if本身不删除元素,只是重新排列,真正删除的是erase。
5. 从能跑到能看:用随机数和状态机把玩法做扎实
5.1 用 C++ 随机数让敌机行为不那么「傻」
原始源码里的敌机大概率是直线往下飞,玩两分钟就腻了。要让敌机有点「智能」,可以用随机数控制它的横向移动和发射子弹的行为。C++11 引入了<random>库,比rand()更灵活,但很多课程设计还在用老式的rand()。两种方式我都写一下,你根据源码的风格选。
// 方式一:传统 rand(),简单够用 #include <cstdlib> #include <ctime> void initRandom() { srand((unsigned)time(NULL)); // 播种,只需调用一次 } int randomInt(int min, int max) { return min + rand() % (max - min + 1); } // 方式二:C++11 <random>,更均匀,推荐 #include <random> std::mt19937 gen(std::random_device{}()); // 梅森旋转引擎 int randomInt(int min, int max) { std::uniform_int_distribution<> dist(min, max); return dist(gen); }方式一的rand()在 Windows 上周期较短,而且取模运算会导致分布不均匀(低位比高位随机性差)。方式二用梅森旋转算法,周期长,分布均匀,是现代 C++ 的推荐做法。如果你只是做课程设计,方式一够用;如果你想写得更专业,用方式二。
用随机数控制敌机行为的例子:
// 敌机更新逻辑:随机左右移动 + 随机发射子弹 void updateEnemy(Enemy& e, float deltaTime) { // 每帧有 2% 的概率改变移动方向 if (randomInt(1, 100) <= 2) { e.horizontalSpeed = randomInt(-2, 2); } e.x += e.horizontalSpeed; // 边界反弹 if (e.x < 0 || e.x > WIDTH - 36) { e.horizontalSpeed = -e.horizontalSpeed; } e.y += e.speed; // 每帧有 0.5% 的概率发射子弹 if (randomInt(1, 1000) <= 5) { // 生成敌方子弹... } }参数说明:randomInt(1, 100) <= 2表示 2% 的概率,这个值可以根据难度调整。horizontalSpeed的范围是 -2 到 2,负数向左,正数向右,0 表示不横向移动。randomInt(1, 1000) <= 5是 0.5% 的概率,控制敌机射击频率,太高会让玩家没法躲。
5.2 用状态机管理游戏流程
很多源码的游戏流程是线性的:开始 → 游戏中 → 结束。但如果你想加个开始菜单、暂停功能、关卡切换,就需要状态机。状态机说白了就是一个变量记录当前处于哪个状态,然后在主循环里根据状态决定做什么。
// 游戏状态枚举 enum GameState { STATE_MENU, // 主菜单 STATE_PLAYING, // 游戏中 STATE_PAUSED, // 暂停 STATE_GAME_OVER // 游戏结束 }; GameState currentState = STATE_MENU; void gameLoop() { while (true) { switch (currentState) { case STATE_MENU: handleMenuInput(); renderMenu(); break; case STATE_PLAYING: handleInput(); update(); render(); break; case STATE_PAUSED: handlePauseInput(); renderPauseOverlay(); break; case STATE_GAME_OVER: handleGameOverInput(); renderGameOver(); break; } // 帧率控制... } }这个结构的优点是清晰,每个状态的处理逻辑分开,加新状态只需要加一个 case。缺点是所有状态共享同一个循环,如果某个状态的处理很耗时,会影响其他状态的响应。对于飞机大战来说,这个缺点可以忽略。
状态切换的例子:在游戏中按 ESC 键进入暂停,再按 ESC 回到游戏;玩家生命值为 0 时进入游戏结束状态。
// 在 STATE_PLAYING 的处理里检测暂停 if (GetAsyncKeyState(VK_ESCAPE) & 0x8000) { currentState = STATE_PAUSED; // 防止按键重复触发,等待按键释放 while (GetAsyncKeyState(VK_ESCAPE) & 0x8000) { Sleep(10); } }这里加了一个等待按键释放的循环,目的是防止一次按下被多次检测到。如果不加这个,按一下 ESC 可能会在暂停和游戏之间快速切换,看起来像卡住了。
5.3 用结构体数组管理多关卡配置
如果你想让游戏有多个关卡,不要把每关的参数硬编码在 if-else 里,用结构体数组更清晰。
// 关卡配置结构体 struct LevelConfig { int enemySpawnInterval; // 敌机生成间隔(帧数) int enemySpeedMin; // 敌机最小速度 int enemySpeedMax; // 敌机最大速度 int enemyShootRate; // 敌机射击概率(千分之几) int targetScore; // 过关所需分数 }; // 关卡配置表 LevelConfig levels[] = { {60, 1, 2, 2, 100}, // 第 1 关:敌机慢,射击少 {45, 2, 3, 5, 250}, // 第 2 关:速度提升 {30, 3, 4, 8, 500}, // 第 3 关:生成更快 {20, 4, 5, 12, 1000}, // 第 4 关:高压 }; int currentLevel = 0; // 当前关卡索引参数说明:enemySpawnInterval越小,敌机生成越快;enemySpeedMin和enemySpeedMax控制敌机下落速度范围;enemyShootRate是千分比,比如 5 表示 0.5% 的概率;targetScore是过关分数。调整这些参数就能改变难度曲线,不用改逻辑代码。
提示:关卡配置表放在全局作用域,方便调整。如果你想让游戏更耐玩,可以在每关结束后根据玩家表现动态调整下一关的参数,而不是用固定表。
6. 把源码变成自己的作品:三个能写进简历的扩展方向
6.1 加一个简单的粒子爆炸效果
飞机被击毁时如果只是消失,视觉反馈太弱。加一个粒子爆炸效果,代码量不大,但看起来专业很多。思路是:敌机被击毁时,在它的位置生成一批小粒子,每个粒子有随机速度和方向,每帧移动并逐渐消失。
// 粒子结构体 struct Particle { float x, y; // 位置 float vx, vy; // 速度 int life; // 剩余生命帧数 COLORREF color; // 颜色 }; std::vector<Particle> particles; // 生成爆炸粒子 void spawnExplosion(int x, int y) { for (int i = 0; i < 20; i++) { Particle p; p.x = (float)x; p.y = (float)y; // 随机速度方向,范围 -3 到 3 p.vx = (randomInt(-30, 30)) / 10.0f; p.vy = (randomInt(-30, 30)) / 10.0f; p.life = randomInt(15, 30); // 存活 15~30 帧 p.color = RGB(255, randomInt(100, 200), 0); // 橙黄色 particles.push_back(p); } } // 更新粒子 void updateParticles() { for (auto& p : particles) { p.x += p.vx; p.y += p.vy; p.life--; } // 移除生命耗尽的粒子 particles.erase( std::remove_if(particles.begin(), particles.end(), [](const Particle& p) { return p.life <= 0; }), particles.end() ); } // 渲染粒子 void renderParticles() { for (auto& p : particles) { // 根据剩余生命调整亮度,越暗表示越接近消失 int brightness = p.life * 8; if (brightness > 255) brightness = 255; setfillcolor(RGB(brightness, brightness / 2, 0)); fillcircle((int)p.x, (int)p.y, 2); } }参数说明:randomInt(-30, 30) / 10.0f生成 -3.0 到 3.0 之间的浮点速度,除以 10 是为了让速度变化更细腻。p.life控制粒子存活时间,15 到 30 帧大约对应 0.25 到 0.5 秒。fillcircle画实心圆,半径 2 像素,看起来像小火花。
这个效果加进去之后,每次击毁敌机都有反馈,游戏的「打击感」会提升一个档次。而且代码不复杂,面试的时候也可以拿出来讲。
6.2 用文件存储最高分和游戏设置
课程设计如果只把数据存在内存里,关掉程序就没了,显得不完整。加一个简单的文件读写,把最高分和音量设置存下来,代码量很小,但让项目看起来更像一个「产品」。
// 存档结构体 struct SaveData { int highScore; // 最高分 int soundVolume; // 音量 0~100 int lastLevel; // 上次玩的关卡 }; // 保存存档 void saveGame(const SaveData& data) { FILE* fp = fopen("save.dat", "wb"); if (fp == NULL) { MessageBox(NULL, _T("存档失败"), _T("错误"), MB_OK); return; } fwrite(&data, sizeof(SaveData), 1, fp); fclose(fp); } // 读取存档 SaveData loadGame() { SaveData data = {0, 50, 0}; // 默认值 FILE* fp = fopen("save.dat", "rb"); if (fp == NULL) { return data; // 文件不存在,返回默认值 } fread(&data, sizeof(SaveData), 1, fp); fclose(fp); return data; }参数说明:fopen的"wb"表示二进制写入,"rb"表示二进制读取。fwrite和fread的第三个参数是元素个数,这里是 1。用二进制格式的好处是读写快,坏处是文件不可读,调试的时候看不到内容。如果你想用文本格式,可以用fprintf和fscanf,但解析起来麻烦一些。
注意:
save.dat的路径是相对路径,实际保存位置取决于程序的工作目录。如果你希望存档和 exe 放在一起,可以用GetModuleFileName获取 exe 路径,然后拼接文件名。
6.3 用 CMake 把项目变成跨平台工程
如果你想让这个项目在简历上更有分量,可以把它从 Visual Studio 工程改成 CMake 工程。CMake 是 C++ 世界的事实标准构建工具,很多公司招人时会看你是否熟悉 CMake。改造的过程不复杂,但能让你理解项目构建的底层逻辑。
# CMakeLists.txt cmake_minimum_required(VERSION 3.15) project(PlaneWar) # 设置 C++ 标准 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 根据平台链接不同的图形库 if(WIN32) # Windows 下用 EasyX include_directories("C:/EasyX/include") link_directories("C:/EasyX/lib/VC/x64") add_executable(PlaneWar src/main.cpp src/game.cpp src/player.cpp src/enemy.cpp ) target_link_libraries(PlaneWar EasyXa) else() # Linux/macOS 下用 SDL2 find_package(SDL2 REQUIRED) add_executable(PlaneWar src/main.cpp src/game.cpp src/player.cpp src/enemy.cpp ) target_link_libraries(PlaneWar SDL2::SDL2 SDL2::SDL2main) endif()这个 CMakeLists 的逻辑是:先设置 C++17 标准,然后根据操作系统选择不同的图形库。Windows 用 EasyX,其他平台用 SDL2。add_executable列出所有源文件,target_link_libraries链接对应的库。
改造过程中你会遇到一个问题:EasyX 和 SDL2 的 API 完全不同,源码里所有绘图函数都要改。所以更现实的做法是:先抽象一层绘图接口,把initgraph、putimage、fillrectangle这些调用封装成自己的函数,然后在不同平台下实现不同的版本。这个工作量不小,但做完之后你对「跨平台」的理解会深很多。
// 绘图接口抽象层(头文件) class Renderer { public: virtual void init(int width, int height) = 0; virtual void clear() = 0; virtual void drawRect(int x, int y, int w, int h, int color) = 0; virtual void drawImage(int x, int y, const char* path) = 0; virtual void present() = 0; virtual ~Renderer() {} }; // EasyX 实现 class EasyXRenderer : public Renderer { public: void init(int width, int height) override { initgraph(width, height); } void clear() override { cleardevice(); } void drawRect(int x, int y, int w, int h, int color) override { setfillcolor(color); fillrectangle(x, y, x + w, y + h); } // ... 其他实现 };这个抽象层的意义在于:游戏逻辑代码只依赖Renderer接口,不依赖具体的图形库。换库的时候只需要写一个新的实现类,游戏逻辑不用动。这是软件工程里「依赖倒置原则」的典型应用,面试的时候能讲清楚这个,比单纯说「我会 C++」有说服力得多。
我自己的习惯是:每做完一个课程设计,就花半天时间把它整理成一个能拿得出手的项目。整理的过程包括:把代码拆成合理的文件结构、加注释、写 README、用 CMake 重新组织构建。这些工作不会让游戏本身变得更好玩,但会让你的项目在 GitHub 上看起来更专业,面试的时候也更有东西可聊。飞机大战这个题材虽然简单,但如果你能把上面说的粒子效果、存档系统、跨平台构建都做出来,它就不再是一个「大作业」,而是一个能展示你工程能力的作品。希望帮到你。
本文还有配套的精品资源,点击获取