简介这份源码面向具备一定C基础、希望动手实践游戏开发的学生与开发者提供了一套完整的飞机大战小游戏实现方案可用于课程设计、个人练手或团队项目参考。压缩包共71个文件约64.23MB其中16个cpp源文件按版本递进组织从滚动背景、Hero移动、子弹发射到碰撞检测、敌机爆炸、多关卡难度升级与背景音乐设置清晰呈现功能迭代脉络24个png图片承担飞机、子弹、爆炸与背景等界面素材另有工程配置、日志与可执行文件方便直接编译运行与调试。目前已有887人学习下载。读者可借此掌握游戏循环、对象封装、碰撞判定与资源加载等核心思路并对照分阶段源码理解各模块的演进过程快速搭建自己的游戏框架。1. 从零写一个能跑的飞机大战C 小游戏源码到底该包含什么很多人搜「基于C的飞机大战小游戏设计源码」脑子里想的是下载一个压缩包、双击运行、看到飞机能开枪就完事。但真正拿过几份源码的人会发现同样是飞机大战有的只有三百行、所有逻辑塞在 main 里有的分了十几个类、还带资源管理器和状态机。这两种都能叫「源码」可你拿去做课程设计、拿去改造成自己的项目、拿去面试时讲价值完全不一样。飞机大战这个题材之所以常年出现在 C 小游戏编程100例、c小游戏代码这类搜索里是因为它刚好卡在一个甜点位置比控制台猜数字复杂能体现面向对象、游戏循环、碰撞检测、资源加载这些真东西又比完整引擎简单一个人两三天能从头写出来。这篇不给你灌概念而是把一份「结构完整、能编译、能扩展」的飞机大战源码拆开讲清楚——它由哪些模块组成、每个模块的代码长什么样、参数怎么调、编译时会在哪里翻车。适合正在做课程设计的学生、想用 C 练手游戏开发的新手以及需要一份可讲清楚的小项目放进简历的人。2. 飞机大战源码的模块划分与核心类设计一份能拿得出手的飞机大战源码不该是「一个文件写到底」。我见过太多课程设计交上来的代码两千行全在 main.cpp改一个子弹速度要翻半天。合理的做法是按职责拆成几个类每个类只干一件事。下面这套划分是我自己写过多遍之后固定下来的结构不依赖任何第三方游戏引擎只用标准 C 加一个图形库。2.1 为什么选面向对象而不是面向过程飞机大战里有三类会动的东西玩家飞机、子弹、敌机。它们有共同点——都有坐标、都有速度、都要在每一帧更新位置、都要判断碰撞。如果面向过程写你会写一堆平行的数组playerX、playerY、bulletX[100]、bulletY[100]、enemyX[50]……每加一种实体就要加一组数组删除实体还要处理数组空洞越写越乱。面向对象的做法是抽一个基类把「位置、速度、存活状态、更新、绘制」这些共性放进去玩家、子弹、敌机各自继承并实现自己的行为。这样新增一种敌人只需要加一个子类主循环完全不用改。这不是为了炫技是为了你后期改需求时少流血。// GameObject.h —— 所有可动实体的基类 class GameObject { public: float x, y; // 屏幕坐标左上角为原点 float vx, vy; // 每帧位移量单位像素 bool alive; // 标记是否存活false 时由管理器回收 GameObject(float x, float y, float vx, float vy) : x(x), y(y), vx(vx), vy(vy), alive(true) {} virtual ~GameObject() {} // 每帧调用dt 为距上一帧的秒数保证不同帧率下速度一致 virtual void update(float dt) { x vx * dt; y vy * dt; } virtual void draw() 0; // 纯虚函数子类必须实现绘制 };这段代码的关键在update(float dt)这个设计。很多新手直接写x vx结果在自己电脑上跑得好好的换一台刷新率不同的显示器飞机速度就变了。用dtdelta time把速度从「每帧多少像素」改成「每秒多少像素」游戏在任何帧率下表现一致。alive标志配合后面的对象管理器解决「子弹飞出屏幕后怎么删」的问题——不是立刻 delete而是标记为 false统一在帧末清理避免遍历容器时删除元素导致的迭代器失效。2.2 三个核心类的职责边界基类定好之后玩家、子弹、敌机各自继承。这里最容易犯的错是把「碰撞检测」写进各个类的 update 里导致逻辑分散、互相引用。正确做法是让类只管自己的状态和绘制碰撞交给一个独立的检测函数或管理器。// Player.h —— 玩家飞机只负责自身移动和开火冷却 class Player : public GameObject { public: float fireCooldown; // 开火冷却剩余时间秒 float fireInterval; // 两次开火的最小间隔秒 Player(float x, float y) : GameObject(x, y, 0, 0), fireCooldown(0), fireInterval(0.2f) {} void update(float dt) override { // 玩家速度由输入直接设置这里只推进冷却计时 if (fireCooldown 0) fireCooldown - dt; x vx * dt; y vy * dt; } // 返回 true 表示这次可以开火同时重置冷却 bool canFire() { if (fireCooldown 0) { fireCooldown fireInterval; return true; } return false; } void draw() override { /* 调用图形库绘制玩家贴图 */ } };fireInterval设成 0.2 秒意思是每秒最多开 5 枪。这个值直接决定游戏手感设成 0.05 会变成弹幕倾泻设成 0.5 又太稀疏。我一般先给 0.2试玩之后再微调。fireCooldown用「剩余时间」而不是「上次开火时间戳」是因为前者在暂停、变速时更好处理——暂停时只要不调用 update冷却自然冻结。子弹类更简单它几乎不需要额外逻辑只要在飞出屏幕边界时把alive置 false。敌机类则要多一个血量字段和受击方法为后面的碰撞处理做准备。三个类加起来不到两百行但职责清清楚楚这就是「结构完整」和「一坨代码」的区别。2.3 对象管理器统一更新与回收有了实体类还需要一个地方统一存放它们、每帧遍历更新、清理死亡对象。这就是对象管理器通常用std::vectorstd::unique_ptrGameObject来存。// ObjectManager.h —— 统一管理所有游戏对象 class ObjectManager { std::vectorstd::unique_ptrGameObject objects; public: void add(std::unique_ptrGameObject obj) { objects.push_back(std::move(obj)); } void updateAll(float dt) { for (auto obj : objects) { if (obj-alive) obj-update(dt); } // 帧末统一清理避免遍历时删除 objects.erase( std::remove_if(objects.begin(), objects.end(), [](const std::unique_ptrGameObject o) { return !o-alive; }), objects.end()); } void drawAll() { for (auto obj : objects) { if (obj-alive) obj-draw(); } } std::vectorstd::unique_ptrGameObject getObjects() { return objects; } };这里用unique_ptr而不是裸指针是为了不用手写 delete对象管理器析构时自动释放全部实体。updateAll里先更新再统一 erase用的是经典的 remove_if erase 组合这是 C 里删除容器元素的正确姿势。如果你在 for 循环里直接objects.erase(it)迭代器会失效程序大概率崩溃——这是新手最常见的翻车点之一。3. 游戏主循环、碰撞检测与随机敌机生成模块拆好之后要把它们串起来跑。串起来的核心就是游戏主循环而飞机大战的「游戏性」几乎全压在碰撞检测和敌机生成这两件事上。这一章讲怎么让它们真正动起来。3.1 固定时间步长的主循环怎么写游戏主循环的本质是一个 while 循环处理输入、更新逻辑、绘制画面、重复。但「多久循环一次」有讲究。如果不管不顾地全速循环CPU 会被吃满而且不同机器速度差异巨大。常见做法是固定时间步长配合一个帧率上限。// main.cpp —— 游戏主循环骨架 #include chrono #include thread int main() { initWindow(480, 720); // 初始化图形窗口竖屏更适合飞机大战 ObjectManager manager; auto player std::make_uniquePlayer(240, 600); Player* playerPtr player.get(); // 保存裸指针供碰撞检测使用 manager.add(std::move(player)); const float FIXED_DT 1.0f / 60.0f; // 逻辑固定按 60 帧推进 auto lastTime std::chrono::steady_clock::now(); while (!windowShouldClose()) { auto now std::chrono::steady_clock::now(); float frameTime std::chrono::durationfloat(now - lastTime).count(); lastTime now; handleInput(playerPtr); // 读取键盘设置玩家速度 spawnEnemyIfNeeded(manager); // 按节奏生成敌机 manager.updateAll(FIXED_DT); // 用固定步长更新保证物理稳定 checkCollisions(manager, playerPtr); // 碰撞检测 render(manager); // 绘制 // 限制最高帧率避免空转吃满 CPU std::this_thread::sleep_for(std::chrono::milliseconds(1)); } closeWindow(); return 0; }FIXED_DT固定成 1/60 秒意味着无论实际帧率多少逻辑更新都按 60 分之一秒推进。这样做的好处是碰撞检测和移动的数值稳定不会因为某帧卡顿导致子弹「瞬移」穿过敌机。steady_clock是 C 标准库里的单调时钟不受系统时间调整影响比system_clock更适合测帧间隔。最后那个sleep_for(1ms)是防止循环空转把 CPU 占满实际项目中更精细的做法是配合垂直同步但小游戏里这样已经够用。3.2 矩形碰撞检测够用且好调飞机大战不需要像素级精确碰撞矩形包围盒AABB检测完全够用而且计算量小、参数直观。核心就是判断两个矩形在 x 轴和 y 轴上是否都有重叠。// Collision.h —— 轴对齐矩形碰撞检测 struct Rect { float x, y, w, h; }; bool isOverlap(const Rect a, const Rect b) { // 两个矩形在任一轴上分离就不碰撞 if (a.x a.w b.x || b.x b.w a.x) return false; if (a.y a.h b.y || b.y b.h a.y) return false; return true; } void checkCollisions(ObjectManager manager, Player* player) { Rect playerRect{ player-x, player-y, 40, 40 }; // 玩家碰撞盒 40x40 for (auto obj : manager.getObjects()) { if (!obj-alive) continue; // 用 dynamic_cast 判断类型实际项目可用枚举标签替代以提升性能 if (auto* bullet dynamic_castBullet*(obj.get())) { for (auto other : manager.getObjects()) { if (auto* enemy dynamic_castEnemy*(other.get())) { Rect bulletRect{ bullet-x, bullet-y, 8, 16 }; Rect enemyRect{ enemy-x, enemy-y, 40, 40 }; if (isOverlap(bulletRect, enemyRect)) { bullet-alive false; enemy-hp - 1; if (enemy-hp 0) enemy-alive false; } } } } } }碰撞盒的尺寸这里玩家和敌机都是 40x40子弹 8x16要和贴图视觉大小匹配。如果碰撞盒比贴图大玩家会觉得「明明没碰到就死了」比贴图小又会「穿模」。我一般让碰撞盒比贴图略小一圈玩家体验更宽容。dynamic_cast在对象多的时候有性能开销如果敌机子弹上百个建议给 GameObject 加一个type枚举字段用switch判断类型速度会快不少。这是从「能跑」到「跑得顺」的一个优化点。3.3 敌机生成节奏与随机数控制敌机不能一窝蜂全出来也不能半天不来一个。生成节奏靠一个计时器控制而生成位置和敌机类型需要随机数。这里就涉及 c随机数 这个高频搜索点——很多人还在用rand()但它在 C11 之后有更好的替代。// Spawner.h —— 按时间间隔生成敌机 #include random class Spawner { float timer; float interval; // 生成间隔秒 std::mt19937 gen; // 梅森旋转随机数引擎 std::uniform_real_distributionfloat xDist; std::uniform_int_distributionint typeDist; public: Spawner() : timer(0), interval(0.8f), gen(std::random_device{}()), xDist(40.0f, 440.0f), typeDist(0, 2) {} void update(float dt, ObjectManager manager) { timer dt; if (timer interval) { timer 0; float spawnX xDist(gen); // 在 40~440 之间随机横坐标 int type typeDist(gen); // 随机敌机类型 0/1/2 manager.add(std::make_uniqueEnemy(spawnX, -40, type)); } } void setInterval(float sec) { interval sec; } };std::mt19937是梅森旋转算法引擎随机质量远好于rand()而且uniform_real_distribution能保证区间内均匀分布不用自己做取模运算。std::random_device{}()用来给引擎播种保证每次运行结果不同。interval设成 0.8 秒是初始难度随着游戏进行可以逐步调小比如每过 30 秒减 0.05最低到 0.3这样难度曲线自然上升。敌机从y -40生成也就是屏幕上方外面这样它是「飞进来」而不是「凭空出现」视觉上更合理。4. 编译环境搭建与资源加载的避坑清单代码写完了能不能顺利编译运行是另一回事。C 的编译环境是新手最大的拦路虎尤其是图形库的链接。这一章把常见坑集中列出来每条都按「现象 → 原因 → 解决」讲清楚。4.1 图形库选型SFML 还是 EasyX飞机大战需要画图纯控制台做不了。常见选择有两个方向跨平台的 SFML和 Windows 平台专用的 EasyX。选哪个取决于你的目标。对比项SFMLEasyX平台Windows / Linux / macOS仅 Windows配置难度需要手动配库路径安装后基本即用教学价值高接口规范中偏封装适合场景想学正规游戏开发只想快速交课程设计如果你只是要交个课程设计、时间紧EasyX 上手最快。如果你想借这个项目真正理解游戏循环和渲染SFML 更值得投入。两者代码结构可以完全一样只是绘制和窗口初始化的 API 不同。我一般建议先想清楚你是要「完成作业」还是「学东西」再选库别在选型上反复横跳浪费时间。4.2 编译链接报错的三种典型情况现象一undefined reference to sf::RenderWindow::RenderWindow。原因是编译器找到了头文件但链接阶段没找到库文件。解决在编译命令里显式加上库比如g main.cpp -o game -lsfml-graphics -lsfml-window -lsfml-system。用 IDE 的话去项目属性里把 SFML 的 lib 目录加进链接器搜索路径并逐个添加依赖库。现象二程序启动就闪退没有任何报错。常见原因是资源文件路径不对。代码里写loadTexture(plane.png)但程序的工作目录不是图片所在目录。解决用绝对路径调试或者把资源目录配置成相对于可执行文件的路径。更稳妥的做法是在代码里打印当前工作目录确认程序到底在哪里找文件。现象三Microsoft Visual C Redistributable缺失导致无法启动。这个在分发编译好的 exe 时特别常见。你本机装了 Visual Studio 所以能跑别人电脑没有对应运行库就报错。解决要么让对方安装对应版本的运行库要么在编译时选择静态链接运行库/MT 而不是 /MD把依赖打包进 exe。这是发布环节的坑自己调试时遇不到一发给别人就暴露。4.3 资源加载失败与内存泄漏排查资源加载这块血泪经验是永远不要假设文件一定存在。加载贴图、音效时都要判断返回值失败就打印明确日志而不是让程序带着空纹理继续跑最后在绘制时崩溃你还以为是逻辑问题。// 安全的资源加载封装 sf::Texture loadTextureOrThrow(const std::string path) { sf::Texture tex; if (!tex.loadFromFile(path)) { // 明确报错包含路径方便定位 throw std::runtime_error(贴图加载失败: path); } return tex; }内存泄漏方面只要你全程用unique_ptr和标准容器基本不会漏。真正容易漏的是手动new出来的纹理、字体对象。养成习惯凡是new出来的问自己一句「谁负责 delete」。答不上来就改用智能指针。另外游戏退出时确保所有窗口、音频资源正常关闭否则某些库会在退出时卡住或报错。5. 从能跑到好玩难度曲线与手感调优的进阶技巧代码能编译、飞机能飞、子弹能打中敌机这只是「能跑」。真正决定这个飞机大战源码值不值得留下来、能不能放进作品集的是手感和难度曲线。这一章讲几个我反复调过、确实有效的技巧。5.1 用参数表把难度调优变成改数字难度调优最忌讳把数值硬编码散落在各处。我的习惯是集中到一个配置结构体或配置文件里改难度只动一处。// GameConfig.h —— 所有可调参数集中管理 struct GameConfig { float playerSpeed 300.0f; // 玩家每秒移动像素 float bulletSpeed 600.0f; // 子弹每秒移动像素 float fireInterval 0.2f; // 开火间隔秒 float enemyBaseSpeed 120.0f; // 敌机基础速度 float spawnInterval 0.8f; // 初始生成间隔 float spawnMinInterval 0.3f; // 生成间隔下限 float difficultyRamp 0.05f; // 每 30 秒生成间隔减少量 };把这些值集中之后调优就变成改数字、重编译、试玩、再改。playerSpeed300 意味着玩家横穿 480 宽的屏幕大约 1.6 秒这个速度下躲避比较从容。bulletSpeed是玩家速度的两倍子弹飞出去有明显的前冲感。enemyBaseSpeed120 比玩家慢给玩家反应时间。这些比例关系比单个绝对值更重要——子弹一定要比敌机快玩家要比敌机灵活否则游戏要么太难要么太无聊。5.2 难度随时间上升的实现固定难度玩两分钟就腻了。让难度缓慢上升玩家会有「我变强了」的错觉其实是敌机变密了。实现很简单在主循环里累计时间定期调整生成间隔。// 在主循环中调用elapsed 为游戏已进行秒数 void updateDifficulty(Spawner spawner, float elapsed, const GameConfig cfg) { // 每 30 秒提升一档但不低于下限 float reduction (elapsed / 30.0f) * cfg.difficultyRamp; float newInterval cfg.spawnInterval - reduction; if (newInterval cfg.spawnMinInterval) newInterval cfg.spawnMinInterval; spawner.setInterval(newInterval); }difficultyRamp设成 0.05意味着每 30 秒生成间隔减少 0.05 秒。从 0.8 降到 0.3 需要 10 档也就是 300 秒五分钟。这个节奏对一局小游戏来说刚好——大部分人玩三五分钟能明显感到后期压力又不至于第一分钟就被劝退。如果你想让游戏更硬核把 ramp 调到 0.1两分半就拉满。5.3 手感微调三个容易被忽略的细节第一个是输入响应。玩家按键后飞机应该立刻动不要有加速缓冲。很多新手给玩家加了惯性结果操作起来像在冰上滑非常难受。飞机大战要的是「指哪打哪」直接设置速度不要做插值。第二个是受击反馈。玩家被撞时除了扣血一定要有视觉反馈——闪烁、屏幕震动、音效哪怕只是让飞机变红半秒。没有反馈的伤害玩家会怀疑「我到底有没有被撞到」。这是体验分水岭。第三个是子弹与敌机的销毁时机。子弹击中敌机后两者都应该立刻标记为死亡但绘制上最好让敌机有一个短暂的爆炸动画再消失。如果直接消失打击感会弱很多。实现上可以给敌机加一个dying状态和计时器死亡后播放 0.2 秒动画再真正移除。5.4 验证你的飞机大战是否合格写完别急着交按这几条自测帧率是否稳定在 60 附近用帧计时打印验证连续玩五分钟内存占用是否稳定任务管理器观察持续上涨说明有泄漏窗口拉伸或切换后画面是否正常连续快速按键开火是否有卡顿。这几条过了这份源码才算真正「能跑且靠谱」。我自己写飞机大战最大的教训是一开始总想着加新功能——多武器、Boss、道具结果基础手感一塌糊涂加再多也是白搭。后来学乖了先把移动、射击、碰撞这三件事的手感调到满意再考虑扩展。这个顺序反过来返工成本极高。希望帮到你。本文还有配套的精品资源点击获取