简介:飞机大战C语言版.zip是一份使用C语言实现的飞行射击游戏源码包,适合正在学习C语言基础、对游戏编程感兴趣的读者用作练手项目,帮助理解结构化编程与小型游戏的组织方式。压缩包共3个文件,包含源码文件(.cpp)、编译生成的.exe可执行程序以及.o目标文件,整体仅7KB,轻量简洁,便于快速阅读和调试;运行exe可直接体验游戏效果,对照源码则可观察游戏循环、碰撞检测、对象状态更新等关键实现。已有967人下载学习,人气不俗。通过研读这份源码,可以学习如何用结构体或类表示飞机、子弹和敌人,如何捕捉键盘输入控制移动,以及如何组织主循环让游戏持续运行;同时也能了解C语言项目从编写、编译到最终生成可执行文件的基本流程。对初学者来说,这是一份麻雀虽小、五脏俱全的入门资料,有助于将语法知识落到一个完整的小游戏中。
1. 飞机大战(C语言).zip:一份课程设计里最少要写多少行代码
“飞机大战(C语言).zip”这种压缩包,在大学机房的 U 盘里、网盘的课程设计文件夹里反复出现,通常解压出来就是几个 .c 文件加一张运行截图。它和“图书管理系统”“贪吃蛇”一起,常年霸占 C 语言课程设计的题库。为什么这个题目这么受欢迎?因为它小到能在一周内写完,又刚好把数组、结构体、指针、随机数、键盘输入这些 C 语言基础代码的重点全部覆盖。这篇笔记讲的是这套项目最常用的一套实现方案:基于 Windows 控制台和 C 标准库,不依赖图形引擎,新手能照着写,熟手能照着改参数调难度。适合正在做课程设计、或者想借一个跑得起来的游戏复盘 C 语言的人。
2. 游戏主循环与双缓冲渲染:控制台不闪屏的底层原理
2.1 主循环的三段式结构:输入、更新、绘制
控制台游戏没有图形引擎帮你管理场景,所有逻辑都发生在你自己写的那个 while(1) 里。飞机大战的工作量,拆到底就是三句话:读键盘,算位置,画画面。这三句话在代码里对应主循环的三个阶段,顺序不能乱:先读取输入,再根据输入更新所有对象的位置,最后才把结果画到屏幕上。如果先把画面画了再去读输入,玩家会感觉按键慢半拍,这几乎是课程设计里手感差的头号原因。
难点其实不在“写循环”,而在“循环里维护一堆对象”。飞机大战里最少同时存在三类对象:玩家、子弹、敌机。你要给每一类写更新函数和绘制函数,再在主循环里挨个调用。这种按对象拆函数的思路,比把所有逻辑堆在一个 main 里清晰得多,也是后期加关卡、加敌人类型时唯一不会改一处崩三处的写法。
有人会用 EasyX 这类图形库把飞机大战做成窗口版,但我建议第一版老老实实做控制台版。控制台版的所有状态都可以用 int 坐标和字符表示,调试时 printf 一个坐标值就能定位问题,比调试图形贴图舒服得多。图形库版本等控制台版跑通了再升级,那时你已经知道每一帧要更新什么了。
2.2 双缓冲的原理与控制台光标操作
控制台渲染的第一个坑是闪烁。新手最常见的写法是每帧 system("cls") 清屏,再把整个画面从头打印一遍。这样做的结果是整个屏幕像频闪一样抖,玩起来眼睛痛。闪烁的本质是:清屏和重绘之间有一段时间屏幕是空的,肉眼捕捉到了这个过程。
解决思路有两种。一种是用两个内存缓冲区交替显示,这在图形编程里叫双缓冲;控制台里没有现成的缓冲切换 API,但可以把“屏幕内容”先画到内存里的字符数组中,再一次性输出。另一种是反着来:不清屏,只把变化的部分用定位光标的方式覆盖掉。常见做法是两者结合:每一帧把所有要显示的内容写进一个 screen[MAP_H][MAP_W] 数组,然后和上一帧的 oldFrame 数组逐字符比较,只有不同的字符才用 SetConsoleCursorPosition 定位去重写。这样既避开了清屏的空窗期,也把每帧的写屏次数压到很小。
SetConsoleCursorPosition 是 Windows 控制台 API 里的关键函数,作用是直接把光标移动到指定坐标。配合 CONSOLE_CURSOR_INFO 把光标隐藏,画面就不会有那个一闪一闪的下划线。隐藏光标的代码要在进入主循环前执行一次,放在每帧里做反而会消耗性能。坐标系的原点是控制台窗口左上角,x 向右,y 向下,这和我们后面做碰撞检测用的坐标一致,不用额外转换。
注意:SetConsoleCursorPosition 的坐标是 short 类型,取值超过 255 会溢出。控制台游戏的地图宽度设置到 80 以内比较安全,超过这个值就要先调用 SetConsoleScreenBufferSize 调整缓冲区。
2.3 一个最小可跑的渲染循环代码
下面这个代码是一个最简的飞机大战主循环框架,只画一个玩家,用字符 A 表示,w 和 s 控制上下,a 和 d 控制左右。这段代码放在 main.c 里可以直接编译运行:
// main.c — 飞机大战主循环与帧缓冲渲染框架(Windows 控制台) #include <stdio.h> #include <string.h> #include <windows.h> #include <conio.h> #define MAP_W 40 // 地图宽度 #define MAP_H 20 // 地图高度 HANDLE hOut; // 控制台标准输出句柄 char screen[MAP_H][MAP_W]; // 当前帧缓冲区 char oldFrame[MAP_H][MAP_W]; // 上一帧缓冲区 // 把光标移动到 (x, y),坐标系原点在左上角 void gotoxy(int x, int y) { COORD pos; pos.X = (short)x; pos.Y = (short)y; SetConsoleCursorPosition(hOut, pos); } // 隐藏控制台光标,避免渲染时光标闪烁 void hideCursor(void) { CONSOLE_CURSOR_INFO info; info.dwSize = 1; info.bVisible = FALSE; SetConsoleCursorInfo(hOut, &info); } // 差异渲染:只重绘和上一帧不同的字符 void render(void) { int row, col; for (row = 0; row < MAP_H; row++) { for (col = 0; col < MAP_W; col++) { if (screen[row][col] != oldFrame[row][col]) { gotoxy(col, row); putchar(screen[row][col]); } } } memcpy(oldFrame, screen, sizeof(screen)); // 当前帧成为下一帧的旧帧 } int main(void) { int playerX = MAP_W / 2; // 玩家初始 x 坐标 int playerY = MAP_H - 3; // 玩家初始 y 坐标 int running = 1; hOut = GetStdHandle(STD_OUTPUT_HANDLE); hideCursor(); memset(screen, ' ', sizeof(screen)); // 先把缓冲区填成空格 while (running) { // 1. 输入:非阻塞读键盘 if (kbhit()) { int ch = getch(); if (ch == 'a' && playerX > 0) playerX--; else if (ch == 'd' && playerX < MAP_W - 1) playerX++; else if (ch == 'w' && playerY > 0) playerY--; else if (ch == 's' && playerY < MAP_H - 1) playerY++; else if (ch == 27) running = 0; // ESC 退出 } // 2. 更新:把玩家写进当前帧缓冲区 memset(screen, ' ', sizeof(screen)); // 清掉上一帧留下的字符 screen[playerY][playerX] = 'A'; // 3. 绘制:差异渲染到控制台 render(); Sleep(33); // 33 毫秒一帧,约 30 FPS } return 0; }这段代码的关键点有三个。第一,screen 数组的每个元素代表地图上的一个格子,写到屏幕上的其实是字符,玩家、敌机、子弹在代码里都只是数组里某几个位置的字符。第二,render() 里 memcpy 把当前帧复制成下一帧的 oldFrame,这是上一帧数据的唯一来源,漏掉这行,差异渲染会失效,屏幕会拖出残影。第三,Sleep(33) 控制帧率,33 毫秒一帧就是 30 FPS,调到 10 游戏会快得失控,调到 100 会明显发木,课程设计里 30 到 50 毫秒是手感最好的区间。
kbhit() 和 getch() 都来自 conio.h,它们的行为和标准输入函数不一样:kbhit() 只检测键盘缓冲区有没有按键,没有键时立即返回,不会阻塞程序;getch() 读取一个按键但不回显。这两个函数是控制台小游戏的标准组合,但要小心方向键问题,后面第 5 章会专门讲。初次进入渲染循环时 oldFrame 是空数组,第一帧会把整张地图写一次,这是初始化,不是 bug;从第二帧起就只画真正变化的字符了。
提示:如果你用的是 Linux 或 macOS 的终端,conio.h 和 windows.h 都不存在。这个标题下的绝大多数课程设计依赖 Windows 控制台 API,我在后文提到的编译命令也以 MinGW 环境为准。纯 Linux 跑这套代码需要改用 termios 和 ANSI 转义序列,属于另一个话题。
3. 打到敌人:子弹、敌机生成与碰撞检测的实现细节
3.1 用结构体和静态数组管理三种对象
第 2 章里玩家只用两个局部变量就能表达,一旦加入子弹和敌机,全靠局部变量传参就会变得非常啰嗦。常见做法是把这三类对象定义为结构体,再用固定大小的数组作为对象池。飞机的本质是位置加状态:位置用 x、y 两个 int 就够了,状态则要区分,玩家有血量有分数,敌机有血量有移动速度,子弹只问一句“还活着吗”。把这些打包成结构体,是 C 语言基础代码里最标准的设计。
下面这段定义是整份代码的数据地基:
// game.h — 对象定义与全局声明 #ifndef GAME_H #define GAME_H #define MAP_W 40 #define MAP_H 20 #define MAX_BULLETS 50 #define MAX_ENEMIES 20 typedef struct { int x, y; // 当前坐标 int active; // 0 表示可回收,1 表示存活 } Bullet; typedef struct { int x, y; // 当前坐标 int active; // 是否存活 int hp; // 剩余血量,打一枪减 1 int speed; // 每帧向下移动的格数 } Enemy; typedef struct { int x, y; // 玩家当前位置 int hp; // 玩家血量 int score; // 总分 } Player; extern Bullet bullets[MAX_BULLETS]; // 对象池定义在 game.c extern Enemy enemies[MAX_ENEMIES]; extern Player player; #endif对应在 game.c 中,只有一处真正定义这些全局变量:
// game.c — 全局对象池实例化 #include "game.h" Bullet bullets[MAX_BULLETS]; Enemy enemies[MAX_ENEMIES]; Player player;把变量定义放在 .c 文件、声明放在 .h 文件,是为了避免多文件包含时出现重复定义。如果你把数组直接写进 game.h,main.c 和 game.c 各包含一次 header,链接阶段就会报错“重复定义”。这是一个非常典型的 C 语言多文件组织坑,很多新手第一次把代码拆成两个文件时就会踩到。
active 这个标志是整个管理机制的核心。子弹出界或被命中时,不删数组元素,只把 active 置 0;生成新子弹时遍历数组找第一个 active 为 0 的槽位,把数据填进去。这样数组大小固定,不会动态扩容,也永远不会越界。代价是数组中会留下“尸体”,所以所有遍历都要先判断 active,常见错误就是忘了这个判断,导致游戏出现打不死的幽灵敌机。
3.2 碰撞检测:矩形相交与边界判定
飞机大战的子弹和飞机都不是一个点:敌机字符在屏幕上至少占 3 个字符宽度,子弹占 1 个字符。这时碰撞检测不能用坐标相等,要用“两个矩形有没有重叠”来判断。矩形相交的条件是两个矩形在 x 轴方向有重叠,并且在 y 轴方向也有重叠,写成函数就是下面这样:
// game.c — 矩形碰撞检测 // 参数分别是:a 的 x、y、宽、高,b 的 x、y、宽、高 int hitTest(int ax, int ay, int aw, int ah, int bx, int by, int bw, int bh) { return (ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by); }这个函数的思路是反证法:先找不相交的情况。a 完全在 b 左边,对应 ax + aw <= bx;a 完全在 b 右边,对应 ax >= bx + bw;a 完全在 b 上边,对应 ay + ah <= by;a 完全在 b 下边,对应 ay >= by + bh。四种情况只要有一种,就不会相交;把这四种取反,剩下的就是相交。代码里用的是短路的与运算,四个条件缺一不可。
攻击判定放在主循环的更新阶段。每次循环都遍历所有子弹和所有敌机,两层循环里做 hitTest。下面这段是实际游戏里的判定代码:
// 子弹 vs 敌机:命中后扣血,血量为 0 则回收敌人并加分 int i, j; for (i = 0; i < MAX_ENEMIES; i++) { if (!enemies[i].active) continue; for (j = 0; j < MAX_BULLETS; j++) { if (!bullets[j].active) continue; // 敌机宽 3 高 1,子弹宽 1 高 1 if (hitTest(enemies[i].x, enemies[i].y, 3, 1, bullets[j].x, bullets[j].y, 1, 1)) { enemies[i].hp--; bullets[j].active = 0; // 子弹命中后消失 if (enemies[i].hp <= 0) { enemies[i].active = 0; player.score += 10; // 每架敌机 10 分 } } } }注意这里的坐标匹配:敌机宽 3 高 1,是因为一个敌机通常用大于号加小于号这类三字符表示;子弹宽 1 高 1,就是一个普通的竖线。如果你把飞机画成上下两行,那 hitTest 的高就要改成 2,否则会出现子弹穿过飞机尾巴的打空现象。判断打空是否合理,只需要想一想屏幕上飞机字符实际占了几行几列,再对照这里的宽高参数。
玩家和敌机的碰撞同理,但处理方式不同:玩家被撞是掉血,而不是直接消失。可以复用同一个 hitTest,参数换成玩家和敌机的坐标与宽高。碰撞后通常要把敌机置为 inactive,给玩家扣血后再加一小段无敌帧,不然玩家会在敌机重叠的那几帧里连续掉血,一秒钟就死。
3.3 敌机生成参数:速度、频率与难度递进
敌机是主动产生的,不能一开始就铺满屏幕。常见做法是维护一个生成计时器 spawnTimer,每帧减 1,减到 0 就尝试生成一架敌机,然后重置计时器。计时器的初始值就是生成间隔,间隔越小游戏越难。下面这段生成函数可以直接放进 game.c:
// game.c — 敌机生成 // spawnTimer 是生成倒计时,由外部每帧调用并传入地址 void spawnEnemy(Enemy enemies[], int n, int *spawnTimer) { int i; (*spawnTimer)--; if (*spawnTimer > 0) return; // 还没到生成时间 for (i = 0; i < n; i++) { if (!enemies[i].active) { // 找一个空闲槽位 enemies[i].active = 1; enemies[i].x = rand() % (MAP_W - 3); // 留出敌机宽度 enemies[i].y = 0; // 从屏幕顶部出现 enemies[i].hp = 1; enemies[i].speed = 1 + rand() % 2; // 1 或 2 *spawnTimer = 30; // 30 帧后再生成下一架 break; } } }这里有个细节值得说明:spawnTimer 用指针传入,因为函数要修改它的值,而对象池是全局数组,不需要返回任何东西。如果想做得更优雅一点,可以把 timer 收进一个 Game 结构体,但课程设计里这属于过度设计。rand() % (MAP_W - 3) 把横坐标限制在 0 到 36 之间,保证宽度为 3 的敌机不会从右边界半截扎进屏幕。
敌机的移动也不能写成每帧 y++,因为 speed 可能大于 1。移动函数里应该写成 y += speed,同时判断是否越过屏幕底部,越界就回收并扣掉玩家一条命:
// game.c — 敌机移动与越界回收 for (i = 0; i < MAX_ENEMIES; i++) { if (!enemies[i].active) continue; enemies[i].y += enemies[i].speed; if (enemies[i].y >= MAP_H) { // 飞出屏幕底部 enemies[i].active = 0; player.hp--; // 漏掉一架敌机扣一条命 } }生成参数的数值直接影响难度,给一组可以直接抄的初始值:敌机下移速度 speed 初始给 1,每击落 5 架后全局速度加 1,同时让 spawnTimer 从 30 降到 25;血量 hp 跟随关卡提升,第 3 关开始部分敌机 hp 为 2,需要用两次射击才能打掉。子弹发射间隔建议 8 到 12 帧一发,太密游戏没难度,太疏玩家会砸键盘。这些参数全部写成宏或全局变量,集中在文件头部,调起来不用满文件翻。
很多翻车现场是敌机越界后什么都不做,敌机数量越积越多,最后数组塞满,新敌机永远生成不出来,游戏像卡死一样。所以在更新循环里,越界回收和碰撞扣血是第一优先级,永远先于生成新敌机执行。
4. 从 zip 到可执行文件:文件组织与两种编译方式
4.1 解压后常见的文件组织与职责划分
一个能正常交付的飞机大战 zip,解压后通常会看到这样几个文件:main.c、game.c、game.h,可能还有 README.txt 和一张运行效果截图。main.c 放主循环和渲染函数,game.c 放对象更新、生成、碰撞检测,game.h 放结构体定义和函数声明。这样拆的依据不是文件越大越好,而是“声明放在头文件,实现放在源文件,main 只负责调度”。game.c 里写了函数后,必须在 game.h 里声明,main.c 和 game.c 各自 include 这个头文件,编译器才能在对的地方找到函数签名,否则就会出现隐式声明的警告。
头文件里除了函数声明,还要写上宏和结构体定义。常见禁忌是在游戏.h 里定义变量,比如 int playerX;。头文件被两个 .c 文件各自包含后,链接阶段会报重复定义。正确的做法是变量定义只写在某个 .c 文件里,头文件里只放 extern 声明,或者像第 3 章那样,把对象池直接定义为全局数组,由唯一一个源文件持有。课程设计规模下,全局数组是够用的,但要在文件顶部注释一句“这些变量在 game.c 中定义”,省得后来的人找不到。
4.2 用 GCC 命令行编译和 Makefile
编译多文件 C 程序,最直接的命令是:
gcc -Wall -O2 main.c game.c -o plane.exe-Wall 打开所有常见警告,代码里有未使用的变量、危险的类型转换都会提示;-O2 做优化,对控制台游戏来说几乎不影响帧率,但对 CPU 占用有改善。如果只想编译一个文件、先检查语法,可以单独执行 gcc -c main.c,它会生成 main.o 目标文件,最终链接时再把所有 .o 一起交给 gcc。
每次改完代码都敲一长串命令太痛苦,我一般会放一个 Makefile 在项目根目录。下面这个 Makefile 是 Windows 下 MinGW 环境最简可用的版本:
# Makefile — 在项目根目录执行 make 即可编译 CC = gcc CFLAGS = -Wall -O2 TARGET = plane.exe OBJS = main.o game.o $(TARGET): $(OBJS) $(CC) $(OBJS) -o $(TARGET) main.o: main.c game.h $(CC) $(CFLAGS) -c main.c game.o: game.c game.h $(CC) $(CFLAGS) -c game.c clean: del /Q *.o $(TARGET)两个地方容易踩坑。第一,Makefile 里命令行的开头必须是 Tab 键,不能用四个空格,否则 make 会报 missing separator 直接退出,我上面展示为空格只是为了排版,复制后记得把命令行的缩进替换成 Tab。第二,clean 目标在 Windows 下用 del /Q 而不是 Linux 的 rm -f;你从网上抄的 Makefile 经常是 Linux 风格,拿到 Windows 的 cmd 环境下 clean 会失败。如果只是想跑一次,也可以忽略 clean,手动删掉 .o 文件。
用 GCC 的好处是编译错误信息相对直白,哪里少个分号、哪个变量没定义,会指到具体某行。遇到看不懂的报错,先看第一个错误,别被后面的瀑布式输出吓到,绝大多数情况是第一个错引发的连锁反应,修完第一个,后面成片报错会自动消失。这就是 C 语言编译的“玄学”里最不玄的一环。
4.3 VSCode 与 Visual Studio 的工程配置
很多人的开发环境不是命令行,而是 VSCode 或 Visual Studio。VSCode 里跑 C 程序的常见做法是用 MinGW-w64 提供 gcc,用官方 C/C++ 扩展做语法提示。先确认 gcc 在环境变量里,打开终端执行 gcc --version 能输出版本,然后直接按 4.2 的命令编译运行。Code Runner 默认的单文件运行方式跑不了多文件工程,需要手动在 settings.json 里把 executor 改成完整编译命令,或者干脆在集成终端里敲 gcc 命令,这样最不容易出错。
Visual Studio 的坑主要是工程里没加文件。新建一个空工程后,默认只有 main.c,你需要右键源文件,添加,现有项,把 game.c 加进去。漏掉 game.c 的典型症状是编译通过但链接报错,错误列表里全是无法解析的外部符号,比如对 hitTest 函数的引用找不到定义。如果你看到这种错误,先检查 game.c 是否在工程里,而不是急着改代码。
Dev-C++ 是课程设计里常用的一体化环境,用起来更简单:把三个文件打开,按 F11 编译加运行。它的默认编译器是 MinGW 系的 GCC,对标准 C 支持没问题,但如果你把文件后缀存成 .cpp,它会按 C++ 编译,结构体赋值、函数指针这些地方可能会报出一些让人摸不着头脑的错。老老实实保存成 .c 结尾,能省掉一堆移植问题。
5. 常见问题排查:乱码、按键不响应与内存泄漏
5.1 中文乱码:UTF-8 源码在 GBK 控制台下的“锟斤拷”
现象:游戏菜单、提示文字全是锟斤拷、烫烫烫一类乱码,英文和数字正常。
原因:源码文件保存成 UTF-8 编码,而 Windows 控制台默认用简体中文代码页 936(GBK)渲染字符。printf 把 UTF-8 字节流交给控制台,控制台按 GBK 解码,两套字符集对不上,就出现乱码。这和代码逻辑无关,属于字符编码问题。
解决:两个方案,任选其一。方案一,在 main 函数最开头加 system("chcp 65001 >nul");,强制控制台切到 UTF-8,同时源码文件保持 UTF-8。方案二,把源码另存为 ANSI 也就是 GBK 编码,控制台代码页不用动。我一般用方案二:课程设计报告要交截图,GBK 下中文标题显示最正常,而且 Dev-C++ 老版本对 UTF-8 支持不好。注意方案一里大于号加 nul 是为了让 chcp 的输出不污染画面,漏了它会有多余的一行字出现在游戏界面上。
5.2 方向键读取不对与 CPU 占用 100%:扩展键与 Sleep 参数
现象:按方向键游戏没有反应,或者按一次上键,飞机跑了两格;开任务管理器一看,进程 CPU 占用一直在 90% 以上。
原因:方向键和 F1 这些是扩展功能键,getch() 第一次返回的是 0x00 或 0xE0 前缀,第二次才返回真正的按键码。如果只在判断里写了普通字母键,扩展键带前缀根本匹配不上。CPU 占用高则是因为主循环里 Sleep 没有生效,或者 Sleep 值太短、循环空转太快。
解决:读取键盘的代码里对两个前缀值做分支处理。遇到 0xE0 或 0x00 时再读一次 getch(),用第二个值判断方向;普通字母键走原来的分支。CPU 占用高就把 Sleep(33) 放在每帧末尾不要漏掉,同时检查是不是有哪个循环里写了死等。注意 getch() 本身是阻塞的,如果在主循环里直接写 getch() 而不是先 kbhit() 判断,游戏会卡在等按键上,画面完全不动,这是初学最容易犯的“按键等死”错误。
5.3 VS 报“无法打开源文件”或“无法解析的外部符号”
现象:Visual Studio 里双击打开 main.c,发现 #include "game.h" 这一行飘着红线,编译时报错无法打开源文件 game.h;稍微改一下又变成无法解析的外部符号 hitTest。
原因:第一种是 game.h 不在工程目录里,或者编译器找不到包含路径。第二种是 game.c 没有加入工程,虽然头文件能找到,但函数实现没有被链接进来。这两个问题经常连续出现:头文件找不到时改了一通,重新把 game.h 放进目录后,又发现 game.c 没加。
解决:先在解决方案资源管理器里确认 game.c、game.h 都被加入了工程,源文件没有可以在源文件节点右键添加现有项。然后检查 include 路径:一般 game.h 和 main.c 在同一目录,默认配置就能找到;如果你把 game.h 放在上级目录,需要在 VC++ 目录里的包含目录中加上那个路径。最后,如果还报打不开头文件,确认文件扩展名真的是 .h 而不是 .h.txt,Windows 默认隐藏扩展名很容易让你保存成双后缀文件还浑然不觉。
5.4 进程内存只涨不降:C 语言内存管理最常见翻车点
现象:游戏运行时间越长,任务管理器里该进程的内存工作集数值一路往上走,反复开始新游戏也不降;或者运行几分钟后游戏变卡。
原因:代码里用了 malloc 创建子弹节点或敌机节点,释放时只写了 free(p) 但忘了处理链表指针,或者干脆漏掉 free。循环里每次都创建新节点,旧的没释放,堆内存就被一点点吃光。控制台游戏本身不占资源,内存持续上涨基本可以断定是泄漏。
解决:课程设计规模下,最稳的办法是别用动态内存,用第 3 章那种静态数组加 active 标志。如果你一定要用链表练手,把释放并断开写成独立函数,每次回收都调用它,并且坚持用计数验证:在循环外打印 malloc 次数和 free 次数,两个数字对不上就是泄漏。Valgrind 在 Linux 下好用,Windows 下用 Visual Studio 的 CRT 库自带泄漏检测,但做题的场景里,打印计数已经足够定位。
5.5 敌机永远同一批:rand 种子与 srand 的玄学
现象:每次启动游戏,敌机的出现位置、顺序、速度序列完全一样,玩第二局和第一局毫无区别。
原因:rand() 生成的是伪随机序列,种子固定时序列固定。程序没有调用 srand,或者调用的是 srand(time(NULL)) 但两次启动在同一秒内完成,time(NULL) 返回值没变,种子还是同一个。
解决:程序启动后只调用一次 srand((unsigned)time(NULL)); 放在 main 开头,别放在生成函数里面。如果你连续快速重启程序还是重样,可以在种子里面加一点扰动:srand((unsigned)time(NULL) ^ (unsigned)GetCurrentProcessId());。GetCurrentProcessId 是 Windows API,每次进程都不一样,作为扰动足够打破重样。这个现象是课程设计报告里最常见的一条测试记录,因为人人都遇到过。
6. 让游戏有灵魂:存档、计分与难度曲线的进阶做法
把飞机大战从“能玩”推到“想玩”,关键不是加多少敌人类型,而是两件事:分数留得住,难度跟得上。分数存档用 C 标准库的 fprintf 和 fscanf 就能解决,不用碰文件系统底层的 API。游戏开始时打开 best.txt,用 fscanf 把最高分读进来;游戏结束时把当前分数写回去。注意 fopen 的返回值一定要判断,文件不存在时按“最高分为 0”处理,否则未初始化变量会让首次启动显示一个天文数字:
// 存档:fprintf 与 fscanf 成对使用 void saveBest(int score) { FILE *fp = fopen("best.txt", "w"); if (fp != NULL) { fprintf(fp, "%d", score); fclose(fp); } } int loadBest(void) { FILE *fp = fopen("best.txt", "r"); int best = 0; if (fp != NULL) { fscanf(fp, "%d", &best); fclose(fp); } return best; }难度曲线我一般做成“击杀数递进”而不是“时间递进”。击落敌机累计到 10 架进入第 2 关,到 25 架进入第 3 关。每升一关做三件事:敌机生成间隔从 30 帧降到 25 帧,敌机平均速度从 1 提到 2,部分敌机血量从 1 变为 2。这三件事分开用一组全局变量控制,不要散落在多个函数里的 if 判断中。比如定义 int spawnInterval = 30; 关卡推进时直接改这个变量,所有生成逻辑读到的是同一个值,不会出现“敌人变快了但生成还是老频率”的割裂感。
| 关卡 | 生成间隔(帧) | 敌机速度 | 常见敌机血量 |
|---|---|---|---|
| 1 | 30 | 1 | 1 |
| 2 | 25 | 1 到 2 | 1 |
| 3 | 20 | 2 | 1 到 2 |
关卡推进函数每帧检查当前击杀数,这是整个游戏里改动最少却能明显提升体验的一段逻辑。我通常还会在屏幕顶部显示当前关卡和最高分,这也是练习 fscanf 的绝佳场景。最后一条血泪经验:每次改完一个功能就立刻编译运行一次,别指望把所有改动攒到最后再去调。我曾为省事把所有代码堆在 main.c 里,最后改一个变量名,连带碰撞检测和渲染全都牵进去,连改三天才收拾干净。先让主循环跑起来,再往里加功能,这个顺序比任何优化技巧都重要。希望帮到你。
本文还有配套的精品资源,点击获取