学完C语言,大多数人都会面临同一个问题:语法全懂,可一打开编辑器,脑子里空空的,不知道能写点什么像样的东西。书上的练习题都是“打印三角形”“交换两个数”,写多了只想吐。如果你也卡在这里,我强烈建议你试试“C语言—飞机大战小游戏”这个项目。
这是一个麻雀虽小、五脏俱全的练手项目。玩家控制飞机左右上下移动,按空格发射子弹,敌机从屏幕上方不断出现,打中敌机加分,敌机撞到你或者漏到屏幕底部就掉血,血量归零游戏结束。整个项目看起来简单,但要把变量、循环、数组、结构体、指针、链表、随机数、键盘事件、绘制逻辑全部揉在一起,还要保证不卡顿、不闪屏、手感不别扭,真不是那种“两个小时内糊弄完”的水平。我前后带着几位同学做过这个项目,也帮人排查过无数次bug,今天就把整个过程从头到尾拆给你看。
这篇内容适合两类人:一是刚学完C语言、想找一个合适项目练手的初学者,二是打算把这个小游戏项目作为课程设计、期末展示任务,但又不想直接去网上抄一整段代码的同学。我会把设计思路、踩坑记录、可复现的步骤和关键代码片段都写出来,你照着走一遍,基本能跑出一个自己亲手造的、能玩的游戏。
1. 动手前的全局设计:先别写代码,把这几件事想明白
很多新手拿到这种项目就直接开始敲代码,敲到一半发现这个函数要加参数、那个结构体要加字段、主函数里逻辑越堆越多,最后乱成一锅粥。我建议你什么代码都别写,先拿张纸,把下面几件事定下来。
1.1 需求清单怎么抓——先把游戏拆成四件大事
飞机大战看起来功能一大堆,但真正归类后只有四件事:玩家控制、子弹系统、敌机系统、碰撞与结算。把这四件事拆清楚,你写的不是“一个游戏”,而是四个相互独立的小模块。
玩家控制包括:飞机跟随键盘方向键移动、移动范围限制在窗口内、飞机图标的加载与绘制。子弹系统包括:发射子弹、子弹向上飞行、子弹消失条件、限制发射频率。敌机系统包括:敌机生成、敌机下落、敌机与子弹碰撞销毁、敌机飞出屏幕清理。碰撞与结算包括:子弹打中敌机的判定,敌机撞击玩家的判定,分数累加与生命值扣减。
这四块之间只通过共享的数据交换,相互之间不要直接调用对方内部逻辑。比如玩家按下空格,你应该做的只是“在子弹系统里添加一颗新子弹”,而不是在主函数里写一堆子弹移动的代码。这样后期无论是调速度、加道具还是加关卡,都只是改动一个小模块,而不是把所有代码重写一遍。
四件事理清楚之后,你会发现整个项目就是一个数据集合加上三类操作:玩家是一堆数据,子弹是一堆数据,敌机是一堆数据,而初始化、更新、绘制是处理这些数据的操作。明白了这一点,数据结构设计就有方向了。
1.2 图形库选型:为什么我推荐新手用EasyX
C语言的标准库里没有图形接口,所以你要做飞机大战,必须选一个图形库。目前市面上常见的方案有三类:控制台字符界面、Windows下的EasyX图形库、跨平台的SDL或raylib。
控制台字符界面就是那些用*号画飞机、用printf刷屏的方案。优点是零依赖,任何电脑都能跑,但画面实在太简陋了,做出来的东西自己都不想玩第二遍。我个人不太推荐,除非你纯粹是为了练逻辑。
跨平台的SDL、raylib功能强大,能做的远不止2D小游戏,但环境配置对新手极不友好。你光是要搞定头文件、库文件、链接配置、运行时动态链接库就够折腾好几个小时,还没开始写游戏代码,热情已经被浇灭一半。适合有一定经验之后再深入。
EasyX是Windows平台下的轻量级图形库,安装流程只有“下载exe、双击、选择对应编译器”三步。它提供了initgraph、loadimage、putimage、BeginBatchDraw这些和Windows绘图高度封装的接口,对新手特别友好。缺点是只能在Windows上跑,而且它对编译器版本有一定要求,但作为个人项目、课程设计已经绰绰有余。
我平时带人做这个项目,只要不是明确要求跨平台,一律推荐EasyX。核心逻辑的写法其实和SDL、raylib大差不差,以后想迁移到别的图形库,改动成本非常低。我们先拿最容易上手的图形库把游戏做出来,比什么都重要。
1.3 数据结构与模块划分——这一步决定了你后面会不会越写越乱
把所有游戏元素抽象成结构体,是C语言做小游戏的核心习惯。我建议至少定义下面几个结构体:
// 玩家的飞机 typedef struct Player { int x, y; // 坐标(左上角) int width, height; // 本体宽高 int life; // 生命值 int score; // 分数 IMAGE img; // 飞机图片 } Player; // 子弹 typedef struct Bullet { int x, y; int speed; int exist; // 0表示不存在,1表示存活 } Bullet; // 敌机 typedef struct Enemy { int x, y; int width, height; int hp; IMAGE img; int exist; struct Enemy* next; // 链表指针 } Enemy;这里有一个关键选择:子弹用数组还是链表,敌机用数组还是链表。我的建议是:子弹用结构体数组,敌机用链表。
子弹的数量是可控的,玩家按住空格发射,就算每秒射20发,同一时刻屏幕上的子弹一般也就几十颗,开一个固定长度比如50的数组完全够用。数组的好处是没有内存分配的开销,遍历和修改都很快。
敌机就不一样了,你不知道什么时候会刷出来几只,用链表可以随时动态申请节点、随时释放销毁,并且在遍历时跳过exist == 0的节点。当然你也可以用固定数组,比如定义100个敌机槽位,但那样“存活管理”要手写一个分配机制,不知不觉就给自己加了很多不必要的复杂度。链表加上内存管理也就十行代码,性价比更高。
模块划分上,至少要有这几个文件:
main.c:程序入口、游戏主循环game.h:结构体定义、全局变量声明、函数声明game.c:初始化、更新、绘制、销毁逻辑
等代码多了之后,你还可以拆出input.c、render.c、enemy.c,但一开始三个文件足够了。核心思路是:main里不放具体实现,只负责调用初始化、循环更新、绘制和释放资源。
1.4 游戏状态机:让程序知道“现在该干什么”
你一定遇到过这种情况:游戏还没按开始,敌机就开始刷了;游戏明明已经结束,飞机还能移动。这就是没有“游戏状态”的概念导致的。
在游戏里加一个枚举,用全局变量保存当前状态:
typedef enum GameState { MENU, // 开始菜单 PLAYING, // 游戏进行中 OVER // 游戏结束 } GameState; GameState g_state = MENU;主循环每帧都先看当前是什么状态,然后只处理当前状态该做的事情。比如状态是MENU,就只绘制菜单背景、检测玩家是否按了空格或回车;如果按了,把状态切到PLAYING,重新初始化游戏数据。状态是OVER时,绘制结算画面,玩家按R键重新开始。
这样做的最大好处是逻辑不串线,也为后面扩展“暂停”“关卡切换”留下了空间。不少新手嫌麻烦不做状态机,结果整个游戏逻辑全靠一堆if判断硬扛,改着改着就发现某个功能在错误的状态下被触发了。我强烈建议你一开始就把状态机写上。
2. 核心细节解析:新手最容易卡住的六个关键点
设计想清楚了,接下来进入具体的实现细节。这一节讲的不是那种“随便能问到大路边的答案”,而是我自己实际写这个项目时反复碰壁总结出来的点。
2.1 游戏循环与帧率控制:没有它一切都白搭
游戏和普通程序最大的区别是:普通程序等用户输入,按一下动一下;游戏是不断刷新画面,你的每一次按键只是改变刷新的结果。要把这个机制落地,靠的是一个死循环加上每帧之间合适的延迟。
结构大致长这样:
while (1) { // 处理用户输入 // 更新游戏逻辑 // 绘制画面 // 延迟一段时间,控制帧率 }关键是最后那个延迟。如果不加延迟,循环会以极高的速度空转,CPU占用率直接拉满,画面也会快到看不清。加Sleep(10)的话,每秒大概刷新100次;Sleep(16)大概是每秒60次,也就是常说的60 FPS。60 FPS对飞机大战这种游戏完全够用,手感也比较顺滑。
为什么不直接用Sleep(16)卡死在60帧?因为每帧的运算时间不固定,有的帧花1毫秒,有的帧花5毫秒。如果你只加Sleep(16),实际一帧可能是17毫秒、21毫秒,导致游戏忽快忽慢。更科学的做法是计算“这一帧从开始到结束真正花了多少时间”,然后用目标帧间隔减去它。但做新手项目没必要一开始就上这么复杂的帧率控制,先固定Sleep(16),把游戏跑顺了,再回来优化也不迟。
2.2 双缓冲绘图:消灭闪屏的唯一正解
你可能已经试过用EasyX直接putimage贴图,结果画面疯狂闪烁,眼睛看得难受。原因很简单:你在屏幕上一边擦除旧图、一边绘制新图,这个过程被用户肉眼捕捉到了。解决方法是开头加上双缓冲绘图接口。
具体来说就是:
BeginBatchDraw(); // 开始批量绘图(所有绘制先画到内存中) // 在这里画背景、飞机、子弹、敌机、文字 FlushBatchDraw(); // 一次性将内存中的画面送到屏幕BeginBatchDraw之后,所有绘制操作都不会立即出现在屏幕上,而是先写入一个后台画布;等全部画完,FlushBatchDraw一次性把整块后缓冲交换到前台。这样用户看到的就是完整的新画面,而不是被逐步涂鸦的画面。闪烁问题几乎立刻消失。
这个接口不算复杂,但必须记住:每一次重绘都要以BeginBatchDraw开头、以FlushBatchDraw结束,顺序不能乱,否则会出现画面只绘制了一半就闪出来的情况。
2.3 键盘输入处理:别再让getchar()堵住你的游戏
很多刚从控制台编程转过来的同学,第一反应是用getchar()或scanf接收按键。这在控制台程序里没问题,但在图形界面游戏里,getchar()会阻塞整个程序,游戏画面直接卡死——因为它在等用户敲一个回车。显然,这样的程序没法玩。
正确的做法是用GetAsyncKeyState这个API来检测按键是否被按下,而且它可以检测“按住不放”的状态:
if (GetAsyncKeyState(VK_UP) & 0x8000) { // 上方向键一直被按住 }这里& 0x8000的意思是检查按键的当前状态是否为“按下”。不用getchar之后,程序不会等待输入,而是每一帧主动问系统“这个键现在有没有被按住”,从而实现流畅的连续移动。
还有一点要注意:如果你需要检测“首次按下”和“持续按住”的不同逻辑(比如按一下空格只发一颗子弹),单纯用GetAsyncKeyState很容易造成连发。这时候需要配合一个“上帧是否已按下”的标记,只有“上帧没按,这一帧按了”才触发一次发射。这块代码虽然多几行,但手感会好很多。
2.4 碰撞检测:矩形碰撞的“差一点点”哲学
碰撞检测是飞机大战的灵魂。飞机和敌机、子弹和敌机之间都要做碰撞判断,而最常见的做法就是矩形碰撞:把每个物体看作一个矩形,两个矩形如果有任何重叠,就判定碰撞。
矩形重叠的判断逻辑是:
// 用左上角坐标和宽高围出碰撞框 int aLeft = a.x, aRight = a.x + a.width; int aTop = a.y, aBottom = a.y + a.height; int bLeft = b.x, bRight = b.x + b.width; int bTop = b.y, bBottom = b.y + b.height; // 没有重叠的条件:一个矩形在另一个的左边、右边、上方或下方 if (aRight < bLeft || aLeft > bRight || aBottom < bTop || aTop > bBottom) { // 未碰撞 } else { // 碰撞! }判断条件是“一个矩形在另一个矩形完全没重叠”则没碰,否则就碰了,逻辑很直观。但实际游戏中,直接把图片的原始宽高当成碰撞框,玩起来会觉得很“冤”——明明飞机的翅膀和敌机擦了一点点边,就被判定撞死了,玩家体验非常差。
专业的做法是给碰撞框留一点余量,也就是把实际的矩形缩小几像素。比如飞机图片是60x60,碰撞框只取中间的52x52,相当于上下左右各让出4像素。这种“差一点点哲学”能让玩家觉得判定更公平,体感更好。
2.5 图片与音效资源的加载:路径问题是最隐蔽的坑
做飞机大战,背景图、飞机图、子弹图、按钮图这些资源肯定要放了。EasyX里加载图片用的是loadimage函数:
IMAGE playerImg; loadimage(&playerImg, "res/player.png", 60, 60);这里最坑的是路径。很多新手在IDE里运行程序,以为“当前目录”就是代码文件所在的目录,其实不然——对于Visual Studio来说,当前工作目录通常是项目下的.vcxproj文件所在的目录,而不是.c文件所在的目录。如果图片路径写错了,loadimage会静默失败或者直接报错,程序运行到一半就崩了。
所以建议把资源文件统一放在项目目录下的res文件夹里,然后用相对路径访问。如果实在加载不出来,可以先写死一个绝对路径试试,比如loadimage(&img, "D:/code/plane/res/player.png", ...),确认能运行之后再改回相对路径。另外别忘了,EasyX加载同一张图片多次会有内存开销,建议只在初始化时加载一次,全局保存下来,绘制时反复使用。
音效这块,Windows下可以用mciSendString播放简单的wav、mp3,但配置稍显繁琐,刚开始不必强求。先把画面和手感调好,音效最后再加也不迟。
2.6 随机数:为什么敌机总是同时冒出来
敌机生成需要随机性,所以要用rand()。但新手常常忘记设置随机种子,结果每次启动游戏,敌机出现的规律一模一样。因为rand()生成的是伪随机序列,如果你不设置种子,默认种子就是1。
正确写法是在初始化时加上:
srand((unsigned)time(NULL));这样生成的随机数序列才会随着开机时间不同而变化。不过还有一个体验上的问题:直接写rand() % WIDTH生成敌机横坐标,可能会让敌机在一段时间内非常密集地同时出现,玩家根本躲不开。我的做法是:先设一个“敌机生成倒计时”,每帧减1,减到0时生成一架敌机,然后把倒计时重置为一个范围内的随机值。
static int spawnTimer = 0; if (spawnTimer <= 0) { spawnEnemy(); spawnTimer = 30 + rand() % 60; // 每30~90帧生成一个 }这样敌机出现的间隔有波动,但不会突然变成“那谁啊,几秒钟刷了七八个”的离谱状态,玩家也更有操作空间。
3. 实操过程:从零搭建一个能跑的飞机大战
思路讲完了,下面进入实际搭建环节。我会按真实开发顺序走一遍,从打开编译器到写出第一个可玩的版本。
3.1 开发环境与工程目录:别把图片放错地方
开发工具推荐Visual Studio社区版,或者Dev-C++配EasyX。注意EasyX只支持Visual C++系列编译器,你用mingw或者gcc去链接会碰到各种乱七八糟的问题。Visual Studio安装好之后,先去EasyX官网下载对应VS版本的安装包,关掉VS再安装。
新建项目时选择“Windows桌面向导”或者“控制台应用”,我个人建议建“控制台应用”,因为调试时还能看到printf输出,方便排查问题。建完项目之后,右键解决方案里的项目名称,打开属性,找到“字符集”,设为“使用多字节字符集”,避免后面中文乱码。
然后在项目目录下建一个res文件夹,把素材放进去。素材可以自己找几个PNG或BMP图片,飞机做成60x60,子弹做成10x30,背景做成游戏窗口一样大的图。图片格式最好用PNG,EasyX对PNG支持得比较稳定。
3.2 代码结构:不该写在一个main里的逻辑
我不建议把所有东西都写在main.c里,哪怕你觉得省事。一个游戏主循环里至少要包含初始化、输入处理、逻辑更新、渲染、释放资源五种操作,你全部堆在一个函数里,一旦要调整一个细节,很容易波及无关代码。
我的文件结构大致是这样的:
main.c:定义游戏主循环,调用各个模块的入口game.h:结构体、全局变量声明、函数原型game.c:初始化、更新逻辑、渲染逻辑、销毁逻辑input.c(可选):收集按键状态res/:存放图片资源和音效
主循环里只放几行代码,逻辑非常简单:
int main() { initGame(); while (1) { handleInput(); updateLogic(); renderGame(); Sleep(16); } closeGame(); return 0; }这样一旦手感不好,你只需要去改updateLogic(),不用在main里翻几百行找逻辑。
3.3 核心功能实现细节:碰撞、移动、生成与计分
玩家移动:读取上下左右方向键,每次改变player.x或player.y,然后加边界判断。比如:
if (GetAsyncKeyState(VK_LEFT) & 0x8000) player.x -= 5; if (GetAsyncKeyState(VK_RIGHT) & 0x8000) player.x += 5; if (GetAsyncKeyState(VK_UP) & 0x8000) player.y -= 5; if (GetAsyncKeyState(VK_DOWN) & 0x8000) player.y += 5; // 边界限制 if (player.x < 0) player.x = 0; if (player.x > WINDOW_WIDTH - player.width) player.x = WINDOW_WIDTH - player.width;速度5像素/帧可以先用着,等实际跑起来再按手感微调。
发射子弹:按住空格时每隔一段时间添加一颗子弹。这里用时间间隔控制,否则按住空格会一秒发射几十颗,太夸张了:
static int fireTimer = 0; if ((GetAsyncKeyState(VK_SPACE) & 0x8000) && fireTimer <= 0) { // 在飞机头部位置生成子弹 addBullet(player.x + player.width / 2, player.y); fireTimer = 8; // 每8帧一颗 } fireTimer--;敌机移动与清理:每次更新逻辑时,遍历所有敌机,让y增加一两像素。如果敌机超出屏幕底部,就把这个节点从链表里删掉,并释放内存。这样不用担心链表越变越长。
碰撞处理:遍历所有存活的子弹和所有敌机,如果子弹和敌机的矩形有重叠,就标记子弹消失、敌机血量减一、分数增加。敌机血量归零则删除敌机节点。
3.4 关键参数设置的参考值:这些数字不是随便拍的
很多同学第一次做游戏,不知道该把飞机速度设成多少、子弹速度设成多少,结果要么飞机慢得像蜗牛,要么子弹快得像瞬移。
我给出一组我调试过比较适中的数值(窗口大小800x600):
| 参数 | 建议值 | 说明 |
|---|---|---|
| 玩家移动速度 | 5像素/帧 | 以60帧算,每秒300像素,横跨窗口约2.6秒,手感适中 |
| 子弹速度 | 10像素/帧 | 每秒600像素,一秒多就能从底部飞到顶部 |
| 敌机速度 | 2~4像素/帧 | 依敌机类型设定,一般比玩家慢 |
| 发射间隔 | 8帧 | 约每秒7~8发,适合持续输出的节奏 |
| 敌机生成间隔 | 30~90帧 | 约0.5秒~1.5秒一个,节奏比较紧张但不至于无解 |
| 玩家生命值 | 3 | 经典设定,既有压力又不至于太快结束 |
这些数值不是随便拍的。玩家速度太慢会导致躲不开敌机;太快又会觉得难以精确定位。一般原则是:玩家速度应小于子弹速度、大于敌机速度,这样玩家才能主动出击,而不是只能被动挨打。至于具体数值,你可以跑起来之后用二分法微调,一次改两三像素,找到自己最顺手的手感。
3.5 实战代码走读:核心函数怎么写才不凌乱
下面这段是“更新逻辑”的核心写法,我只展示关键部分,你替换成自己的结构体就能用:
void updateLogic() { if (g_state != PLAYING) return; // 1. 更新玩家 handleInput(); // 读键盘、移动、限制边界 fireBullet(); // 按空格发射子弹 // 2. 更新子弹位置 for (int i = 0; i < MAX_BULLETS; i++) { if (bullet[i].exist) { bullet[i].y -= bullet[i].speed; // 子弹向上移动 if (bullet[i].y < 0) bullet[i].exist = 0; // 超出屏幕则消失 } } // 3. 更新敌机位置 Enemy* p = enemyHead; while (p != NULL) { p->y += p->speed; if (p->y > WINDOW_HEIGHT) { Enemy* toDelete = p; p = p->next; deleteEnemy(toDelete); // 释放节点并做链表维护 continue; } // 4. 对每个存活敌机检测与玩家碰撞 if (checkCollision(player, *p)) { player.life--; deleteEnemy(p); if (player.life <= 0) g_state = OVER; } p = p->next; } // 5. 子弹与敌机碰撞检测 for (int i = 0; i < MAX_BULLETS; i++) { if (!bullet[i].exist) continue; Enemy* q = enemyHead; while (q != NULL) { if (q->exist && checkCollisionRect( bullet[i].x, bullet[i].y, 10, 30, q->x, q->y, q->width, q->height)) { bullet[i].exist = 0; q->hp--; if (q->hp <= 0) { player.score += 10; deleteEnemy(q); } break; } q = q->next; } } }这段代码里几个细节值得注意:第一,遍历链表时如果要删除当前节点,一定要先保存下一个节点的指针,否则就遍历丢序了;第二,碰撞检测里子弹和敌机用的是小型碰撞框,不是整张图片的大小;第三,生命值扣减和游戏结束状态的变更放在同一处,状态位不容易乱。
绘制部分相对机械,用putimage把对应图片贴在对应坐标上,再用outtextxy绘制分数和生命值。注意outtextxy默认用的是窄字符编码,如果你的工程字符集没改成多字节,中文会乱码。最简单的办法是分数用纯数字,生命值用数字或英文,先别在界面上显示中文,后期再处理。
4. 常见问题与排查技巧实录
做这个项目时,我遇到的、以及帮别人排查过的问题,翻来覆去就那么几个。我把它们列成表,再说说具体的排查思路。
| 表现 | 常见原因 | 解决方案 |
|---|---|---|
| 游戏窗口一闪而过 | 主循环没有运行,或初始化图片失败 | 检查图片路径,检查是否是控制台工程 |
| 画面疯狂闪烁 | 没有使用双缓冲 | 加BeginBatchDraw()和FlushBatchDraw() |
| 中文显示乱码 | 工程使用Unicode字符集 | 右键工程属性改“多字节字符集” |
| 飞机移动一顿一顿 | Sleep间隔不合适或帧率不稳定 | 改用每帧固定耗时,或把Sleep调低 |
| 子弹一发就是一堆 | 没有做发射间隔控制 | 加fireTimer发射冷却 |
| 敌机删除后程序崩溃 | 链表删除时指针丢失 | 删除前保存next指针或使用双指针 |
| CPU占用率极高 | 循环里没有Sleep或没有消息泵 | 主循环加Sleep(16)或Sleep(10) |
| 图片加载不出来 | 当前工作目录和代码目录不一致 | 用res/图片名.png相对路径,先试绝对路径 |
4.1 编译通过但一运行就闪退
这种问题九成是图片资源加载失败。loadimage加载失败的常见原因有三个:文件名拼错、文件路径找不到、图片格式不受支持。排查的时候,你可以在loadimage之后马上判断返回值,如果加载失败就打印错误日志,而不是直接崩溃:
if (loadimage(&img, "res/player.png", 60, 60) != 0) { printf("图片加载失败\n"); system("pause"); // 停下来看错误 }另一个常见闪退原因是malloc失败后没有判断空指针。特别是链表节点用malloc分配时,如果连续创建很多敌机导致内存耗尽,程序直接退出。建议在malloc之后加一句:
Enemy* newEnemy = (Enemy*)malloc(sizeof(Enemy)); if (newEnemy == NULL) return;防御性编程做得好,调试成本至少降一半。
4.2 文字乱码和图片加载失败
字符集问题只影响中文显示,不影响游戏逻辑。建议在项目属性里搜索“字符集”,改为“使用多字节字符集”,再重新编译。还有一个坑是:当你修改了字符集,但你用的文本文件编码是UTF-8,里面的中文字符串在编译时可能还是乱码。优先把源文件另存为GBK或ANSI编码,然后再用中文字面量。
图片加载失败的路径问题,我强烈建议在程序初始化时打印一下当前工作目录:
printf("当前目录: %s\n", _getcwd(NULL, 0));这样你就能看到程序真实认为的“当前目录”在哪里,然后把res文件夹放到那个目录下,或者调整相对路径的写法。
4.3 碰撞体感和性能问题
体感问题多半是碰撞判定过于严格。如图片本身包含透明边,肉眼看起来没碰,矩形已经重合了。解决办法是把碰撞框缩小,具体做法就是前面说的:在checkCollision时传入的矩形不是直接从图片宽高取,而是取中心区域。
我在实际项目里是这么写的:
// 玩家碰撞框:宽高仅取中间部分,缩留边距 int pLeft = player.x + 5; int pRight = player.x + player.width - 5; int pTop = player.y + 5; int pBottom = player.y + player.height - 5;飞机贴图是60x60时,碰撞框长宽就变成了50x50。这个余量对你躲避敌机来说,影响感非常明显,玩家会觉得“这一擦居然没死”,体验会舒服很多。
性能问题一般出在遍历所有子弹和所有敌机做碰撞上。子弹数量50颗、敌机数量30架时,每帧最多1500次碰撞检测,这对电脑来说完全不值一提。真正拖垮性能的是图片加载和释放的频率。比如你把飞机图片在每一帧里重新loadimage一次,那必然卡到怀疑人生。解决办法就是:所有图片只在初始化时加载一次,以后全部复用。
4.4 内存泄漏与游戏后期卡顿
用链表管理敌机,最常见的问题就是只删了节点没释放内存,导致内存越涨越高。更隐蔽的是删除节点时操作了悬空指针。我建议你在调试时把“销毁敌机”单独封装成一个函数,并对所有的链表删除路径统一走这个函数。定义如下:
void deleteEnemy(Enemy* enemyToDelete) { // 先从链表中断开 if (enemyToDelete->prev) enemyToDelete->prev->next = enemyToDelete->next; if (enemyToDelete->next) enemyToDelete->next->prev = enemyToDelete->prev; free(enemyToDelete); EnemyCount--; // 全局计数,调试时监控 }这样每次删除都做了三件事:更新前驱、更新后继、释放内存。后期你甚至可以建立一个“敌机总数”的全局计数变量,每帧判断是否异常增长,一旦发现敌机数量没减少,立刻排查是不是有节点没被释放。
4.5 存档功能:分数排行榜这样做最省事
飞机大战要不要保存最高分?要的话,最简单的方案是拿文件操作实现。游戏结束时,把当前分数写入本地文件score.txt,启动时读出来显示在右上角。
// 保存最高分 FILE* fp = fopen("score.txt", "w"); if (fp != NULL) { fprintf(fp, "%d", player.score); fclose(fp); } // 读取最高分 int highScore = 0; fp = fopen("score.txt", "r"); if (fp != NULL) { fscanf(fp, "%d", &highScore); fclose(fp); }细节是:文件读写要做好异常处理,文件打不开就做个兜底值,不要影响游戏运行。这一小段功能虽然只有十行,但足以让你体会到“文件操作在真实项目里到底怎么用”。
5. 写在最后的几点扩展建议
这个项目做完能玩之后,我建议你先别急着收工,试着做以下这几个小扩展,每个扩展都不需要推倒重来,但都能让项目从“能跑”进化到“像样”。
第一个是增加音效。可以用Windows自带的mciSendString接口播放背景音乐和爆炸音效,只需注意头文件以及链接winmm.lib`。加入音效之后,整个游戏的沉浸感立刻就不一样了。
第二个是增加Boss关卡。当分数达到某个阈值,生成一只血厚、体积大、会横向移动的Boss;Boss被击败后游戏进入下一个循环。这个扩展用到的还是已有的链表和碰撞检测,只是把敌机参数改得更极端,逻辑上基本是复用。
第三个是增加道具掉落。敌机被消灭时有概率掉出道具,玩家吃到后获得“子弹加速”“双倍分数”“护盾”等效果。这里的关键在于新增一个“道具列表”,以及道具和玩家的碰撞检测——所有机制你在这个项目里都已经实现过了,相当于把整个流程再验证一遍。
我个人在实际操作中最深的体会是:一个练手项目不在于代码量有多少,而在于你是否把一个完整的循环亲手跑通了。从设计数据结构、画一个会动的飞机、发射子弹、敌人出现、碰撞反馈、分数变化到游戏结束,每一步都会逼你把之前学过的零散知识重新组装一遍。我第一次带同学做这个项目时,对方连指针传参都理解得不透彻,等做完整个游戏,链表增删、文件读写、模块化编程这些概念全都落地了。
最后分享一个小技巧:调试的时候不要从完整功能开始,我习惯先把“玩家能移动”做出来,跑通了再加子弹,再跑通了加敌机,最后才加碰撞和计分。每加一个功能就运行测试一遍,这样就算出bug,你也能准确知道是哪一步引入的,而不是一口气写完一大堆代码,然后对着几十个报错发呆。按这个节奏走,两到三个晚上的时间,一个自己能玩的C语言飞机大战就能稳稳落地。