实话实说,C语言飞机大战是我个人认为最适合零基础练手的C语言小项目。语法学了一堆,结构体、指针、数组、循环、函数都翻来覆去看了好几遍,但一到“做点什么”就不知道从哪里下手,这种状态我太熟了。飞机大战的规模刚刚好:没有复杂的图形界面,不需要搭服务器,不依赖第三方框架,一个黑乎乎的控制台窗口就能把完整游戏流程跑起来。更关键的是,它能把C语言里最常用的知识点全部串一遍——结构体描述游戏对象、数组管理动态实体、随机数控制游戏节奏、键盘输入驱动交互、坐标加循环完成渲染。做完这个项目,你对“C语言到底能干什么”会有一个非常直观的答案。
这篇博文主要面向两类人:一类是刚学完语法、想做第一个小项目的学生,另一类是准备课程设计或毕业设计、想找一个范围可控又有展示效果的初学者。不管你有没有完整写过项目,按下面的思路一步步来,一定能跑出一个能玩的版本。废话不多说,先理思路,再上代码。
1. 项目整体设计与思路拆解
1.1 先想清楚:飞机大战到底在“玩”什么
很多初学者一上来就想直接写代码,结果写两天就放弃。原因很简单:脑子里没有“程序怎么运转”的完整画面。飞机大战这类游戏,本质上都在跑一个死循环,叫游戏主循环,核心就三步:
- 读取输入:玩家按了什么键,方向键、字母键还是空格。
- 更新逻辑:根据输入去移动飞机、发射子弹,让敌机往下掉,检查是否打中。
- 渲染画面:把最新的位置画到屏幕上,让玩家看到结果。
然后这个循环一遍又一遍地跑,画面就“动”起来了。你看电影是每秒24帧,我们这个程序跑慢一点,每秒钟刷新十几二十次,足够让眼睛觉得流畅。
理解这个结构之后,飞机大战这个项目就被拆成了几个独立的小问题:
- 飞机怎么画、怎么移动?
- 子弹怎么生成、怎么往上飞?
- 敌机怎么出现、怎么往下掉?
- 子弹碰到敌机怎么判定?
- 敌机撞到玩家或漏到屏幕底部怎么扣命?
- 生命归零怎么结束游戏?
把每个问题拆开解决,再放进同一个循环里,整个项目就没有想象中那么复杂了。这也是我反复跟学C语言的朋友强调的一点:写项目之前先拆需求,拆得越细,写起来越轻松。
1.2 为什么选控制台字符画布,而不是图形库
做游戏谁不想用好看的图形界面?但我强烈建议零基础先忍一忍,用控制台字符画布把逻辑跑通。这里有个现实的理由:图形库会把你学习的重心从“C语言逻辑”拉到“SDK调用、事件机制、资源加载”去,纯新手很容易被劝退。
我做了个对比,方便你理解不同方案的取舍:
| 方案 | 依赖 | 上手难度 | 学到的东西 | 适合人群 |
|---|---|---|---|---|
| 控制台字符画布 | 标准C + 少量系统API | 低 | 数据结构、逻辑、循环 | 零基础、课程设计 |
| EasyX图形库 | Windows专用图形库 | 中 | 绘图API、贴图、消息处理 | 想快速出图形效果 |
| ncurses库 | Linux/Unix终端库 | 中 | 终端控制、跨平台思路 | Linux下练手 |
| 完整游戏引擎 | 引擎SDK | 高 | 引擎生态,和C关系不大 | 已有基础再考虑 |
控制台方案最大的优势是逼着你把所有游戏逻辑用C语言自己写出来。没有现成的精灵组件、没有物理引擎,飞机就是你打印出来的一个字符,移动就是坐标的加减,碰撞就是两个坐标比较。这个过程虽然原始,但对理解编程本质非常有帮助。
等你把字符版跑通了,再去学EasyX或者换图形界面,会发现所谓图形版飞机大战,无非是把“打印字符”换成“绘制图片”,核心游戏逻辑还是那套循环。这才是做项目的正确顺序。
1.3 功能清单:从能玩到好玩
既然是零基础项目,第一版不用追求华丽,先把“能玩”做出来。我的目标功能清单是这样的:
- 玩家飞机:用符号表示,可上下左右移动,不能飞出边界。
- 子弹:按空格键发射,子弹向上飞,飞出屏幕消失。
- 敌机:从屏幕顶部随机位置出现,向下移动,触底消失。
- 碰撞判定:子弹命中敌机则敌机消失、得分加一;敌机与玩家相撞则玩家扣命。
- 游戏状态:初始生命3条,生命归零游戏结束,显示最终得分。
这套功能做出来大概就是两百行左右的代码,逻辑不复杂,但麻雀虽小五脏俱全。做完这个版本之后,你自然会发现“无聊”的地方,比如敌人速度太慢、关卡没有变化、没有音效。这些条件会逼着你去加功能,加着加着,C语言水平就上去了。
2. 核心数据结构与关键逻辑实现
2.1 结构体:让每个对象都有“身份证”
初学C语言时很多人写代码喜欢全局变量满天飞:飞机一个x、一个y,子弹又是好几个数组,敌机再来一堆变量。这样写一时爽,代码一长就乱了,因为你根本分不清哪几个变量属于同一个对象。
更好的做法是用结构体把对象的属性打包。飞机大战里的子弹,本质上就三样信息:横坐标x、纵坐标y、是否还活着alive。敌机也一样。于是我定义了这样两个结构体:
typedef struct { int x; // 横坐标,范围 0 ~ W-1 int y; // 纵坐标,范围 0 ~ H-1 int alive; // 1 表示存活,0 表示消失 } Bullet; typedef struct { int x; int y; int alive; } Enemy;这里alive字段是新手容易忽略的一个东西。你可以把它理解成对象的“生死状态”。子弹打中敌人后不是真的从内存里删掉,而是把它标记成alive=0,渲染时跳过它,更新逻辑时跳过它。这种方式叫对象池,在游戏开发里非常常见,比频繁malloc和free简单得多,也更适合展示C语言基础。
2.2 对象池:用固定数组管理“活”的子弹和敌机
既然子弹和敌机会有很多个,自然要用数组。我预定义两个数组:
#define MAX_BULLETS 50 #define MAX_ENEMIES 10 Bullet bullets[MAX_BULLETS]; Enemy enemies[MAX_ENEMIES];50个子弹、10个敌机,这个数量对单机小游戏完全够用。每次发射子弹时,遍历bullets数组,找一个alive == 0的空位,把子弹坐标填进去,再把alive设为1。子弹消失时把alive设为0。这样数组中的空位会被反复利用,不会造成“子弹越攒越多”的问题。
有基础的同学可能会问:为什么不用链表?链表确实更灵活,可以无限添加节点,删除时也更“省空间”。但对零基础来说,链表的指针操作、内存分配、释放时机都是额外负担,一个不小心就内存泄漏。先用数组把游戏逻辑跑通,后续再改成链表,这才是高效的学习路径。我在后面“项目扩展”里会专门讲怎么改链表。
2.3 坐标系、移动规则与碰撞检测
控制台屏幕可以看成一张坐标纸,左上角是原点(0, 0),x向右增大,y向下增大。所以:
- 玩家初始位置在底部中间,x = W/2,y = H-1。
- 子弹向上飞,每帧y减1。
- 敌机向下落,每帧y加1。
碰撞检测在这个游戏里简单到让人怀疑:两个对象坐标相等就算碰到了。子弹和敌机都是一个字符,所以只要:
bullets[i].x == enemies[j].x && bullets[i].y == enemies[j].y就说明子弹命中敌机。玩家和敌机同理。这种判定在字符画布游戏中足够准确,换成图片游戏后则需要考虑矩形区域重叠,但核心思路一致。
边界处理也要注意。玩家不能飞出屏幕,所以按左移时要先判断playerX > 0;子弹飞到y < 0就标记为消失;敌机落到y > H-1就判定为漏网之鱼,扣玩家生命。
2.4 随机数和难度控制:让每次开局都不一样
如果敌机每次都在同一个位置出现,游戏就没什么可玩性了。这里要用C语言的随机数:
srand((unsigned)time(NULL)); // 用当前时间做随机种子 int x = rand() % W; // 生成 0 ~ W-1 的随机坐标srand设置随机种子,rand()生成随机数,% W把结果限制在0到W-1之间。没有srand的话,每次启动程序生成的“随机”序列都是一样的,游戏就会看起来像复读机。
难度控制也靠随机数。比如每帧有12%的概率生成一个敌机:
if (rand() % 100 < 12) { // 找一个空位生成敌机 }这个12%配合程序帧率决定了敌机出现的密度。如果觉得敌人太多,调成8;如果太少,调成15。这种“改一个数字就能调游戏手感”的体验,会让你对参数设计有直观感受。更进阶的做法是让概率和玩家得分挂钩,比如rand() % 100 < (10 + score / 5),得分越高,敌机越多,游戏越来越刺激。
3. 手把手写代码:完整实现一个能跑的飞机大战
3.1 开发环境:VSCode还是C-Free,都能跑
写C语言第一步是配置环境。我用的是VSCode + MinGW,网上“VSCode配置C语言环境”的教程很多。安装好C/C++扩展,配置好tasks.json和launch.json,就能F5直接运行。如果你嫌配置麻烦,C-Free 5.0这种老牌IDE更省心,新建一个C源文件,写好代码直接点编译运行,非常适合新手。
飞机大战只有一个源文件main.c,不涉及工程管理。后面所有代码按顺序复制到同一个文件里就能编译。需要说明的是,下面代码用到conio.h和windows.h,这两个头文件是Windows平台提供的,Linux和macOS下会报错。如果你是Mac或Linux用户,建议先在Windows机器上跑,或者把控制台相关代码换成ncurses库。
3.2 全局定义与初始化函数实现
先把需要用到的头文件、常量和全局变量列出来:
#include <stdio.h> #include <stdlib.h> #include <time.h> #include <conio.h> #include <windows.h> #define W 30 // 游戏区域宽度 #define H 20 // 游戏区域高度 #define MAX_BULLETS 50 #define MAX_ENEMIES 10 typedef struct { int x; int y; int alive; } Bullet; typedef struct { int x; int y; int alive; } Enemy; Bullet bullets[MAX_BULLETS]; Enemy enemies[MAX_ENEMIES]; int playerX, playerY; int score = 0; int lives = 3;这里W和H用宏定义而不是魔法数字,是写C语言项目的基本习惯。后面所有地方都用W和H,调整游戏区域大小只改这一处就行,不用到处找数字替换。
初始化函数负责把玩家放到屏幕底部中央,把所有子弹和敌机标记为死亡状态:
void initGame() { playerX = W / 2; playerY = H - 1; for (int i = 0; i < MAX_BULLETS; i++) { bullets[i].alive = 0; } for (int i = 0; i < MAX_ENEMIES; i++) { enemies[i].alive = 0; } }注意初始化一定要在游戏开始前调用,否则数组里存的是垃圾值,alive可能随机是1,会出现“一开局满屏子弹”的诡异画面。
3.3 子弹发射与敌机生成:两个最简单的函数
发射子弹的逻辑前面说过:遍历子弹数组,找第一个alive=0的空位,把子弹安排在玩家头顶,标记存活:
void fireBullet() { for (int i = 0; i < MAX_BULLETS; i++) { if (!bullets[i].alive) { bullets[i].x = playerX; bullets[i].y = playerY - 1; bullets[i].alive = 1; break; } } }这里有个小细节:子弹的y是playerY - 1,也就是玩家头顶正上方。玩家打出的子弹总感觉是从机头发射的,实际上就是坐标偏移一个位置。
生成敌机的函数也用同样的遍历思路,先判断概率,再找空位:
void spawnEnemy() { if (rand() % 100 < 12) { for (int i = 0; i < MAX_ENEMIES; i++) { if (!enemies[i].alive) { enemies[i].x = rand() % W; enemies[i].y = 0; enemies[i].alive = 1; break; } } } }这个函数每帧都被主循环调用一次。12%的概率听起来不高,但程序一秒钟刷新大约16-20帧,实际效果是每秒钟出现两三个敌机,节奏比较紧凑。
3.4 位置更新:子弹往上飞,敌机往下落
游戏逻辑更新集中在两个函数里,一个管子弹,一个管敌机:
void updateBullets() { for (int i = 0; i < MAX_BULLETS; i++) { if (bullets[i].alive) { bullets[i].y--; if (bullets[i].y < 0) { bullets[i].alive = 0; // 飞出了屏幕顶部 } } } } void updateEnemies() { for (int i = 0; i < MAX_ENEMIES; i++) { if (enemies[i].alive) { enemies[i].y++; if (enemies[i].y > H - 1) { enemies[i].alive = 0; // 漏到了屏幕底部 lives--; if (lives < 0) lives = 0; } } } }子弹和敌机的移动就一个字段的加减,但背后有个重要原则:先更新逻辑,再检测碰撞。因为碰撞检测依赖的是对象最新的坐标,如果先渲染再更新,玩家会看到子弹还差一格打中敌机,下一帧敌机就消失了,视觉上就会觉得莫名其妙。
3.5 碰撞检测与生命值扣除
碰撞检测是游戏的核心判断函数,我把它单独拎出来:
void checkCollision() { // 子弹打中敌机 for (int i = 0; i < MAX_BULLETS; i++) { if (!bullets[i].alive) continue; for (int j = 0; j < MAX_ENEMIES; j++) { if (!enemies[j].alive) continue; if (bullets[i].x == enemies[j].x && bullets[i].y == enemies[j].y) { bullets[i].alive = 0; enemies[j].alive = 0; score++; } } } // 敌机撞到玩家 for (int j = 0; j < MAX_ENEMIES; j++) { if (enemies[j].alive && enemies[j].x == playerX && enemies[j].y == playerY) { enemies[j].alive = 0; lives--; if (lives < 0) lives = 0; } } }有人说“continue是优化写法”,但我用continue更看重的是代码可读性。每个循环一进来先跳过dead对象,后面就不用再套一大层if了,阅读起来平铺直叙,不会嵌套三层括号。
关于命中判定,这里再补充一个容易碰到的问题:如果用坐标相等判断,子弹和敌机必须要“擦肩而过”同一个格子才能命中。但如果两者移动速度都很快,比如子弹一次跳2格,敌机一次跳2格,那子弹可能会“跨过”敌机,看起来打过了却没命中。我们这个版本速度都是1,没有问题。以后你要调速度,一定记得同步调整碰撞检测的精度。
3.6 渲染与主循环:把画面画出来
渲染函数我采用“先画到二维数组,再统一输出”的方式。这种方式比每帧逐个打印字符要稳定,也不容易闪烁:
void render() { char map[H][W]; // 1. 清空画布 for (int i = 0; i < H; i++) { for (int j = 0; j < W; j++) { map[i][j] = ' '; } } // 2. 把敌机、子弹、玩家画到画布上 for (int i = 0; i < MAX_ENEMIES; i++) { if (enemies[i].alive) { map[enemies[i].y][enemies[i].x] = 'M'; } } for (int i = 0; i < MAX_BULLETS; i++) { if (bullets[i].alive) { map[bullets[i].y][bullets[i].x] = '|'; } } map[playerY][playerX] = 'A'; // 3. 输出边框和画布内容 system("cls"); printf("+"); for (int i = 0; i < W; i++) printf("-"); printf("+\n"); for (int i = 0; i < H; i++) { printf("|"); for (int j = 0; j < W; j++) { printf("%c", map[i][j]); } printf("|\n"); } printf("+"); for (int i = 0; i < W; i++) printf("-"); printf("+\n"); printf("score: %d lives: %d\n", score, lives); }这里用字母符号区分对象:A是玩家飞机,M是敌机,竖线是子弹。你可以换成任意字符,比如^表示玩家、@表示敌机,调成自己喜欢的样子。
主循环是整段程序的心脏,把前面所有函数串起来:
int main() { srand((unsigned)time(NULL)); initGame(); while (lives > 0) { // 输入处理:非阻塞检测按键 if (_kbhit()) { char ch = _getch(); if (ch == 'a' && playerX > 0) playerX--; else if (ch == 'd' && playerX < W - 1) playerX++; else if (ch == 'w' && playerY > 0) playerY--; else if (ch == 's' && playerY < H - 1) playerY++; else if (ch == ' ') fireBullet(); } // 逻辑更新 spawnEnemy(); updateBullets(); updateEnemies(); checkCollision(); // 渲染画面 render(); // 控制帧率 Sleep(60); } printf("Game Over! final score: %d\n", score); system("pause"); return 0; }主循环里有个新知识点:_kbhit()和_getch()。_kbhit()是非阻塞的键盘检测函数,有按键输入就返回非0,没有按键立刻返回0,程序不会卡住等待。_getch()从缓冲区读一个字符,但不显示在屏幕上。这两个函数配合起来,游戏才能做到“玩家不按任何键时,敌机依然往下掉”。
之所以用_kbhit而不是直接用scanf或getchar,是因为后者都是阻塞式输入,程序会停在那边等你敲回车。游戏里一旦阻塞,画面就定格了,这显然是行不通的。
3.7 完整可运行源码:整合成一个main.c
上面这些函数按顺序放进同一个main.c,就是一个能直接运行的飞机大战。为了让你省去拼接的麻烦,我把完整代码整理如下,复制保存成main.c后编译运行即可:
#include <stdio.h> #include <stdlib.h> #include <time.h> #include <conio.h> #include <windows.h> #define W 30 #define H 20 #define MAX_BULLETS 50 #define MAX_ENEMIES 10 typedef struct { int x; int y; int alive; } Bullet; typedef struct { int x; int y; int alive; } Enemy; Bullet bullets[MAX_BULLETS]; Enemy enemies[MAX_ENEMIES]; int playerX, playerY; int score = 0; int lives = 3; void initGame() { playerX = W / 2; playerY = H - 1; for (int i = 0; i < MAX_BULLETS; i++) bullets[i].alive = 0; for (int i = 0; i < MAX_ENEMIES; i++) enemies[i].alive = 0; } void fireBullet() { for (int i = 0; i < MAX_BULLETS; i++) { if (!bullets[i].alive) { bullets[i].x = playerX; bullets[i].y = playerY - 1; bullets[i].alive = 1; break; } } } void spawnEnemy() { if (rand() % 100 < 12) { for (int i = 0; i < MAX_ENEMIES; i++) { if (!enemies[i].alive) { enemies[i].x = rand() % W; enemies[i].y = 0; enemies[i].alive = 1; break; } } } } void updateBullets() { for (int i = 0; i < MAX_BULLETS; i++) { if (bullets[i].alive) { bullets[i].y--; if (bullets[i].y < 0) bullets[i].alive = 0; } } } void updateEnemies() { for (int i = 0; i < MAX_ENEMIES; i++) { if (enemies[i].alive) { enemies[i].y++; if (enemies[i].y > H - 1) { enemies[i].alive = 0; lives--; if (lives < 0) lives = 0; } } } } void checkCollision() { for (int i = 0; i < MAX_BULLETS; i++) { if (!bullets[i].alive) continue; for (int j = 0; j < MAX_ENEMIES; j++) { if (!enemies[j].alive) continue; if (bullets[i].x == enemies[j].x && bullets[i].y == enemies[j].y) { bullets[i].alive = 0; enemies[j].alive = 0; score++; } } } for (int j = 0; j < MAX_ENEMIES; j++) { if (enemies[j].alive && enemies[j].x == playerX && enemies[j].y == playerY) { enemies[j].alive = 0; lives--; if (lives < 0) lives = 0; } } } void render() { char map[H][W]; for (int i = 0; i < H; i++) { for (int j = 0; j < W; j++) map[i][j] = ' '; } for (int i = 0; i < MAX_ENEMIES; i++) { if (enemies[i].alive) map[enemies[i].y][enemies[i].x] = 'M'; } for (int i = 0; i < MAX_BULLETS; i++) { if (bullets[i].alive) map[bullets[i].y][bullets[i].x] = '|'; } map[playerY][playerX] = 'A'; system("cls"); printf("+"); for (int i = 0; i < W; i++) printf("-"); printf("+\n"); for (int i = 0; i < H; i++) { printf("|"); for (int j = 0; j < W; j++) printf("%c", map[i][j]); printf("|\n"); } printf("+"); for (int i = 0; i < W; i++) printf("-"); printf("+\n"); printf("score: %d lives: %d\n", score, lives); } int main() { srand((unsigned)time(NULL)); initGame(); while (lives > 0) { if (_kbhit()) { char ch = _getch(); if (ch == 'a' && playerX > 0) playerX--; else if (ch == 'd' && playerX < W - 1) playerX++; else if (ch == 'w' && playerY > 0) playerY--; else if (ch == 's' && playerY < H - 1) playerY++; else if (ch == ' ') fireBullet(); } spawnEnemy(); updateBullets(); updateEnemies(); checkCollision(); render(); Sleep(60); } printf("Game Over! final score: %d\n", score); system("pause"); return 0; }如果你用的编译器比较老,_kbhit和_getch不识别,可以把下划线去掉,改成kbhit()和getch(),但要注意Visual Studio下会提示使用_kbhit更安全,两者本质是同一个函数族。
4. 常见问题与排查技巧实录
4.1 中文乱码:编码不统一
很多新手在printf里写“得分:”,一运行满屏乱码。这本质是源文件编码和控制台编码不一致。解决方式有两个:要么在printf里用英文,代码示例里我全部用英文提示,就是为了避开编码坑;要么统一编码为UTF-8后手动切换控制台代码页。对新手来说,直接英文最省事,等以后做中文界面再研究编码也不迟。
4.2 画面闪烁:遇到了先别慌
system("cls")每次清空整个屏幕再重新打印,一快一慢之间肉眼能看到闪烁感,尤其当Sleep延时调得很小时更明显。这是控制台程序的天生缺陷,想彻底解决有两种办法:
第一种是改用光标定位代替清屏。用SetConsoleCursorPosition把光标移动到左上角,再重新打印整个画布,不清屏、不强刷,闪烁感会大幅降低。第二种是使用双缓冲输出,先在内存里构建好整块字符串,再一次性输出。这两种优化对新手来说可以放一放,能正常跑起来最重要。
4.3 按键粘滞或“按一下走好几格”
如果你按住A不放,飞机会一直向左移动,这是正常现象,因为程序每帧都在读键盘缓冲区。但有时候你明明只按了一下,飞机却跳了两三格,原因通常是帧率太低或者_getch读到了重复触发。可以试试把Sleep调小到40-50ms,或者减少system("cls")带来的耗时。如果问题依旧,说明键盘缓冲里积压了多个按键事件,可以在读取循环里多取几次直到缓冲区清空,但零基础先不用管这个。
4.4 子弹发不出去或敌机卡在屏幕里
最常见的原因是对象池满员了。如果for循环遍历所有子弹,发现alive的子弹占满了50个,新子弹就找不到空位。解决方法是把MAX_BULLETS调大,或者在发射逻辑里允许清除最早的那颗子弹。敌机卡住不算卡,只是生成概率低、数量少,看起来“半天不出来”。想确认到底是代码问题还是概率问题,最简单的方法是临时把概率改成100,如果每帧都出敌机,说明逻辑没问题,纯粹是概率太低。
还有一个隐藏坑:如果你忘记调用srand((unsigned)time(NULL)),每次启动时随机数序列一样,玩家会发现敌机“永远按同一个剧本出现”,虽然不影响运行,但游戏体验非常奇怪。
4.5 调试技巧:打印法永远的神
写游戏遇到问题时,别急着猜。我调试这种小型C语言项目,最喜欢在关键位置加printf,把每次更新的坐标、alive状态打印出来。比如子弹明明飞出去了但敌机不死,就把子弹坐标和敌机坐标都打一遍,看一眼就知道是碰撞条件写错还是坐标不在同一个格子里。调试完再把这些printf删掉,或者用注释包起来。
下表汇总了我做这个项目时踩过的坑和对应的解决办法:
| 问题现象 | 可能原因 | 处理方案 |
|---|---|---|
| 中文乱码 | 源文件编码与控制台编码不一致 | 先统一用英文输出 |
| 画面闪烁 | system("cls")反复清屏 | 用光标定位或双缓冲,调大Sleep |
| 按一下走好几格 | 键盘缓冲积压、帧率太低 | 调小Sleep或循环读取缓冲区 |
| 子弹打不到敌机 | 坐标偏移、移动速度过快 | 检查坐标更新顺序、降低速度 |
| 敌机太多或太少 | 生成概率不合适 | 调rand() % 100后的阈值 |
| 生命变成负数 | 扣命逻辑没做下限保护 | 扣命后判断if (lives < 0) lives = 0 |
| 每次敌人出现顺序相同 | 忘记设置随机种子 | 调用srand((unsigned)time(NULL)) |
最后再分享两个我自己常用的调试小技巧。第一是“放慢速度看过程”,把Sleep临时调大到500毫秒,画面更新就会像慢动作回放,敌机怎么生成、子弹怎么飞、碰撞在哪一帧发生,看得一清二楚。第二是“单步加日志”,在checkCollision函数里临时加一行printf("hit! x=%d y=%d\n", ...),打中一次就输出一次,能快速确认碰撞逻辑到底有没有执行。等游戏稳定了,再把这些辅助代码清掉。
这个项目做完之后,你可以顺着几个方向继续扩展:把alive数组改成链表,用指针动态管理子弹和敌机,顺便练一下malloc和free;给游戏增加难度关卡,分数越高敌机越快;用文件读写把最高分保存到本地,下次启动还能读取;甚至可以把控制台switch成EasyX图形界面,所有逻辑都不用大改,只是把渲染部分换掉。这些扩展每一步都能逼着你再学一遍C语言里那些“背过但没用过”的知识点,这才是做项目最大的收获。