尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++控制台飞机大战实战:从游戏循环到碰撞检测的核心教程

C++控制台飞机大战实战:从游戏循环到碰撞检测的核心教程 我至今还记得第一次在命令行里跑起自己写的飞机大战时的感觉——屏幕上用字符拼出来的小飞机左右挪动子弹一格一格往上飞敌机从顶部往下压撞在一起时满屏数字开始跳动。虽然画面简陋得寒酸但它确实是我用C写出的第一个“活”的东西。如果你刚学完循环、数组和函数正愁找不到一个合适的练手项目或者你只是好奇不装游戏引擎、不开图形库纯靠C能不能做出一个能玩的游戏那么控制台版飞机大战的拆解应该能帮到你。这个项目不涉及复杂库和框架却能把C最核心的语法全串起来而且做完之后你会对“程序是怎么让屏幕动起来”这件事有一个特别直观的理解。1. 为什么在图形引擎满天飞的年代我仍建议你用控制台写一次飞机大战很多初学者一上来就想着用 EasyX、SFML、Unity 做游戏结果光是把环境配好、把库的 API 弄明白就耗掉了大半力气最后写出来的代码全是照着别人的示例抄自己脑子里还是一团浆糊。控制台版飞机大战相反它把“图形渲染”这件事压缩到极致——你不需要知道纹理、贴图、精灵这些概念只需要理解一件事屏幕上显示的每一个字符本质上都是二维数组里的一个元素。这个项目里每一个核心的 C 知识点都不是纸面概念而是当场就要用到的工具循环游戏主循环、遍历子弹数组、遍历敌机数组全是死在循环上数组地图、子弹坐标、敌机坐标、分数面板都需要数组来存储函数移动、射击、绘制、碰撞检测拆成函数才能让代码不臭结构体/类如果你想把代码写出人样至少会用结构体把“飞机”的坐标和生命值捆在一起。相比之下用图形库写飞机大战你要同时面对事件系统、渲染循环、资源管理这些额外负担。不是说图形库没用而是第一遍练手时它们会把“游戏逻辑”和“表现层”混在一起让你分不清到底哪些是自己写的逻辑哪些是库帮你实现的。方案环境成本对C核心语法覆盖上手难度可玩性控制台版本文方案只需编译器VSCodeMinGW或VS都行高语法全裸奔低中EasyX图形版需要额外下载安装但不大中很多逻辑被绘制函数替代中高SFML/Qt版环境配置复杂链接库烦人低大量时间花在API上高高所以我的建议很直接如果你想认认真真把 C 基本功打牢或者想快速获得“我能用代码写出东西”的信心控制台版是性价比最高的选择。等这版跑通了再去碰图形库你会发现自己对游戏循环、状态抽象的理解完全不一样。再补充一句这个项目不需要“写得多大”。我当时写的第一个版本只有不到三百行可玩性已经够了。项目成功的标准不是代码行数而是你亲手把“输入-逻辑-输出”的闭环跑通并且能解释每一行代码为什么存在。2. 先别急着写飞机把“主循环双缓冲”这两个地基打好飞机大战再小也是一个实时游戏。实时游戏就离不开一个东西主循环。它的天然形态是“不断刷新画面同时接收输入并更新状态”只要刷新频率足够高人眼就会把静态帧连接成动态画面。这就像手翻书每页静止的画面连续翻动就产生了动画。2.1 最基础的主循环while、Sleep、刷新频率我先给你看一个入门阶段最标准的主循环骨架#include iostream #include conio.h #include windows.h using namespace std; int main() { while (true) { // 1. 处理用户的输入方向键、空格 // 2. 根据输入更新游戏状态玩家位置、子弹位置、敌机位置 // 3. 清屏并重新绘制画面 // 4. 控制帧率这帧干完歇一下 Sleep(50); // 50毫秒 ≈ 每秒约20帧 } return 0; }很多人会问为什么要 Sleep因为如果这个循环运行得毫无节制CPU 会被一个空循环吃满机器风扇狂转。而且游戏逻辑更新速度太快也会让难度失控——一眨眼子弹就飞出屏幕了。Sleep(50) 的意思是每帧最多执行 20 次人眼看起来已经比较流畅。这里有个容易犯的低级错误把 Sleep 放在循环最前面。理论上差别不大但会影响输入响应的即时性。标准做法是“先处理输入、再更新、再绘制、最后休息”这样每一帧的开始都会尽快读取最新按键。2.2 双缓冲到底解决了什么从闪烁到稳定画面如果你天真地用 system(cls) 来清屏运行起来你就会发现画面像抽风一样闪得眼睛疼。原因是 controlC 这类清屏命令会先把整个屏幕内容抹掉再重新输出中间有短暂的全空白状态人眼对高频闪烁极其敏感。解决方式有两个层次。第一层是“减少刷新范围”也就是说不要每帧都把整个控制台全清了只更新变化的那几个坐标。但控制台里逐个坐标移动光标本身也有性能开销代码容易写得很绕。第二层也就是我推荐的做法用“内存画布”代替“屏幕直写”。你先维护一个二维字符数组想画什么就改数组里的内容这一帧所有逻辑更新完了一次性把整个数组重绘到屏幕顶部。这样做等效于双缓冲你拿着画笔画在幕后的画布上画完了再整体推到前台人眼看到的是“完整的一帧”自然不闪。最简单的实现是让光标回到左上角然后一次性输出整个数组void gotoxy(int x, int y) { COORD pos { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos); } void redraw(char map[20][40]) { gotoxy(0, 0); // 把光标移回左上角而不是清屏 for (int i 0; i 20; i) { for (int j 0; j 40; j) { cout map[i][j]; } cout \n; } }这样光标从左上角开始覆盖输出把旧画面的残留字符直接“盖”掉了没有清屏动作闪烁问题基本解决。如果你想要更彻底的双缓冲可以用 Windows 控制台 API 里的 WriteConsoleOutput 一次写一块区域但初学者用“回到左上角覆盖输出”已经足够。2.3 让光标消失、把终端窗口调小等预处理细节游戏画面上如果一直有个闪烁的下划线光标观感会很出戏。隐藏光标其实就两句 APIvoid hideCursor() { CONSOLE_CURSOR_INFO cursorInfo { 1, FALSE }; SetConsoleCursorInfo(GetStdHandle(STD_OUTPUT_HANDLE), cursorInfo); }还有个细节是终端窗口的默认大小往往太大40 列 x 20 行的小地图在默认窗口里只占一小块看起来像是缩在角落。你可以通过 system(mode con cols40 lines25) 把窗口调小或者用 SetConsoleScreenBufferSize 调整缓冲区大小。这些不是核心逻辑但能显著提升“这游戏像个正经作品”的观感。提示如果你在 Visual Studio 里跑可能需要在项目属性里把字符集改成“多字节字符集”或者在代码里同时调用 setlocale否则控制台中文会乱码。这个坑我们后面专门讲。3. 让玩家飞机动起来之前先解决“按键读取”这个隐形坑新手写游戏交互时最常见的操作是用 cin 读一个字符或者数字然后根据输入移动飞机。但你会立刻发现一个致命问题cin 会阻塞而且你按一次方向键后程序不会马上响应必须按回车才能把输入送进去。用 cin 做实时游戏交互等于游戏变成回合制按一下回车动一下。飞机大战需要的是“非阻塞按键检测”每帧循环里看一眼键盘如果按了就立刻拿到键值没按就继续执行逻辑。Windows 控制台下的经典方案是 _kbhit() 和 _getch() 这对组合声明在 conio.h 里。3.1 为什么不能用 cin缓冲区阻塞问题cin 是把输入读到标准输入流里默认遇到回车才提交。你在循环里写 cin key程序就会停在那里等用户输入游戏画面冻结。除非你另开线程否则主循环根本跑不动。而 _kbhit() 的作用是“探测键盘缓冲区里有没有内容”有就返回真没有就立即返回假。配合 _getch() 在检测到内容后读取一个按键完美契合游戏主循环。3.2 _kbhit 与 _getch 组合的正确姿势下面这段代码可以实现“按住方向键飞机连续移动”bool left false, right false, up false, down false; while (true) { while (_kbhit()) { // 把缓冲区里所有按键读干净 int ch _getch(); if (ch 224) { // 方向键是双字节第一个字节是224需要再读一次 ch _getch(); if (ch 75) left true; else if (ch 77) right true; else if (ch 72) up true; else if (ch 80) down true; } else if (ch ) { // 空格射击 shoot(); } } if (left) playerX--; if (right) playerX; if (up) playerY--; if (down) playerY; left right up down false; // 只响应一次还是按住持续移动看你的设计 Sleep(30); }这里面有一个特别容易踩的坑方向键在 Windows 控制台里不是单个字符而是“224 键值”两个字节。如果只读一次_getch() 返回的是224后面的实际键值被留在了缓冲区里下一次循环以为你又按了一个键会产生非常诡异的延迟。所以第一字节是224或0有的环境是0必须再读一次。关于“按住持续移动”和“按一下移动一格”我多说两句。连续移动的实现方式是在 _kbhit 检测到按键后设置一个布尔标记循环里根据标记持续移动。但如果你想要更丝滑的“按住加速、松开停止”就需要在检测到松开的时候重置标记。控制台 API 里没有简单好用的“按键状态查询”大多数人会选择每帧把标记清零实现“按一下只动一次”。这样操作手感偏硬但逻辑简单适合第一版。3.3 边界裁剪别让飞机飞出屏幕飞机移动到屏幕边缘时如果不加限制坐标会变成负数数组访问直接越界程序可能崩掉。边界策略很简单if (playerX 1) playerX 1; if (playerX WIDTH - 2) playerX WIDTH - 2; if (playerY HEIGHT - 5) playerY HEIGHT - 5;这里给玩家的活动区域留了一个“安全区”比地图底部高几格不至于直接贴着屏幕底边给画面留出计分位置。边界判断虽然逻辑简单但它体现了游戏设计中“约束玩家状态”的思想——规则不是凭空存在的每一条边界都是游戏性的一部分。4. 子弹、敌机与碰撞判定在字符网格里实现“命中”飞机大战最核心的玩法就两件事打中敌人、躲开敌人。这两件事拆到代码层面都是坐标和集合的运算。4.1 坐标模型的建立用二维字符数组当画布前面我提到用二维数组当画布现在具体说说。你定义一个全局二维数组 map[HEIGHT][WIDTH]每个格子存一个字符。空白格存 玩家飞机占一格用A子弹占一格用|敌机占一格用V。每帧把所有游戏对象的位置换算成数组里的字符然后一次性输出整个数组。这个模型的好处是你不需要关心“某个飞机画在屏幕上第几行第几列”你只操作数组下标。数组下标就是整个游戏世界的坐标轴。碰撞检测也天然变成“数组里某个位置的字符是什么”——如果子弹坐标那一格已经不是空格说明有东西挡着。4.2 敌机生成、移动、出界删除的实现顺序敌机不能一拥而上要有节奏地生成。最常见的做法是维护一个敌机数组每帧按概率在顶部随机列生成新敌机struct Enemy { int x, y; bool alive; }; Enemy enemies[20]; void spawnEnemy() { for (int i 0; i 20; i) { if (!enemies[i].alive) { enemies[i].x rand() % WIDTH; enemies[i].y 0; enemies[i].alive true; break; } } }生成后的移动逻辑是每帧让所有存活敌机的 y 坐标加 1。这里有个经典的数组管理问题敌机出界或被消灭后要不要从数组里删除如果频繁删除会涉及元素的搬移初学者容易写出一堆 bug。更省心的方法是“标记删除”用 alive 字段标记是否存活每帧只处理 alive 的敌机。绘制时对 !alive 的敌机不输出字符相当于它在画布上消失了。数组大小为 20但一次循环里生成敌机、移动敌机、绘制敌机都会访问这个数组。如果数组下标写错访问越界可能不会立刻崩溃而是悄悄修改了相邻内存里的数据导致屏幕上出现莫名其妙的多余字符。这类问题极难排查后面会说。4.3 碰撞检测的三种粒度单点、矩形、重叠像素我们选哪种控制台场景下每个对象占用的网格非常小玩家通常是 1x3 或 2x1 的字符区域子弹是单点。因此不需要像素级碰撞用“矩形区域相交”已经足够。判断两个矩形是否相交有一个很直观的条件两个矩形的横向区间有交集纵向区间也有交集。写成代码就是bool collide(int ax1, int ay1, int ax2, int ay2, int bx1, int by1, int bx2, int by2) { return ax1 bx2 ax2 bx1 ay1 by2 ay2 by1; }用的时候你得知道每个物体的实际占据范围。比如玩家飞机可能占 3 列中心点是 playerX那玩家横向范围就是 playerX-1 到 playerX1纵向范围是 playerY 到 playerY2。子弹就简单了是一个点 (bulletX, bulletY)把这个点扩展成 1x1 的矩形再参与判断即可。碰撞检测的时机也很重要先在内存画布里移动子弹和敌机然后立刻检测碰撞最后才绘制。如果你先绘制再检测画面会比逻辑慢一帧玩家会感觉到子弹穿过了敌机但没反应体验很差。子弹和敌机的检测循环大致长这样for (int i 0; i BULLET_MAX; i) { if (!bullets[i].alive) continue; for (int j 0; j ENEMY_MAX; j) { if (!enemies[j].alive) continue; if (bullets[i].x enemies[j].x bullets[i].y enemies[j].y) { enemies[j].alive false; bullets[i].alive false; score 10; break; } } }这里我用的是最简单的“坐标完全相等”来判断因为子弹和敌机都只占一个字符。如果你的飞机体型变大了就需要矩形相交否则会出现“子弹明明打在翅膀上程序却说没打中”的尴尬。5. 计分、生命值和难度曲线把demo变成游戏的临门一脚能移动、能打子弹、能消灭敌机这已经是一个跑通的 demo 了。但它还不像游戏。游戏和 demo 的区别在哪里游戏有反馈、有风险、有节奏。反馈来自分数和音效风险来自生命值节奏来自难度曲线。5.1 用简单的计数器实现分数分数本质就是一个整数变量在碰撞检测命中时加上固定分值。但为了让玩家感受到“分数在实时跳动”你需要在绘制时把分数显示在固定位置最好跟随每一帧刷新而不是只在结算界面显示gotoxy(0, HEIGHT); cout Score: score Lives: lives ;注意这里输出末尾加一个空格是为了用空格覆盖掉上一帧残留的更长字符串。比如分数从 1000 变成 999长度不同如果不清除尾部的字符屏幕上就会出现一个旧的尾巴。这一类“残留字符”问题在控制台游戏里非常常见属于最低级的显示 bug但第一次碰到的人往往想破头也找不到原因。5.2 难度曲线怎么调敌机生成概率、移动速度、每帧步长难度曲线是游戏设计的核心。很多初学者只会做一个“敌机匀速出现”的版本玩起来索然无味。我建议你先给自己定一个简单的公式分数越高生成概率越高移动速度越快。比如我第一版用的是这样的逻辑int level score / 100 1; int spawnRate min(30, 5 level * 2); // 每帧1/30概率生成逐渐提升到1/5 int moveSpeed min(3, 1 level / 3); // 敌机每帧移动格数上限3生成概率不能一下子提高否则玩到后面满屏都是敌机根本没有走位空间。你需要自己多玩几遍调到“手忙脚乱但还能应付”的区间。这一步不是写代码是感知游戏节奏。游戏循环里 Sleep 的毫秒数也可以随着难度增加而减小相当于整体提速。但注意 Sleep 数值不宜低于 20否则控制台输出刷新会反过来成为瓶颈你调高刷新率也没有意义画面反而可能闪烁。5.3 游戏结束与重开状态机的雏形当你加上生命值就会出现三种游戏状态运行中、玩家撞机、游戏结束。最 naive 的写法是用 if 散乱判断但代码很快就会变成一团乱麻。我建议用枚举表示状态enum GameState { MENU, RUNNING, GAME_OVER }; GameState state MENU;主循环这样组织while (true) { if (state MENU) { // 显示开始界面按空格进入 RUNNING } else if (state RUNNING) { // 移动、射击、碰撞、刷新分数 // 如果 lives 0state GAME_OVER } else if (state GAME_OVER) { // 显示最终分数按R重置所有变量回到 MENU 或 RUNNING } Sleep(50); }这个状态机模式看起来简单但它会帮你建立“程序有状态”的意识。后期如果加暂停、加关卡只需要在枚举里多几个值在对应分支里加逻辑而不用把整个 main 函数推倒重来。状态机是游戏逻辑里非常基础也非常重要的组织方式。重开一局时最容易被忽略的是“把所有全局状态都清干净”。玩家坐标、子弹数组、敌机数组、分数、生命值、难度参数一个都不能漏。你可以专门写一个 resetGame() 函数把所有变量恢复到初始值。如果你把初始化写在 main 开头重开时就没法调用了。6. 跑起来之后我踩过的四个坑闪屏、乱码、CPU占用、数组越界这部分我想把实际调试过程中遇到的坑原原本本说出来。这些坑你迟早会踩提前知道能少走很多弯路。6.1 闪屏问题双缓冲写在哪一层我最早写的版本每帧用 system(cls) 清屏然后重新绘制结果闪得眼睛都花了。后来我改成 gotoxy(0,0) 覆盖输出但发现还是有轻微闪烁问题出在我每帧先清空了 map 数组然后又逐个把 map 输出中间经过了一段空白状态。正确的做法是“先改数组再一次性输出”不要在输出过程中改动数组内容。如果你的程序还是闪可以检查一下是不是用了多个 cout中间夹杂了 endl。endl 除了换行还会刷新输出缓冲区频繁 flush 也会导致闪烁。改用 \n 而不是 endl在循环里差别很大。6.2 中文乱码setlocale与UTF-8/GBK的纠缠这是我在中文 Windows 上遇到的另一个大坑。当你写 cout 得分: score编译运行后发现输出的是乱码。原因通常是源文件是 UTF-8 编码而控制台默认代码页是 GBK两者不匹配。最简单的修复是让控制台代码页跟着源文件走。在 main 开头加一行SetConsoleOutputCP(CP_UTF8);如果你的输出里包含中文并且源文件是 UTF-8这行代码基本能解决。但如果你的源文件是 GBK 编码可能需要 setlocale(LC_ALL, zh_CN.GBK) 或者干脆全用英文。我个人更倾向在控制台游戏里用英文单词做界面文本因为控制台对中文的排版宽度问题很麻烦——一个中文占两个英文字符宽度你在计算坐标时经常要额外处理非常分心。等代码逻辑成熟后再换 EasyX 图形界面时处理中文字体才舒服。6.3 CPU占用过高Sleep精度与无输入延时如果你的循环里没有 Sleep或者 Sleep 参数是 0CPU 占用率会直接飙到一核满载。这个很好理解我就不多说了。但还有一个隐蔽的问题当玩家没有任何操作时_kbhit() 会一直轮询这个轮询本身不消耗太多 CPU但结合高频率循环还是有压力。合适的做法是保持 30ms 到 50ms 的固定帧间隔。另外提醒一句Windows 的 Sleep 并不是高精度定时器它可能出现 15.6ms 左右的最小睡眠单位导致实际帧率和你算的不完全一致。对飞机大战这个精度要求来说完全够用不用纠结。如果你以后写需要精确到毫秒级节奏的游戏再考虑用 timeSetEvent 或者高分辨率计数器。6.4 数组越界调试器报错但你找不到的经典场景数组越界是 C 新手最容易犯、也最难查的错误之一。比如你定义敌人数组 int ex[20]在循环里写成 for (int i 0; i 20; i)最后那次访问 ex[20] 已经越界。有些平台下你不会马上崩溃而是读取了紧挨着数组的那块内存数值随机敌机位置变得魔幻。排查这类问题我不建议只看代码。你可以先给数组“灌毒药”初始化时把所有数组元素设成特殊值比如 -1循环里访问每个元素前断言它的下标范围。一旦某个坐标变成 -1你立刻就能在绘制时发现异常。还有一个笨但有效的办法把数组长度临时放大十倍如果程序运行得正常了说明你原来的数组长度不够而不是逻辑写错。排查结束后改回正常长度游戏就越界崩溃这类问题就少很多。7. 跑通之后还能怎么进化三个方向任选第一版控制台飞机大战跑通你已经拥有一个完整的游戏循环和一套清晰的对象模型下一步就要看你自己的兴趣在哪。7.1 面向对象重构把飞机、子弹、敌机抽象成类如果你正在学 C 的类和对象这个项目是你练手的最好材料。你可以定义一个 Plane 类成员变量有坐标、生命值、攻击力成员函数有 move()、shoot()、hit()。敌机和子弹也各自抽象成类。重构完之后你会发现碰撞检测循环里不再需要一堆散落的数组变量只需要遍历 object 列表。更重要的是你会切身理解“封装”到底封装了什么——它让对象内部的实现细节不暴露给外部逻辑。7.2 存档与最高分用文件流接住玩家的成就感一个能记录最高分的游戏给人的感受完全不同。用 C 的标准文件流就能做游戏结束时把最高分写入二进制或文本文件下次启动时读出来显示在标题界面。这里你还会顺带练习到文件打开失败的判断、读写状态的注意点。别小看这个功能它会让你的项目从“一次性玩具”变成“能留下来反复玩的作品”。7.3 音效、动画和中文界面如果你想让画面不那么寒酸控制台版最让人遗憾的就是“看起来不够酷”。你可以用 system(color 0A) 把整个终端变成经典DOS风格的绿色荧光效果也可以用 Beep() 函数在发射子弹时发一个短音虽然声音粗糙但反馈感瞬间上来了。如果你愿意踏入图形世界EasyX 的上手路径很平滑——同一套游戏逻辑只需要把“绘制字符数组”换成“绘制矩形和图片”碰撞和状态机这部分代码基本不用动。这也是我一直推荐先做控制台版的原因逻辑和表现分离你在这套小项目里学会的组织能力迁移到图形版时全部都是收益。我个人最喜欢的后续方向是先去重构类设计再把最高分存到文件里。因为这两个方向的代码量不大但会让你把一个“能玩的 demo”升级成“像样的项目”这个过程中的成就感会推着你继续写更多的代码。等哪天你把控制台版玩腻了再回头给飞机换一张图形皮肤的瞬间你会特别庆幸自己当初没有一头扎进图形库的汪洋大海而是老老实实地从字符网格开始搭起了整个世界。
返回列表