
简介基于C的飞机大战小游戏完整源码包以Visual Studio工程形式呈现适合C初学者、课程设计或游戏开发爱好者理解小型游戏的设计思路与实现细节。源码包共71个文件包含24张PNG图片背景、敌机、英雄机、子弹等素材、16个C源文件另含Visual Studio解决方案、工程配置文件、编译日志及两个可直接运行的程序压缩包约64.23MB。代码按功能拆分为滚动背景、Hero移动、子弹发射、双发、碰撞检测、敌机爆炸、游戏结束判断、计分与多关卡难度升级等多个版本模块层次分明地展现从单一功能到完整玩法的演进过程。已有887人学习浏览。读者既可运行EXE直接体验游戏效果也能对照源文件研究界面设计、输入响应、音频播放与碰撞逻辑等实现方法整体目录结构清晰适合个人练习或团队二次开发。1. C飞机大战小游戏源码版本号就是最好的开发笔记C飞机大战这套小游戏源码拿到手先别急着点F7。我最推荐它的理由不是画面有多炫而是里面的版本号本身就是一条教学链路v1.0滚动背景、v2.0 Hero移动、v3.0单个子弹发射、v4.0多个子弹发送、v6.0双发……一路排到V13.0多关卡、V14.0难度升级、V15.0背景音乐v16应该就是整合后的当前版本。这不是一份只有最终代码的项目而是同一个游戏从空窗口长到完整可玩状态的中间快照集合非常适合想学C小游戏开发、又不想从零抠窗口API的读者。技术栈不杂Visual Studio工程、EasyX图形库、Windows GDI贴图覆盖滚动背景、键盘操控、子弹系统、碰撞检测、爆炸帧动画、计分、多关卡和BGM。C入了门、觉得只写控制台程序不过瘾的人用这份源码当跳板正合适。2. 打开工程前的功课先看清资源结构与VS环境要求看一份源码不先看目录结构后面一定会被源.cpp到底管什么这种问题绕晕。飞机大战这套包的摆法其实很有条理解决方案文件、工程文件、按版本命名的C历史文件、res素材目录、x64/Debug下编译好的exe。搞清楚这几块至少能少走一小时弯路。2.1 源码包里的两条主线工程文件与版本快照先把眼睛放到最该看的几个文件上文件或目录在项目里扮演的角色飞机大战.slnVisual Studio解决方案入口双击直接打开飞机大战.vcxproj工程配置记录编译平台、字符集、依赖项源.cpp当前版本整合后的程序入口v16的落点v1.0~V16共16个cpp按版本号保留的历史源码快照是本包最大亮点res/素材目录enemy、hero、bg、blast、zd、over前缀的PNG全在这x64/Debug/飞机大战.exe已经编译好的Debug可执行程序不想搭环境可以直接跑这套命名很有规律enemy是敌机hero是主角bg是背景blast是爆炸帧zd是子弹子弹拼音over是结束画面。素材名和代码变量名对得上读代码时不至于抓瞎。res里出现了enemy0、enemy1、zd11、zd12、zd20、bg01~bg03、blast和blast2说明敌机和子弹有多套贴图爆炸也至少做了两帧这是后面做帧动画的基础。版本快照这层设计是我最想夸的。普通源码包给你一个最终版最多配个说明这份把v1.0到v15.0每个功能里程碑都留了底。我拿到手第一反应就是这相当于作者的git提交记录被平铺在文件夹里不用装git也能看到滚动背景→移动→子弹→碰撞→爆炸→计分→关卡的完整演进顺序。学的时候建议从最旧的v1.0开始看每个版本编译一次而不是一上来就啃源.cpp。我见过不少新手拿到这份源码直接改源.cpp改完发现想退回v9.0看爆炸实现只能撤销编辑器操作。更稳妥的习惯是源.cpp只当集成后的参照按版本学习时把对应版本cpp单独拷出来建临时工程玩玩完删掉不污染主工程。血泪经验值得记。2.2 环境与字符集EasyX依赖、x64 Debug、多字节源码是Visual Studio工程环境要求不外乎三件事VS桌面开发组件、EasyX图形库、工程字符集。从源码里大量出现loadimage、putimage、initgraph来判断这套项目用的是EasyX不是SDL也不是Qt。选型理由也很直白图形方案上手成本中文教程密度与本项目吻合度EasyX低装完直接#include graphics.h高完全吻合SDL2中要理解窗口、渲染器、纹理三层一般需要大量改写Qt较高信号槽体系有学习曲线高杀鸡用牛刀对只想快速跑通一个2D小游戏的人来说EasyX把窗口创建和画像素都封装好了专心写游戏逻辑就好。装EasyX时注意下载对应自己VS版本的安装包装完在VS里能正常包含graphics.h和easyx.h就算成功。字符集是另一个容易被忽略的配置项。打开飞机大战.vcxproj找CharacterSet这一项!-- 飞机大战.vcxproj 中 Debug|x64 配置片段 -- PropertyGroup Condition$(Configuration)|$(Platform)Debug|x64 CharacterSetMultiByte/CharacterSet UseDebugLibrariestrue/UseDebugLibraries /PropertyGroup这段配置的含义是Debug x64模式下使用多字节字符集MultiByte。EasyX的outtextxy在绘制文字时宽字符和多字节字符的调用方式不一样如果被改成Unicode文字部分会直接编译报错。这也是很多EasyX项目从别的机器复制过来后第一波红灯的元凶。如果你习惯用VSCode而不是VS这套带.sln的工程我不建议手工写task.json去拉编译EasyX的include和lib路径在VSCode里要额外配置收益不高。直接在VS里打开.sln按F7是最省事的路径。想用命令行编译也可以MSBuild.exe 飞机大战.sln /p:ConfigurationDebug /p:Platformx64 /m这里/p:Configuration指定Debug而非Release/p:Platform指定x64而不是Win32/m是并行编译加快速度。Debug版保留调试信息初学时跑Debug更合适碰到问题能下断点看变量Release留给做完之后打包用。环境上最容易翻车的是EasyX装完VS没识别到如果graphics.h一直报找不到用EasyX安装程序重新装一次并重启VS别在包含目录里手动乱指路径。提示拿到陌生EasyX源码包先看.vcxproj里的CharacterSet和PlatformToolset再决定要不要改配置。改字符集前想清楚改动会牵动所有文字接口。3. 核心机制拆解滚动背景、Hero移动与子弹系统3.1 v1.0滚动背景与v2.0 Hero移动游戏循环的地基先看v1.0这个版本只有一个功能背景向下滚动。代码是整个项目的地基后面所有版本的循环结构都从这里长出来。核心逻辑是把一张背景图贴两次配合取模运算实现无限循环// v1.0-滚动背景.cpp 的核心循环 #include graphics.h #include conio.h int main() { initgraph(480, 700); // 创建480x700的游戏窗口 IMAGE bg; loadimage(bg, res/bg01.png); // 加载背景图 int bgY 0; // 第一张背景的Y坐标 int speed 2; // 每帧向下移动2像素 while (true) { putimage(0, bgY, bg); // 贴第一张背景 putimage(0, bgY - bg.getheight(), bg); // 第二张补在上方 bgY speed; if (bgY bg.getheight()) // 滚完一圈就回位 { bgY - bg.getheight(); } Sleep(10); // 约100帧每秒控制循环节奏 } return 0; }这段代码有三个设计点值得记。第一窗口高700背景图高度正好对应滚动区间两张图一上一下拼接就能做到无缝循环。第二bgY累加到超过背景高度后减掉一个背景高度用减法回绕而不是if清零循环里少一点开销是一点。第三Sleep(10)是帧节奏的唯一控制手段数值越小游戏越快后面V14调难度时改的就是这些速度参数而不是重写逻辑。v2.0加的是Hero移动和背景配合起来游戏才有角色在动的感觉。移动用的是GetAsyncKeyState轮询键盘// v2.0-Hero移动.cpp 的按键处理 int heroX 240, heroY 600; // 英雄初始位置 int step 5; // 每帧移动5像素 // 主循环内每帧执行 if (GetAsyncKeyState(VK_LEFT) 0x8000) { heroX - step; } if (GetAsyncKeyState(VK_RIGHT) 0x8000) { heroX step; } // 边界限制防止飞机飞出屏幕 if (heroX 0) heroX 0; if (heroX 480 - 60) heroX 480 - 60; // 60按飞机贴图宽度来GetAsyncKeyState返回值的最高位表示按键是否被按下所以用0x8000做位与判断这是Windows按键检测的常规姿势。step5配合Sleep(10)约为每秒500像素手感偏快改成2~3更细腻这个参数直接决定游戏飘不飘。边界限制里480是窗口宽度60是飞机贴图宽度减掉它才不让飞机半个身子卡在屏幕外。这里坐标还是裸变量v7.0之后会封装成结构体那是后话。3.2 v3.0到v6.0子弹演进从单发到双发与音效v3.0到v6.0是一个典型的先跑通再优化过程。v3.0只有一颗子弹按一下打一发子弹飞出屏幕前再按毫无反应手感约等于没有。v4.0最大的变化是引入子弹数组把子弹从单个变量升级成一批槽位牺牲一点内存换手感// v4.0-多个子弹发送.cpp 的子弹管理 #define MAX_BULLETS 20 struct Bullet { int x, y; bool alive; // false表示槽位空闲 } bullets[MAX_BULLETS]; // 发射从数组里找一个空闲槽位填入子弹数据 void fire(int x, int y) { for (int i 0; i MAX_BULLETS; i) { if (!bullets[i].alive) { bullets[i].x x; bullets[i].y y; bullets[i].alive true; break; } } } // 主循环里对每个子弹做更新上移、出界回收 for (int i 0; i MAX_BULLETS; i) { if (bullets[i].alive) { bullets[i].y - 8; // 子弹每帧上移8像素 if (bullets[i].y 0) { bullets[i].alive false; // 出界槽位让出来 } } }这里用原生数组而不是STL的vector不是作者偷懒。游戏主循环每帧都要遍历子弹和敌机vector的动态扩容和析构在这种高频短对象场景里会影响帧率稳定性。MAX_BULLETS20意味着同屏最多20发子弹发满后fire循环找不到空闲槽位自然表现为按了没反应这是对象池的边界不是bug。y - 8配合Sleep(10)子弹每秒上升800像素从屏幕底部飞到中部大约几百毫秒这套数值链可以整体按比例调。v5.0在发射时加了音效v6.0实现双发。音效的常见做法是PlaySound// v5.0-多发子弹连续播放声音.cpp 的音效触发 #include mmsystem.h #pragma comment(lib, winmm.lib) // 链接Windows多媒体库 void fireWithSound(int x, int y) { PlaySound(res/zd.wav, NULL, SND_FILENAME | SND_ASYNC); fire(x, y); // 复用v4.0的发射逻辑 }SND_FILENAME表示第一个参数是文件路径SND_ASYNC表示异步播放、不阻塞主循环。如果连续高频发射导致音效叠成一团可以先PlaySound(NULL, NULL, 0)停止上一个再播新的。双发的实现更有意思v6.0没有改数据结构只是把fire调用从一次变两次子弹起点x分别偏左和偏右// v6.0-实现双发.cpp 的核心 fire(heroX 10, heroY); // 左膛 fire(heroX 40, heroY); // 右膛英雄贴图宽度约60像素时两颗子弹从x10和x40飞出视觉上正好是机翼两侧。双发用两行代码就完成了因为它吃透了v4.0的数组槽位设计。这就是版本演进的价值每一步都有迹可循v6.0的简单建立在v4.0的打底上。4. 碰撞、爆炸与计分v7.0到v12.0把玩法闭环4.1 v7.0封装与v8.0碰撞检测结构体和矩形相交判定v7.0之前英雄坐标是裸变量敌机也是零散变量。当敌机变成一批时裸变量就撑不住了。v7.0做的事是封装Plane结构体把飞机共同的属性收拢在一起// v7.0-重新封装飞机-创建敌机.cpp 的飞机结构 #define MAX_ENEMIES 8 struct Plane { int x, y; int width, height; IMAGE img; bool alive; } enemy[MAX_ENEMIES]; // 敌机生成从上方随机位置进入复用槽位 void spawnEnemy() { for (int i 0; i MAX_ENEMIES; i) { if (!enemy[i].alive) { enemy[i].x rand() % (480 - enemy[i].width); enemy[i].y -enemy[i].height; // 从屏幕外上方出现 enemy[i].alive true; break; } } }rand() % (480 - width)保证敌机横坐标不会超出右边界这个取模上限的写法比生成后再判断重新生成干净得多。敌机从y为负值开始往下落玩家看到的是敌机滑入屏幕而不是凭空冒出来。记得程序启动时调一次srand(time(NULL))否则每次运行随机序列都一样敌机永远出现在同一批位置。透明贴图是图形部分最隐蔽的坑。PNG素材直接putimage飞机会带一圈矩形底色。v7.0里的常见做法是素材出成统一底色再用两阶段光栅操作抠掉底色// 透明贴图白底素材用SRCANDSCRINVERT两段式输出 putimage(p.x, p.y, p.img, SRCAND); putimage(p.x, p.y, p.img, SCRINVERT);SRCAND先把白色区域与屏幕做与运算SCRINVERT再做反色叠加两张图配合后白色被透明化飞机本体保留。素材如果是黑底逻辑要反过来。这套做法在EasyX游戏里沿用多年比逐像素处理省事。新版EasyX对带alpha通道的PNG已经可以loadimage后直接putimage素材质量好的话用不上抠色这一套。v8.0碰撞检测是整个玩法从打空气变成能打中的关键。这里用的是矩形相交判定不是像素级碰撞// v8.0-碰撞检测.cpp bool isHit(const Plane a, const Plane b) { // 两个矩形不重叠的充要条件某一条轴的内侧距离为负 if (a.x a.width b.x || b.x b.width a.x) return false; if (a.y a.height b.y || b.y b.height a.y) return false; return true; }逻辑是把两架飞机都当作轴对齐矩形x方向和y方向只要有一方不重叠矩形必然不相交只要两个方向都有重叠区间就判定命中。实战中我不会直接用原始宽高做判定飞机贴图往往不是实心矩形机翼两侧和尾部会白送碰撞。常见做法是把判定矩形往里缩一圈// 实际调用时缩小碰撞盒手感更宽容 bool hitBulletEnemy isHit( { bullet.x 2, bullet.y 2, 8, 8 }, { enemy[i].x 4, enemy[i].y 4, enemy[i].width - 8, enemy[i].height - 8 } );子弹碰撞盒缩成8x8敌机四周各让出4像素玩家打中机翼边缘不会误判也不会出现明明擦到却没死的憋屈感。这个参数属于手感调校不同人偏好不同但方向是统一的碰撞盒永远比视图片小一点。4.2 v9.0到v12.0爆炸帧动画、游戏结束与计分v9.0实现敌机爆炸素材里blast.png和blast2.png就是为这个功能准备的。爆炸本质是短时间内连续播放几帧贴图// v9.0-实现敌机爆炸.cpp 的爆炸帧展示 void showBlast(int x, int y) { IMAGE blastImg[2]; loadimage(blastImg[0], res/blast.png); loadimage(blastImg[1], res/blast2.png); for (int i 0; i 2; i) { putimage(x, y, blastImg[i]); Sleep(50); // 每帧停留50ms两帧共100ms } }两帧动画只求炸了一下的视觉反馈够用。帧数多的话可以抽成通用函数传入帧数组和帧间隔循环贴图。注意showBlast是阻塞式的内部Sleep(50)会拖慢主循环爆炸期间其他物体也跟着停顿。帧数少时感知不到帧数多了尽量改成非阻塞的爆炸状态机。v10.0的Hero和敌机碰撞和v8.0用的是同一套isHit只是入参换成hero和enemy[i]——这就是复用结构体的甜头一个函数服务所有碰撞对。v11.0游戏结束判断和v12.0计分是玩法的收尾。结束画面的标准姿势// v11.0-游戏结束判断.cpp if (!hero.alive) { IMAGE over; loadimage(over, res/over.png); putimage(0, 0, over); _getch(); // 等待玩家按键再退出或者重置游戏 break; }计分则是每次消灭敌机时累加// v12.0-更新分数.cpp score 10; TCHAR text[32]; swprintf_s(text, _T(SCORE: %d), score); outtextxy(10, 10, text);score加多少是设计问题10分一架是常见起步值。敌机可以按类型区分价值和速度普通机10分、精英机30分这正好对应res里enemy0和enemy1两套贴图的差异。outtextxy的文字输出在宽字符下容易出问题多字节字符集下用TCHAR配合_T宏是通用写法这又回到第2章强调的MultiByte配置——字符集不对这里就是第一个爆点。到这里一个能玩的游戏闭环已经形成背景滚动、角色移动、子弹发射、碰撞消灭、爆炸反馈、死亡结束、分数累计。欠的只剩纵向扩展也就是第5章的多关卡和BGM。5. 多关卡与背景音乐V13.0到V15.0把游戏推向可发布状态5.1 V13.0多关卡实现过关判定与战场重置单关游戏做完后最大瓶颈是玩家5分钟就腻。V13.0引入关卡数的思路不复杂用一个整数记录当前关卡每消灭N架敌机进入下一关。源码里V13.0-多关卡实现.cpp解决的就是这一层// V13.0-多关卡实现.cpp 的过关逻辑 int stage 1; // 当前关卡从第1关开始 int killCount 0; // 本关已消灭的敌机数 const int PASS_COUNT 10; // 每关需消灭10架敌机 // 每次成功消灭一架敌机后调用 killCount; if (killCount PASS_COUNT) { stage; // 关卡1 killCount 0; // 击杀数清零下一关重新计算 resetBattlefield(); // 关键重置战场 hero.x 240; // 英雄回到初始位置 hero.y 600; }过关判定本身不复杂真正的坑在resetBattlefield。我在这里翻过车只重置了敌机数组的alive状态忘了清理场上残余子弹和爆炸帧下一关开场满屏子弹乱飞就像上一场战斗的残骸粘到了新关卡。正确的重置顺序是先清爆炸特效再清子弹数组全部槽位最后重置敌机坐标顺序反了玩家就会看到子弹还在飞、敌机还在掉。另一个值得提的是关卡重置后的开场保护。如果resetBattlefield后不重置敌机生成计时器新关卡第一秒就可能连续刷出好几架敌机玩家还没回过神就撞机。我一般会在关卡切换时设置一个60帧约0.6秒的生成暂停窗口给玩家一个呼吸空间。对应的重置函数大概长这样// 常见的战场重置顺序注意先后关系 void resetBattlefield() { for (int i 0; i MAX_ENEMIES; i) { enemy[i].alive false; // 敌机全部撤退 } for (int i 0; i MAX_BULLETS; i) { bullets[i].alive false; // 玩家子弹清空 enemyBullets[i].alive false; // 敌机子弹也清空 } spawnTimer 60; // 重置敌机出生计时器给玩家缓冲 }这个函数在源码里没有单独成文件但V13之后的版本几乎都隐含着这套逻辑。学习时自己补上这段比只抄过关判断理解更深。5.2 V14.0难度升级与V15.0背景音乐参数曲线与MCI切歌V14.0做多关卡难度升级本质是让难度参数随关卡号平滑变化而不是每关硬编一组数据。源码版本名直说了难度升级具体参数曲线常用线性公式// V14.0-多关卡难度升级.cpp 的难度参数 // 敌机下落速度第1关为1每关递增0.5 int enemySpeed 1 (stage - 1) * 0.5; // 敌机出生间隔第1关60帧一只每关少5帧 int spawnInterval 60 - (stage - 1) * 5; // 同屏敌机上限第1关3架每关加1 int maxEnemies 3 (stage - 1);三个公式对应三条难度曲线速度快了躲着难生成密了压力大同屏多了操作繁琐。如果全部线性增长第5关敌人速度到3.0配合60帧每秒的循环已经很难闪避所以更稳妥的玩法是给参数设上限比如enemySpeed最大到2.5、spawnInterval最低到35帧避免第10关变成不可能通关的死局。参数表大概是这种感觉关卡敌机速度生成间隔(帧)同屏上限第1关1.0603第2关1.5554第3关2.0505第4关及以后2.5封顶35封底8封顶V15.0的背景音乐用的是MCI命令字符串接口。相比PlaySound只播一个固定wavMCI能管理独立句柄、支持循环播放切关卡换BGM就靠它// V15.0-设置各关卡的背景音乐.cpp #include mmsystem.h #pragma comment(lib, winmm.lib) // 切换关卡背景音乐 void playStageMusic(int stage) { mciSendString(close bgmusic, NULL, 0, NULL); // 关掉旧音乐 TCHAR cmd[128]; swprintf_s(cmd, _T(open res/bg%d.mp3 alias bgmusic), stage); mciSendString(cmd, NULL, 0, NULL); // 打开第stage关的mp3 mciSendString(play bgmusic repeat, NULL, 0, NULL); // 循环播放 }MCI命令的特征是open xxx alias 名字之后所有操作都通过alias指代这个媒体对象。切歌前必须先close同一个alias否则连续open会积累多个打开实例内存和句柄双泄漏跑十几关后音乐开始卡顿甚至无声这是MCI方案最经典的坑。repeat后缀表示循环播放不加的话一首播完就停。bg%d.mp3这类资源命名要求素材名和关卡号严格对应否则第3关open失败后音频全无代码会调但没准备素材一样白搭。注意MCI对mp3格式有兼容性要求部分采样率或码率的mp3会打不开。遇到open命令返回错误时先把所有BGM统一转成44100Hz、128kbps的常规参数这是成本最低的解法。V13到V15做完这套源码已经从能玩几分钟变成想打穿全部关卡的成品状态。玩法闭环、难度曲线、听觉反馈三块凑齐剩下的是工程化的事怎么把它安全地从源码编译成exe、碰到环境问题怎么定位。最后一章把这些琐碎但致命的坑收干净。6. 编译避坑与验证清单装好环境再跑通一轮6.1 三个高频编译坑现象、原因、解决先说坑一编译时报C2065graphics.h 未声明或无法打开包含文件graphics.h。原因基本都是EasyX没安装或者装的时候选的VS版本和当前工程用的VS工具集不一致。解决方法是下载匹配当前VS版本的EasyX安装包重新安装装完在项目属性→VC目录→包含目录里确认EasyX头文件路径已存在。最常见的场景是从别人那里拷来源码人家装了EasyX你没有。坑二链接时报LNK2019无法解析的外部符号 _PlaySound12或mciSendString相关报错。现象是编译通过、链接失败。原因是PlaySound和mciSendString属于winmm.lib工程没链接这个库。解决方法是加#pragma comment(lib, winmm.lib)或者在项目属性→链接器→附加依赖项里手动补winmm.lib。坑三运行后飞机周围一圈黑底或白底色块贴图不透明。现象是素材图是PNG但直接用putimage整块贴出矩形底色。原因是对不带alpha通道的PNGEasyX按不透明位图处理。解决方法是先确认素材是否真透明如果真透明还出黑框用loadimage重新加载如果素材本身是白底图用第4章的SRCANDSCRINVERT两段式抠色贴图。另外提醒一句Debug模式下降帧很常见IDE调试器占用会让Sleep(10)失真子弹飞不动先用Release模式验证是不是性能问题。6.2 上手验证清单十五分钟跑通一轮拿到源码后按这个流程走一遍能省掉大部分自我怀疑双击飞机大战.sln打开工程确认右上角解决方案配置是Debug、平台是x64按F7编译看到0错误0警告再运行运行后先确认背景在持续向下滚动按左右方向键英雄贴图跟随移动且不越界按空格发射子弹连续向上飞音效正常让子弹击中敌机观察爆炸帧是否出现故意迎头撞敌机确认over.png结束画面弹出打够过关数量确认关卡号增加、BGM切换、敌机变快如果想要快速验证碰撞检测有没有粘到正确对象可以在v12.0的score 10这行下断点killCount; score 10; // 此处下断点打中一架敌机应命中一次 if (killCount PASS_COUNT) { ... }断点如果没触发说明碰撞路径根本没走到回头查isHit的调用位置和矩形参数先确认敌机alive状态再看矩形是否缩得过多导致永远不重叠。Debug模式下按F11跟进去看两个矩形的实际数值比盯着贴图猜快。我自己第一次上手这套源码时上来直接编译源.cpp撞上graphics.h找不到装完EasyX又撞上Unicode字符集报错折腾一整晚才发现问题全在环境配置不在代码。从那以后我拿到任何EasyX游戏源码第一件事永远是确认字符集和EasyX版本编译前强制走一遍环境检查再顺手过一遍上面的验证清单最后才动手调碰撞参数。这套流程帮我避开了不少冤枉路希望也帮到你。本文还有配套的精品资源点击获取