C++飞机大战游戏开发实战:从零搭建可编译运行的桌面游戏
2026/9/23 6:24:39 网站建设 项目流程

简介:这是一份面向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.vcxproj

Game持有Playervector<Enemy>vector<Bullet>,主循环只做三件事:处理输入、调用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::duration<double>(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 没有内置的按键状态查询,需要用GetAsyncKeyStatepeekmessage。前者简单,适合飞机大战。

// 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::vector<Bullet>& 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::vector<Bullet>& 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_cast<float>(sin(t * 2.0) * 60.0 * dt); } } }; Enemy spawnEnemy() { static std::mt19937 rng(std::random_device{}()); std::uniform_int_distribution<int> typeDist(0, 99); std::uniform_real_distribution<float> 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::mt19937rand()分布更均匀,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,敌机不超过 20,O(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,再用putimageSRCANDSRCPAINT模式绘制,或者用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之后没有进入主循环,或者主循环里没有调用cleardeviceFlushBatchDraw。EasyX 默认是双缓冲,不调用刷新函数画面不会更新。

解决:确认主循环存在且不会立即退出。在render开头调用cleardevice()清屏,结尾调用FlushBatchDraw()。如果用了BeginBatchDraw,必须配对EndBatchDrawFlushBatchDraw,否则画面卡在第一帧。

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/deletevector频繁增删会产生内存碎片。对象池的思路是预分配一批对象,用freeList记录空闲索引,需要时从池里取,不用时归还。

template<typename 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。这个土办法帮我抓过好几次“只在低帧率下出现的碰撞穿透”。飞机大战的源代码网上很多,但能跑和能改是两回事,把状态机和对象池加进去,这份代码才真正属于你。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询