前阵子在旧帖子里翻到一份被人重新整理过的 C++ 小游戏,标题里挂着玄幻小说的名头,版本号标到 2.11.1,末尾还留着原作者的署名缩写。压缩包解开一看,几个 .cpp、几个 .h,再加两个 txt 配置,编译出来是个黑框框。很多人看到这种控制台作品第一反应是"土",但真拆开看,养成、战斗、背包、商店、存档、多周目,该有的闭环一个不缺。这类项目最大的价值不在于好不好玩,而在于它是一份能完整跑通、规模又控制得住的 C++ 工程样本:没有第三方库,没有构建系统,没有花哨的框架,所有的设计取舍都摊在你面前,对就是对,错就是错。
我把这套东西从头到尾读了一遍,又照着它的思路重写了一个简化版,中间踩的坑比想象中多。这篇文章聊的就是这类"C++ 控制台小游戏"到底该怎么读、怎么改、怎么自己从头搭一个。适合三类人:刚学完 C++ 语法、不知道能写什么的新手;想拿一个完整小项目练手感和工程习惯的在校同学;以及手里有一堆散装代码、想整理成能长期迭代的小作品的独立开发者。下面全部是我自己动手之后的记录,代码都是能直接编译跑起来的片段,不是伪代码。
1. 先把这类项目看清楚:它到底在练什么
1.1 一个控制台小游戏的完整闭环长什么样
很多人对控制台游戏的印象还停留在"猜数字""贪吃蛇",觉得就是个循环加几个 if。实际上带剧情和成长线的小游戏,复杂度会陡增一个量级。我拆的这份源码,光功能模块就分成了七八块:角色创建与属性初始化、地图或章节推进、随机遇敌与战斗结算、经验与等级成长、装备与消耗品、商店买卖、存档读档、以及一个撑起全部交互的主菜单。这些东西单拎出来都不难,难的是它们之间互相引用:战斗要读角色属性,属性升级要看经验表,经验表又要跟怪物掉落挂钩,掉落的物品还得能进背包、能卖钱、能影响下一场战斗。
这就带来一个很现实的问题:如果一开始没有把边界划清楚,写到第三个模块的时候你就会发现,一个结构体里塞了三十个字段,随便改一处,别处全崩。我见过太多新手写的版本,一个main.cpp写到两千行,函数之间靠全局变量通信,改个数值要全局搜索替换,最后自己都不敢动。所以读这类项目,第一步不是看它实现得多花哨,而是看它的文件是怎么分的、全局变量有多少、函数职责单不单一。分得清楚的项目,改起来是享受;分不清楚的,改起来是受刑。
1.2 为什么这类练手项目用 C++ 反而合适
有人会问,写文字游戏用 Python 不是更快吗,用 C++ 不是自找麻烦。这话对一半。如果目标只是赶快做出一个能玩的东西,脚本语言确实快得多。但如果你想练的是"把一件事物抽象成类型、把生命周期管清楚、把资源分配想明白",C++ 在这类项目上的训练密度是最高的,原因有三点。
第一,没有现成的容器替你做脏活。你要自己决定角色列表用std::vector还是std::array,决定物品是值语义还是指针语义,决定存档读出来的时候对象怎么构造。这些决策在脚本语言里往往被动态类型抹平了,你根本没意识到自己做过选择。
第二,编译期检查会逼你把接口想清楚。头文件写错一个const,链接阶段就报错给你看。这个过程很烦,但正是这种"烦"在帮你建立对接口契约的直觉。我自己的体会是,写过两三个几千行的 C++ 小项目之后,再看别的语言写的代码,会本能地去想"这里的依赖方向对不对"。
第三,零依赖带来的可移植性。整份源码拷到任何一台装了编译器的机器上,一条命令就能出可执行文件,不用配虚拟环境、不用装包。对一个小作品来说,这种"拿到就能跑"的体验相当重要,尤其是你想把它发给别人试玩的时候。
1.3 从版本号 2.11.1 能读出什么
标题里的 2.11.1 不是随便写的,这是标准的语义化版本格式:主版本号、次版本号、修订号。放在一个小游戏项目上,它其实透露出不少信息。修订号到 1,说明这是一次 bug 修复或者小调整;次版本号到 11,说明功能层面已经迭代过至少十轮,中间加过新系统;主版本号是 2,意味着曾经有过一次不向后兼容的大改动,比如存档格式变了、战斗公式推倒重来。
这个信息对我很有用。因为读别人代码最怕的是不知道哪些是历史包袱。看到次版本号这么高,我就知道源码里大概率存在"为了兼容旧存档而保留的分支"、"废弃但没删的字段"、"新旧两套数值表并存"这类东西。事实也确实如此,里面有好几个字段在逻辑里根本没人读,只在存档写入时被原样带出去。这不是作者懒,是典型的迭代痕迹:删字段要改存档读写、要处理旧档、要测试,成本比留着高,于是就留着了。
理解这一点,对你自己的项目也有指导意义。版本号不是装饰,它是给未来的自己留的说明书。我现在的习惯是,只要存档格式动过,主版本号必加一;只加功能不动存档,次版本号加一;纯修 bug,修订号加一。这个规矩定下来之后,回滚和排查会轻松很多。
2. 整体设计拆解:三层切分与状态机
2.1 数据、规则、表现:三层怎么切才不打架
这份源码虽然不大,但内部其实是按三层思路组织的,只是作者没明说。数据层负责角色属性、物品定义、怪物配置、经验表这些东西,它们的共同特点是可以被序列化、可以被外部修改、生命周期跨越整个游戏进程。规则层负责战斗结算、成长判定、掉落抽取、买卖校验,这一层的输入是数据,输出是数据的变化,不碰任何输入输出。表现层负责所有的std::cout和std::cin,把状态翻译成人能看懂的文字,把人的操作翻译成规则层的调用。
这个切分的价值在改需求的时候才体现出来。比如你想把"暴击率"从固定值改成受装备影响,改动只发生在数据层和规则层的交界处,表现层一行都不用动。反过来,如果你想把控制台输出改成彩色或者改成图形界面,只要表现层换掉,规则层完全不用碰。我见过反例:有人在战斗结算函数里直接cout一句"你造成了 128 点伤害",然后这个函数就没法在别的地方复用了,你想做个自动战斗测试都做不到,因为它会疯狂往屏幕上刷字。
判断标准很简单:如果一个函数里同时出现了
cin或cout,又出现了数值计算或者容器操作,那它就该被拆开。这条我几乎当成硬规矩在用。
具体落地时,我的做法是规则层的函数签名只接受数据引用、返回结果结构体,绝不返回字符串。需要给玩家看的文案,由表现层根据结果结构体自己拼。这样一来,规则层就是纯函数,写测试极其方便,扔几个固定输入进去看输出对不对就行。
2.2 用状态机把"游戏"拆成一串可测试的开关
控制台游戏最大的特点是"单线程、阻塞式、一步一步走"。玩家看到的永远是一个菜单,选一个数字,进一个界面,再选一个数字。这种交互天然适合用状态机描述。我给自己的简化版定义了这么一组状态:
enum class GameState { MainMenu, // 主菜单 Explore, // 探索/推进剧情 Battle, // 战斗回合 Inventory, // 背包 Shop, // 商店 LevelUp, // 升级结算 SaveLoad, // 存读档 Quit // 退出 };主循环就变成一个巨大的switch,每个分支处理自己那点事,处理完设置下一个状态。这套东西看着笨,但它解决了一个致命的坑:界面跳转关系复杂时,嵌套循环会把你埋了。我第一版就是这么干的,主菜单里while套探索,探索里while套战斗,战斗里while套背包,结果玩家在战斗里打开背包想用个药,用完返回,返回层级一多,退出逻辑就乱了,有时候按 0 退不出去,有时候直接退出整个游戏。改成状态机之后,任何界面都只负责"设置下一个状态",返回逻辑变成一行赋值,清清楚楚。
这里有个细节要注意:状态机里的状态不要搞太多。我一开始把"确认购买""确认出售""输入数量"都单独设了状态,结果状态图变成一团麻。后来改成"商店状态内部再用一个小循环处理子流程",层级分明,代码量少了一半。经验是:状态机管的是"大界面切换",界面内部的连续交互继续用循环,别把所有东西都状态化。
2.3 数值为什么要外置成配置,而不是写在 if 里
源码里有个细节我挺欣赏:怪物属性、物品价格、经验需求这些数值,都放在一个文本配置文件里,程序启动时读进来。这个做法在小项目里经常被忽略,大家习惯直接if (monsterId == 3) { hp = 120; atk = 18; },写起来快,后面痛苦。
外置的好处有三个,都很实在。一是调平衡不用重编译。小游戏的数值几乎一定要反复调,写在代码里意味着每次改一个数字都要编译一遍,哪怕只有两秒钟,那种打断感也很影响状态。放在文本里,改完直接重跑,节奏完全不一样。二是配置能被非程序员改。如果你把项目发给朋友试玩,他反馈"第三章太难了",你让他自己改配置文件里的怪物血量,比你来回改代码发版高效得多。三是配置本身就是文档。打开文本一看,所有怪物的属性一目了然,比翻代码直观。
代价也有:解析配置的代码要写,格式错了要处理,读取失败的兜底要想清楚。我的建议是格式越简单越好,不要一上来就上 JSON 解析器。用最土的行式格式就够:
# 怪物配置 名称|血量|攻击|防御|经验|掉落物ID|掉落概率 小怪A|80|12|5|30|1001|60 小怪B|150|20|10|75|1002|45分隔符用竖线或者制表符,一行一个对象,解析就是按行读、按分隔符切、字段转数字。这套东西二十行代码能写完,健壮性靠"解析失败就跳过并打日志"兜住,足够用了。
2.4 存档格式的选择:文本、二进制还是自描述
存档这块我踩过坑,值得单独说。三种做法各有取舍。二进制直接 dump 结构体最省事,fwrite(&player, sizeof(player), 1, fp)一行搞定,但它有两个致命问题:结构体里一旦有std::string就崩(因为存的是指针),以及换个编译器、加个字段,旧存档直接读不出来或者读成垃圾。我早期版本就这么干过,后来加了个字段,所有测试存档全废,只能删档重来。
纯文本键值对是我现在推荐给所有小项目的方案。格式长这样:
version=21101 name=A同学 level=12 exp=340 hp=520 maxhp=600 atk=48 def=22 gold=1580 bag=1001:3,1002:1好处是显而易见的:人眼可读,出问题直接用记事本打开看;加字段不用改旧档,读的时候缺什么补默认值;调试的时候手动改数值比写调试命令还快。缺点是文件大一点、解析要写代码,但这点量级对单机游戏完全无所谓。
自描述格式(JSON、XML)适合字段多且嵌套深的场景,比如你要存整张地图的状态。但小项目引入第三方解析库会破坏"零依赖"这个优点,得不偿失。真需要的话,自己写个只支持键值对和数组的迷你解析器也就一百多行的事。
有个细节必须处理:存档一定要带版本号,并且在读取逻辑里做"字段兼容"。新版本加的字段,读旧档时给默认值;旧版本不认识的字段,读新档时直接忽略。这两条做到了,你的存档就能跨版本活下去,玩家的进度不会因为一次更新清零。
3. 核心细节:数值、结构与实现要点
3.1 角色属性表的字段设计和类型选择
角色结构体看着简单,实际有很多讲究。我最初写的版本大概这样:
struct Player { std::string name; int level; int exp; int hp, maxHp; int mp, maxMp; int atk, def, spd; long long gold; std::vector<Item> bag; };跑起来没问题,但随着功能增加开始暴露毛病。第一个问题是hp和maxHp没有约束关系,某次吃药逻辑写错,血量超过了上限,之后所有基于血量的判断全歪了。后来我加了一个setHp的封装,内部做 clamp,问题才根治。第二个问题是属性之间没有分组,装备加成、buff 加成、基础值混在一起,算最终攻击力的时候要东拼西凑。
改进后的思路是把属性拆成"基础值 + 加成"两部分,用一个小结构体装起来:
struct Attr { int base = 0; int bonus = 0; int total() const { return base + bonus; } }; struct Player { std::string name; int level = 1; int exp = 0; Attr hp{100, 0}, atk{10, 0}, def{5, 0}, spd{8, 0}; long long gold = 0; std::vector<Item> bag; };这样装备穿脱只需要改bonus,基础值永远不动,卸装备的时候不会算错。类型上,血量、攻击这些用int足够,别用float,浮点数参与比较和取整会给你带来无穷麻烦。金币用long long,因为玩家刷钱是必然会发生的,int上限二十亿看着远,但在一个能循环刷的游戏里真的会被顶到。等级和经验也用int,并且经验值判断要写成while而不是if,因为一次战斗可能连升两级,这是我实际遇到过的 bug。
3.2 伤害公式:整数化计算与取整陷阱
战斗是这类游戏的核心,而伤害公式是核心里的核心。我最开始写的是最直觉的版本:
int dmg = atk * 1.2 - def * 0.5;用了浮点数,结果出现两个问题。一是不同平台上浮点精度略有差异,同一份存档在别人机器上打出来的伤害可能差一点,虽然不影响大局但很别扭。二是负数问题,防御高的时候atk * 1.2 - def * 0.5会算出负数,然后治疗敌人的血,非常尴尬。
后来我统一改成整数运算,把所有系数放大一百倍再用整数除法:
int calcDamage(const Player& a, const Player& d, int skillPercent) { // skillPercent: 100 表示普通攻击,150 表示 1.5 倍技能 long long raw = static_cast<long long>(a.atk.total()) * skillPercent / 100; long long afterDef = raw - static_cast<long long>(d.def.total()) * 60 / 100; // 保底伤害:至少造成原始伤害的 10%,避免高防单位完全免疫 long long floorDmg = raw / 10; if (afterDef < floorDmg) afterDef = floorDmg; // 暴击:整数概率,避免浮点比较 int roll = randInt(0, 99); bool crit = roll < a.critPercent; // critPercent 是 0-100 的整数 if (crit) afterDef = afterDef * 150 / 100; // 上下浮动 10% int var = randInt(90, 110); afterDef = afterDef * var / 100; if (afterDef < 1) afterDef = 1; return static_cast<int>(afterDef); }这段代码有四个刻意的设计。用long long做中间量,是因为atk可能到几万,乘 150 再乘 110 有溢出风险,int顶不住。先乘后除,如果写成atk / 100 * skillPercent,低攻击力时会被截断成 0,这是新手最常犯的错误之一。保底伤害解决了"防御堆到无敌"的问题,让战斗不会陷入死循环。用 0-99 的整数做概率判定,比(rand() / RAND_MAX) < 0.15稳得多,也不会有浮点误差导致概率偏差。
参数怎么定?我的经验是让普通攻击打同等级怪物需要 3 到 5 下,技能 2 到 3 下,BOSS 需要 10 到 15 回合。这个节奏下玩家不会觉得太肉也不会觉得太快。定完公式后一定要跑一遍模拟,用脚本自动打一千场,看平均回合数,比拍脑袋靠谱得多。
3.3 经验曲线和掉落表:可配置化才有迭代空间
经验曲线最忌讳线性。线性升级会让前期太慢后期太快,玩家的爽感曲线是反的。常见做法是二次或者一点五次曲线,我用的是递推式,因为好调:
int expNeed(int level) { // 1级需 100,之后每级递增约 25%,并加一个固定量 static std::vector<int> table = {0, 100}; while (static_cast<int>(table.size()) <= level) { int last = table.back(); table.push_back(last + last / 4 + 50); } return table[level]; }用静态表缓存的好处是避免每帧重算,坏处是要注意线程安全(单机游戏不用管)和表被外部修改。更干净的写法是启动时一次性预计算到 100 级,存成std::array<int, 101>或者从配置文件读。
掉落表我用的是"权重累积 + 随机命中"的经典写法,比一堆独立概率判定好维护:
struct DropEntry { int itemId; int weight; }; int rollDrop(const std::vector<DropEntry>& table) { int total = 0; for (const auto& e : table) total += e.weight; if (total <= 0) return 0; int r = randInt(0, total - 1); for (const auto& e : table) { if (r < e.weight) return e.itemId; r -= e.weight; } return 0; }这套写法的好处是权重之和自动归一,加一条掉落只要给个权重,不用管其他条目概率会不会超 100%。我在实际项目里还加了一层"保底机制":连续 N 次没掉稀有物品,就强制提高一次权重,避免玩家运气差到弃坑。这个逻辑不复杂,一个计数器加一个判断就行,但对体验的提升非常明显。
3.4 随机数:把 rand() 换掉,越早越好
rand()有两个老毛病:一是分布质量差,低位随机性尤其糟糕,rand() % 2在某些实现上会明显偏向;二是全局状态,任何一个第三方库调用了srand就会打乱你的序列。现在标准库给了<random>,没理由不用。
#include <random> std::mt19937& engine() { static std::mt19937 gen(std::random_device{}()); return gen; } int randInt(int lo, int hi) { std::uniform_int_distribution<int> dist(lo, hi); return dist(engine()); }用static局部变量做惰性初始化,既避免了全局对象构造顺序的问题,又保证只播种一次。std::random_device用来取真随机种子,如果它在某些平台上退化成固定值(确实有个别实现这样),可以再加时间戳和进程 ID 混合。还有个实际需求:调试的时候需要可复现的战斗结果,那就把种子做成可配置的,启动参数传进去,固定种子跑固定序列,复现 bug 特别好用。
4. 动手实现:从空文件夹到能跑起来
4.1 工程结构和编译命令
我的目录长这样,够小项目用一辈子:
game/ ├── src/ │ ├── main.cpp │ ├── player.cpp / player.h │ ├── battle.cpp / battle.h │ ├── data.cpp / data.h │ └── ui.cpp / ui.h ├── data/ │ ├── monsters.txt │ └── items.txt ├── saves/ └── Makefile原则是"一个模块一对文件,头文件只放声明,实现全放 cpp"。头文件里能不放定义就别放,实在要放常量或者小函数,用inline或者constexpr,否则多个 cpp 包含同一个头文件会直接报重复定义。我见过新手把int g_level = 1;写在头文件里,一编译就是一屏错误。
编译命令我习惯直接写:
g++ -std=c++17 -O2 -Wall -Wextra -o game src/*.cpp-Wall -Wextra一定要开,它抓出来的未使用变量、有符号无符号比较、隐式类型转换,十有八九是 bug 的源头。-O2在这个量级其实无所谓,但开着也不影响调试体验。真正需要调试的时候换成-g -O0单独编一个版本,别混着用,否则断点会乱跳。
4.2 主循环和输入健壮性:这一步决定体验下限
控制台游戏最容易崩的地方不是逻辑,是输入。玩家输入一个字母,你的cin >> num直接进入失败状态,之后所有读取全部失效,菜单疯狂刷屏,程序看起来像卡死。这个问题必须在一开始就解决。
int readInt(int lo, int hi, const std::string& prompt) { int v; while (true) { std::cout << prompt; if (std::cin >> v && v >= lo && v <= hi) { std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); return v; } std::cin.clear(); std::cin.ignore(std::numeric_limits<std::streamsize>::max(), '\n'); std::cout << "输入无效,请输入 " << lo << " 到 " << hi << " 之间的数字。\n"; } }关键在于失败之后的两步:clear()清掉错误标志,ignore()把缓冲区里剩下的垃圾字符吃掉。少了任何一个,循环都会立刻再次失败,变成死循环刷屏。另外读取成功后也要ignore一次,把行尾的回车清掉,否则下一次读字符串会读到一个空串,这个现象非常迷惑人,我调了半小时才反应过来。
主循环配合状态机大约是这样:
int main() { GameState st = GameState::MainMenu; Player p; bool running = true; while (running) { switch (st) { case GameState::MainMenu: st = ui::mainMenu(p); break; case GameState::Explore: st = ui::explore(p); break; case GameState::Battle: st = ui::battle(p); break; case GameState::Inventory: st = ui::inventory(p);break; case GameState::Quit: running = false; break; default: st = GameState::MainMenu; break; } } return 0; }每个ui::xxx返回下一个状态,自己不做跳转。这种写法让整个流程变成一张可以画出来的图,任何"退不出去"的问题,看一眼返回值就能定位。
4.3 战斗流程怎么组织才不写成面条
战斗回合的典型结构是:玩家选择行动、结算、检查怪物是否死亡、怪物行动、结算、检查玩家是否死亡、循环。直接写在一个函数里大概五十行能搞定,但加个技能、加个道具、加个逃跑,很快就会膨胀到三百行,全是 if 嵌套。
我的做法是把"一个回合"拆成几个小函数:playerTurn、enemyTurn、checkEnd。主循环只负责调度和判断结束条件:
BattleResult battle(Player& p, Monster& m) { while (true) { int action = ui::chooseBattleAction(p); if (action == 1) { int dmg = calcDamage(p, m, 100); m.hp -= dmg; ui::showDamage(p.name, m.name, dmg); } else if (action == 2) { if (!useSkill(p, m)) continue; // 蓝不够,重选 } else if (action == 3) { if (tryEscape(p, m)) return BattleResult::Escaped; continue; } if (m.hp <= 0) return BattleResult::Win; int dmg = calcDamage(m, p, 100); p.hp.set(p.hp.total() - dmg); ui::showDamage(m.name, p.name, dmg); if (p.hp.total() <= 0) return BattleResult::Lose; } }返回枚举而不是直接改全局标志,好处是调用方可以决定胜利后做什么:给经验、掉落、升级提示、回主菜单。如果战斗函数自己就把经验加了,将来你想做"连续战斗"或者"试炼场"模式,就完全复用不了。
4.4 存档读写:二十行代码解决大问题
存档我用的就是前面说的文本键值对。写入很简单,注意每个字段一行,写完关闭文件。读取要小心三件事:文件不存在、字段缺失、格式错误。我的实现是先给对象填一套默认值,再逐行覆盖,遇到不认识的 key 就跳过,遇到解析失败就那一行跳过并打日志。
bool saveGame(const Player& p, const std::string& path) { std::ofstream out(path); if (!out) return false; out << "version=" << SAVE_VERSION << '\n'; out << "name=" << p.name << '\n'; out << "level=" << p.level << '\n'; out << "exp=" << p.exp << '\n'; out << "hp=" << p.hp.base << '\n'; out << "atk=" << p.atk.base << '\n'; out << "def=" << p.def.base << '\n'; out << "gold=" << p.gold << '\n'; return static_cast<bool>(out); }这里有两个坑我踩过。第一,写入之后一定要检查流状态,磁盘满、权限不足都会静默失败,玩家以为存上了其实没存,那种反馈极其糟糕。第二,存档路径不要用相对路径,尤其是从桌面双击运行时,工作目录可能和你以为的不一样,存档会跑到奇怪的地方。我的做法是启动时把可执行文件所在目录记下来,所有资源路径都基于它拼。
4.5 中文输出与编码:控制台的老大难
这一块是控制台项目绕不过去的。Windows 控制台默认代码页和源文件编码不一致时,中文全是乱码。我试过的稳妥方案是:源文件一律保存为 UTF-8,程序启动时设置控制台输出代码页。
#ifdef _WIN32 #include <windows.h> #endif void initConsole() { #ifdef _WIN32 SetConsoleOutputCP(CP_UTF8); SetConsoleCP(CP_UTF8); #endif }在main开头调用一次。如果环境不允许这么干,退而求其次是把源文件存成系统默认编码,但那样跨平台就麻烦了。另外提一句,控制台排版对齐问题,中文字符占两个显示宽度,用setw对不齐。真要美观,得自己算显示宽度:ASCII 算 1,中文算 2,然后手动补空格。这个函数写一次能用一辈子,值得花十分钟。
5. 常见问题与排查实录
5.1 问题速查表
下面这张表是我调试过程中记下来的,绝大部分新手都撞过:
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 菜单一闪一闪疯狂刷屏 | 输入失败后没清缓冲区 | cin.clear()加cin.ignore() |
| 输入字母后程序再也不响应 | 流进入失败状态且未恢复 | 同上,用带校验的输入封装 |
| 每次运行随机结果完全一样 | 没播种或用了固定种子 | std::random_device播种 |
| 打出来的伤害忽正忽负 | 整数溢出或防御减成负数 | 中间量用long long,加保底 |
| 存档读进来全是 0 | 字段缺失且没默认值 | 先填默认再覆盖 |
| 加了个字段旧档就读不出 | 结构体直接二进制 dump | 改用文本键值对格式 |
| 头文件一包含就报重复定义 | 头文件里定义了变量 | 用inline或移到 cpp |
| 中文输出全是问号 | 编码/代码页不匹配 | UTF-8 +SetConsoleOutputCP |
| 战斗胜利后卡死在升级界面 | 一次升多级只判断了if | 改成while循环升级 |
| 程序一闪就退出 | 双击运行没有暂停 | 命令行运行,或末尾等回车 |
5.2 几个印象深刻的踩坑记录
有一次我调了半天"战斗中用药无效"的问题。现象是喝了药血量不涨,但药确实从背包里扣掉了。查了半天战斗逻辑没发现问题,最后发现是useItem里判断"是否满血"用了这样一个条件:if (p.hp == p.maxHp) return false;,而hp是当前值,maxHp是上限,两个字段在升级时被同步更新过,但升级加血的那段代码只改了maxHp没改hp,导致玩家血量恒等于上限,药永远判定为无效。这个 bug 的教训很深:状态同步一定要走统一入口,不要在两处分别赋值。后来我加了setHp和addMaxHp两个方法,内部做一致性保证,这类问题再没出现过。
还有一次更离谱。测试反馈说"打怪有时候掉两件装备,有时候一件都不掉"。我查了掉落代码,逻辑很干净,就是按概率判定。后来发现是rand()没播种,每次启动序列固定,而玩家习惯先做同样的操作,于是每次启动后的第 N 次掉落判定结果都一样,看起来就像"有时候"有问题。换成mt19937加强制播种之后现象消失。这个例子说明,有些"偶发 bug"其实是确定性 bug,只是观测方式让人以为是随机的。
第三个坑是性能。这类游戏本来不需要考虑性能,但我写了一个"自动战斗一千场"的测试脚本,跑起来慢得离谱。查下来是战斗日志每次都std::endl刷新缓冲区,一千场战斗几十万行输出,全花在刷盘上了。换成'\n'之后速度提升了一个数量级。这个经验很通用:日志多的时候永远用\n,需要立即看到输出时才用endl。
5.3 调试小技巧:让问题自己浮出来
控制台项目的调试手段确实有限,但也不是没办法。我常备三个工具。第一是日志宏,带文件名和行号:
#define LOG(fmt, ...) \ std::printf("[%s:%d] " fmt "\n", __FILE__, __LINE__, ##__VA_ARGS__)排查复杂流程时,在关键分支上撒几行LOG("进入商店,金币=%lld", p.gold);,比单步调试快得多,尤其适合"偶发跳转错误"这类问题。第二是固定种子开关,启动参数加个--seed 12345,就能百分之百复现某次战斗,用断点跟进去看每一步数值。第三是作弊菜单,编译时用宏包起来,只在调试版开启,可以一键加钱、加经验、跳章节。有了它,测试后期内容不用真的打一遍,效率能翻好几倍。
一点提醒:作弊功能一定要用
#ifdef DEBUG和发布版隔离开,我见过有人忘了关,结果线上版本可以随便加钱,玩家截图一发,场面相当难看。
6. 后续怎么把这套东西扩展下去
6.1 数据驱动:让内容制作和程序分离
如果只打算玩几天,现在这个结构够了。但如果想持续加内容,下一步一定要做的是彻底的数据驱动:所有的怪物、物品、技能、章节,全部变成配置文件里的一行,代码里不出现任何具体的 id 判断。判定标准是:加一个新怪物,只改文本文件,不改一行 cpp。
做到这一步需要两个东西:一套稳定的配置格式,以及一个统一的加载器把配置转成内存对象。我的做法是用std::unordered_map<int, MonsterDef>存所有定义,用 id 索引,战斗开始的时候按 id 查出来拷贝一份作为实例。这样定义和实例分离,改配置不影响已经进入战斗的实例,也不会出现"改了一份配置,场上所有怪物属性全变"的问题。
6.2 加一层图形界面:分离带来的红利
前面说三层切分的时候提过,表现层和规则层分离后,换界面很容易。真的动手时,最省事的路径是保留控制台版本作为核心,另外写一个界面层。哪怕只是用系统自带的绘图接口画几个矩形和文字,体验也会完全不同。关键是规则层一行不用改,因为所有函数都是"输入数据、输出结果",不依赖cout。
我建议先做局部替换:保留控制台作为战斗界面,只把地图和角色面板换成图形显示。这样风险可控,出问题能马上退回纯控制台版本。一次性全换的代价太大,很容易做到一半发现某个逻辑和输出耦合太深,卡在那里进退两难。
6.3 加自动化测试:小项目也值得
很多人觉得小游戏不用测试,手点就行。但数值相关的 bug 手点根本测不出来,比如"某个职业在 30 级之后伤害计算会溢出",你得练到 30 级才能发现。写几个自动化测试,成本极低,收益极高。最土的做法是写一个test.cpp,里面直接调用规则层函数,断言结果:
void testDamageFloor() { Player a; a.atk.base = 10; Player d; d.def.base = 9999; int dmg = calcDamage(a, d, 100); assert(dmg >= 1); // 保底伤害必须生效 } void testExpLoop() { Player p; p.level = 1; p.exp = 0; addExp(p, 100000); // 巨额经验 assert(p.level > 1); // 必须能连升多级 }不用引入任何测试框架,assert加main函数就够了。每次改完数值或者公式,跑一遍,几秒钟的事。这个习惯我从这个项目开始养成的,后来所有小工具都这么干,省下的调试时间非常多。
最后分享一个我在改这类源码时养成的小习惯:先把整个项目的所有全局变量列一遍,通常不超过十个,逐个判断它为什么是全局的。如果找不到明确理由,就把它挪进某个结构体或者改成函数参数。做完这一步,代码的可读性往往会有质的变化,而且你会对这份代码的"真实结构"有一个完全不同于初读时的理解。这个动作花不了半小时,但比从头读一遍代码有用得多。