news 2026/10/18 1:50:33

C++实战:从零开发飞机大战游戏,掌握面向对象与游戏循环

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++实战:从零开发飞机大战游戏,掌握面向对象与游戏循环

1. 从零上手:为什么用C++写飞机大战是个靠谱的练手项目

很多人第一次接触编程语言,学完变量、循环、函数之后,最大的困惑就是:接下来该干什么?语法书翻完了,练习题也做了不少,但真要自己从零写一个能跑起来、能玩、有反馈的东西,脑子里一片空白。如果你正好处于这个阶段,又恰好选了C++作为主力语言,那我真心建议你拿“飞机大战”这个项目来练手。它足够小,小到一个周末就能看到雏形;又足够完整,完整到能让你把面向对象、容器管理、碰撞检测、游戏循环这些核心概念全部串一遍。

我自己带过不少刚入门的朋友,发现一个规律:凡是能独立把飞机大战写完并且调通的人,后面学数据结构、学设计模式、学图形界面,心理障碍会小很多。因为这个项目逼着你面对真实问题——敌机怎么生成、子弹怎么回收、碰撞怎么判断、帧率怎么稳定。这些问题在教科书里是分散的知识点,在飞机大战里是一个个必须解决的坑。

这篇文章面向的是有C++基础语法知识、但没做过完整项目的小白。我会把整个开发过程拆开,从环境选择到代码结构,从核心逻辑到调试技巧,尽量把每一步的“为什么”讲清楚。你不需要有游戏开发经验,也不需要懂图形学,只要会写类、会用vector、能看懂简单的循环,就能跟着走下来。文章里涉及的工具和库都是常见选择,代码示例可以直接参考复现。

提示:本文假设你使用的是Windows或Linux桌面环境,有基本的命令行操作能力。如果你用的是Mac,大部分内容同样适用,只是编译命令需要微调。

2. 动手之前的整体设计:别急着写代码,先把思路理清楚

2.1 为什么选控制台版本而不是直接上图形库

很多教程一上来就让你装EasyX、SFML或者SDL,结果环境配置卡了半天,代码还没写一行,热情先耗掉一半。我的建议是:第一版飞机大战,用控制台字符界面来做。原因有三点。

第一,控制台版本不需要额外依赖,Windows下用<windows.h>里的SetConsoleCursorPosition就能控制光标位置,Linux下用ANSI转义序列也能实现类似效果。你不需要下载任何第三方库,编译器自带的功能就够用。第二,字符界面把“渲染”这件事简化到了极致——所谓画飞机,就是在指定坐标打印一个字符。这样你可以把全部精力放在游戏逻辑上,而不是纠结纹理、精灵、图层这些概念。第三,控制台版本的调试成本极低,哪里不对直接打印出来看,不需要断点调试图形缓冲区。

等你把控制台版本写通了,逻辑全部跑顺了,再迁移到图形库,你会发现只是把“打印字符”换成“绘制图片”,核心架构几乎不用动。这个迁移路径对小白来说是最友好的。

2.2 游戏循环:整个项目的发动机

飞机大战的本质是一个循环,每一帧做四件事:处理输入、更新状态、检测碰撞、渲染画面。这个循环跑得够快,画面就流畅;循环里任何一步卡住,游戏就顿挫。控制台版本一般用Sleep(50)或者Sleep(30)来控制帧率,对应大约20帧或33帧每秒。别小看这个数字,字符界面下20帧已经足够流畅了。

这里有个关键点:游戏循环里绝对不能有阻塞操作。比如你不能用cin >>去等用户输入,因为那会卡住整个循环。正确的做法是用_kbhit()检测是否有按键,有的话再用_getch()读取。这两个函数在<conio.h>里,Windows和大部分Linux编译器都支持。

while (gameRunning) { handleInput(); // 非阻塞检测按键 updateGame(); // 更新所有对象位置 checkCollision(); // 碰撞检测 render(); // 清屏并重新绘制 Sleep(30); // 控制帧率 }

这个结构看起来简单,但它是所有实时游戏的骨架。你后面学的Unity、Unreal,本质上也是这个循环,只是引擎帮你把细节封装了。

2.3 面向对象设计:三个核心类就够了

小白最容易犯的错误是把所有逻辑塞进main函数,写到最后几百行,改一个地方牵动全身。飞机大战的对象很明确:玩家飞机、敌机、子弹。每个对象都有自己的位置、速度、存活状态,以及更新和绘制的方法。用类来封装它们是自然而然的选择。

我建议设计三个类:Player、Enemy、Bullet。每个类至少包含坐标x, y、宽高width, height、存活标志alive,以及update()和draw()方法。玩家类额外有移动和射击逻辑,敌机类额外有自动下落逻辑,子弹类额外有飞行方向。

容器方面,用std::vector<Enemy>和std::vector<Bullet>来管理动态对象。每帧遍历这些容器,更新位置,然后移除alive == false的元素。这里有个C++的经典坑:在遍历vector的同时删除元素会导致迭代器失效。正确的做法是用“移除-擦除”惯用法,或者用std::remove_if配合erase。

bullets.erase( std::remove_if(bullets.begin(), bullets.end(), [](const Bullet& b) { return !b.alive; }), bullets.end() );

这段代码值得你背下来,后面只要涉及动态对象管理,基本就是这个套路。

3. 核心细节逐个拆解:坐标、碰撞、生成与回收

3.1 坐标系与边界处理:别让飞机飞出屏幕

控制台窗口的坐标系和数学课本上的不一样。原点(0,0)在左上角,x轴向右递增,y轴向下递增。窗口宽度一般是80列,高度25行(默认控制台大小)。玩家的活动范围是x从0到79,y从0到24。但飞机本身有宽度,所以实际边界要减去飞机宽度。

我见过不少新手写的代码,飞机走到最右边直接消失,或者坐标变成负数导致画面错乱。解决办法很简单:在移动逻辑里加边界钳制。

void Player::moveLeft() { if (x > 0) x -= speed; } void Player::moveRight() { if (x + width < SCREEN_WIDTH) x += speed; }

注意这里用的是x + width < SCREEN_WIDTH而不是x < SCREEN_WIDTH,因为飞机占多个字符宽度。如果你用单个字符表示飞机,那width就是1,逻辑一样成立。这个细节虽小,但体现了“对象有体积”的思维,后面做碰撞检测时你会感谢自己提前考虑了宽度。

3.2 碰撞检测:矩形相交就够了

飞机大战的碰撞检测不需要像素级精确,矩形相交判断完全够用。两个矩形相交的条件是:一个矩形的左边缘小于另一个矩形的右边缘,同时它的右边缘大于另一个矩形的左边缘,y轴方向同理。

bool isCollide(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; }

这个函数是整个游戏里调用最频繁的。玩家和敌机之间、子弹和敌机之间,每帧都要判断。假设屏幕上有10架敌机和20颗子弹,每帧就是200次判断,对现代CPU来说毫无压力。但如果你以后做弹幕游戏,子弹上千颗,那就需要优化了——比如用空间分割或者只检测附近的对象。不过那是后话,飞机大战这个量级,暴力遍历完全没问题。

注意:碰撞检测的时机很重要。一定要在更新完所有对象位置之后再做碰撞判断,否则会出现“子弹穿过了敌机但没检测到”的情况。因为如果先检测后更新,子弹这一帧移动了10个像素,可能直接跳过了敌机。

3.3 敌机生成:随机但不完全随机

敌机不能一次性全生成出来,那样游戏一开始就满屏敌人,玩家没法玩。也不能生成太慢,否则玩家等得无聊。常见的做法是用一个计时器,每隔一定帧数生成一架敌机,同时随着时间推移逐渐缩短生成间隔,提高难度。

int spawnTimer = 0; int spawnInterval = 30; // 初始每30帧生成一架 void updateGame() { spawnTimer++; if (spawnTimer >= spawnInterval) { spawnTimer = 0; int enemyX = rand() % (SCREEN_WIDTH - enemyWidth); enemies.push_back(Enemy(enemyX, 0)); // 难度递增:间隔逐渐缩短,但不低于10帧 if (spawnInterval > 10) spawnInterval--; } }

这里的rand() % (SCREEN_WIDTH - enemyWidth)保证了敌机不会生成在屏幕外。难度递增的逻辑让游戏有挑战性,但下限设为10帧是为了防止敌机生成过快导致玩家无法反应。这个参数你可以根据实际手感调整,我试过8帧就有点变态了,12帧又太轻松,10帧左右比较合适。

3.4 子弹回收:别让内存无限增长

子弹是消耗品,打出去之后要么击中敌机,要么飞出屏幕。无论哪种情况,子弹对象都应该被销毁。如果你不回收,子弹vector会越来越大,每帧遍历的时间越来越长,最终游戏卡死。

回收逻辑很简单:子弹飞出屏幕上边界(y < 0)就标记alive = false,击中敌机也标记alive = false。然后在每帧末尾统一清理。

void Bullet::update() { y -= speed; if (y + height < 0) alive = false; }

敌机同理,飞出下边界就标记死亡。玩家被击中则游戏结束,不需要回收玩家对象。

这里有个性能上的小技巧:子弹和敌机的vector可以预留容量,比如bullets.reserve(100),避免频繁扩容。虽然飞机大战的规模用不上这个优化,但养成习惯没坏处。

4. 完整实操流程:从建工程到跑通第一版

4.1 环境准备与工程结构

Windows下我推荐用Visual Studio Community版,新建一个空项目,添加一个main.cpp就行。Linux下用g++直接编译,命令是g++ main.cpp -o plane -std=c++11。不需要额外链接任何库,因为控制台API是系统自带的。

工程结构建议分成三个文件:main.cpp放游戏循环和主逻辑,player.h和player.cpp放玩家类,enemy.h和enemy.cpp放敌机类,bullet.h和bullet.cpp放子弹类。如果嫌麻烦,全部塞一个文件也能跑,但分文件更清晰,后面加功能不容易乱。

头文件里记得加#pragma once或者#ifndef守卫,防止重复包含。这是C++的基本功,小白经常忽略,结果编译报一堆重定义错误。

4.2 玩家控制与射击节奏

玩家移动用A/D或者左右方向键,射击用空格。这里的关键是射击不能太快,否则子弹像激光一样,游戏没难度。也不能太慢,否则打不到敌机。我的经验是每15帧允许发射一颗子弹,对应大约每秒2发,手感比较适中。

int shootCooldown = 0; void handleInput() { if (_kbhit()) { int ch = _getch(); if (ch == 'a' || ch == 'A') player.moveLeft(); if (ch == 'd' || ch == 'D') player.moveRight(); if (ch == ' ' && shootCooldown <= 0) { bullets.push_back(Bullet(player.x + player.width/2, player.y - 1)); shootCooldown = 15; } } if (shootCooldown > 0) shootCooldown--; }

注意_getch()读取的是按键的ASCII码,方向键是双字节的,处理起来麻烦,所以建议用字母键。空格键的ASCII码是32,直接比较就行。

实操心得:_kbhit()和_getch()在Visual Studio里会报安全警告,需要在文件开头加#define _CRT_SECURE_NO_WARNINGS,或者用_kbhit()和_getch()的下划线版本。这个坑我踩过,编译报错的时候别慌,加个宏定义就好。

4.3 渲染与闪烁问题

控制台渲染最简单的做法是每帧system("cls")清屏,然后重新打印所有对象。但system("cls")有个致命问题:闪屏严重。因为清屏和重绘之间有时间差,人眼能察觉到闪烁。

解决办法是用SetConsoleCursorPosition把光标移到(0,0),然后覆盖打印,不清屏。这样只有变化的地方会更新,闪烁感大大降低。

void gotoxy(int x, int y) { COORD coord = { (SHORT)x, (SHORT)y }; SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), coord); }

每帧先gotoxy(0,0),然后逐行打印。对于空白区域,打印空格覆盖上一帧的内容。这个技巧在字符界面游戏里非常常用,值得掌握。

如果你在Linux下,可以用ANSI转义序列\033[H把光标移到左上角,效果一样。

4.4 游戏状态管理:开始、进行、结束

一个完整的游戏不能只有进行状态,还得有开始界面和结束界面。最简单的做法是用一个枚举变量gameState,取值START、PLAYING、GAMEOVER。在游戏循环里根据状态决定调用哪些更新和渲染函数。

enum GameState { START, PLAYING, GAMEOVER }; GameState state = START; while (running) { switch (state) { case START: renderStartScreen(); if (_kbhit() && _getch() == ' ') state = PLAYING; break; case PLAYING: handleInput(); updateGame(); checkCollision(); renderGame(); if (player.dead) state = GAMEOVER; break; case GAMEOVER: renderGameOver(); if (_kbhit() && _getch() == 'r') resetGame(); break; } Sleep(30); }

这个状态机结构清晰,后面加暂停功能、加关卡选择都很容易扩展。小白一开始可能觉得多此一举,但等你调试的时候就会发现,能快速切换状态比在main里写一堆if-else舒服多了。

5. 常见问题与排查技巧实录

5.1 编译报错速查表

报错信息可能原因解决方法
'rand' was not declared没包含<cstdlib>在文件开头加#include <cstdlib>
'_kbhit' was not declared没包含<conio.h>加#include <conio.h>,Linux下可能需要<ncurses.h>
unresolved external symbol函数声明了但没定义检查.cpp文件是否加入了编译
vector iterator not dereferencable遍历时删除了元素用remove_if+erase替代循环内删除
Sleep was not declared没包含<windows.h>加#include <windows.h>,Linux下用usleep

5.2 游戏逻辑常见Bug

敌机重叠:多架敌机生成在同一位置,看起来像一架。原因是随机数种子没设,每次运行rand()序列一样。解决办法是在main开头调用srand(time(0))。

子弹穿过敌机:子弹速度太快,一帧移动超过敌机高度,碰撞检测跳过了。解决办法是降低子弹速度,或者用连续碰撞检测(比较麻烦,飞机大战没必要)。

玩家被击中后还能移动:游戏结束状态没有正确切换,或者玩家死亡后没有停止更新。检查gameState的切换逻辑,确保GAMEOVER状态下不调用handleInput。

内存持续增长:子弹和敌机没有回收,vector越来越大。用任务管理器观察内存,如果每秒钟增长几十KB,基本就是这个问题。

5.3 提升手感的几个参数调整

游戏好不好玩,很大程度上取决于参数调校。以下是我反复试出来的经验值,你可以直接抄:

  • 玩家移动速度:1格/帧,太快不好瞄准,太慢躲不开
  • 子弹速度:2格/帧,比敌机下落速度快一倍左右
  • 敌机下落速度:1格/帧,后期可以加到2
  • 射击冷却:15帧,约每秒2发
  • 敌机生成间隔:初始30帧,每生成一架减1帧,最低10帧
  • 帧率:30ms/帧,约33FPS,字符界面足够流畅

这些数字不是绝对的,你可以根据自己的手感微调。但建议一次只改一个参数,改完玩两把,感受变化。同时改多个参数,你根本不知道是哪个起了作用。

避坑技巧:调试参数时,可以在屏幕角落打印当前帧数、敌机数量、子弹数量。这样你能直观看到游戏运行状态,比盲猜高效得多。

6. 从能跑到好用:几个值得加的扩展功能

6.1 分数系统与最高分记录

分数系统是最简单的正反馈机制。每击落一架敌机加10分,分数实时显示在屏幕顶部。最高分可以存到本地文件,下次启动时读取。

int score = 0; int highScore = 0; // 读取最高分 std::ifstream fin("highscore.txt"); if (fin) fin >> highScore; fin.close(); // 保存最高分 if (score > highScore) { highScore = score; std::ofstream fout("highscore.txt"); fout << highScore; fout.close(); }

文件读写是C++的基础操作,顺便练一下fstream的用法。注意文件路径用相对路径就行,别写绝对路径,否则换台电脑就找不到文件了。

6.2 多种敌机类型

只有一种敌机太单调。可以加一种“快速敌机”,下落速度是普通敌机的两倍,但分数也翻倍。再加一种“大型敌机”,需要打三下才死,但会掉落道具。

实现方式很简单:给Enemy类加一个type字段,在update()里根据类型决定速度和血量。生成时用随机数决定类型,比如70%普通、20%快速、10%大型。

enum EnemyType { NORMAL, FAST, TANK }; EnemyType type = NORMAL; int hp = 1; void Enemy::update() { switch (type) { case NORMAL: y += 1; break; case FAST: y += 2; break; case TANK: y += 1; break; } if (y > SCREEN_HEIGHT) alive = false; }

这个扩展不需要改架构,只是在现有类上加分支,非常适合小白练手。

6.3 音效与视觉反馈

控制台版本做不了复杂音效,但可以用Beep()函数发出简单提示音。击落敌机时响一声,玩家被击中时响一声长音。虽然简陋,但反馈感立刻提升一个档次。

#include <windows.h> Beep(1000, 50); // 1000Hz,持续50毫秒

视觉反馈方面,可以在击落敌机的位置打印一个“爆炸”字符,持续几帧后消失。实现方式是维护一个vector<Explosion>,每个爆炸对象有生命周期,每帧递减,归零后移除。

这些细节看起来不起眼,但它们是“能跑”和“好玩”之间的分界线。我见过太多人写完基本功能就停下了,其实再加一两百行代码,体验会好很多。

7. 我个人在实际操作中的体会

这个项目我前前后后写过三遍。第一遍是刚学C++的时候,所有代码塞在main里,写了400多行,改一个bug要翻半天。第二遍是学了类之后重构,分成三个类,代码清爽了很多,但碰撞检测写错了,子弹和敌机永远碰不上,调试了一晚上才发现是坐标算错了。第三遍是带朋友写的时候,把整个流程重新梳理了一遍,才形成现在这篇文章里的结构。

最大的体会是:先让程序跑起来,再让它跑得好。很多小白卡在“设计完美架构”上,结果一行代码没写。其实你完全可以先用最笨的办法实现功能,哪怕所有逻辑都在main里,哪怕用全局变量,哪怕碰撞检测写死了。等它能跑了,你自然知道哪里别扭,哪里需要重构。这时候再动手改,比一开始就追求完美高效得多。

另一个体会是关于调试的。字符界面游戏最大的优势就是调试直观。你可以在任何地方gotoxy然后打印变量值,立刻看到结果。我建议你在开发过程中保留一个调试开关,按F1显示碰撞框、显示坐标、显示帧数。这个习惯能帮你省下大量猜测的时间。

最后说一个心态问题。飞机大战这个项目,网上有无数现成代码,你搜一下就能找到完整实现。但抄一遍和你自己写一遍,收获完全不一样。抄的时候你觉得什么都懂,自己写的时候才发现连随机数怎么用都要查。我的建议是:可以看别人的代码找思路,但一定要自己从头写一遍,哪怕写得丑,哪怕功能少。写完这一遍,C++的面向对象、容器、循环、条件判断,你就真正用起来了,而不是停留在“我知道这个概念”的层面。

后续如果你想继续深入,可以尝试把控制台版本迁移到EasyX或SFML,加上图片和音效,做成一个真正能拿出手的小游戏。那个过程会逼你学资源管理、事件循环、渲染管线,是另一个层次的成长。但那是下一步的事了,先把控制台版本跑通,你会发现自己对“写一个完整程序”这件事的信心,完全不一样了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 1:48:45

3分钟拿到带时间戳的歌词文件:网易云QQ音乐歌词下载实操

3分钟拿到带时间戳的歌词文件&#xff1a;网易云QQ音乐歌词下载实操 【免费下载链接】163MusicLyrics 云音乐歌词获取处理工具【网易云、QQ音乐】 项目地址: https://gitcode.com/GitHub_Trending/16/163MusicLyrics 你在网易云或QQ音乐里听到一首好歌&#xff0c;想下载…

作者头像 李华
网站建设 2026/10/18 1:48:42

抖音批量下载教程:douyin-downloader 三步跑通竞品素材归档

抖音批量下载教程&#xff1a;douyin-downloader 三步跑通竞品素材归档 【免费下载链接】douyin-downloader A practical Douyin downloader for both single-item and profile batch downloads, with progress display, retries, SQLite deduplication, and browser fallback …

作者头像 李华
网站建设 2026/10/18 1:47:22

SSM整合Quartz持久化到数据库:动态定时任务管理实战

简介&#xff1a;这份资源面向Java后端开发者与需要实现定时任务动态管理的项目团队&#xff0c;提供SSM框架整合Quartz并将任务配置与状态持久化到数据库的完整示例。包内共55个文件&#xff0c;以40个jar依赖包、7个xml配置、3个properties属性文件、2个jsp页面及少量class与…

作者头像 李华
网站建设 2026/10/18 1:47:04

ESP32-C3 Super Mini WiFi连不上?十大坑与排查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/18 1:46:49

5分钟上手embeddinggemma-2-bf16:文本向量嵌入快速入门教程

【免费下载链接】embeddinggemma-2-bf16 项目地址&#xff1a; https://ai.gitcode.com/hf_mirrors/mlx-community/embeddinggemma-2-bf16 点击查看 免费下载 embeddinggemma-2-bf16 是 Google EmbeddingGemma 2 官方模型的 MLX 转换版&#xff08;BF16 精度&#xff09;&…

作者头像 李华
网站建设 2026/10/18 1:45:03

通达OA功能无限制版源码拆解:OA+ERP+CRM一体化部署与二次开发实战

简介&#xff1a;这份资源面向中小企业信息化实施人员、ERP/OA二次开发者及需要搭建协同办公系统的技术团队&#xff0c;提供通达OA功能无限制版本&#xff0c;集成ERP应用、OA手机端、CRM源码以及微信企业号接口考勤模块&#xff0c;可解决商业版本功能受限、接口封闭、移动端…

作者头像 李华