首页 / 资讯中心 / 文章详情

C语言飞机大战项目实战:从链表到碰撞检测的EasyX游戏开发全解析

C语言飞机大战项目实战:从链表到碰撞检测的EasyX游戏开发全解析 ★ FEATURED ARTICLE
手写一个能跑、能玩、还能拿出去当课程设计的小游戏大概是每个学C语言的人都动过的念头。我当年也是从“飞机大战”入的门一个窗体、几块矩形、一串链表最后做出来的东西虽然简陋但从结构体到指针、从链表到文件读写全给串起来了。这篇文章就把整个项目的设计思路、关键代码和调试过程完整拆开讲适合正在学C语言、想拿小项目练手或者马上要交课程设计的朋友。整个项目完全基于C语言和EasyX图形库实现源码结构不复杂核心逻辑就几百行但覆盖的知识点相当齐全结构体管理游戏对象、链表动态维护敌机、矩形碰撞检测、双缓冲绘图、键盘非阻塞输入还有文件存档。你把它跑起来之后再回头看C语言课本里的那些概念感觉完全不一样。1. 项目全貌这个游戏到底做了什么1.1 玩法设计与功能清单先明确一下游戏是什么样屏幕上方会不断生成敌机向下移动玩家控制己方飞机左右上下移动自动发射子弹子弹击中敌机则得分敌机撞到玩家则扣命生命值归零游戏结束。整个循环就是“生成敌机 - 移动 - 发射子弹 - 检测碰撞 - 更新画面”看起来简单但这其实是一个完整游戏的核心骨架。具体功能我列一下方便你做对照玩家飞机方向键控制上下左右移动子弹自动连发。敌机系统随机从屏幕顶部生成以一定速度下落可设置多种速度和大小。碰撞检测子弹与敌机碰撞、敌机与玩家碰撞均采用矩形碰撞。得分与生命每击毁一个敌机加分碰撞玩家扣命命归零游戏结束。最高分记录用文件读写保存历史最高分下次启动仍然有效。游戏状态包含开始界面、游戏运行中、结束画面三个状态。1.2 技术选型为什么用EasyX而不是其他方案做C语言图形小游戏摆在你面前的方案其实有好几种控制台字符画、EasyX图形库、SDL跨平台库甚至用Win32 API硬写。我最终选了EasyX核心原因是它和C语言配合最省事。对比一下你就能明白方案上手难度效果适用场景控制台字符画最低粗糙但能体现逻辑练习语法和算法EasyX低2D图形适合小游戏课程设计、初学者项目SDL中高跨平台性能好正式开源游戏项目Win32 API较高系统级绘制底层编程练习你如果只是想在控制台里打印几个字符那学不到图形绘制和双缓冲这些概念直接上SDL又要折腾环境配置很容易劝退新手。EasyX本质上是封装了Windows GDI绘图接口initgraph一行代码就能创建一个窗口circle、rectangle、fillrectangle这些函数跟画图板一样直觉化完全不用碰底层的设备上下文。我的建议是用EasyX把游戏逻辑跑通一次再回头去理解SDL或者Unity你会有一种“原来如此”的通透感。2. 核心数据结构与关键机制拆解2.1 用结构体组织游戏对象游戏里牵扯到三类核心对象玩家飞机、子弹、敌机。你当然可以用一堆散落的变量去记录它们的位置和状态但那样的代码写到后面就是一锅粥。我在项目里为每个对象定义了对应的结构体把相关的属性打包在一起代码清晰度会提升很多。先看玩家和子弹的结构体定义// 玩家飞机 typedef struct { int x, y; // 中心坐标 int width, height; // 飞机的宽高 int lives; // 剩余生命 int score; // 当前得分 } Player; // 子弹 typedef struct { int x, y; // 子弹中心坐标 int speed; // 移动速度 int active; // 1表示存活0表示已失效 } Bullet;这里有个细节坐标我统一用“中心点”而不是“左上角”来记录。为什么因为绘制矩形时我们需要左上角坐标但坐标运算时用中心点更符合直觉比如玩家左右移动时直接改player.x就行碰撞检测也只需要计算两个中心点的距离。最后绘制前做个减法转换即可一举两得。子弹用了固定数组Bullet bullets[MAX_BULLETS]配合active标记位来管理存活状态。为什么这里不用链表因为子弹数量有限且频繁创建销毁用数组加标记的方式逻辑简单也不容易出内存泄漏的问题。2.2 敌机管理链表是这道题的重点敌机和子弹不一样它生成的频率和数量是动态变化的如果用固定数组你需要预先分配很大的空间还得频繁判断哪些位是空的。我这里用了链表来管理敌机每次生成新敌机就插入一个节点敌机被击毁或飞出屏幕就删除节点。这个过程把C语言里最核心的指针操作给练透了。定义和插入节点的代码// 敌机节点 typedef struct EnemyNode { int x, y; // 位置 int width, height; // 尺寸 int speed; // 下落速度 int alive; // 0表示待删除 struct EnemyNode* next; // 指向下一节点 } EnemyNode; // 在链表头部插入新敌机 void addEnemy(EnemyNode** head, int x, int y, int speed) { EnemyNode* newNode (EnemyNode*)malloc(sizeof(EnemyNode)); newNode-x x; newNode-y y; newNode-width 40; newNode-height 30; newNode-speed speed; newNode-alive 1; newNode-next *head; *head newNode; }为什么用头插法因为头插法的时间复杂度是O(1)不需要遍历链表找尾节点代码也最简洁。你如果把新节点插到尾部每次生成敌机都得从头遍历一遍敌机数量多了之后开销会被放大。游戏每帧更新时我们遍历链表把所有敌机的y坐标加上速度模拟下落。同时判断每个节点的alive标记和是否飞出窗口底部如果满足删除条件记录要删除的节点然后通过前一个节点的next指针跳过它最后free释放内存。这里要特别提醒在链表中遍历时删除节点容易丢后续节点一定要用一个临时指针先保存next再删当前节点。我用删除链表中所有死亡敌机的代码做示范void removeDeadEnemies(EnemyNode** head) { EnemyNode* prev NULL; EnemyNode* curr *head; while (curr ! NULL) { EnemyNode* next curr-next; // 先保存下一个节点 if (curr-alive 0 || curr-y WINDOW_HEIGHT) { if (prev NULL) { *head next; // 删除的是头节点 } else { prev-next next; // 断开当前节点 } free(curr); } else { prev curr; } curr next; } }2.3 碰撞检测为什么“碰上了”又好像没碰上碰撞检测是整个游戏最核心的算法。常见的碰撞检测有圆形碰撞和矩形碰撞两种。圆形碰撞需要计算两点间的距离矩形碰撞只需要比较坐标差运算量更小也更适合飞机大战这种大体上由矩形对象组成的游戏。我的实现方式是计算两个矩形中心点的横向距离和纵向距离如果横向距离小于两个矩形宽度和的一半纵向距离小于两个矩形高度和的一半则说明二者相交。对应代码如下// 矩形碰撞检测a和b分别为两个对象的中心坐标、宽高 int isCollide(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { if (abs(ax - bx) (aw bw) / 2 abs(ay - by) (ah bh) / 2) { return 1; } return 0; }这个判断逻辑其实很像两个人擦肩而过横向距离小于两人身宽之和的一半纵向距离小于两人身长之和的一半那必然是撞上了。如果你把这里的坐标系画在纸上就更容易理解。实操中有一个坑直接用整个飞机的宽高做碰撞矩形视觉上会觉得“还没碰到就判定撞了”。这是因为飞机的图片或矩形边缘往往有一些透明区域或装饰性的尖角。解决方法是留安全边距在实际判定时把碰撞框调小一点。比如敌机宽度是40判定时只取30int collideW enemy-width - 10; // 预留5像素误差 int collideH enemy-height - 10; if (isCollide(player.x, player.y, player.width - 10, player.height - 10, enemy-x, enemy-y, collideW, collideH)) { // 扣命、销毁敌机 }这种“预留位”的思想在游戏开发里叫碰撞框微调做2D格斗游戏时尤其常见很多看起来手感极好的游戏秘密全在碰撞框的宽窄调整上。3. 从零搭建游戏主循环与关键模块实现3.1 游戏主循环所有游戏的心脏游戏本质就是不断重复三件事处理输入、更新逻辑、绘制画面。这个循环只要不退出游戏就一直在跑。我先把主流程框架贴出来#include graphics.h #include conio.h #include stdlib.h #include time.h #define WINDOW_WIDTH 640 #define WINDOW_HEIGHT 480 int main() { initgraph(WINDOW_WIDTH, WINDOW_HEIGHT); srand((unsigned)time(NULL)); // 初始化玩家、子弹、敌机链表等数据 // ... BeginBatchDraw(); // 开启双缓冲 while (!gameOver) { // 1. 处理输入 handleInput(); // 2. 更新游戏逻辑移动、碰撞、生成敌机 updateGame(); // 3. 绘制画面 drawGame(); Sleep(10); // 控制帧率 FlushBatchDraw(); // 将绘制内容一次性显示到屏幕 } EndBatchDraw(); closegraph(); return 0; }Sleep(10)是控制游戏速度的关键。如果不加暂停程序会以CPU允许的最快速度疯狂刷新游戏速度快到根本没法玩。加了Sleep之后每帧大约10毫秒算下来游戏大概是100帧每秒。不过要注意Sleep的精度在Windows下并不高如果需要精确帧率更好的做法是用GetTickCount或高精度计时器计算每帧的真实耗时然后在下一帧做补偿感兴趣的同学可以自己扩展。这里要重点解释一下双缓冲。你如果尝试过不用双缓冲直接画会看到窗口疯狂闪烁。原因是每次while循环里清理屏幕、画图形、显示结果是一系列操作如果每一步都直接输出到屏幕用户就会看到图形被逐笔绘制的过程闪烁感就来了。双缓冲的做法是先把所有图形画到内存中的缓冲区里全部画完再一次性拷贝到屏幕上。这就好比你在厨房里把菜全部做好摆盘最后才端出去食客只看到完整的菜不会看到你切菜的过程。3.2 玩家控制与子弹发射的实现细节游戏输入这块需要用非阻塞方式检测键盘不能让程序卡在getchar()上等用户输入。我用了_kbhit()和_getch()两个函数来检测键盘状态。_kbhit()的作用是检查键盘缓冲区中是否有输入如果有则返回非零值。配合_getch()读取按键值就能实现实时响应。这里有个历史原因要说清楚这两个函数在标准C语言中是kbhit()和getch()但在Visual Studio的C运行库中它们被加了前导下划线变成了_kbhit()和_getch()因为下划线开头的是编译器私有函数。你在别的编译器里编译时可能要去掉下划线才行。方向键在_getch()里不是普通字符而是两个字节0xE0或0x00之后跟一个扫描码。代码里需要先读掉第一个字节再读第二个字节否则方向键会被识别成两个独立的按键。我示范一下处理逻辑void handleInput() { while (_kbhit()) { int key _getch(); if (key 0xE0 || key 0x00) { // 方向键是扩展键 key _getch(); switch (key) { case 72: player.y - 5; break; // 上 case 80: player.y 5; break; // 下 case 75: player.x - 5; break; // 左 case 77: player.x 5; break; // 右 default: break; } } else if (key 27) { // Esc键退出 gameOver 1; } } }子弹发射我是放在更新逻辑里的每帧检查一个冷却计数器如果计数器归零且子弹数量未满就生成一颗新子弹子弹的x坐标等于玩家飞机的中心点y坐标等于玩家的顶部。为什么要用冷却时间而不是每按一次空格发射一颗因为自动连发是这类小游戏的标准手感玩家只需要专注移动躲避射击交给系统自动完成。冷却时间设置为150毫秒左右手感比较适中太快屏幕全是子弹反而没有压力。3.3 敌机生成与计分随机数和文件存档敌机生成讲究一个“节奏感”。如果敌机生成太快玩家根本没有喘息机会如果生成太慢游戏又显得无聊。常见的做法是每隔一定帧数生成一个敌机同时随着游戏得分的提升逐步缩短生成间隔、提高敌机下落速度这就是最简单的难度曲线。敌机生成的核心代码如下static int spawnTimer 0; spawnTimer; if (spawnTimer currentSpawnInterval) { spawnTimer 0; int x rand() % (WINDOW_WIDTH - 80) 40; // 避免贴边 int speed rand() % 3 2; // 速度2~4 addEnemy(enemyList, x, -30, speed); // 从顶部外进入 }随机数要放在srand((unsigned)time(NULL))初始化之后才有意义否则每次运行游戏的敌机位置都是一样的。这一点很多新手会踩坑觉得“随机”不随机其实就是忘了播种。计分和最高分保存用了文件读写正好把课本上的fopen、fwrite、fread用上了。游戏开始时读取最高分int highScore 0; FILE* fp fopen(score.dat, rb); if (fp) { fread(highScore, sizeof(int), 1, fp); fclose(fp); }游戏结束时若当前分数高于最高分就写回文件if (player.score highScore) { FILE* fp fopen(score.dat, wb); if (fp) { fwrite(player.score, sizeof(int), 1, fp); fclose(fp); } }注意fread和fwrite都以二进制方式操作比使用格式化文本更高效也不用考虑数字To字符串的转换问题。文件读写这种操作你在课本上练十遍都不如按下“重新开始”按钮、看着最高分真的被记住的那一刻来得有成就感。3.4 渲染绘制怎么把“飞机”画得像样渲染部分我用了两种方案。第一版全部用基础图形绘制玩家飞机画成一个绿色的等腰三角形敌机用红色矩形子弹用黄色小竖条。优点是零素材依赖代码拷贝过去就能跑缺点是视觉效果非常朴素只适合练手。第二种方案是加载外部图片比如用loadimage函数从PNG或JPG文件中加载飞机素材然后用putimage绘制到窗口。如果你的素材有透明背景EasyX还提供了putimage配合透明颜色处理的技巧。但使用外部图片有两个问题一是图片版权要留意二是loadimage对图片格式和路径有要求路径写错程序会直接崩溃。这里建议初学者先用图形绘制把游戏逻辑跑通之后再换素材两个方案其实互补。我画玩家飞机时用的是一组直线围成的三角形// 绘制玩家飞机示例 setfillcolor(GREEN); POINT pts[] { { player.x, player.y - 20 }, { player.x - 15, player.y 15 }, { player.x 15, player.y 15 } }; solidpolygon(pts, 3);游戏画面绘制顺序也讲究先画背景再画敌机再画子弹最后画玩家最后绘制计分信息。因为后画的图形会盖住先画的图形所以玩家的飞机要最后画保证它始终显示在最上层。这和Photoshop的图层逻辑一模一样。4. 调试与运行常见问题排查实录4.1 编译环境问题下划线函数和图形库链接我在本地调试时用的是Visual Studio第一次编译直接报了一堆错误全是函数未定义。排查下来有几个经典问题这里全部列出来现象原因解决办法kbhit未定义VS要求用下划线开头的私有函数改成_kbhit()getch未定义同上改成_getch()scanf不安全编译报错VS强制要求安全函数改用scanf_s()或开头加#define _CRT_SECURE_NO_WARNINGS找不到graphics.h没有安装EasyX到EasyX官网下载安装包按对应VS版本安装链接时WinMain冲突控制台程序和窗口程序入口不同在项目属性里把子系统设为控制台graphics.h链接失败是最容易卡住的。EasyX的安装包需要根据VS版本手动选择装完之后它会把头文件和库文件放到VS的对应目录下。如果你用的是VS2022就选VS2022对应的安装选项装完之后重启VS才能生效。4.2 中文乱码问题游戏界面上要显示“得分”“游戏结束”这些中文字我第一次运行时满屏都是乱码。原因在于源文件的编码格式和EasyX默认的字符集不一致。VS默认使用GBK编码保存文件如果你的源文件是UTF-8格式中文字符串在运行时就会乱。解决办法有两种最简单的是用宽字符函数和宽字符串代码如下settextstyle(30, 0, _T(宋体)); outtextxy(100, 100, _T(得分: 100));_T()宏会自动适配宽窄字符配合settextstyle使用。还有一种是在代码开头加上system(chcp 936)强制控制台使用GBK编码但这治标不治本换了环境又会变。4.3 画面闪烁与游戏卡顿前面提到过不用双缓冲会闪烁。如果你技术上已经用了BeginBatchDraw但画面还是偶尔闪原因大概率是FlushBatchDraw调用得太频繁或者绘制时使用了大量的透明图像处理。再检查一下Sleep是否在大循环里被某些耗时操作绕过。绘制大量图片时建议用屏幕裁剪或只重绘变化区域避免整屏刷新画面会顺滑一些。至于卡顿最大的坑是malloc和free使用不匹配。比如敌机链表中每个节点分配的内存释放次数错了重复释放会产生未定义行为轻则崩溃重则内存泄漏。建议在删除节点时打印一条日志看到每个节点都被正确释放心里才踏实。4.4 链表操作野指针与死循环链表删除节点是最容易出问题的环节。我在调试中遇到过两次死循环第一次是在遍历链表时直接free了当前节点但curr又指向已释放的内存第二次是没有处理头节点删除的情况导致head变成一个野指针整个链表断掉。排查这类问题我的建议是在游戏循环外单独写一个测试函数生成固定数量的节点执行插入和删除操作每次操作后遍历打印链表的长度和节点地址。把这个小测试跑通了再放回游戏里你会省下大量调试时间。问题可能原因排查与修复程序闪退数组越界检查敌人的出生坐标是否超出窗口确保rand()%在合理区间敌机消失删除节点时丢链用临时变量保存next指针再释放内存分数不增加碰撞检测判定太严打印碰撞帧的坐标手动判断是否触发最高分未更新文件没读写权限检查当前程序目录是否可写以管理员身份运行5. 如何把项目做得更完整优化与扩展方向飞机大战做到能跑能玩只是第一步。很多同学做完基础版本后想知道项目怎么继续“值钱”起来。我这里给出几个切实可行的方向你可以按需选做。5.1 使用真实素材替换自绘图形基础版本用基础图形绘制适合逻辑验证。但如果你想把它做成一个“看起来像样”的课程设计换素材是最直接的办法。你可以在网上找一些免费可商用的PNG素材或者自己用画图软件抠图然后用loadimage加载。加载图片后用putimage带透明色参数绘制飞机的锯齿感和方块感立刻消失。关键是注意图片文件夹路径。我的做法是在项目文件夹下建一个res目录存放所有素材用相对路径访问。如果用绝对路径代码换到别人的电脑上就会因为路径不一致而崩溃这一点在交作业时尤其致命。5.2 增加道具与Boss系统道具系统是“飞机大战”扩展的经典方向。你可以定义道具类型比如加速弹、护盾、炸弹让被击毁的敌机有一定概率掉出道具。道具从屏幕上方落下玩家碰到后触发效果这就需要在结构体中增加一个type字段并在碰撞检测后通过switch分发不同逻辑。Boss的设计会更复杂一些需要给Boss增加血量条和多个阶段。我建议的做法是定义一个独立的Boss结构体拥有比普通敌机更大的碰撞框同时周期性发射子弹。Boss每掉落一定血量会切换攻击模式这种“阶段转换”机制是很多射击游戏的核心爽点。C语言实现阶段转换只需要用一个phase整数变量加上switch判断即可难度不大但项目档次会提升许多。5.3 用动态数组或对象池替代链表我前面推荐用链表练指针但从工程角度看频繁malloc和free会产生内存碎片也可能造成性能抖动。另一种常见方案是动态数组用realloc扩容更高级的做法是对象池预分配一个足够大的数组用空闲链表维护可复用节点。你做到这一步相当于从“能跑”走向“跑得稳”。可以自己给两种方案各写一版监控运行时的帧率和内存占用用数据做对比。这个过程对理解数据结构和性能优化非常有帮助也是在面试中能跟面试官聊起来的东西。5.4 把游戏体验打磨到“舍不得关掉”还有一个方向不要忽略——游戏手感。很多小游戏逻辑没问题但玩家玩两局就关了多半是手感不行。手感这个词听起来虚拆开看就是三件事灵敏度、反馈、节奏。灵敏度是移动速度是否和人的反应匹配反馈是击中敌机时有没有加分飘字、爆炸特效、屏幕震动节奏是敌人密度和玩家火力之间的平衡。你可以给代码增加一个简单的粒子系统来模拟爆炸效果敌机被击毁时生成几个小固体圆向外扩散并逐渐消失整个过程只需要一个粒子结构体数组和几十行代码。加分飘字也可以做在得分位置向上移动一行文字同时淡出。这些细节加起来会让整个游戏有质的飞跃。最后再分享一点经验做完这个项目后我的一个直观感受是C语言不是靠背知识点学会的是靠一个又一个真实问题逼出来的。你在写飞机大战时会遇到内存错误、逻辑边界、随机数分布、碰撞判定各种问题每修一个问题你对C语言的理解就深一层。不要怕代码写得丑第一版能跑再谈优化。另外调试时善用打印输出窗口上画不出来的信息用printf打到控制台或者写到日志文件里定位问题的速度会快很多。这个小习惯比任何调试器都实惠。如果你的最终目标是做游戏开发C语言这门课打下的内存和算法底子会是你未来用Unity或虚幻引擎时最大的底气。
阅读完成 · 觉得有帮助?
咨询建站