
简介飞机大战C语言.zip 提供了一套基于C语言实现的经典飞行射击游戏项目非常适合C语言初学者和游戏编程入门者便于通过一个可运行的小项目理解游戏循环、碰撞检测、键盘输入、时间驱动以及外部图形库调用等核心概念。压缩包仅含3个文件分别是 planegame.cpp 源代码、planegame.o 目标文件和 planegame.exe 可执行程序整体只有7KB体量虽小却已覆盖从编写源码、编译目标文件到直接运行的主要环节。源码中用结构体描述飞机、子弹和敌人的位置、速度与生命值并通过函数更新状态游戏循环不断检查键盘事件、刷新对象位置并渲染画面碰撞检测控制生命值变化和敌人清除。目前已有967人学习支持直接运行 exe 体验游戏效果再对照 cpp 分析实现细节并自行修改逻辑从而加深对C语言实际项目开发的理解。1. 从刷题到“飞机大战”为什么这是C语言课设里最值得复现的一个很多人在翁恺的C语言练习题里刷过冒泡排序、字符串逆序刷到后面会感觉“会写但没写出过完整的东西”。飞机大战这种控制台小游戏恰好卡在一个很妙的位置它用到的全是C语言基础——分支、循环、数组、指针、结构体、文件但又要你处理输入响应、坐标更新、碰撞检测这些刷题时根本碰不到的时序问题。说白了刷题是让你认识语法飞机大战是让你把语法捏成一个能跑的“系统”。这份资源适合两类人一是刚学完C语言基础、想找个像样的综合练习来巩固的人二是做课程设计时不想交“超市管理系统”那种套路项目的人。复现它不需要图形库一个编译器加上对控制台API的了解就能跑起来但“能编译”和“能玩”之间隔着的恰恰是C语言最值钱的那部分实践。2. 准备控制台游戏地基隐藏光标、非阻塞输入与帧循环2.1 环境与编译先确定你的运行平台写控制台游戏之前第一个要决定的事是“跑在哪个控制台上”。常见做法是Windows 下用windows.h加conio.hLinux/macOS 下用ncurses库。这两套东西的函数名不一样边界行为也不一样。如果你跟着网上教程走必须先分清楚对方写的是哪一套。在这个飞机大战资源里大概率是 Windows 控制台方案因为课程设计在 Windows 上验收最方便而且_kbhit、_getch、SetConsoleCursorPosition这套 API 对初学者最友好。编译环境我建议用 Visual Studio 或者 VSCode 配 MinGW。VSCode 里要跑 C 语言代码关键是把tasks.json里的编译命令配成gcc 源文件.c -o 输出名.exe并且保证终端是集成终端而不是外部终端否则_getch的输入行为会变得很奇怪。这里有一个很多人翻车的细节conio.h是 Windows 特有头文件如果你用的是 Linux 的 GCC会直接报“找不到 conio.h”。解决办法是换成 ncurses或者用 MinGW 在 Windows 上编译。别在环境上死磕确定平台再动手。2.2 显示层光标隐藏与坐标定位是控制台游戏的命根子控制台游戏和图形游戏最大的差异在于没有“画面”只有字符。所以游戏里所有物体的位置本质上是“屏幕上的行列坐标”。要在指定位置画东西就得用SetConsoleCursorPosition。这个函数接收一个COORD结构体里面是X和Y两个短整型字段。注意X是列坐标、Y是行坐标别写反写反了手机会在屏幕上斜着飞。#include windows.h void GotoXY(int x, int y) { COORD pos; pos.X x; // 列坐标 pos.Y y; // 行坐标 HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); SetConsoleCursorPosition(hOut, pos); }这段代码的意义是把光标移动到指定位置然后后面再用putchar或者printf输出的字符就会出现在那个位置。这里我不推荐用printf输出游戏画面因为格式控制太脆弱一个中文宽度不统一就会让整个画面错位。我一般用putchar逐字符绘制虽然代码琐碎但每个字符的位置是精确可控的。光标隐藏是另一个必做操作。默认光标在控制台里是闪烁的方块或下划线不藏起来的话你每次重绘画面用户都会看到光标在角色旁边闪观感很差而且会让屏幕上的残影更难排查。void HideCursor() { HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); CONSOLE_CURSOR_INFO curInfo; GetConsoleCursorInfo(hOut, curInfo); curInfo.bVisible FALSE; // 隐藏光标 curInfo.dwSize 1; // 光标大小设为最小范围 1-100 SetConsoleCursorInfo(hOut, curInfo); }这里有一个容易踩的坑dwSize字段必须是 1 到 100 之间的值。不少人从网上抄代码把dwSize设成 0 或者忘了初始化结果SetConsoleCursorInfo返回失败光标照样显示。我的习惯是显式赋值为 1并且在调用后检查返回值。注意这不是玄学是结构体字段校验失败导致的。2.3 输入系统非阻塞读键与帧循环的配合飞机大战的输入处理和黑板上的“用 scanf 读数字”完全不同。你不能让程序停在scanf那里等用户输入因为游戏要持续刷新画面敌机不会因为你没按键盘就停下来。所以要用非阻塞输入。Windows 下最简单的一对组合是_kbhit和_getch它们都在conio.h里#include conio.h // 返回 1 表示缓冲区里有按键返回 0 表示没有 int HasInput() { return _kbhit(); } // 从缓冲区读一个字符不等用户回车 int ReadKey() { return _getch(); }_kbhit是“非阻塞”的关键它不等待只探测。_getch也是立即返回不回显不需要按回车。但要注意_getch和getchar的差异必须拎清楚getchar是行缓冲必须在用户按下回车后数据才进入程序_getch是即时键码。两者混用是新手最容易犯的错误现象就是“按了没反应按完回车才动一下”。帧循环是控制台游戏的主心骨结构大概是这样的int running 1; while (running) { // 第一件事把这一帧里积压的所有输入处理掉 while (HasInput()) { int key ReadKey(); // 处理方向键、空格等 } // 第二件事更新所有物体的位置 // 第三件事绘制一帧画面 // 第四件事让出 CPU控制帧率 Sleep(50); }这个循环里的顺序是有讲究的先处理输入再更新状态最后绘制。如果把绘制放在更新前面那么用户看到的画面总比实际状态晚一帧体现在屏幕上就是“子弹怎么老打不中敌机”的错觉。Sleep(50)表示每帧至少间隔 50 毫秒约等于 20 帧每秒。这个帧率对控制台游戏来说足够流畅也能让子弹速度看起来稳定。但注意Sleep只是个粗糙的延时它受系统调度影响后面我会在排查章节专门说这个问题。3. 拆成三类数据结构子弹池、敌机数组和碰撞判定3.1 数据建模结构体数组还是链表飞机大战里的游戏对象有三种玩家飞机、子弹、敌机。每一种都可以用结构体表示但管理方式不一样。玩家只有一个用普通结构体变量就行。子弹和敌机则是“动态出现、消失”的对象这就涉及到一个选型问题用定长数组还是链表。我见过不少课设代码用链表做子弹每个空格发射时malloc一个节点碰撞后free。逻辑上没错但很容易出内存泄漏而且调试时你看不到全貌。在这个飞机大战项目里我强烈建议用“对象池”的思路预定义一个数组每个元素带一个live字段表示是否存活。发射子弹时找第一个live 0的槽位占用子弹出界或击中敌人后把live置 0。这样整个游戏过程中内存零分配、零释放不会有泄漏问题也方便你遍历所有子弹来做碰撞检测。#define MAX_BULLETS 32 #define MAX_ENEMIES 10 typedef struct { int x, y; // 当前位置 int live; // 1 存活0 死亡/空闲 } Bullet; typedef struct { int x, y; // 当前位置 int speed; // 每帧下落的行数 int live; // 1 存活0 死亡 } Enemy; typedef struct { int x, y; // 玩家位置 int hp; // 生命值 int score; // 当前得分 } Player;这个设计的核心参数是MAX_BULLETS。如果你把数值设得太小比如 8玩家连续按空格时子弹会被“吃掉”表现为子弹不管怎么按都只出几发。设得太大比如 256遍历开销在控制台游戏里倒不是问题但会让代码失去“对象池该多大”的思考过程。对于这个项目32 个子弹、10 个敌机是够用的。敌机速度用 int 表示“每帧下落几行”初始给 1 就行进阶后再让它动态增长。3.2 子弹发射与敌机生成复用空闲槽位有了对象池发射子弹的逻辑就非常清楚了遍历子弹数组找到第一个live 0的项把它放到玩家飞机上方然后置live 1。这个操作是 O(n) 的但 n 只有 32完全不是问题。void FireBullet(Player *p, Bullet *bullets, int max_bullets) { for (int i 0; i max_bullets; i) { if (bullets[i].live 0) { // 找到空闲槽位 bullets[i].x p-x; bullets[i].y p-y - 1; // 从机头前方发射 bullets[i].live 1; break; } } }注意这里传的是指针不是结构体。如果直接传Player p函数内部修改的只是副本玩家飞机的位置不会更新。这部分对应着C语言指针的核心考点什么时候传值、什么时候传地址。我的规则很简单结构体只在读的时候传值要修改就传指针。子弹数组本身就是指针所以不用额外取地址。敌机生成可以放在主循环里按帧数计数也可以用“每隔 N 帧生成一个”的计数器。常见做法是维护一个int spawn_counter每帧递减减到 0 时生成一架敌机并重置计数。void SpawnEnemy(Enemy *enemies, int max_enemies, int counter) { if (counter ! 0) return; // 计数器没到 0 就不生成 for (int i 0; i max_enemies; i) { if (enemies[i].live 0) { enemies[i].x rand() % 60 5; // 避免贴边留出边界 enemies[i].y 2; enemies[i].speed 1; enemies[i].live 1; break; } } }rand()是伪随机函数不写srand(time(NULL))的话每次运行随机序列完全一样。这个在调试时是好事因为可复现但给用户玩的时候就会被人发现“这次的敌机路线和上次一模一样”。另外rand() % 60 5这个写法的取值范围是 5 到 64你得根据自己控制台窗口的宽度来调整这两个数字。窗口宽 80 的话5 到 74 更合理。这里务必要让敌机出生点避开玩家初始位置否则开局敌机直接出现在玩家头顶体验极差。3.3 碰撞检测矩形相交比中心距靠谱碰撞检测是飞机大战里最容易写出“看起来对、实际不对”的代码。很多初学者喜欢用“两个中心点距离小于某个值”来判断但这在角色是长方形的控制台世界里很不直观。一个角色占两行两列另一个占一行三列中心距离该取多少不同角色的尺寸不同这个阈值就很难定得统一。我更推荐用矩形相交来检测#include stdlib.h int HitTest(int x1, int y1, int w1, int h1, int x2, int y2, int w2, int h2) { // 两个矩形在 X 轴和 Y 轴方向上的投影都重叠才算碰撞 return (abs(x1 - x2) (w1 w2) / 2) (abs(y1 - y2) (h1 h2) / 2); }这里参数的含义是(x, y)是物体中心坐标w是宽度h是高度。条件用绝对值比较两个中心的距离是否小于两者宽度之和的一半同时 Y 方向也要小于高度和的一半。这样子弹宽度为 1、高度为 1敌机宽度为 3、高度为 2碰撞判定就非常精确。注意扩大到“半宽半高”的做法相当于把角色近似成一个内接矩形视觉上比“点碰撞”宽容会让玩家感觉“明明擦边了但没打中”的抱怨少很多。碰撞检测的调用顺序也有讲究。正确流程是先让所有子弹向上移动、所有敌机向下移动再统一检测碰撞。如果你每颗子弹移动完立刻检测一次而敌机还没移动那么子弹就会“穿过”本来应该撞上的敌机因为判定时两者的相对位置差了一帧。这就是“子弹穿模”问题的根源之一后面排查章节我还会再提。4. 从单帧逻辑到可玩的一局刷新策略、状态机与收尾4.1 游戏主循环一个骨架撑起整个玩法把输入、绘制、更新串起来之后整个游戏的主循环大概长这样int main() { Player player {40, 20, 3, 0}; Bullet bullets[MAX_BULLETS] {0}; Enemy enemies[MAX_ENEMIES] {0}; int spawn_counter 20; HideCursor(); srand((unsigned)time(NULL)); while (player.hp 0) { while (HasInput()) { int key ReadKey(); HandleInput(key, player); } UpdateBullets(bullets, MAX_BULLETS); if (--spawn_counter 0) { SpawnEnemy(enemies, MAX_ENEMIES, spawn_counter); spawn_counter 30; // 重置生成间隔 } UpdateEnemies(enemies, MAX_ENEMIES); CheckCollisions(player, enemies, MAX_ENEMIES, bullets, MAX_BULLETS); DrawFrame(); Sleep(50); } printf(Game Over! Score: %d\n, player.score); return 0; }这段代码里最容易被忽略的是spawn_counter的初始值和重置值。初始值 20 表示游戏开始后大约 1 秒才出现第一架敌机给玩家一个反应时间。重置值 30 表示平均每 1.5 秒生成一架敌机。如果你想做难度递增可以把这个数值做成和player.score相关例如spawn_counter 30 - player.score / 100但要注意下限别低于 5否则屏幕上敌机密集到无法躲避游戏直接失去可玩性。另外注意printf打印游戏结束信息的位置。如果你在DrawFrame之后用printf输出控制台会把文字追加在游戏画面下方这是正常的。但如果你在DrawFrame前打印那么下一帧重绘会把 Game Over 信息覆盖掉玩家根本看不到分数。4.2 刷新策略全屏清屏 vs 局部重绘控制台游戏的画面刷新有两种思路。第一种是每帧先system(cls)再全部重画代码简单但有两个致命问题cls会清空整个缓冲区导致屏幕闪烁非常严重而且每次清屏后输出 200 个字符加上系统调用开销帧率会掉到让人难受的程度。第二种是局部重绘只输出发生变化的部分。这才是控制台游戏该有的做法。void DrawFrame() { // 只重画子弹上一帧的位置清掉新位置画上 for (int i 0; i MAX_BULLETS; i) { if (bullets[i].live) { GotoXY(bullets[i].x, bullets[i].y 1); putchar( ); GotoXY(bullets[i].x, bullets[i].y); putchar(|); } } // 玩家和敌机同理记录上一帧坐标清旧画新 }这里GotoXY(bullets[i].x, bullets[i].y 1)中的1是清除子弹上一帧的位置。因为子弹每帧向上移动一行它原来所在的位置必须被空格覆盖掉否则轨迹上会留下一条长长的“|”。这个残影问题几乎是每个控制台游戏都会遇到的解决方式就是任何物体移动后都必须在旧位置画一个空格。你可以给每个游戏对象加一个old_x, old_y字段或者在移动前暂存旧坐标。我亲手写过不下三次才养成这个习惯。4.3 游戏状态机菜单、暂停、死亡判定一个合格的飞机大战不能只有“正在游戏”一种状态。至少要有菜单态、运行态、暂停态、结束态。C 语言里用enum来表示这几态主循环里用switch分发逻辑这是最清晰的做法typedef enum { MENU, PLAYING, PAUSED, GAME_OVER } GameState;状态机的核心价值在于不同状态下同一键盘按键的含义完全不同。比如在 PLAYING 状态下按P进入暂停在 PAUSED 下按P或Esc退出暂停在 MENU 状态按空格开始游戏在 GAME_OVER 状态按R重新开始。如果不用状态机你会写出一堆if (state 1 || state 2)的嵌套判断代码很快变成一锅粥。用状态机还有一个好处UpdateEnemies只在 PLAYING 状态下执行Enemies移动逻辑不会被 PAUSED 状态意外触发。暂停时最典型的 bug 就是敌机还在下落、子弹还在飞玩家按暂停形同虚设。对于死亡判定我采用的是“碰撞即扣血扣完即结束”。用player.hp作为主循环条件听起来直观但如果你做到了状态机主循环条件应该改成state ! GAME_OVER因为重新开始时要重置hp。一个隐藏的坑是敌机飞出屏幕底部后如果不处理它仍然占着对象池里的live槽位导致新敌机无法生成。正确做法是每帧检查y 窗口高度直接置live 0。不然玩着玩着敌机越来越少你以为是难度下降实际上是槽位被幽灵敌机占满了。5. 常见问题与排查五个让课设原地爆炸的坑5.1 光标隐藏无效闪烁的方块一直跟着飞机跑现象调用HideCursor()后光标仍然可见或者在本该显示飞机的坐标处有一个闪烁的方块。原因CONSOLE_CURSOR_INFO结构体里dwSize字段被设为 0 或未初始化。Windows API 校验这个字段必须在 1 到 100 之间0 会直接导致调用失败但很多人不会检查返回值所以错误被静默吞掉了。解决在SetConsoleCursorInfo之前显式把dwSize设为 1并检查返回值。另外确认你隐藏的是正确的标准句柄STD_OUTPUT_HANDLE如果你在函数里取得的是错误句柄隐藏操作也会无声失败。5.2 子弹“穿”过敌机明明画面上都重叠了却没命中现象子弹和敌机视觉上已经重合但碰撞检测没有触发子弹直接飞过去了。原因最常见的有两种。第一碰撞检测使用的是中心点距离法而角色尺寸不同导致阈值不对第二你的代码在“子弹更新”和“敌机更新”中间穿插了碰撞检测判定时两者的位置不是同一帧的状态。后者是时序问题不是数学问题。解决先用矩形相交法替换中心距离法。再确认更新顺序是“所有子弹移动 → 所有敌机移动 → 统一检测碰撞”不要拆开写更不要每颗子弹移动完立刻在循环里检测。5.3 按方向键时角色跳了两格或者画面里出现奇怪的字符现象按一下方向键飞机移动了两次或者屏幕上出现一个带方块符号的字符。原因方向键在 Windows 控制台里属于扩展键_getch第一次返回的是 0xE0 或 0x00 的前缀码第二次才返回真正的按键码。如果你只调用一次_getch程序会把前缀码当成普通按键处理或者在处理完真正键码后又把前缀码处理一遍导致移动两次。解决在读键时判断如果返回值为 0xE0 或 0x00就再调用一次_getch读取真实键码int key ReadKey(); if (key 0xE0 || key 0) { key ReadKey(); // 真实键码 }5.4 画面残影越来越重整个屏幕一团乱现象飞机飞过之后原来的位置留下一个字符或者子弹的轨迹变成一条长竖线时间越长越乱。原因移动物体后没有清除旧坐标。控制台不像图形界面会主动刷新背景你画了什么它就留什么。所有物体在移动前都没存旧坐标旧位置不填空格就会出现残影。解决在DrawFrame里对所有存活物体先GotoXY(旧坐标)画空格再GotoXY(新坐标)画新字符。如果你没有存旧坐标最简单的补救是每次更新状态前把当前坐标视为旧坐标更新后再绘制。我给每个物体加一对old_x和old_y字段一劳永逸。5.5 Sleep(50) 并没有让游戏稳定在 20 帧现象在性能不同的电脑上游戏速度明显不一样。新电脑上飞机飞得像开了加速器旧电脑上慢吞吞且时快时慢。原因Sleep(50)只是“至少”等待 50 毫秒实际执行时间还要加上绘制和更新的耗时。机器性能不同每帧实际耗时自然不同。更准确的说法是Sleep是系统级调度行为不是实时定时器。解决用高精度计时器计算本帧实际耗时再调整 Sleep 的时间让“更新时间 Sleep 时间”尽量等于固定帧长。Windows 下用GetTickCount64或clock()做相对简单的实现进阶用QueryPerformanceCounter。我一般这样写DWORD frame_start GetTickCount64(); // ... 更新与绘制 ... DWORD frame_cost GetTickCount64() - frame_start; if (frame_cost 50) { Sleep(50 - frame_cost); // 补足到 50ms }这样关键动作的帧耗时才稳定在 50ms 左右游戏速度不会因为你换了台高配电脑就翻倍。6. 进阶文件存档、难度曲线与调试日志的验证习惯6.1 把最高分和设置写进文件课程设计做到“能玩”只是及格加上最高分存档才算完整。最高分需要用文件保存这正好把 C 语言的文件操作融入项目里。这里有一个选型点用文本文件还是二进制文件。文本文件的优势是打开就能看、能改适合交作业时让老师直观看到内容二进制文件的优势是读写结构体方便。我建议最高分用二进制方式读写整型变量代码最简洁void LoadBest(int *best) { FILE *fp fopen(best.dat, rb); if (fp NULL) { *best 0; // 首次运行没有存档 return; } fread(best, sizeof(int), 1, fp); fclose(fp); } void SaveBest(int best) { FILE *fp fopen(best.dat, wb); if (fp NULL) return; // 磁盘只读等情况要容错 fwrite(best, sizeof(int), 1, fp); fclose(fp); }注意fopen的打开模式写文件用wb而不是w因为w是文本模式在 Windows 下涉及换行符转换写整数没问题但读别的数据可能出偏差。fread读取时先判断fopen是否失败首次运行没有存档文件时必须给best赋初始值否则你会读到一个未初始化的垃圾值最高分变成几千亿。这两个函数的验证方式也很直接跑一局游戏拿个分数退出再跑一局看最高分是否保留。这个过程不用写测试代码但你自己得走一遍“先存档、再读档”的完整流程也就是我常说的“验证一次等于十次调试”。6.2 难度曲线别让玩家刚上手就死难度曲线用公式来表达最简单的是每得 100 分敌机速度加 1生成间隔减 2。这里的关键是加下限否则后期屏幕上全是飞机游戏失去策略性。我在项目里用的临界值是生成间隔不低于 5 帧。int get_spawn_interval(int score) { int interval 30 - score / 100; if (interval 5) interval 5; return interval; }速度同理enemy.speed 1 score / 200上限 3 就行。控制台上 3 行每帧的下落速度已经很快了再快就超出玩家的反应极限。这里的参数没有放之四海而皆准的值必须自己试玩三局再调。这就是为什么我把“可复现”看得比“写出来”更重你调参数时必须知道你上一局用的是哪一组数值。为了做到这一点我强烈建议在项目里加一个调试开关用宏或者变量控制是否打印 FPS 和帧耗时。在游戏循环里加几行判断输出到屏幕角落或者直接输出到控制台标题不影响游戏画面if (debug_enabled) { // 把帧耗时的信息写到标题栏玩的时候随时能看到 char title[64]; sprintf(title, FPS:%d, current_fps); SetConsoleTitleA(title); }这一步是验证新功能有没有破坏原有体验的“后悔药”。比如你改了一版碰撞检测觉得帧率可能掉了不需要靠感觉看一眼标题栏的 FPS 就知道结论。6.3 用调试日志代替“感觉不对劲”调试控制台游戏最难受的一点是画面刷新太快你根本来不及观察某个对象的行为。加断点也不现实因为每帧都有几十个对象在动。我的习惯是维护一个简单的日志数组记录关键事件发生的时间点和坐标void LogEvent(const char *event, int x, int y) { if (!log_enabled) return; // 追加写入 debug.log方便事后复盘 FILE *fp fopen(debug.log, a); if (fp) { fprintf(fp, [score%d] %s at (%d,%d)\n, player.score, event, x, y); fclose(fp); } }调用点放在碰撞命中、敌机生成、玩家受伤这些关键位置。跑几局之后打开debug.log你能清晰看到“子弹和敌机在什么坐标发生了碰撞”、“是不是有敌机飞出屏幕后还占着槽位”。这比你在循环里临时加输出强得多因为游戏过程中刷屏的输出你是看不完的写到文件里才能慢慢分析。从那以后我每写完一个模块都强制自己先跑三局并打印一次调试日志再做边界输入测试——比如连续按空格 100 次会不会数组越界、玩家移动到窗口边缘会不会溢出坐标。这些边界多测一次就能少一次在验收现场翻车的风险。希望帮到你。本文还有配套的精品资源点击获取