
简介这是一份面向C初学者与游戏开发入门者的经典飞机大战小游戏完整源码适合用来练习面向对象编程、图形绘制与游戏循环等核心技能。项目在躲避敌机的同时消灭更多敌人并应用函数模板降低代码重复玩家可用WSAD键控制飞机移动、按空格释放炸弹交互逻辑清晰便于二次修改与扩展。压缩包共46个文件约587KB其中30个png图片承担战机、敌机、子弹与背景等美术素材4个h头文件与4个cpp源文件构成游戏主体逻辑另含vcxproj、sln等Visual Studio工程文件及ttf字体、ico图标、rc资源脚本开箱即可编译运行。目前已有370人学习下载读者可从中获得完整的工程目录结构、模块划分思路与可运行的代码范例适合作为课程设计、C大作业或自学练手项目的参考。1. 飞机大战的 C 实现从零搭建一个可编译运行的桌面游戏很多人第一次接触 C 游戏开发都是从飞机大战开始的。它不像引擎项目那样庞大也不像算法题那样抽象一个主循环、几个类、几十张图就能跑出一个完整可玩的桌面程序。但真正动手时你会发现卡住新手的从来不是“怎么写子弹”而是环境怎么配、窗口怎么开、资源怎么加载、帧率怎么稳。这篇笔记就围绕“基于 C 实现的经典小游戏飞机大战源代码”这个方向把一套能编译、能运行、能继续扩展的实现路径讲清楚。适合刚学完 C 基础语法、想找一个完整项目练手的同学也适合想用 C 小游戏给孩子做编程启蒙的开发者。整套方案不依赖冷门库常见做法是用 EasyX 或 SFML 做图形层Windows 下用 Visual Studio 或 Dev C 都能落地。2. 选型与工程骨架为什么飞机大战适合用 C 手写而不是套引擎2.1 图形库怎么选EasyX、SFML 与原生 Win32 的取舍飞机大战的核心需求其实很朴素开一个窗口、按帧刷新、贴图、检测矩形碰撞、播放音效。能满足这些的库不少但选错了会在环境配置阶段就翻车。EasyX 是国内教学场景里最常见的方案头文件少、API 直白initgraph一行就能出窗口配 Visual Studio 几乎零配置。缺点是只支持 Windows且对透明通道和旋转的支持比较弱飞机倾斜、爆炸特效这类需求要自己写像素操作。SFML 跨平台支持纹理、精灵、音频、字体API 设计现代适合想把项目继续做大的同学代价是要手动配 CMake 或链接库。原生 Win32 GDI 最“纯”但双缓冲、位图加载、按键消息都要自己封装写飞机大战会有一半时间花在窗口框架上不划算。我一般建议如果只是交作业或练手选 EasyX半天能出可玩版本如果想把这个项目当成后续 C 游戏开发的起点选 SFML前期多花两小时配环境后面省很多事。下面以 EasyX 为主线讲因为它的代码结构最贴近“源代码”这个词的字面含义——打开就能读读完就能改。2.2 工程目录与类的划分别把逻辑全塞进 main新手写飞机大战最容易犯的错是把玩家、敌机、子弹、碰撞、绘制全部写在main的 while 循环里。第一版能跑加第二个敌机类型时就崩了。合理的骨架应该按职责拆成几个类每个类只关心自己的状态和行为。一个可用的目录结构如下PlaneWar/ ├── src/ │ ├── main.cpp // 入口主循环 │ ├── Game.h/.cpp // 游戏状态管理、场景切换 │ ├── Player.h/.cpp // 玩家飞机 │ ├── Enemy.h/.cpp // 敌机基类与派生 │ ├── Bullet.h/.cpp // 子弹 │ └── ResourceMgr.h/.cpp// 图片、音效加载与缓存 ├── res/ │ ├── player.png │ ├── enemy.png │ ├── bullet.png │ └── bgm.mp3 └── PlaneWar.vcxprojGame持有Player、vectorEnemy、vectorBullet主循环只做三件事处理输入、调用Game::update()、调用Game::render()。这样后面加 Boss、加道具、加关卡都只是往Game里加成员不会动主循环。2.3 主循环与帧率控制固定时间步长比 sleep 更靠谱游戏循环的写法直接决定手感。最简单的写法是while(true){ update(); render(); Sleep(16); }但Sleep(16)在 Windows 上实际会睡 16 到 20 毫秒不等帧率会飘敌机速度在不同机器上表现不一致。更稳的做法是用固定时间步长累加#include graphics.h #include chrono const double FIXED_DT 1.0 / 60.0; // 逻辑帧固定 60Hz int main() { initgraph(480, 720); Game game; game.init(); using clock std::chrono::steady_clock; auto last clock::now(); double accumulator 0.0; while (!game.isOver()) { auto now clock::now(); double frameTime std::chrono::durationdouble(now - last).count(); last now; if (frameTime 0.25) frameTime 0.25; // 防止卡顿后追帧爆炸 accumulator frameTime; while (accumulator FIXED_DT) { game.handleInput(); game.update(FIXED_DT); accumulator - FIXED_DT; } game.render(); } closegraph(); return 0; }这段代码的关键在accumulator渲染帧率可以随机器波动但逻辑更新永远按 1/60 秒推进。frameTime上限 0.25 秒是后悔药防止窗口被拖动或断点调试后一次性补几百帧导致敌机瞬移。FIXED_DT这个参数不要随便改改成 1/30 会让碰撞检测变粗改成 1/120 对飞机大战这种精度需求没必要还增加 CPU 占用。3. 核心模块实现玩家、敌机、子弹与碰撞检测的代码落地3.1 玩家飞机移动、射击与边界限制玩家类要处理三件事按键状态、位置更新、射击冷却。EasyX 没有内置的按键状态查询需要用GetAsyncKeyState或peekmessage。前者简单适合飞机大战。// Player.h #pragma once #include graphics.h class Player { public: Player() : x(240), y(600), speed(300), hp(3), shootCooldown(0) {} void update(double dt); void render() const; void shoot(std::vectorBullet bullets); bool isAlive() const { return hp 0; } void takeDamage() { if (--hp 0) hp 0; } float getX() const { return x; } float getY() const { return y; } float getRadius() const { return 20.0f; } private: float x, y; float speed; // 像素/秒 int hp; double shootCooldown; // 秒 IMAGE img; }; // Player.cpp void Player::update(double dt) { if (GetAsyncKeyState(VK_LEFT) 0x8000) x - speed * dt; if (GetAsyncKeyState(VK_RIGHT) 0x8000) x speed * dt; if (GetAsyncKeyState(VK_UP) 0x8000) y - speed * dt; if (GetAsyncKeyState(VK_DOWN) 0x8000) y speed * dt; // 边界限制留出飞机半径 if (x 20) x 20; if (x 460) x 460; if (y 20) y 20; if (y 700) y 700; if (shootCooldown 0) shootCooldown - dt; } void Player::shoot(std::vectorBullet bullets) { if (shootCooldown 0) return; bullets.emplace_back(x, y - 20, 0, -600); // 向上飞 shootCooldown 0.15; // 每秒约 6.6 发 }speed用像素/秒而不是像素/帧是为了和固定时间步长配合。shootCooldown设 0.15 秒是手感调出来的值设 0.05 会变成弹幕设 0.3 会明显卡顿。边界限制里的 20 是飞机贴图半宽如果你的图是 48×48就改成 24。3.2 敌机生成与运动模式用工厂函数管理类型敌机不能每帧 new 一个也不能全部用同一种运动。常见做法是定义一个EnemyType枚举用一个生成函数按概率产出不同属性的敌机。enum class EnemyType { Small, Medium, Large }; struct Enemy { float x, y; float speed; int hp; int score; EnemyType type; float radius; void update(double dt) { y speed * dt; // 中型机左右摆动 if (type EnemyType::Medium) { static double t 0; t dt; x static_castfloat(sin(t * 2.0) * 60.0 * dt); } } }; Enemy spawnEnemy() { static std::mt19937 rng(std::random_device{}()); std::uniform_int_distributionint typeDist(0, 99); std::uniform_real_distributionfloat xDist(40.0f, 440.0f); int r typeDist(rng); if (r 60) return { xDist(rng), -40, 120, 1, 10, EnemyType::Small, 16 }; if (r 90) return { xDist(rng), -60, 80, 3, 30, EnemyType::Medium, 24 }; return { xDist(rng), -80, 50, 8, 80, EnemyType::Large, 36 }; }std::mt19937比rand()分布更均匀uniform_int_distribution保证 0 到 99 每个数概率一致。三种敌机的速度、血量、分数按“小快少血、大慢多血”设计这是飞机大战最基础的难度曲线。生成位置y设为负值让敌机从屏幕外滑入避免凭空出现。3.3 碰撞检测矩形与圆形的选择及性能取舍飞机大战的碰撞不需要像素级精确圆形检测足够而且比矩形检测更符合飞机的外形。判断两个圆是否相交只需要比较圆心距离和半径和。inline bool circleHit(float x1, float y1, float r1, float x2, float y2, float r2) { float dx x1 - x2; float dy y1 - y2; float rr r1 r2; return dx * dx dy * dy rr * rr; }用平方距离避免开方这是每帧要跑几百次的函数省一次sqrt在低端机上能看出差别。子弹和敌机的半径可以比贴图略小比如贴图 32×32 的敌机半径设 14手感上“擦边不算中”玩家会觉得更公平。碰撞处理放在Game::update里用两层循环遍历子弹和敌机。子弹数量通常不超过 30敌机不超过 20O(n*m) 完全够用。如果以后做弹幕游戏再考虑四叉树或网格分区飞机大战这个量级上空间换时间没有意义。3.4 资源加载与内存管理IMAGE 对象不要每帧加载EasyX 的loadimage每次调用都会读磁盘放在render里就是灾难。正确做法是在Game::init里一次性加载存成成员变量或放进ResourceMgr。class ResourceMgr { public: static ResourceMgr instance() { static ResourceMgr mgr; return mgr; } IMAGE playerImg, enemySmall, enemyMedium, enemyLarge, bulletImg; void load() { loadimage(playerImg, _T(res/player.png)); loadimage(enemySmall, _T(res/enemy_small.png)); loadimage(enemyMedium, _T(res/enemy_medium.png)); loadimage(enemyLarge, _T(res/enemy_large.png)); loadimage(bulletImg, _T(res/bullet.png)); } private: ResourceMgr() default; };单例在这里是合理的因为图片资源全局只有一份不需要多个实例。loadimage的路径用_T()包裹是为了兼容 Unicode 和多字节字符集Visual Studio 默认 Unicode 下不加会编译报错。如果图片带透明通道EasyX 需要先用loadimage加载到IMAGE再用putimage的SRCAND或SRCPAINT模式绘制或者用AlphaBlend做半透明后者代码稍多但效果更好。4. 避坑与排查飞机大战从编译到运行最常见的 5 个翻车点4.1 现象编译报错“无法打开 graphics.h”原因EasyX 没有安装或者安装的版本和 Visual Studio 版本不匹配。EasyX 官网的安装包会检测 VS 版本装错版本头文件路径不会自动配置。解决确认 VS 版本2019/2022下载对应 EasyX 安装包安装时勾选当前 VS。装完后新建项目如果还报错检查项目属性 → C/C → 常规 → 附加包含目录里是否有 EasyX 的 include 路径。Dev C 用户注意EasyX 对 Dev C 的支持不完整建议换 VS 或改用 SFML。4.2 现象窗口一闪而过或者黑屏不刷新原因initgraph之后没有进入主循环或者主循环里没有调用cleardevice和FlushBatchDraw。EasyX 默认是双缓冲不调用刷新函数画面不会更新。解决确认主循环存在且不会立即退出。在render开头调用cleardevice()清屏结尾调用FlushBatchDraw()。如果用了BeginBatchDraw必须配对EndBatchDraw或FlushBatchDraw否则画面卡在第一帧。4.3 现象敌机速度在不同电脑上不一样原因用了Sleep(16)或delay(16)控制帧率实际睡眠时间受系统调度影响。或者速度按“像素/帧”计算帧率一变速度就变。解决改用固定时间步长累加速度全部按“像素/秒”定义update接收dt参数。这样无论渲染帧率是 60 还是 144逻辑速度一致。4.4 现象子弹和敌机碰撞后敌机要过几帧才消失原因碰撞检测和对象移除分在两处或者用了vector遍历时删除元素导致迭代器失效。解决用“标记删除”代替立即删除。给每个对象加bool dead碰撞后设dead true在update末尾统一erase(remove_if(...))。这样遍历安全逻辑也清晰。enemies.erase( std::remove_if(enemies.begin(), enemies.end(), [](const Enemy e) { return e.dead || e.y 760; }), enemies.end());4.5 现象程序运行几分钟后越来越卡原因子弹或敌机飞出屏幕后没有销毁vector无限增长。或者每帧都在loadimage。解决在update里检查对象是否超出屏幕边界超出就标记dead。资源加载全部移到init阶段。用任务管理器观察内存正常飞机大战内存应该稳定在几十 MB持续上涨就说明有泄漏或未回收的对象。5. 进阶技巧用状态机加对象池把飞机大战改成可扩展的骨架5.1 用状态机管理游戏流程第一版飞机大战通常只有“运行中”和“结束”两个状态用bool isOver就够了。但加上开始菜单、暂停、关卡切换后bool会变成一堆嵌套if。改成状态机每个状态一个类Game只持有当前状态指针。class GameState { public: virtual ~GameState() default; virtual void handleInput(Game g) 0; virtual void update(Game g, double dt) 0; virtual void render(Game g) 0; }; class PlayingState : public GameState { /* ... */ }; class PauseState : public GameState { /* ... */ }; class GameOverState: public GameState { /* ... */ };Game::changeState里做指针替换旧状态delete新状态new。状态切换只发生在帧边界不会出现“更新到一半换了状态”的玄学问题。这个模式在后续加 Boss 战、加商店界面时优势明显。5.2 对象池子弹和敌机的复用飞机大战里子弹每秒生成十几发敌机每秒几架用new/delete或vector频繁增删会产生内存碎片。对象池的思路是预分配一批对象用freeList记录空闲索引需要时从池里取不用时归还。templatetypename T, size_t N class ObjectPool { public: T* acquire() { for (size_t i 0; i N; i) { if (!used[i]) { used[i] true; return pool[i]; } } return nullptr; // 池满可扩容或丢弃 } void release(T* obj) { size_t idx obj - pool; if (idx N) used[idx] false; } private: T pool[N]; bool used[N] { false }; };子弹池设 128 个敌机池设 64 个对飞机大战绰绰有余。acquire返回nullptr时直接丢弃这次生成不要动态扩容否则池就失去意义了。这个技巧在嵌入式或性能敏感场景里很常见桌面小游戏用上属于“提前量”但代码量不大值得写。5.3 验证方法怎么确认你的实现没有隐藏 bug写完不是能跑就行建议做三个验证。第一把FIXED_DT临时改成 1/10观察敌机移动是否仍然平滑如果出现跳跃说明update里有依赖帧率的代码。第二在render里用outtextxy打印当前子弹数、敌机数、帧时间跑十分钟看数值是否稳定。第三把窗口拖到屏幕外再拖回来看敌机是否瞬移如果有说明frameTime上限没生效。我自己的习惯是每加一个新功能先把FIXED_DT调成 1/20 跑一遍再调回 1/60。这个土办法帮我抓过好几次“只在低帧率下出现的碰撞穿透”。飞机大战的源代码网上很多但能跑和能改是两回事把状态机和对象池加进去这份代码才真正属于你。希望帮到你。本文还有配套的精品资源点击获取