写火柴人大战这个 C++ 小游戏,最早纯粹是手痒。那阵子我在啃 C++ 的对象模型和指针,看了一堆语法书,脑子里全是"这个能干嘛",但真让我写点什么又憋不出来。后来我想通了:与其死磕概念,不如找个能立刻看到画面的东西练手。火柴人就是最好的选择——它的形体简单到用几行 ASCII 字符就能画出来,但背后的战斗逻辑、状态切换、帧循环、输入响应,一个都不少。这个项目我从一个 200 行的控制台文件,慢慢改成了一千多行、分了四个模块的小工程,中间踩的坑比我预想的多得多,尤其是环境配置和终端渲染这两块。这篇就把整个火柴人大战的思路、结构、数值、代码和踩坑记录完整捋一遍,适合刚学完语法想找个落地项目的朋友,也适合写过控制台程序但没做过游戏循环的人。
1. 先把"火柴人大战"这个项目想明白
1.1 一局游戏到底要发生什么
动手写代码之前,我把一局对战在纸上跑了一遍。玩家和敌人各站一边,各自有血量和攻击力,玩家按方向键靠近,按某个键出拳,敌人也会打回来,谁先被打空血谁输。听起来简单,但拆开看至少有四件事同时在进行:按键的读取、角色的位置和状态更新、战斗数值的结算、画面的重绘。如果这四件事搅在 main 函数的 while 里面,写不了两百行你自己就看不懂了。
所以我在纸上的第二件事,是把"角色"这个概念抽出来。它得有血量、最大血量、攻击力、防御力、当前状态(站立、前进、出拳、受击、倒地)、朝向、在屏幕上的坐标。有了这个抽象,后面所有逻辑都变成"操作一个 Fighter 对象",而不是一堆散落的 int 变量。这一步看起来是废话,但真动手写的时候,很多人就是直接从int hp = 100; int enemyHp = 100;开始的,写到后面发现要加个"能量条"就得改十几个地方。
还有一件必须提前定的事:这游戏到底是回合制还是实时制。回合制的逻辑好写,你一下我一下,用状态机切换就行,但缺乏"打起来"的感觉。实时制好看,但要对每一帧做碰撞判定、动画推进、输入缓冲,复杂度翻倍。我最后选的是半实时——用固定时间步长跑主循环,攻击动作有个 0.3 秒的前摇和后摇,这期间不能再次出拳。这样既有动作感,又把判定逻辑收拢到了一个可控的范围里。这个选择在后面的第 3 章会详细讲,因为它直接决定了主循环怎么写。
1.2 图形库还是控制台,两条路怎么选
这是绕不开的第一个岔路口。C++ 做小游戏,常见的选择无非这几种:
| 方案 | 上手成本 | 视觉效果 | 依赖 | 适合谁 |
|---|---|---|---|---|
| 纯控制台 + 字符画 | 极低 | 粗糙 | 无 | 刚学完语法的练手 |
| EasyX | 低 | 中等 | 仅 Windows + 图形库 | 想快速看到图形界面 |
| SDL2 / SFML | 中等 | 好 | 需配置库和链接 | 想认真做 2D 游戏 |
| 引擎(如某类 2D 框架) | 高 | 好 | 大量配置 | 目标明确的成品项目 |
我一开始也想过用图形库,但配环境花了一晚上之后我放弃了。原因很实在:我的目标是把 C++ 的语法和程序设计练熟,不是学图形 API。换成图形库之后,我一半的时间会花在理解渲染管线、纹理加载、坐标变换上,这些跟我当时想解决的问题没关系。而且控制台程序有个巨大的好处——跨平台几乎零成本,同一个 cpp 换个编译器就能跑,分享给别人也不用附带一堆 dll。
代价当然也有。字符画的表现力有限,没法做旋转、缩放、透明混合。为了弥补,我把力气花在了"节奏感"上:出拳有前后摇、命中会闪一下、被击退会往后退一格、血条用色块实时变化。实际玩过的人反馈是,虽然没有图像,但打击感居然有。这让我意识到一件事——游戏的手感不来自画面精度,来自反馈的及时和可预期。你的按键在 16 毫秒内就有视觉回应,比什么画面都管用。
注意:控制台方案在 Windows 和 Linux/macOS 上的终端行为不一致,尤其是清屏命令和光标定位。后面第 5 章会给出两种平台的封装写法,别直接用
system("cls"),那个跨平台跑不起来,而且在 Windows 上闪屏很明显。
1.3 工程目录怎么分
我最早的版本就是一个main.cpp,三百多行的时候还能忍,到八百行的时候改个伤害公式要在几百行里翻。后来我把它拆成了四个文件:
fireman-fight/ ├── src/ │ ├── main.cpp // 入口、主循环、输入分发 │ ├── fighter.h/.cpp // 角色结构、状态机、属性计算 │ ├── battle.h/.cpp // 伤害结算、命中判定、战斗日志 │ └── render.h/.cpp // 帧素材、缓冲区拼装、终端控制 ├── Makefile └── README.md拆分的依据是"改动的理由是否相同"。伤害公式的调整只会动battle.cpp,火柴人的帧素材只会动render.cpp,主循环的节奏只动main.cpp。同一个文件里的代码,应该是因为同一个原因被修改的——这话是我后来读代码整洁相关的东西时才看到的,但我当时就是凭直觉这么分的。如果你还在学习阶段,我建议先写单文件,等到超过四百行、开始找不到东西了再拆,提前拆分只会让人纠结"这个函数算谁的"。
编译器这边我推荐 g++(MinGW-w64 或者直接在 Linux/macOS 上),原因是命令行编译很好理解每一步在干什么。也可以用 MSVC,但它的报错信息和链接方式跟 g++ 有差别,新手容易懵。VSCode 是编辑器里比较顺手的选择,配置方式我在第 6 章会讲清楚。
2. 角色数据结构:结构体、指针与容器的取舍
2.1 一个 Fighter 里该装什么
角色的定义是整个项目的地基,字段定错了后面全是补丁。我的第一版是这么写的:
struct Fighter { std::string name; // 数值 int hp = 100; int maxHp = 100; int atk = 12; int def = 5; int energy = 0; // 能量,满了放技能 int maxEnergy = 100; // 概率,用万分比存,避免浮点 int critRate = 1500; // 15% int dodgeRate = 500; // 5% // 状态 int state = 0; // 见 4.3 的状态枚举 int stateTimer = 0; // 当前状态剩余帧数 bool facingRight = true; // 位置 int x = 0; int y = 0; // 指向对手,方便结算时不传两遍参数 Fighter* target = nullptr; };这里有几个刻意的设计,值得说一下。
第一,概率用万分比整数存,不用 float。原因是浮点比较有精度陷阱,if (rand() % 100 < 15.0f)这种写法在不同平台上的行为你要反复确认,直接用整数万分比,if (rand() % 10000 < critRate),一目了然,也不用担心浮点误差。这个习惯我是在写过一次"重置玩家属性时因为 0.1+0.2 导致的判定异常"之后养成的。
第二,能量条单独存在角色身上,而不是全局变量。我最初的版本里能量是static int energy,只有玩家有,后来想给敌人加技能,发现所有地方都要改。放进结构体之后,加一个敌人就是加一个对象,零成本。
第三,target指针。这是整个项目里指针用得最自然的地方。攻击结算时需要知道"我打的是谁",如果不用指针,你就得写dealDamage(attacker, defender),每次调用都要把两个对象都传进去。有了target,就是dealDamage(fighter),函数内部直接fighter->target。这点小便利在写 AI 的时候价值会被放大,因为 AI 的决策函数只接收一个Fighter&,它自己就知道对面是谁。
2.2 指针在这个项目里到底能干什么
很多人学 C++ 的时候都会被指针卡住,因为教材上的例子总是"交换两个变量"、"遍历数组",这些场景用引用和下标都能做,体会不到指针的必要性。但在游戏里,指针出现的理由非常自然,我给你列三个真实用到的地方。
第一个是对象间的互相引用。刚才说的Fighter* target就是。注意这里不能用引用,因为Fighter里塞一个Fighter&成员会有问题——引用必须在构造时绑定,而我想在运行中动态切换目标(比如后面加"召唤物"机制)。指针可以为空,可以在运行时改指向,这就是它比引用合适的地方。
第二个是避免对象拷贝。dealDamage(Fighter defender)这种写法会复制一份对象,你改了它的血量主对象完全不知道。正确写法是dealDamage(Fighter& defender)或者dealDamage(Fighter* defender)。引用和指针在这里都能用,我选引用的原因是它天然非空,用起来不用到处判空。
第三个是运行时创建敌人。如果敌人种类要按关卡变化,就需要在运行时决定创建什么样的敌人。这时候可以用简单的工厂:
Fighter* createEnemy(const std::string& kind) { if (kind == "grunt") { Fighter* f = new Fighter; f->name = "小兵"; f->hp = f->maxHp = 80; f->atk = 10; f->def = 3; return f; } if (kind == "brute") { Fighter* f = new Fighter; f->name = "壮汉"; f->hp = f->maxHp = 160; f->atk = 16; f->def = 8; f->critRate = 2500; return f; } return nullptr; }提示:用
new就必须有人负责delete。这个项目里我最后没用裸指针管理敌人,而是直接存值(见下一节),原因就是"谁负责释放"这个问题在一个小项目里不值得付出心智成本。上面这段代码只是用来演示指针的运行时多态能力,实际项目里建议用std::unique_ptr<Fighter>替代裸new,连手写 delete 都省了。
顺便说一个新手很容易踩的坑:结构体里的指针成员在拷贝时会"浅拷贝"。如果你的Fighter里有一个int* buffList,那你Fighter b = a;之后,两个对象指向同一块内存,改一个另一个也变,析构时还会二次释放直接崩溃。这就是常说的"三法则"问题的来源。我整个项目里尽量不在结构体里放裸指针成员,就是为了绕开这个雷区。
2.3 敌人列表用 vector 还是链表
如果是单挑,一个Fighter就够了,用不上容器。但我想做的是"闯关",一串敌人依次上,这就需要一个列表。选择很明确:std::vector<Fighter>。
为什么不选链表?链表的长处是中间插入和删除 O(1)。可我这个场景里,敌人的增删都发生在关卡开始和结束,中间没有任何插入删除操作,全程只需要按下标顺序访问和遍历。这种情况下 vector 的连续内存带来的缓存友好性,性能优势反而更大——虽然一百个以内的敌人根本谈不上性能,但代码写起来也简单得多。
如果你确实想练一下结构体链表,也可以手写一个:
struct EnemyNode { Fighter data; EnemyNode* next; }; // 头插 EnemyNode* pushFront(EnemyNode* head, const Fighter& f) { EnemyNode* node = new EnemyNode{f, head}; return node; }这个写法考试的频率极高,但真实项目里用std::vector或者std::list就行,除非有明确的理由自己管内存。不要为了练语法把生产代码写成教学代码,这是我吃过亏的地方——自己手写的链表在一个项目里因为忘记释放,跑几百局之后内存一直涨,排查了半天才想起来。
还有一个和容器配合的点:如果后面要做多人混战,出手顺序需要排序,这时候就是std::sort出场的地方:
#include <algorithm> std::vector<Fighter*> order; // ... 收集所有存活角色 std::sort(order.begin(), order.end(), [](const Fighter* a, const Fighter* b) { return a->speed > b->speed; // 速度高的先动 });std::sort默认是升序,要降序就得自己写比较函数,这里用 lambda 更紧凑。如果你坚持要手写,冒泡排序也能排对,但它是 O(n²),对七八个角色来说其实也无所谓——性能优化要看数据规模,为 8 个元素上快排属于自我感动。不过话说回来,冒泡排序写的时候有个常见错误是内层循环边界写成n-1而不是n-1-i,虽然只影响效率不影响正确性,但面试被问到会显得不熟。
3. 让游戏跑起来的主循环
3.1 固定时间步长:为什么不能直接 while(1)
新手最容易写出的主循环是这样:
while (true) { handleInput(); update(); render(); }这段代码的问题是:它在不同的机器上跑得快慢完全不同。在一台快电脑上每秒可能跑 5000 次循环,火柴人的动画一秒钟闪几百下;在慢电脑上可能只有 200 次,动作就变得拖沓。游戏的"速度"不应该取决于机器的性能,这是常识,但只有你真正遇到"在我这好好的,拷给同学就变成快进"的时候才会深刻理解。
解决办法是引入固定时间步长。核心思路是把"逻辑推进"和"画面渲染"分开:逻辑永远以固定的 1/60 秒为一步推进,渲染则能画多快画多快。中间用累加器(accumulator)连接:
#include <chrono> #include <thread> using Clock = std::chrono::steady_clock; constexpr double STEP = 1.0 / 60.0; // 每步 16.67ms void runGame() { auto last = Clock::now(); double acc = 0.0; while (!g_quit) { auto now = Clock::now(); double delta = std::chrono::duration<double>(now - last).count(); last = now; // 防"死亡螺旋":如果一帧花了太久,截断 if (delta > 0.25) delta = 0.25; acc += delta; while (acc >= STEP) { handleInput(); update(STEP); acc -= STEP; } render(); } }这里有两个细节我想强调。第一个是delta > 0.25的截断。如果你的程序被操作系统挂起了半秒(比如用户切了窗口、系统在跑杀毒扫描),恢复后delta可能是 0.5 秒,那就要一次补 30 步逻辑,补的过程中又消耗时间,下一帧的 delta 更大,直接卡死。这个现象叫"死亡螺旋",截断是最简单的解法。
第二个是为什么渲染放在内层循环外面。因为渲染一次就够了,画 30 次没人看得出来,纯属浪费。逻辑更新必须精确,渲染可以偷懒,这个区分很重要。
3.2 不需要回车的按键读取
用std::cin >> key读输入有个致命问题:它要等回车。你按一下方向键,程序毫无反应,直到你敲了回车,才能收到一堆字符。这显然不能用来做游戏操作。
Windows 下的办法是用<conio.h>里的_kbhit()和_getch():
#ifdef _WIN32 #include <conio.h> bool keyPressed() { return _kbhit() != 0; } int readKey() { return _getch(); } #endif_kbhit()是非阻塞的,有按键就返回非零,没有立即返回 0。_getch()是无回显读取,读到就走,不需要回车。这两个函数组合起来就是游戏输入的标准做法。
Linux/macOS 上没这套东西,要用termios把终端切到原始模式:
#include <termios.h> #include <unistd.h> #include <fcntl.h> static termios g_oldTermios; void enableRawMode() { tcgetattr(STDIN_FILENO, &g_oldTermios); termios raw = g_oldTermios; raw.c_lflag &= ~(ICANON | ECHO); // 关掉行缓冲和回显 raw.c_cc[VMIN] = 0; raw.c_cc[VTIME] = 0; tcsetattr(STDIN_FILENO, TCSANOW, &raw); int flags = fcntl(STDIN_FILENO, F_GETFL, 0); fcntl(STDIN_FILENO, F_SETFL, flags | O_NONBLOCK); } void disableRawMode() { tcsetattr(STDIN_FILENO, TCSANOW, &g_oldTermios); }注意:切到原始模式之后,Ctrl+C 的信号处理也会变,程序如果异常退出,终端可能停在无回显状态,用户得手动敲
reset才能恢复。所以一定要用 RAII 或者atexit保证退出时还原,这个坑我第一次写的时候踩得很惨,整个终端敲啥都不显示。
还有一个坑是方向键的处理。在 Windows 上按一下上箭头,_getch()会连续返回两个值:224 和 72。所以你不能简单地用一个 switch 处理,得先判断是不是 224 或 0,是的话再读第二个字节。这个和后面讲的字符串处理、协议解析里的"前缀字节"思想其实是一回事,理解了就好办。
3.3 输入、更新、渲染三段式的拆分
拆成三段的理由,是为了让每一段都能单独测试。输入段只负责把按键翻译成本帧的"意图",比如"玩家想往右"、"玩家想出拳",它不关心角色现在能不能动。更新段拿到意图后,结合角色当前状态判断是否允许执行——比如在出拳后摇期间,往右的意图会被忽略。渲染段只读数据不改数据。
struct InputFrame { bool left = false; bool right = false; bool punch = false; bool kick = false; bool quit = false; }; InputFrame pollInput(); void update(const InputFrame& in, double dt); void render();这个分法有个额外好处:你可以在没有键盘的时候测试游戏逻辑。写一个假的InputFrame喂给update,让它跑一万帧,看看数值会不会溢出、状态机会不会死锁。这种不依赖人手的测试方式,在调 AI 行为的时候特别有用——AI 对打本来就是自己打自己,根本不需要人参与。
4. 战斗数值与火柴人 AI
4.1 伤害公式:护甲为什么不能线性减伤
我第一版的伤害计算是这样的:伤害 = 攻击力 - 防御力。结果很快就崩了:当敌人的防御堆到 12 而玩家攻击只有 12 的时候,伤害变成 0,双方永远打不死对方,游戏卡死。
第二版改成线性减伤:伤害 = 攻击力 * (1 - def/100)。新的问题来了:def 堆到 100 就是完全免伤,堆到 99 和堆到 100 的差距是天上地下,玩家会拼命找那 1 点防御,数值设计完全没有回旋余地。而且一旦堆满,游戏又死了。
最后我用的是递减收益的经典公式:
// 返回实际的伤害值 int calcDamage(const Fighter& atk, const Fighter& def, bool isSkill, std::mt19937& rng) { // 1) 基础伤害:技能 1.8 倍 int base = atk.atk * (isSkill ? 180 : 100) / 100; // 2) 减伤:def/(def+K),K 取 100 int K = 100; int reducePct = def.def * 100 / (def.def + K); // 0~99 int dmg = base * (100 - reducePct) / 100; // 3) 浮动 ±10% std::uniform_int_distribution<int> jitter(90, 110); dmg = dmg * jitter(rng) / 100; return std::max(1, dmg); // 保底 1 点,绝不出现 0 }这个公式的好处是:def 从 0 涨到 100,减伤从 0% 涨到 50%;从 100 涨到 200,减伤从 50% 只涨到 66.7%。每一点防御都有用,但边际收益越来越小,这就给数值设计留出了空间。你可以给敌人配很高的防御,玩家也不会完全打不动,只是效率变低。这个思路在很多游戏里都能看到,本质上是把"防御"当成一个和"血量"等效的乘数因子,而不是一个减数。
那 100 这个常数怎么定?我试了三档。K=50 的时候,def 到 50 就减伤 50%,属性膨胀太快;K=200 的时候减伤涨得太慢,堆防御没感觉。K=100 的效果是"每 100 点防御等效于血量翻倍",这个换算关系好记,配数值的时候心里有数。
最后那个std::max(1, dmg)也很重要。它保证了无论防御多高,只要打中就有反馈。玩家看到飘出的 "1" 和看到 "MISS" 的心理感受是完全不同的——前者说明"我在输出,只是效率低",后者会让人怀疑自己的操作。
4.2 暴击、闪避、能量的数值门槛
概率类属性我用万分比整数,判定直接用取模:
int roll = (int)(rng() % 10000); // 先判闪避 if (roll < def.dodgeRate) { return HitResult::Dodged; } // 再判暴击 bool crit = roll < atk.critRate; // 注意:这里和闪避的判据是同一个 roll if (crit) dmg = dmg * 150 / 100;这里有个容易写错的地方:如果闪避和暴击都用同一个roll判定,那么一个角色如果同时有 50% 闪避和 50% 暴击,就会出现"要么闪要么暴"的诡异结果。正确做法是两次独立随机:
int rollDodge = (int)(rng() % 10000); if (rollDodge < def.dodgeRate) return HitResult::Dodged; int rollCrit = (int)(rng() % 10000); bool crit = rollCrit < atk.critRate;提示:随机数生成器一定要在整个游戏生命周期里只初始化一次。常见错误是每次攻击都
std::mt19937 rng(time(nullptr)),因为time(nullptr)只精确到秒,同一秒内的所有随机数会完全一样,表现为"连续几拳伤害一模一样"。把 rng 做成全局的或者当成参数往下传都行,别在循环里重新播种。
数值门槛这块我自己定的参考值是:暴击率 15%~30% 之间手感最好。低于 10% 玩家感觉不到,高于 40% 就没惊喜了。闪避率要更保守,5%~15% 为宜,因为闪避对玩家的挫败感远大于惊喜感——你打空一拳的郁闷,要用三拳命中才能补回来。
能量系统我是这么做的:每次成功命中加 20 点,被打中加 10 点,到 100 点可以放技能。这样设计的好处是劣势方也能攒能量,被打得越惨攒得越快,给了翻盘的可能。如果只让命中加能量,那就是顺风局越打越顺,劣势方毫无还手之力,体验很差。
4.3 三状态 AI 状态机
敌人的 AI 不需要多聪明,但一定不能显得傻。我用的是一个三状态的状态机:
enum class AIState { Approach, // 靠近 Attack, // 出拳 Retreat // 后撤(血少时用) }; void updateAI(Fighter& self, Fighter& foe, double dt) { int dist = std::abs(self.x - foe.x); switch (self.aiState) { case AIState::Approach: if (dist > ATTACK_RANGE) { self.x += (self.x < foe.x ? 1 : -1); } else { self.aiState = AIState::Attack; self.stateTimer = 0; } // 血量低于 30% 有一定概率后撤 if (self.hp * 100 / self.maxHp < 30 && rand() % 100 < 2) { self.aiState = AIState::Retreat; self.stateTimer = 40; // 撤 40 帧 } break; case AIState::Attack: if (self.stateTimer == 0) { if (dist <= ATTACK_RANGE) tryAttack(self, foe); self.stateTimer = 30; // 后摇 30 帧 } if (--self.stateTimer <= 0) { self.aiState = AIState::Approach; } break; case AIState::Retreat: self.x += (self.x < foe.x ? -1 : 1); if (--self.stateTimer <= 0) self.aiState = AIState::Approach; break; } }状态机的好处在于行为可预测、可调试。如果你在某个状态里发现敌人卡住了,你能立刻定位是哪个 transfer 条件写错了。如果写成一大堆 if-else 嵌套,出了问题只能靠打印日志慢慢试。
想让 AI 更"像人",可以在决策点加一点随机延迟。比如进入 Attack 状态之后,不要立刻出拳,而是随机等 5~20 帧再打。这样玩家就摸不准对手的节奏,对抗感会强很多。这是我从观察格斗游戏录像里学到的——高手的操作节奏从来不是固定的。
最后一个和数值相关的技巧。如果你以后要做"连击伤害递增"这种设计,比如每连一次伤害乘 1.15,连 10 次的倍率是 1.15^10,可以用快速幂(也就是二分幂)来算:
long long fastPow(long long base, int exp) { long long r = 1; while (exp) { if (exp & 1) r *= base; base *= base; exp >>= 1; } return r; }不过在火柴人大战这种量级下,连击最多十来次,直接循环乘就够了。别为了展示算法把本来三行能写完的代码搞成三十行,这是我给自己定的规矩,也是我看过太多"为了用上某个技巧而硬用"的项目之后的经验。
5. 火柴人在终端里动起来
5.1 帧素材怎么画、怎么对齐
火柴人的素材我就是用文本编辑器一列一列敲出来的。关键约束是每一帧的每一行宽度必须严格相等,否则拼接到一起会全乱。用 raw string 可以省掉反斜杠转义的麻烦:
using Frame = std::vector<std::string>; const Frame FRAME_IDLE = { R"( ___ )", R"( /o o\ )", R"( \ - / )", R"( /| |\ )", R"( | | )", R"( / \ )", R"(/ \)" }; const Frame FRAME_PUNCH = { R"( ___ )", R"( /o o\ )", R"( \ > / )", R"( /|_|==)", R"( | | )", R"( / \ )", R"(/ \)" };每一行是 7 个字符宽。为什么要强调这个?因为绘制逻辑是"逐行拼接"的,行宽不齐就会错位。而且要注意R"(...)"的定界符,如果你的字符画里出现了)"这个序列(比如画了个括号表情),就要换成更长的定界符R"RAW(...)RAW",这是个很隐蔽的坑。
还有一个必须提前想到的问题:中文字符的宽度。在终端里一个汉字占两个字符格,一个 ASCII 字符占一个。如果你的界面里有中文标签,比如血条旁边写"玩家",那这一行的实际显示宽度和字符串长度就对不上,右边的元素会错位。我的做法是把所有可变的英文/数字部分先格式化好,再统一按显示宽度补空格。这需要自己写一个"计算显示宽度"的函数,规则是:字节的高位是 1 就说明这个字符是多字节的,宽度算 2。
int displayWidth(const std::string& s) { int w = 0; for (size_t i = 0; i < s.size(); ) { unsigned char c = (unsigned char)s[i]; if (c < 0x80) { w += 1; i += 1; } else { w += 2; i += 3; } // 简化处理,假设为三字节 UTF-8 汉字 } return w; }这个简化处理对纯中文加 ASCII 的混排是够用的,遇到四字节字符(比如某些特殊符号)会算错。如果你追求严谨,得完整实现 UTF-8 解码。不过在火柴人大战里,大部分元素都是 ASCII 字符画,中文字母只出现在名字和提示上,简化版完全够用。
5.2 双缓冲与防闪烁
最开始我用的是每帧system("cls")然后重新打印。结果就是整个屏幕疯狂闪烁,根本看不清。原因有两个:一是system("cls")会启动一个新的命令行进程,开销极大;二是清屏和重绘之间有肉眼可见的时间差。
解决办法有两个层面。第一个层面是不清屏,把光标移回左上角。在 Windows 上:
#ifdef _WIN32 #include <windows.h> void moveCursorToHome() { HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); COORD pos = {0, 0}; SetConsoleCursorPosition(h, pos); } #endifLinux 上更简单,直接输出 ANSI 转义序列"\033[H"就行。
第二个层面是双缓冲。不要边拼边输出,而是先在内存里把整帧画面拼成一个完整的std::string,然后一次性std::cout << buffer(配std::flush)。这样终端拿到的是一个大块数据,渲染过程不会被人看到,闪一下就完成了。
std::string buildFrame(const Fighter& player, const Fighter& enemy) { std::string buf; buf.reserve(4096); // 预留空间,减少重新分配 // 顶部:血条 buf += renderHealthBar(player, 30); buf += " VS "; buf += renderHealthBar(enemy, 30); buf += "\n\n"; // 中部:两个角色并排 for (int row = 0; row < 7; ++row) { int pad = player.x; buf.append(pad, ' '); buf += FRAME_IDLE[row]; buf.append(std::max(0, enemy.x - player.x - 7), ' '); buf += FRAME_IDLE[row]; buf += '\n'; } return buf; }reserve(4096)这个细节值得一提。字符串每次扩容都要重新分配内存并拷贝,虽然现代标准库有指数增长策略,但在 60 帧每秒的循环里,预分配能省下可观的分配次数。这不是必须的优化,但习惯性地预留一下没坏处。
另外,如果你发现某个角色被渲染了两遍、闪烁得很规律,八成是因为你两处都输出了同一个对象。我第一次遇到闪烁排查了半小时,最后发现是 update 里误调了一次 render。
注意:Windows 的控制台默认代码页可能是 GBK,直接输出 UTF-8 中文会乱码。程序开始时加一行
SetConsoleOutputCP(65001);切到 UTF-8,同时把你编辑器的文件保存编码也设成 UTF-8,两边一致才不会出问题。
5.3 命中反馈与击退位移
前面说过,手感来自反馈。我加了三个小效果,加起来不到五十行代码,但对体验的提升非常明显。
第一个是命中闪白。角色被击中的那几帧,把它的字符画整体换成一组全角符号,或者简单地改成大写字母。我的做法是存一个hitFlashFrames计数器,大于 0 的时候渲染用另一套帧:
if (fighter.hitFlash > 0) { fighter.hitFlash--; // 用高亮帧 line = HIT_FRAME[row]; } else { line = IDLE_FRAME[row]; }第二个是击退位移。命中时给受击方一个反向的位移速度,每帧衰减,看起来就是被打得踉跄后退:
void applyKnockback(Fighter& f, int dir, int power) { f.knockback = dir * power; // power 一般取 3~5 } // 在 update 里 if (f.knockback != 0) { f.x += f.knockback; f.knockback = f.knockback * 7 / 10; // 每帧衰减 30% if (std::abs(f.knockback) < 1) f.knockback = 0; }这个衰减用的是整数运算* 7 / 10,比浮点稍快,而且不会因为浮点累积误差导致永远停不下来。任何"衰减到零"的逻辑都要有个硬性阈值兜底,否则会出现角色被推动之后永远以 0.0001 的速度漂移的情况。
第三个是有意的输入延迟。听起来反直觉,但格斗游戏里攻击是有前摇的。如果按下按键瞬间就造成伤害,会感觉很飘。我给出拳动作设了 5 帧前摇(约 83 毫秒),伤害在这 5 帧之后才结算。玩家会感觉"我蓄力打出了这一拳",重量感就出来了。
这三个效果单独看都很小,组合起来就是"打起来有感觉"。我做游戏最大的体会是:细节的叠加效应远大于单个大功能。与其加一个新技能,不如把现有的普通攻击打磨到位。
6. 踩过的坑与排查手册
6.1 编译与开发环境的那些报错
先讲一个跟项目本身没关系但热词里出现的报错:error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Build Tools"。这个报错几乎不会出现在你写 C++ 游戏的时候,它通常出现在你用 Python 的 pip 安装某些需要编译 C 扩展的第三方包时。Python 在装这类包的时候需要本地有 C++ 编译器,找不到就报这个。解决办法是安装 Microsoft C++ Build Tools,安装的时候务必勾选"使用 C++ 的桌面开发"这一项,光装个空壳是没用的。如果你只是写 C++ 游戏,用 g++ 完全不用管这个报错。
再讲一个你以后发游戏给别人时会遇到的:对方双击你的 exe,提示缺少VCRUNTIME140.dll或MSVCP140.dll。这是因为你用 MSVC 编译出来的程序依赖微软的运行库,而对方电脑上没装。解决办法是让对方安装对应的运行库,或者你用静态链接(在编译选项里加/MT)。用 MinGW-w64 编译出来的一般带的是另外一套运行库,同样需要对方有或者你静态链接。发程序给别人之前,一定在一台干净的机器上试一遍,这个教训我是花了很长时间才记住的。
VSCode 配置这块,最容易出问题的是c_cpp_properties.json里的includePath。如果你同时装了 MinGW 和 MSVC,编译器会按includePath的顺序去找头文件,顺序错了就会出现"明明装了库却找不到头文件"的怪现象。路径优先级是从上到下匹配,越靠前越优先。我建议路径只留你实际在用的那一套,别贪多。tasks.json负责编译命令,launch.json负责调试器,两者要保证用的是同一个编译器,否则会出现"编译过了但调试符号对不上"的诡异情况。
我常用的一份最简tasks.json:
{ "version": "2.0.0", "tasks": [ { "label": "build", "type": "shell", "command": "g++", "args": [ "-std=c++17", "-g", "-Wall", "-Wextra", "src/main.cpp", "src/fighter.cpp", "src/battle.cpp", "src/render.cpp", "-o", "build/fight.exe" ], "group": { "kind": "build", "isDefault": true }, "problemMatcher": ["$gcc"] } ] }-Wall -Wextra这两个警告开关强烈建议打开。我在写状态机的时候,就是因为-Wextra报了一个"比较有符号和无符号整数"的警告,才发现int i和vector.size()比较在极端情况下会出问题。把警告当错误看,这是从新手到熟手的一道分水岭。
编译命令行的写法也要注意,多个 cpp 文件可以一起写在命令里,不写会报"未定义引用"(undefined reference)。这个报错的意思是"我看到了调用,但找不到实现",基本都是漏编译了某个源文件,或者函数的声明和定义签名对不上。
6.2 运行期问题速查
下面这张表是我实际写这个项目时遇到并记录下来的问题,按现象整理,方便你对照排查:
| 现象 | 可能原因 | 排查/解决 |
|---|---|---|
| 程序卡住不响应按键 | cin 在读入并等待回车 | 换成_kbhit/_getch或 termios 原始模式 |
| 敌人和玩家重叠在一起 | 位置只做了加法没做边界判定 | 在位移后夹紧到合法范围 |
| 血条越打越长 | 血量恢复时没做上限截断 | 恢复后hp = min(hp, maxHp) |
| 伤害永远是同一个数 | 每帧重新播种随机数 | 生成器只初始化一次并复用 |
| 游戏越跑越卡 | 每帧都在 new / 容器不断扩容 | 预分配、复用对象、检查是否有内存泄漏 |
| 画面抖动闪烁 | 逐行输出 + 清屏 | 改用光标归位 + 拼完整帧后一次输出 |
| 中文乱码 | 控制台代码页不匹配 | SetConsoleOutputCP(65001)并统一文件编码 |
| 退出后终端错乱 | 原始模式没还原 | 用 RAII 或atexit保证恢复 |
这张表里的每一条我都是真的栽过。拿"越跑越卡"来说,我一度以为是渲染太慢,加了一堆优化都没用,最后用任务管理器看内存,发现是每帧都在new Fighter而从来没有 delete。遇到性能问题先看内存和分配,别一上来就优化算法,这是很实用的排查顺序。
还有"敌人和玩家重叠"这个问题,本质上是因为我只做了位移,没做碰撞。最简单的做法是在位移之后夹紧:
if (std::abs(a.x - b.x) < MIN_DISTANCE) { // 推开 if (a.x < b.x) { a.x = b.x - MIN_DISTANCE; } else { b.x = a.x - MIN_DISTANCE; } }MIN_DISTANCE取两个角色字符画宽度的和。这个判定顺序很重要——先移动再碰撞修正,如果你先修正再移动,下一帧还是重叠的。
6.3 几个调试小技巧
第一个是给游戏做个"上帝视角"的日志开关。用一个编译期常量控制详细日志的开关,出问题时打开,能节省大量时间:
#ifdef DEBUG_LOG #define LOG(x) do { std::cerr << x << '\n'; } while (0) #else #define LOG(x) do {} while (0) #endif注意日志要打到std::cerr(不经过缓冲,程序崩了也能看到),画面要打到std::cout。如果两者都往 cout 打,日志会插在画面里,整个渲染全乱。
第二个是把游戏变成"可回放"的。游戏的随机性和输入都是确定的,理论上只要记录"第几帧按了什么键",就能完整重现一局对战。我第一次遇到一个只在特定条件下出现的状态机死锁,就是靠回放定位的——把输入的帧号打印出来,重现那 3 秒,然后逐帧打印状态值,很快就找到了那个写错的转移条件。这种"确定性回放"的思路,在调试复杂的时序问题时价值极高。
第三个是关于浮点数的。游戏里我用到了位置、速度这些量,一开始全用 float,后来发现角色在某个位置会来回抖动,因为x += 0.1f十次之后不等于 1.0。解决办法是要么全用整数坐标(我推荐这个,火柴人一格一格移动更清晰),要么在比较时用容差std::abs(a-b) < 1e-6。永远不要用==比较两个浮点数,这条规矩值得刻在脑子里。
第四个是断点之外的观察手段。调试器很好用,但游戏在主循环里断下来之后,终端状态可能已经被破坏了,继续运行会看到花屏。所以对于游戏逻辑的问题,我更多用日志和状态快照,而不是硬断点。在每一帧把关键状态序列化成一行文本打到文件里,跑完一局再看,比断点高效得多。
最后分享一个我在做这个项目时的小习惯:每次改完代码,都手动玩三局。第一局正常打,第二局故意送死看失败分支,第三局专门试那些平时不会用到的操作(比如同时按左右、在倒地瞬间按键)。很多 bug 就藏在那些"正常人不会这么操作"的路径里。自动化测试能覆盖逻辑,但覆盖不了"手感",而手感恰恰是小游戏最需要打磨的部分。
项目做到后面,我还想过加双人对战、加会扔东西的远程敌人、加个简单的排行榜(这时候std::sort就真的派上用场了,按胜场降序排一下就行)。但加着加着发现,功能越多,每个功能的打磨就越粗糙,最后反而不如三个敌人打起来有意思。所以我现在更倾向于把这个版本做扎实——固定时间步长的主循环、递减收益的伤害公式、三状态的敌人 AI、无闪烁的终端渲染,这几块单独拿出来都能复用到别的项目里。如果你刚开始写,别急着加内容,先把一个敌人打到"手感对",剩下的都是复制粘贴。