简介:这是一份面向C语言初学者与游戏编程爱好者的实战项目资源,以经典飞行射击游戏《飞机大战》为载体,帮助读者在动手实践中理解C语言的核心语法与游戏开发流程。压缩包共3个文件,包含cpp源代码、编译后的exe可执行程序以及o目标文件,整体仅7KB,体积轻巧便于快速获取与本地运行。源码中涉及游戏循环、键盘输入处理、飞机与子弹的移动更新、敌人生成、碰撞检测等典型逻辑,并可能借助图形与音频库完成画面渲染和音效播放,是理解结构体建模、函数封装与事件驱动机制的合适案例。目前已有967人学习下载,读者可对照可执行文件观察运行效果,再回到源码中梳理对象状态更新与碰撞判定思路,适合作为课程设计、C语言大作业或自学练手的参考素材。
1. 用 C 语言手搓一个飞机大战:这份源码把游戏循环讲透了
很多人学完 C 语言语法就卡在“能写算法题,但做不出东西”这一步。这份飞机大战源码正好卡在这个痛点上——它不依赖任何游戏引擎,纯 C 语言加控制台绘图,把游戏主循环、碰撞检测、对象池、帧率控制这些核心机制全部摊开给你看。整个项目编译出来就是一个可执行文件,双击就能玩,适合刚学完指针和结构体、想找个完整项目练手的开发者。你拿到手能直接跑,也能顺着代码把每个模块拆开改,比如换敌机生成逻辑、加新道具、调难度曲线。下面我从项目结构一路讲到二次开发,把这份源码拆干净。
2. 项目架构与核心模块:从 main 到游戏循环的完整链路
2.1 目录结构与文件职责
拿到压缩包解压后,目录结构很干净,没有多余的构建脚本和第三方依赖。常见做法是分成三个部分:源码目录、资源目录、编译输出目录。这份源码的典型布局如下:
plane-war/ ├── src/ │ ├── main.c # 程序入口,初始化窗口和游戏循环 │ ├── game.c # 游戏状态管理,更新与渲染调度 │ ├── player.c # 玩家飞机移动、射击、生命值 │ ├── enemy.c # 敌机生成、移动、碰撞响应 │ ├── bullet.c # 子弹对象池与命中判定 │ ├── render.c # 控制台字符绘制与刷新 │ └── input.c # 键盘输入读取(非阻塞) ├── include/ │ ├── game.h │ ├── player.h │ ├── enemy.h │ ├── bullet.h │ └── render.h ├── assets/ │ └── map.txt # 可选:关卡地图或字符画资源 └── Makefile这个划分方式的好处是每个.c文件只负责一类实体的逻辑,头文件暴露必要的结构体和函数声明。你改敌机行为只需要动enemy.c,不会牵连玩家代码。render.c单独抽出来是因为控制台绘图在不同平台(Windows 的conio.h和 Linux 的termios.h)差异较大,集中处理方便移植。
2.2 游戏主循环:固定时间步长与帧率控制
游戏循环是整个项目的骨架。很多新手写游戏会写成while(1) { 更新; 绘制; },结果在不同机器上速度完全不一样。这份源码用了固定时间步长加帧率限制的写法,核心代码在game.c里:
// game.c - 游戏主循环 #include <time.h> #include "game.h" #define FRAME_TIME_MS 33 // 约30帧每秒 void game_loop(GameState *state) { clock_t last = clock(); while (state->running) { clock_t now = clock(); // 计算距上一帧经过的毫秒数 int elapsed = (int)((now - last) * 1000 / CLOCKS_PER_SEC); if (elapsed < FRAME_TIME_MS) { continue; // 未到下一帧时间,空转等待 } last = now; handle_input(state); // 读取键盘,更新玩家意图 update_world(state); // 移动所有实体,检测碰撞 render_frame(state); // 清屏后重新绘制所有字符 } }这段代码的关键参数是FRAME_TIME_MS,它决定了游戏的最大帧率。改成16就是约 60 帧,画面更流畅但 CPU 占用更高;改成50则明显卡顿。clock()返回的是 CPU 时钟周期数,除以CLOCKS_PER_SEC换算成秒。注意这里用continue空转而不是sleep,是为了在等待期间仍然能响应输入——如果你用Sleep(33),按键会有明显延迟。实际项目中更常见的做法是用SDL_GetTicks()或clock_gettime(),但这份源码为了跨平台兼容只用了标准库。
update_world里按顺序调用update_player、update_enemies、update_bullets,然后统一做碰撞检测。顺序不能乱:先移动再检测,否则会出现子弹穿过敌机但没判定的玄学问题。
2.3 对象池管理:子弹与敌机的内存复用
飞机大战里子弹和敌机是高频创建销毁的对象。如果每次发射子弹都malloc,打完就free,跑几分钟后内存碎片会很严重,帧率也会抖动。这份源码用了对象池:启动时预分配固定数量的子弹和敌机结构体数组,用active标志位标记是否在使用。
// bullet.h - 子弹对象池定义 #define MAX_BULLETS 64 typedef struct { int x, y; // 屏幕坐标 int speed; // 每帧移动像素 int active; // 1=使用中 0=空闲 } Bullet; typedef struct { Bullet bullets[MAX_BULLETS]; int spawn_timer; // 发射冷却计时 } BulletPool; // bullet.c - 从池中取一颗空闲子弹 Bullet* bullet_spawn(BulletPool *pool, int x, int y) { for (int i = 0; i < MAX_BULLETS; i++) { if (!pool->bullets[i].active) { pool->bullets[i].x = x; pool->bullets[i].y = y; pool->bullets[i].speed = 2; pool->bullets[i].active = 1; return &pool->bullets[i]; } } return NULL; // 池满,本次发射失败 }MAX_BULLETS设成 64 是经验值:屏幕宽度 80 列,玩家射速每秒 10 发,子弹飞行 2 秒,最多同时存在 20 颗左右,64 足够冗余。如果你把射速调到每秒 50 发,就要把这个值改大,否则会出现按了射击键但没子弹出来的情况——这就是池满返回NULL的表现。敌机池同理,只是数量少一些,通常 16 到 32 个就够。
对象池的回收在update_bullets里做:子弹飞出屏幕顶部或命中敌机后,把active置 0,而不是释放内存。这样整个游戏运行期间堆内存零分配,帧率非常稳。
2.4 碰撞检测:矩形包围盒与坐标映射
碰撞检测用的是最朴素的 AABB(轴对齐包围盒)。每个实体有x, y坐标和宽高,两个矩形相交就算碰撞。代码在game.c的check_collisions函数里:
// game.c - 碰撞检测 int rect_overlap(int x1, int y1, int w1, int h1, int x2, int y2, int w2, int h2) { return (x1 < x2 + w2 && x1 + w1 > x2 && y1 < y2 + h2 && y1 + h1 > y2); } void check_collisions(GameState *state) { // 玩家子弹 vs 敌机 for (int i = 0; i < MAX_BULLETS; i++) { if (!state->bullets.bullets[i].active) continue; for (int j = 0; j < MAX_ENEMIES; j++) { if (!state->enemies.enemies[j].active) continue; if (rect_overlap(state->bullets.bullets[i].x, state->bullets.bullets[i].y, 1, 1, state->enemies.enemies[j].x, state->enemies.enemies[j].y, 3, 2)) { state->bullets.bullets[i].active = 0; state->enemies.enemies[j].hp--; if (state->enemies.enemies[j].hp <= 0) { state->enemies.enemies[j].active = 0; state->score += 10; } } } } }子弹宽高设为1,1是因为它在屏幕上就是一个字符。敌机3,2对应三字符宽两行高。这里有个容易翻车的地方:如果你的敌机字符画是"<==>"这种 5 个字符宽,但碰撞盒还写3,就会出现“明明打中了却没反应”的情况。改敌机外观时一定要同步改碰撞盒尺寸。
另外注意循环里先判断active再检测,避免对已销毁对象做无效计算。虽然多了一层判断,但在对象池数量不大时性能差异可以忽略,代码可读性更重要。
3. 编译运行与输入处理:让程序在 Windows 和 Linux 都能跑
3.1 Makefile 与跨平台编译
项目根目录的 Makefile 是手写的,没有用 CMake 或 Autotools,就是为了降低上手门槛。核心规则如下:
CC = gcc CFLAGS = -Wall -O2 -Iinclude SRCS = src/main.c src/game.c src/player.c src/enemy.c src/bullet.c src/render.c src/input.c OBJS = $(SRCS:.c=.o) TARGET = plane-war $(TARGET): $(OBJS) $(CC) -o $@ $^ %.o: %.c $(CC) $(CFLAGS) -c $< -o $@ clean: rm -f $(OBJS) $(TARGET)在 Linux 或 macOS 上直接make就能编译。Windows 上如果你用 MinGW,把rm -f改成del即可。-Wall打开所有警告,-O2开优化,-Iinclude让编译器能找到头文件。常见错误是忘记加-Iinclude,报game.h: No such file or directory,加上就好。
编译产物plane-war是单个可执行文件,没有动态库依赖,拷到同架构机器上直接能跑。
3.2 非阻塞键盘输入:Windows 与 Linux 的差异处理
控制台游戏最麻烦的就是键盘输入。标准 C 的getchar()是阻塞的,按了键才返回,游戏就卡住了。这份源码在input.c里做了平台判断:
// input.c - 非阻塞读取按键 #ifdef _WIN32 #include <conio.h> int read_key(void) { if (_kbhit()) { // 有按键按下才读取 return _getch(); // 不等待回车,直接返回键值 } return -1; } #else #include <termios.h> #include <unistd.h> #include <fcntl.h> int read_key(void) { struct termios oldt, newt; int ch; int oldf; tcgetattr(STDIN_FILENO, &oldt); newt = oldt; newt.c_lflag &= ~(ICANON | ECHO); // 关闭行缓冲和回显 tcsetattr(STDIN_FILENO, TCSANOW, &newt); oldf = fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, oldf | O_NONBLOCK); // 设为非阻塞 ch = getchar(); tcsetattr(STDIN_FILENO, TCSANOW, &oldt); // 恢复终端设置 fcntl(STDIN_FILENO, F_SETFL, oldf); if (ch != EOF) { return ch; } return -1; } #endifWindows 下_kbhit()检测是否有按键,_getch()直接返回键值不经过缓冲区,两行搞定。Linux 下要手动改termios结构体,关掉ICANON(行缓冲)和ECHO(回显),再用fcntl设非阻塞。每次读完还要恢复原设置,否则程序退出后终端会乱掉——这是血泪经验,调试时终端不回显了别慌,敲reset命令就能恢复。
read_key返回-1表示当前帧没有按键,游戏循环继续跑。返回其他值就是键值,handle_input里用switch分发。
3.3 渲染刷新:双缓冲与闪烁消除
控制台绘图最怕闪烁。如果每帧先system("cls")再逐行打印,屏幕会明显闪。这份源码用了双缓冲:先在内存里拼好一整屏字符,再一次性输出。
// render.c - 双缓冲渲染 #define SCREEN_W 80 #define SCREEN_H 24 static char back_buffer[SCREEN_H][SCREEN_W + 1]; void render_clear(void) { for (int y = 0; y < SCREEN_H; y++) { memset(back_buffer[y], ' ', SCREEN_W); back_buffer[y][SCREEN_W] = '\0'; } } void render_draw(int x, int y, const char *s) { if (y < 0 || y >= SCREEN_H) return; for (int i = 0; s[i] && x + i < SCREEN_W; i++) { if (x + i >= 0) back_buffer[y][x + i] = s[i]; } } void render_flush(void) { // 光标移到左上角,逐行输出,避免清屏闪烁 printf("\033[H"); for (int y = 0; y < SCREEN_H; y++) { printf("%s\n", back_buffer[y]); } fflush(stdout); }\033[H是 ANSI 转义序列,把光标移到左上角,然后覆盖输出。这样屏幕不会先变黑再重绘,闪烁基本消除。Windows 10 以后的终端默认支持 ANSI 转义,老版本 Windows 7 需要先调用SetConsoleMode开启虚拟终端处理,否则会看到一堆乱码。如果你在 Win7 上跑,在main开头加一句system("")有时也能激活,但更稳妥的是用SetConsoleModeAPI。
render_draw里做了边界检查,x为负或超出屏幕宽度都不会越界写内存。这个检查在敌机从右侧飞入时特别重要——敌机初始x可能等于SCREEN_W,第一帧绘制时x + i会超出,没有检查就会踩内存。
4. 避坑与常见问题:编译、运行、逻辑三层排查
4.1 编译报错undefined reference to 'xxx'
现象:make时链接阶段报某个函数未定义,比如undefined reference to 'bullet_spawn'。原因通常是SRCS变量里漏了对应的.c文件,或者函数在头文件里声明了但没在.c里实现。解决:检查Makefile的SRCS是否包含所有源文件,再检查bullet.c里是否有bullet_spawn的函数体。如果函数是static的,只能在本文件用,跨文件调用要去掉static。
4.2 运行后屏幕闪烁或字符错位
现象:游戏跑起来画面一直闪,或者字符位置对不上。原因一般是没用双缓冲,或者render_flush里用了system("cls")。解决:确认render.c里用的是back_buffer加\033[H的方案。如果字符错位,检查render_draw的坐标是否从 0 开始,以及SCREEN_W和实际终端宽度是否一致——终端窗口太窄会导致自动换行,把画面挤乱。把终端窗口拉宽到至少 80 列。
4.3 按键没反应或延迟很高
现象:按方向键飞机不动,或者要按好几下才动一次。原因可能是read_key在 Linux 下没设非阻塞,或者游戏循环里FRAME_TIME_MS太大导致输入采样间隔过长。解决:确认input.c里fcntl设了O_NONBLOCK;把FRAME_TIME_MS从 33 改成 16 试试。另外注意_getch()返回的是键值不是字符,方向键在 Windows 下是224加一个方向码,需要连续读两次,这份源码里handle_input做了处理,如果你自己改输入逻辑别忘了这一点。
4.4 子弹打中敌机但敌机不消失
现象:明明看到子弹和敌机重叠了,但敌机还在飞。原因通常是碰撞盒尺寸和实际字符画不匹配,或者碰撞检测在移动之前执行了。解决:检查check_collisions里敌机的宽高参数是否和render_draw时用的字符串长度一致;确认update_world里先调用update_bullets和update_enemies再调用check_collisions。如果顺序反了,子弹位置还是上一帧的,自然打不中。
4.5 程序退出后终端不回显
现象:在 Linux 下按Ctrl+C退出游戏后,终端输入命令看不到字符。原因是非阻塞输入改写了termios设置,但异常退出时没恢复。解决:在main里用atexit注册恢复函数,或者在信号处理里恢复终端。最简单的办法是退出后敲reset命令。更稳妥的做法是在read_key里每次读完立即恢复,而不是等程序结束——这份源码就是这么做的,所以正常退出不会出问题,只有Ctrl+C强杀才会。
5. 二次开发改造:从换皮到加机制的实操思路
拿到这份源码,最直接的改造是换字符画。把player.c里玩家飞机的绘制字符串从"^"改成"/^\\",碰撞盒宽度从 1 改成 3,立刻就有不一样的手感。敌机同理,"<==>"比"V"更有压迫感。改完记得同步改碰撞盒,否则就是前面说的“打不中”问题。
再进一步是加道具系统。思路很简单:在enemy.c里给敌机结构体加一个drop_type字段,敌机被击毁时按概率设置掉落类型(比如 1 代表加火力,2 代表加生命)。然后在game.c的碰撞检测里加一段“玩家 vs 道具”的判定,命中后修改玩家属性。道具的绘制可以复用render_draw,用不同颜色或字符区分。这里有个技巧:道具下落速度设成敌机的一半,给玩家反应时间。
难度曲线是另一个值得改的点。现在的敌机生成间隔是固定的,玩久了会腻。可以在enemy.c里加一个difficulty变量,随时间递增,生成间隔从 60 帧逐渐降到 15 帧,同时敌机速度从 1 提到 3。改的时候注意对象池上限,如果生成太快导致池满,新敌机会被丢弃,表现就是“突然没敌机了”。把MAX_ENEMIES从 16 提到 32 能缓解。
最后说一个验证改造是否成功的习惯:每次改完先跑 5 分钟,看帧率是否稳定、内存是否增长(Linux 下用top看 RES 列)。如果 RES 一直涨,说明有对象没回收,检查active置 0 的逻辑是否覆盖了所有销毁路径。我一般会在update_world末尾加一句统计当前活跃对象数量的打印,调试完再删掉。从那以后我每次改对象池相关代码,都强制走一遍“生成-销毁-再生成”的循环测试,确认池子能复用。希望帮到你。
本文还有配套的精品资源,点击获取