C语言飞机大战练手项目:结构体、链表与碰撞检测实战解析
发布时间:2026/9/25 4:22:24来源:尧图网络
简介这是一份用C语言实现的经典飞机大战小游戏完整源码包面向正在学习结构体、函数、指针及游戏循环的C语言初学者也适合希望了解如何借助第三方图形库构建小型项目的开发者。压缩包共3个文件包含源文件、编译生成的目标文件以及可直接运行的exe程序整体仅7KB体积小巧但代码逻辑完整。已有966人学习下载。通过源码可以研究游戏主循环、碰撞检测、飞机移动、子弹发射与敌人生成等核心模块并借助可执行文件快速验证运行效果配合描述中提到的SDL或Allegro等图形库思路还能进一步理解C语言与外部库的配合方式。对于希望从零动手编写入门级游戏、巩固C语言工程实践的同学而言这是一份轻量而实用的参考既便于阅读分析也适合作为课程设计或兴趣练手的起点。1. 用 C 语言写飞机大战先弄清这个项目练的是哪层东西学完 C 语言的人十个里有八个会动手写一个控制台版飞机大战。这个项目看起来只是把飞机、子弹、敌机用字符画出来实际上它把结构体、指针、链表、非阻塞输入、碰撞判定全串在了一起比背 100 段代码练习题好用得多。我这套 C 语言飞机大战源码Windows 下用 Dev-C 或 VS 都能直接编译跑起来。它教的不只是怎么写游戏循环而是让你理解一个小型游戏在没有图形引擎时如何组织数据、控制节奏、处理边界。适合刚学完指针和结构体想找练手项目的读者。2. 游戏核心架构链表管敌机、结构体管实体、循环管节奏2.1 实体与坐标系为什么用结构体而不是一堆全局变量新手写游戏最容易出现的问题就是给飞机、子弹、敌机分别定义一堆全局变量比如 playerX、playerY、bulletX、bulletY、enemyX[10]、enemyY[10]。飞机只有一个还好说子弹和敌机一多数组下标一乱代码就变成了黑匣子。我的做法是先把每个游戏实体定义成结构体并且统一坐标命名规则。控制台窗口的坐标系和数学坐标系不一样原点在左上角行号从上往下增加列号从左往右增加。如果代码里用 x 和 y你写渲染时很可能搞反导致飞机明明按了左键却往上跑。我建议所有实体都直接用 r行和 c列命名从源头杜绝混乱。typedef struct { int r, c; // 玩家位置r 是行c 是列 int w, h; // 碰撞矩形的宽和高后面检测碰撞要用 int hp; // 生命值 } Player; typedef struct { int r, c; // 子弹位置 int speed; // 每帧上移行数 int alive; // 是否存活 } Bullet;结构体语义清楚传参也方便。比如检测玩家是否被击中直接把 Player 和 Enemy 的结构体指针传进函数函数里改 r、c、hp外部就能拿到结果不需要搞一堆全局变量到处飞。对于坐标还有一个容易忽略的点控制台字符宽高比不是 1:1一个字符的高度大约是宽度的两倍所以想让敌机看起来移动速度正常垂直方向每次移动 1 行水平方向也是每次 1 列视觉上会有点偏慢。实际调的时候可以让敌机垂直速度跑 2 帧一次或者干脆依赖后面要讲的 delay 时间调整不用太纠结。2.2 敌机用链表管理插入、遍历、销毁的顺序不能乱敌机是动态生成的数量不固定这时候用固定数组就很不舒服。你开一个Enemy enemies[50]敌人一多就不够用开大了又浪费。而且数组删除一个元素要移动后面的所有元素复杂度不高但代码很啰嗦。正确做法是用单链表。每一架新敌机从屏幕顶部出现头插法最省事新节点直接挂在链表头部遍历时从 head 开始往下走就行。Enemy* spawnEnemy(int c) { Enemy *e (Enemy*)malloc(sizeof(Enemy)); e-r 0; // 从第 0 行进入 e-c c; // 固定列 e-w 3; // 敌机占 3 列 e-h 2; // 占 2 行 e-hp 1; e-next head; // 头插法 head e; return e; }我一般会搭配一个 removeEnemy 函数它接收链表当前节点的前一个节点指针把当前节点从链表中断开然后 free 掉。很多人栽在这里遍历链表时边遍历边 freefree 完了又取 cur-next野指针直接崩溃。正确顺序是先保存 next再处理节点最后把 cur 指向保存好的 next。void updateEnemies() { Enemy *prev NULL; Enemy *cur head; while (cur) { Enemy *next cur-next; // 先把下一个节点存下来 cur-r cur-speed; // 每帧下移 if (cur-r MAP_H) { // 飞出屏幕底部 if (prev) prev-next next; else head next; free(cur); // 这里才能安全释放 } else { prev cur; } cur next; // 继续处理下一个 } }这段代码里最容易忽略的是prev指针。很多人写链表遍历只留 cur删除节点时发现自己找不到前一个节点只能用头节点重新遍历。我建议从一开始就维护 prev删除节点时 prev-next 直接指向 next效率高而且不容易漏。子弹如果数量大也可以改成链表但我个人建议子弹用固定数组加 alive 标记就够了。子弹每秒生成数量有限循环遍历整个数组跳过 alive 为 0 的项代码比链表简单性能也够。2.3 游戏循环输入、更新、渲染、延时的次序就是你的节奏玩过游戏的人都知道所有实时游戏都有一个主循环。飞机大战的主循环只有四件事读取输入、更新逻辑、绘制画面、等待一段时间。顺序不能乱否则会出现输入延迟或者渲染花屏。while (running) { handleInput(); // 1. 读取键盘 if (state PLAY) { updateBullets(); // 2. 子弹向上移动 updateEnemies(); // 3. 敌机向下移动 checkCollisions(); // 4. 碰撞检测 } render(); // 5. 把画面画到控制台 Sleep(50); // 6. 等 50ms约 20 帧每秒 }Update 在 render 前面这是为了确保绘制时拿到的是最新一帧的数据。如果你先画再更新玩家按一次键画面上要下一帧才生效手感会很肉。Sleep 放在循环末尾而不是开头。放开头会导致启动时多等一次放末尾能让每一帧的间隔比较均匀。50ms 对应 20 FPS这个帧率对字符界面的飞机大战足够流畅也不会让 CPU 空转发热。如果机器性能好你可以改成 40ms也就是 25 FPS但不要低于 25 帧否者移动起来一顿一顿的。这里补充一个选型理由为什么不直接用循环忙等来计时while (GetTickCount() - last 50);这种空转会让 CPU 占用率飙到 100%笔记本风扇直接起飞。Sleep 会让出 CPU虽然时间精度不算高但对飞机大战这种游戏足够了。3. 渲染与控制输入控制台动画的两个基础3.1 光标定位从 system(cls) 到 gotoxy几乎每个新手写控制台游戏都从system(cls)开始。它的逻辑很简单清屏然后从头把整个画面重新打印一遍。缺点是两个字闪屏。画面内容一多人眼能明显看到闪烁因为清屏之后到重新绘制之间有一小段时间屏幕是空的。更好的做法是光标定位法。我只需要把光标移动到指定位置然后打印该位置的字符不碰其他区域。这样画面更新局部化闪烁几乎消失。Windows 下用 SetConsoleCursorPosition 函数void gotoxy(int r, int c) { HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); COORD pos { c, r }; // 注意X 对应列Y 对应行 SetConsoleCursorPosition(hOut, pos); }注意这段代码里的 COORD它的第一个成员是 X 轴对应控制台窗口的列第二个成员是 Y 轴对应行。很多人写的时候习惯性写成{ r, c }结果光标跑到了镜像位置调试半天发现所有字符画在了对角线方向。我在项目里干脆把所有坐标变量名定为 r 和 c就是不想让这类问题发生。有了 gotoxy再封装一个 drawChar 函数渲染代码就干净了void drawChar(int r, int c, char ch) { gotoxy(r, c); putchar(ch); }每次渲染时调用 drawChar 把玩家的飞机、每一颗子弹、每一架敌机画出来。这里要提醒一个细节上一帧画过、这一帧已经消失的对象必须用空格擦掉。否则子弹已经飞走了屏幕上还留着一个点看起来就像残影。我一般会在每次主循环开始时先把上一帧记录的旧位置用空格擦除然后再画新位置。或者直接每次渲染时把整个地图区域从头到尾刷一遍这种方式简单但会闪。擦旧画新性能更好代码也不复杂。另外还要隐藏光标。光标默认在每个字符位置闪烁显示游戏运行时光标跳来跳去非常难看。用 SetConsoleCursorInfo 把光标大小设为 1、可见性设为 FALSE 就行。CONSOLE_CURSOR_INFO cci { 1, 0 }; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cci);这个设置只需要在程序开始时做一次后面整个游戏过程中光标都不会显示不需要每帧调用。3.2 非阻塞输入kbhit 与 getch 怎么组合不会留下残键飞机大战不能等玩家按回车才处理按键它需要实时响应。标准库里的 scanf 和 getchar 都是阻塞式输入必须按完回车才返回根本没法用。Windows 下要用 conio.h 里的 _kbhit 和 _getch这是 C 语言做控制台小游戏最常见的组合。_kbhit 检查缓冲区里是否有按键等待返回非 0 说明有输入_getch 立即读取一个字符不回显也不等回车。这样主循环每次都能快速检查一次键盘有按键就处理没有就继续更新画面。if (_kbhit()) { char ch _getch(); switch (ch) { case a: player.c--; break; case d: player.c; break; case w: player.r--; break; case s: player.r; break; case : fireBullet(); break; case 27: running 0; break; // ESC 退出 } }这段代码的问题在于如果你在游戏开始前玩家按了一堆方向键这些按键全都被缓冲下来了。进入游戏后主循环会一口气把这些积压的按键全部读出来飞机瞬间冲到屏幕边缘。我一般在 _kbhit 为真时先清空缓冲区或者只取最后一个有效按键。char ch 0; while (_kbhit()) { ch _getch(); // 一直读到没按键为止 } if (ch a) player.c--;这样几个连按的按键会被合并成最后一次操作玩家从菜单进入游戏时不会出现飞机自己乱动的情况。另外方向键的处理比字母键麻烦。PC 键盘的方向键在控制台输入中返回两个字节第一个是 224或者 0第二个才是具体的扫描码。如果直接 _getch 读一次只能拿到 224屏幕上什么都不会发生。所以遇到 224 或 0 时要再读一次第二次的 ch 才是方向键扫描码。int ch _getch(); if (ch 224 || ch 0) { ch _getch(); // 第二次读取方向键扫描码 if (ch 72) player.r--; // 上 else if (ch 80) player.r; // 下 else if (ch 75) player.c--; // 左 else if (ch 77) player.c; // 右 }Windows 上 Dev-C 的 conio.h 提供的是 kbhit 和 getchVisual Studio 里则强制要求带下划线前缀的 _kbhit 和 _getch。为了两个环境都能编译我在项目头文件里加了条件宏定义后面避坑章节会详细说。4. 碰撞检测与生成节奏让玩家觉得“打得中、有挑战”4.1 矩形相交判定一像素穿模的根因飞机大战的碰撞检测最简单的实现是“坐标相等”。子弹移动到某个位置如果该位置的坐标正好和敌机坐标相等就判定命中。这个方案初看没问题实际跑起来全是穿模。原因很简单子弹一帧移动 1 行敌机也可能在移动两者最小单位是 1但它们的字符宽度不是 1。我的飞机用三行字符表示宽度是 3 列敌机通常占两行两列以上。如果只用子弹左上角那个点的坐标来判断子弹可能已经从敌机右上角擦过去了双方都没被判定命中玩家就会觉得子弹穿模了。正确做法是把每个对象当成一个矩形区域检测两个矩形是否相交。矩形相交的判断条件是两个矩形在横轴和纵轴上的投影都有重叠。int collide(int ar, int ac, int aw, int ah, int br, int bc, int bw, int bh) { return ar br bh ar ah br ac bc bw ac aw bc; }参数含义ar、ac 是 A 对象左上角的行和列aw、ah 是它的宽和高。返回值非 0 说明两个矩形有交集。这个公式不需要记推导起来很简单A 的顶部在 B 的底部之上A 的底部在 B 的顶部之下列方向同理。在碰撞检测循环里我一般这么用for (int i 0; i MAX_BULLETS; i) { if (!bullets[i].alive) continue; Enemy *e head; while (e) { if (collide(bullets[i].r, bullets[i].c, 1, 1, e-r, e-c, e-w, e-h)) { bullets[i].alive 0; // 子弹消失 e-hp - 1; // 敌机扣血 if (e-hp 0) { score 10; e-hp 0; // 后续统一回收 } break; } e e-next; } }子弹是一个点所以宽高传 1、1敌机宽 3 高 2 这样传。碰撞时机很重要先让子弹移动完再让敌机移动完最后统一检测而不是移动一个检测一次。如果移动一格就检测可能同一颗子弹在一帧内被判定命中两个敌机逻辑会乱。玩家和敌机的碰撞也走同一个函数。另外记得在碰撞计算后给玩家一个短暂无敌时间否则敌机刚擦到玩家玩家就直接死亡游戏体验很差。无敌时间可以用计数器实现setInvincible 后每帧减 1减到 0 之前不参与碰撞。4.2 生成间隔与难度曲线用计数器而不是随机数控制敌机敌机生成如果写成“每帧都有概率生成一架”比如if (rand() % 10 0) spawnEnemy(...)会出现一个明显问题同一帧连续冒出好几架敌机后面几帧又一架不出节奏完全随机玩家没法规划走位。我一般用计数器加阈值的方式。每帧累计生成间隔计数器达到阈值就生成一架然后重置计数器并且重新随机一个下一轮间隔。static int spawnTimer 0; static int spawnInterval 8; if (state PLAY) { spawnTimer; if (spawnTimer spawnInterval) { int col rand() % (MAP_W - ENEMY_W); spawnEnemy(col); spawnTimer 0; spawnInterval rand() % 4 3; // 下一架在 3~6 帧后出现 } }随机数只影响下一轮的间隔不影响当前帧是否生成。这样生成节奏既均匀又有变化。基础间隔取多少取决于你主循环的延迟如果 Sleep(50)、每帧约 50ms3~6 帧就是 150ms 到 300ms 出一架敌机这个频率前期压力适中。难度曲线不要只靠生成频率还可以叠加敌机速度。我的做法是给敌机的 speed 字段也做成动态的每局游戏经过一段时间或者消灭一定数量敌机后敌机整体下移速度从 1 提升到 2分数每增加 100spawnInterval 的基础值减 1最低不能低于 2。int difficulty score / 100; spawnInterval 8 - difficulty; if (spawnInterval 2) spawnInterval 2;这里关键是做下限保护。如果不限制分数高了之后 spawnInterval 会变成负数或者 0每次判断 spawnTimer spawnInterval 都会成立每帧生成好几架敌机游戏直接无法进行。这类边界条件写代码时就要想到。另外一个细节敌机生成的位置不要只取全地图随机列否则会有很多敌机出现在屏幕最边缘玩家根本够不到。我一般把敌机列限制在[2, MAP_W - ENEMY_W - 2]留出两边边距看起来更自然。5. 避坑飞机大战在 Windows 控制台上的六个翻车点5.1 中文字符乱码现象控制台里所有中文字符变成一堆乱码或者显示为问号。原因源文件保存成 UTF-8但 Windows 控制台默认代码页是 GBKUTF-8 的中文字节被按 GBK 解码自然乱码。这个问题在 Visual Studio 上尤其明显VS 默认保存 UTF-8 带 BOM而 Dev-C 默认 ANSI。解决有两个方案。如果游戏界面不需要中文字符全部用 ASCII 字符画飞机和敌机这是最省事的根本不需要碰编码问题。如果需要中文在 main 函数开头调用SetConsoleOutputCP(65001)告诉控制台用 UTF-8 输出再把源文件保存为 UTF-8 编码。注意两者缺一不可只改代码不保存文件格式依然乱码。提示这个设置只在当前进程内有效程序退出后控制台会自动恢复默认代码页不需要额外还原。5.2 Visual Studio 编译报错kbhit 未定义现象Dev-C 编译好好的代码放到 VS 里报错“_kbhit is not defined”getch 也一样。原因Dev-C 的 conio.h 兼容了不带下划线的旧函数名VS 里的 C 运行时只提供带下划线前缀的标准版本。解决在项目里统一做个宏映射我一般放在 common.h 里#ifdef _WIN32 #include conio.h #define kbhit _kbhit #define getch _getch #endif这样代码里都写 kbhit 和 getch两个环境都能编译。如果你用的是 Linuxconio.h 不存在需要用 termios 设置终端为非规范模式那是另一套做法这里不展开。这个资源包里的代码默认面向 Windows 控制台我建议初学者先别碰跨平台专心把业务逻辑写完。5.3 光标定位失效或程序运行中突然卡住不动现象使用 gotoxy 后字符没有出现在预期位置或者按 Windows 控制台标题栏时程序整个冻结点一下才恢复。原因第一个问题的根源是控制台窗口的缓冲区与可见窗口大小不一致。屏幕缓冲区可能比窗口大SetConsoleCursorPosition 的坐标是相对于缓冲区的而字符显示在可见区域于是视觉上位置偏了。第二个问题更隐蔽控制台默认开启了“快速编辑模式”鼠标点击标题栏或者窗口内部程序会暂停等待用户输入。解决初始化时固定窗口大小和缓冲区大小一致我一般用system(mode con cols40 lines30)设置成 40 列 30 行再调用 SetConsoleScreenBufferSize 同步缓冲区。对于快速编辑模式用 GetConsoleMode 和 SetConsoleMode 清除 ENABLE_QUICK_EDIT_MODE 标志位或者提示玩家不要点击窗口。前一种方式是根治。HANDLE hIn GetStdHandle(STD_INPUT_HANDLE); DWORD mode; GetConsoleMode(hIn, mode); mode ~ENABLE_QUICK_EDIT_MODE; // 禁止快速编辑 SetConsoleMode(hIn, mode);这段代码放在游戏初始化时执行一次之后鼠标点击不会冻结程序。注意 Windows 10 以上的新终端模拟器兼容性更好但老 CMD 环境必须做这个设置。5.4 Sleep 时间不准游戏帧率忽快忽慢现象设了 Sleep(50)实际体感有时快有时慢用 CPU 占用监控能看到跑不满。原因Windows 默认时钟精度大约 15ms 左右Sleep(50) 实际可能在 30ms 到 65ms 之间抖动帧率不稳定是常态。解决如果只是做飞机大战这个抖动可以接受不用管。但如果追求平滑滚动可以用 timeBeginPeriod 把系统计时精度临时提到 1ms使用完再改回来。timeBeginPeriod 需要链接 winmm.lib并且会影响系统全局进程结束前要调用 timeEndPeriod 还原。#include windows.h #include mmsystem.h // 链接: #pragma comment(lib, winmm.lib) timeBeginPeriod(1); // 主循环结束后调用 timeEndPeriod(1)还有一种做法是不要依赖 Sleep 定时而是记录每帧开始时间用 GetTickCount 计算已经过去的毫秒数再决定这一帧补多少等待。这个方案代码量稍大但对帧率稳定性提升非常明显。我自己写的版本会先测量一帧逻辑加渲染的真实耗时然后 Sleep(max(0, frameTime - costTime))保证总帧时间接近 50ms。5.5 链表内存只增不减内存占用越来越高现象游戏长期运行后内存占用持续上涨敌机明明已经消失内存却没有回降。原因更新敌机时只把存活标识置成了 0没有调用 free 释放节点。整个链表越挂越长遍历也越来越慢最终游戏变得卡顿。解决敌机离开屏幕或者被击杀时要真正从链表断开并 free。对照 2.2 里的 updateEnemies 写法用 prev 指针断开连接然后 free(cur)。不在一段循环里直接 free而是先保存 next 再操作。另一个容易漏的是玩家发射的子弹。子弹数组如果包含大量已经死亡但没清除的弹体遍历成本也不小。我一般会定期压缩数组或者用环形缓冲区反复覆盖旧子弹保证数组里始终有可用空位。判断内存是否泄漏最笨也最有效的方法是任务管理器里盯进程内存运行两分钟如果稳定不涨就基本没问题。提示Visual Studio 调试模式下可以用_CrtDumpMemoryLeaks()输出内存泄露报告这是排查链表问题的好工具。5.6 按住方向键飞机疯狂抖动键盘响应像抽风现象按住左键不放飞机不是连续平滑移动而是一顿一顿地跳松开按键还偶尔多走一步。原因控制台输入对标准字符键有“打字机重复”机制按住一个键会不断产生重复输入事件速度取决于系统键盘设置而不是游戏帧率所以飞机的移动频率和主循环频率对不上表现就是抖动和乱跳。解决如果用的还是 kbhit 循环读按键处理时不要每个按键事件都当成一次独立移动。改为用 GetAsyncKeyState 直接查询键的物理状态按住就是按下松开就是松开这是游戏开发更常用的方式if (GetAsyncKeyState(A) 0x8000) player.c--; if (GetAsyncKeyState(D) 0x8000) player.c;这个函数是轮询式的每次主循环查一次当前状态不需要读输入缓冲区打字机重复机制完全失效键盘响应只取决于你的主循环频率。这也是我后来做控制台小游戏的首选方案前提是只用 Windows 平台因为 GetAsyncKeyState 是 Windows API。使用这个方案时射击按键也一起处理空格键用GetAsyncKeyState(VK_SPACE) 0x8000发射逻辑就会变成按住连发。不需要连发就在里面加个射击间隔计数器。6. 从能玩到好玩状态机、音效与验证习惯6.1 状态机是后续维护的地基飞机大战这种规模的项目不用状态机也能写出来但加入菜单、暂停、结束画面之后只用 if 嵌套就会变得不可维护。枚举状态在每个状态里各自处理输入和更新是最普通的做法效果立竿见影。typedef enum { MENU, PLAY, PAUSE, OVER } GameState; GameState state MENU;主循环结构变成这样while (running) { handleInput(); switch (state) { case MENU: if (spacePressed) state PLAY; drawMenu(); break; case PLAY: updateBullets(); updateEnemies(); checkCollisions(); drawGame(); break; case PAUSE: if (pPressed) state PLAY; drawPauseTips(); break; case OVER: if (rPressed) restartGame(); drawGameOver(); break; } }这个结构里每个状态只处理自己关心的按键菜单不会执行敌人更新逻辑暂停时不推进游戏。后续加设置界面、加关卡切换只需要扩展枚举和对应分支老逻辑不用动。6.2 音效、得分与压力测试收尾的验证手段音效用Beep(frequency, duration)就能做。命中敌机时用较高频率短音效玩家受伤时用低频长音效。注意 Beep 是阻塞的会卡住主循环所以音效频率和时长都要短不能持续播放。真正流畅的做法是放在独立线程里调用但飞机大战的体量不需要做这个优化短音效就够了。得分和最高分可以落盘写入文件重新开始游戏时读取。用标准库的 fopen、fprintf、fscanf 就能实现顺手把文件读写练了。程序退出时把最高分写回下次启动读到后显示在启动画面。验证这个项目是否真的做完了我习惯用最笨的办法开着任务管理器跑十分钟把分数打高一点看内存占用是否平稳然后故意按住方向键撞敌机看碰撞判定是否在边缘出现异常最后从头开始新游戏确认菜单、暂停、结束状态切换都没问题。这三项过了飞机大战基本就立住了。我现在不管写什么控制台小游戏都会先列一个生命周期清单把实体创建、更新、销毁的时间点写清楚再写代码。链表节点该 free 的位置碰撞判定发生在哪一帧状态切换的入口在哪全部先写出来写代码时就少很多纠结。这算是我写这类项目吃过的亏总结下来的习惯。希望你拿到这份 C 语言飞机大战源码后也能按这个流程走一遍改出自己的玩法。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网