☰
从2.10.6拆解C++控制台小游戏:模块、存档迁移与表驱动
2026/10/26 15:26:05 网站建设 项目流程

硬盘角落翻出一个几年前的文件夹,里面躺着main.cpp、一个 exe、还有一堆save1.dat,双击之后黑窗口弹出来,几行方块字慢慢往上滚——“修为提升至炼气三层”“获得下品灵石 x3”。这种玄幻修炼题材的 C++ 控制台小游戏,看着简陋,但版本号已经滚到 2.10.6 了。一个个人开发者能把一个命令行程序迭代上百个小版本,这里面藏着的东西其实比界面多得多:C++ 控制台小游戏的工程组织、回合制战斗的结算骨架、存档的原子写入与版本迁移、数值与代码分离的表驱动设计、还有中文输出和跨平台按键读取这些老掉牙但每次都要重踩的坑。

这篇东西不是讲“怎么写个 Hello World”,而是把这个 2.10.6 版本当成一个活体样本,拆开看看一个人长期维护一个 C++ 小游戏时,哪些设计是撑住迭代的骨架,哪些是迟早要还的技术债。适合已经会写 C++、能跑通一个猜数字程序,但一动真格就发现“加个新境界要改八个文件”的人看;也适合纯粹好奇“这种老派控制台游戏到底怎么搭起来”的读者。代码片段都是可以直接编译的最小验证版本,不是伪代码。

1. 从 2.10.6 这个版本号看一个小游戏的生长方式

1.1 三段式版本号暴露的迭代节奏

2.10.6这种三段式版本号在小游戏圈子里几乎是默认约定,但大部分人对它的用法是糊的。看这个数字结构,主版本号 2 说明核心玩法框架已经重构过一次;次版本号 10 说明在框架之上加了十轮成体系的内容或系统;修订号 6 说明当前这一轮内容里已经修了六次小问题。这个读法不是猜的,是版本号本身的设计意图——主版本变动意味着存档格式大概率不兼容,次版本变动意味着加了新内容但老存档还能读,修订号只改数值和修 bug。

我在自己维护的项目里也是这么用的,但真正让它有意义的前提是:每一次改动都必须在存档里留下版本标记,否则这个号就只是个装饰。很多小游戏的存档文件里根本没有版本字段,结果 2.x 的存档被 3.0 的代码读进去,字段错位,读出来一个 hp 为负数的角色,游戏直接从第一关跳到结算画面。这种事故一旦发生,玩家不会说“这游戏有 bug”,而是说“这游戏把我存档搞坏了”,性质完全不同。

所以我的做法是:版本号写进代码常量,也写进存档头,两边必须能对上或者能走迁移逻辑。

constexpr int kSaveVersion = 2106; // 2.10.6 压缩成整数,方便比较

用整数而不是字符串,原因很实际:字符串比较要处理"2.10.6"和"2.9.0"谁大的问题,字典序会给出错误答案("2.10" < "2.9"),而整数不会有歧义。想知道当前版本是不是比存档新,一个>就够了。

1.2 控制台这个“低配引擎”的取舍

选控制台不是因为简单,而是因为它把“渲染”这一层几乎完全砍掉了,剩下的全是逻辑。这对一个人维护的项目是巨大的优势:没有资源管线,没有贴图路径错误,没有分辨率适配,改动一次数值立刻能看到结果。缺点也很清楚,控制台没有事件循环,没有帧的概念,输入是阻塞的,输出是滚动的,一旦逻辑写得不小心,整个程序就会卡在cin >>上等一辈子。

我见过不少这类项目栽在同一个地方:把“等待玩家输入”直接写成阻塞调用,然后想在等待期间做点别的事(比如播放一个倒计时、刷新状态栏),结果只能上多线程,然后被数据竞争折磨到放弃。正确的思路是把控制台当成一个请求-响应终端,而不是一个需要持续刷新的游戏窗口。玩家的每一次回车就是一次“tick”,所有状态变化都在 tick 里完成,然后一次性把结果打印出来。这个模型下,2.10.6这种几十上百次迭代才站得住——因为它把并发问题从设计里彻底排除了。

2. 单文件撑不住的时候:模块边界怎么切

2.1 从 main.cpp 到职责清晰的几个文件

这类小游戏的起点几乎都是一个大main.cpp,两千行,上面全是函数,下面全是main。它能跑,而且改起来“感觉很快”,因为什么都在眼前。真正的崩盘点不是行数,而是改一个数值要滚三屏去找。我的经验是:当文件超过 1200 行,或者当你开始在注释里写“注意这里改了要同步改上面那个”的时候,就该切了。

切分方式不需要多专业,按“谁会变”来切就够了。下面这个划分是我在一堆类似项目里反复收敛出来的,不追求教科书式的分层,只追求改东西的时候能一眼找到:

文件职责变更频率
main.cpp启动、主菜单循环、模块装配极低
game_state.h/.cpp角色、背包、进度等所有可变状态中
battle.cpp回合结算、伤害计算、胜负判定高
data_loader.cpp读取功法、丹药、境界等配置表中
save.cpp存档读写、校验、版本迁移低但关键
console_ui.cpp输出排版、清屏、按键读取中
rng.h随机数封装极低

注意console_ui单独抽出来这件事,很多人觉得没必要,反正就几个cout。但它恰恰是唯一需要写#ifdef _WIN32的地方,混在业务逻辑里会让“这段代码为什么在 Linux 上编不过”变成一个解不开的谜。

2.2 头文件里该放什么,不该放什么

小项目最常见的头文件问题是把实现塞进头文件。如果只有一个编译单元,这没毛病;但只要拆成多个.cpp,每个包含它的文件都会得到一份函数副本,链接器要么报重定义,要么在运气好的情况下把它们合并。真正麻烦的是变量:在头文件里写int playerHp = 100;,每个包含它的.cpp都有一份独立的playerHp,战斗模块扣了血,界面模块读到的还是 100。这个 bug 极其难查,因为代码看起来“逻辑是对的”。

inline变量(C++17)或者extern声明加单一定义,是两种标准解法。我在小项目里更倾向后者,因为它把“定义在哪”这件事显式化了:

// game_state.h struct GameState { int hp = 100, maxHp = 100; int exp = 0, level = 1; int stones = 0; std::vector<std::string> skills; }; extern GameState g_state; // 声明 // game_state.cpp GameState g_state; // 唯一定义

有人会说全局变量是洪水猛兽。在这类单线程、单存档、无插件的控制台游戏里,一个位置明确的全局状态对象反而是最省心的方案——它省掉了到处传引用的噪音,也不会引入生命周期问题。前提是:只有这一个,并且所有模块对它的写入都集中在少数几个函数里,而不是每处cout前面顺手改一下。

2.3 用一次“加系统”的动作检验切分是否合理

判断模块切得对不对,最好的测试是问自己:现在要加一个“装备强化”系统,需要动几个文件?合理的答案是一到两个——新增一个equip.cpp,然后在game_state里加字段,在菜单里加一个入口。如果答案是“得改六个文件,而且每个文件里都得加一段 if”,说明状态和逻辑纠缠在一起了。

这个检验我在自己项目里用过好几次,每次都能发现一坨隐藏的耦合。最近一次是在“突破境界”功能上:原本境界判断散落在战斗、界面、存档三个地方,加一个新境界要改三处,漏一处就会出现“战力显示是大乘期、实际打出来是炼气期”的鬼畜现象。后来把所有境界相关的判断收进一个函数,新增境界就变成了往配置表里加一行——这是第 5 节要展开的东西。

3. 回合制战斗的骨架:状态机、结算顺序与随机数

3.1 输入层不卡死,战斗循环才站得住

控制台游戏的输入是最容易让人放弃的地方。std::cin >> choice;看起来无害,但它是按空白分词的:玩家输入1,缓冲区里留下一个换行;下一次读字符时会直接读到那个换行,表现为“程序自动跳过了一次输入”。这个 bug 我见过至少二十次,每次都是新手在同一个位置栽倒。

更稳的做法是整行读取 + 自己解析,把“取一行”和“解析这一行”分开:

std::string readLine() { std::string s; if (!std::getline(std::cin, s)) return {}; // EOF 或流错误,返回空表示退出 if (!s.empty() && s.back() == '\r') s.pop_back(); // Windows 换行残留 return s; }

这两个细节都很实在。返回空字符串代表 EOF,这样玩家按 Ctrl+Z(或者管道输入结束)时程序能正常退出而不是死循环;去掉\r是因为从外部文件重定向输入时,Windows 的换行是\r\n,不去掉会让"1\r" == "1"判断失败——这个坑在你做自动化测试的时候一定会遇到。

解析部分用整数转换再加异常兜底,比atoi安全:

bool parseChoice(const std::string& line, int& out) { try { size_t pos = 0; out = std::stoi(line, &pos); return pos == line.size(); // 禁止 "1abc" 这种半截输入 } catch (...) { return false; } }

std::stoi的第二个参数是“解析到哪一位”,用它来确保整行都是数字,可以挡掉一堆脏输入。atoi("abc")返回 0,和玩家输入 0 无法区分,这种模糊在菜单里就是“按错了却执行了第一项”。

3.2 回合结算的顺序比数值本身更重要

伤害公式谁都会写,真正决定手感的是结算顺序。同一个公式,先扣护盾再算法术吸血,和反过来算,结果可能差出一条命。我在类似项目里踩过的坑是“吸血”和“反伤”同时触发:先算吸血再算反伤,玩家能从反伤里回血;反过来算,反伤把玩家打死了就不该再吸血。两种都说得通,但必须在文档和代码里定死一个顺序,否则每次改数值都会引入新的不一致。

我给这类战斗定的顺序是固定的五步,写在函数注释里,谁改谁负责:

  1. 计算双方本回合的临时属性(增益、减益、装备加成)
  2. 结算玩家行动,扣减目标 hp,记录“本次伤害量”
  3. 触发依赖伤害量的效果(吸血、反伤、连击)
  4. 结算敌方行动,同样流程
  5. 检查死亡与胜负,清理本回合的临时状态

第 1 步和第 5 步最容易被省略,也就是“临时属性”和“临时状态”。省掉的后果是增益效果永久挂着,玩家打完一场发现攻击力一直是双倍——这类 bug 在测试时不容易发现,因为下一场战斗的数值看起来“只是有点高”。

struct Combatant { int hp = 0, maxHp = 0; int atk = 0, def = 0; int tempAtk = 0; // 本回合临时加成,回合结束清零 double lifesteal = 0.0; };

临时字段独立存在,而不是直接改atk,这是为了“可回滚”。直接改再改回来的写法,一旦中间有任何一条continue/return提前退出,属性就永久污染了。

3.3 随机数分布的细节决定手感

rand() % 100 < 30这种写法几乎出现在所有新手代码里,它有两个问题。一是rand()在部分实现上周期短、低位随机性差,取模之后偏差更明显;二是% 100本身有偏,因为RAND_MAX+1通常不是 100 的整数倍,前面几十个数被取到的概率会略高。这个偏差在骰子类判定里几乎看不出来,但在“暴击率 5%”这种小概率上,几十万次战斗下来差距是实打实的。

现代 C++ 的解法是<random>,配一个全局只初始化一次的引擎:

static std::mt19937& rng() { static std::mt19937 engine{std::random_device{}()}; return engine; } int rollInt(int lo, int hi) { // 闭区间 [lo, hi] std::uniform_int_distribution<int> d(lo, hi); return d(rng()); } bool rollChance(double p) { // p 在 [0,1] std::bernoulli_distribution d(p); return d(rng()); }

用函数内的static局部变量而不是全局变量,好处是初始化时机确定(第一次调用时),不用操心静态初始化顺序问题;整个程序里只存在一个引擎实例,不会出现“每次调用都重新播种”导致每次结果都一样的事故。重新播种是这类项目里另一个高频 bug:srand(time(0))写在循环里,同一秒内所有随机数完全相同,玩家会觉得“这游戏的暴击是固定的”。

4. 存档:小游戏里最容易埋雷的地方

4.1 文本存档还是二进制存档

这个选择没有绝对答案,但有几个判断依据很实用。文本存档(key=value 或者每行一个字段)的特点是人能看懂,出问题能手工救;缺点是体积大一点,浮点数精度要靠格式化控制,读取时要处理各种格式错误。二进制存档体积小、读写快、精度无损,但一旦格式改了就彻底读不了,也没法用文本编辑器抢救。

对于这类几百 KB 都不到的小游戏,我强烈倾向前者。理由不是性能,而是你可调试:玩家发来一个坏存档,你可以直接打开看哪一行不对;换成二进制,你只能让他重开。唯一的代价是要处理浮点数的精度——用std::setprecision(17)写出,读回来才能保证同一个值,用默认的 6 位精度写出去再读回来,攻击力会慢慢漂移。

// 保存时 out << "hp=" << st.hp << "\n"; out << "crit=" << std::setprecision(17) << st.critRate << "\n"; // 读取时(简易 key=value 解析) size_t eq = line.find('='); if (eq == std::string::npos) continue; // 跳过坏行而不是崩溃 std::string key = line.substr(0, eq); std::string val = line.substr(eq + 1);

“跳过坏行”这个设计很关键。存档读取最容易写成的形态是“碰到不认识的字段就报错退出”,结果玩家升级版本后老存档直接打不开。改成跳过未知字段、缺失字段用默认值填,兼容性问题就消掉一大半。

4.2 原子写入:别让断电毁掉进度

这类游戏最伤玩家感情的事故是“保存时闪退,存档变成 0 字节”。原因是直接往存档文件上写:打开文件的一瞬间内容已经被截断,如果程序在写完之前崩溃或者断电,留下的就是一个空文件。

正确做法是写临时文件,成功之后替换:

bool saveTo(const std::string& path, const GameState& st) { const std::string tmp = path + ".tmp"; { std::ofstream out(tmp, std::ios::trunc); if (!out) return false; writeSave(out, st); out.flush(); if (!out) return false; // 真正检查写入是否成功 } // 这里出作用域,文件句柄关闭 #ifdef _WIN32 std::remove(path.c_str()); // Windows 下 rename 不覆盖已存在的文件 #endif return std::rename(tmp.c_str(), path.c_str()) == 0; }

有三个细节值得单独说。第一,out.flush()之后必须检查流状态,因为磁盘满、权限不足这些问题不会在<<的时候报出来,而是在写盘的时候才失败。第二,花括号作用域是为了确保文件句柄在rename之前关闭——Windows 上文件被占用时rename会失败,这是一个只在 Windows 上出现、在别处测不出来的坑。第三,std::remove那行是必要的,POSIX 的rename允许覆盖目标,Windows 的std::rename不允许,跨平台代码必须显式删掉旧文件。

再严格一点还可以加fsync,但控制台小游戏里通常没必要,因为最常见的崩溃源是程序自己(异常、内存越界),而不是断电,flush加原子替换已经能挡住绝大部分。

4.3 校验与版本迁移:给未来的自己留后路

存档头里放版本号这件事在第 1 节提过,这里说具体怎么用。读取时先看版本,如果是老版本,走一条专门的迁移路径,把老字段映射到新字段;如果版本比当前代码还新(玩家降级了),直接拒绝加载,因为字段含义可能已经变了,硬读会读出垃圾数据。

struct SaveHeader { int version; unsigned checksum; }; // 简易校验:对内容做一次累加,能挡住截断和大多数手工篡改 unsigned checksumOf(const std::string& body) { unsigned h = 2166136261u; for (unsigned char c : body) { h ^= c; h *= 16777619u; } return h; }

这个 FNV-1a 变体只有几行,但它能把“文件被截断”这个最常见的损坏形态准确识别出来。校验不通过时的处理策略我建议是:不要静默覆盖,把坏存档改名成.bak保留,然后提示玩家重新开始。直接删掉是最糟糕的选择,因为玩家会觉得“连个说法都没有”。

5. 数值与内容分离:加一个新境界不该改代码

5.1 表驱动:把会变的东西挪出源码

2.10.6 这种版本号意味着内容已经被加了十轮。如果每一轮新境界、新丹药、新功法都是if (level == 3)这样的分支,代码早就烂成泥了。能撑住十轮迭代的项目,几乎都是表驱动的:境界、物品、技能的全套数值放在外部数据里,代码只负责解释这张表。

struct RealmRow { std::string name; int reqExp; // 突破所需经验 int hp, atk, def; }; std::vector<RealmRow> loadRealms(const std::string& path) { std::vector<RealmRow> rows; std::ifstream in(path); std::string line; std::getline(in, line); // 跳过表头 while (std::getline(in, line)) { if (line.empty() || line[0] == '#') continue; std::stringstream ss(line); RealmRow r; std::string expStr, hpStr, atkStr, defStr; std::getline(ss, r.name, ','); std::getline(ss, expStr, ','); std::getline(ss, hpStr, ','); std::getline(ss, atkStr, ','); std::getline(ss, defStr, ','); if (r.name.empty()) continue; r.reqExp = toIntOr(expStr, 100); // 给默认值,坏数据不致命 r.hp = toIntOr(hpStr, 100); r.atk = toIntOr(atkStr, 10); r.def = toIntOr(defStr, 5); rows.push_back(r); } return rows; }

用逗号分隔而不是复杂的格式,是因为它可以用表格软件直接编辑,改数值不用重新编译。toIntOr这种“解析失败给默认值”的写法看着不严谨,但对小游戏是对的:一个手滑多打的逗号不该让整个游戏起不来,应当只让那一行退化成默认值,然后在日志里留一条警告。

5.2 加载失败的降级策略

配表读取必须考虑“文件不存在”“文件是空”“表头写错”这三种情况。我的做法是内置一份最小可用的默认表,配置加载失败时退回默认表并打印警告。这样即使玩家误删了数据文件,游戏依然能跑起来,只是内容变少——体验上远好于直接闪退。

const std::vector<RealmRow>& realms() { static std::vector<RealmRow> table = [] { auto rows = loadRealms("data/realms.csv"); if (rows.empty()) { warn("realms.csv 缺失或不可用,使用内置默认表"); return defaultRealms(); } return rows; }(); return table; }

用函数内静态变量做惰性单次加载,比在main开头加载更方便,因为它不需要你记得在某个地方初始化,也不会出现“加载了两次”的浪费。唯一要注意的是不要在多线程里第一次调用它(C++11 之后静态局部变量初始化是线程安全的,但这里本身用不到多线程)。

5.3 调参的实操方法:先把曲线画出来再改表

数值平衡最忌讳的做法是“感觉打怪太慢了,把攻击加 5”。改完之后玩家角色 20 级一刀秒怪,然后又去把怪的血加回去,来回振荡。真正有效的做法是先把数值曲线打印出来:每一级的 hp、atk、每只怪的血量、一回合平均能打掉多少,用一张表列出来,一眼就能看到哪一段出现过陡或过平。

我自己写的检查脚本大概就是十几行,直接读那张 csv,输出“第 N 级打第 N 章怪需要几回合”。目标区间定在 4 到 8 回合,低于 3 说明这段太简单、玩家没有决策空间;高于 10 说明太磨,回合制游戏的耐心阈值大概就在这个位置。这个数字没有理论依据,纯粹是反复调出来的手感,但一旦定下来,后面所有新内容都按这个尺子量,就不会走偏。

6. 中文输出与跨平台:控制台里那些显示上的坑

6.1 源码编码和控制台代码页是两回事

中文乱码这件事,90% 的原因是源文件编码和运行环境代码页不一致。源文件用 UTF-8 保存,字符按多字节存在程序里;Windows 控制台默认代码页是 936(GBK),它按 GBK 去解读这些字节,出来的就是一堆问号或者方块。反过来,源文件存成 GBK、控制台是 65001,照样乱。

三种解法,我按推荐程度排:

方案做法适用场景代价
统一 UTF-8调用SetConsoleOutputCP(CP_UTF8),源文件存 UTF-8现代 Windows 10/11极少数老机器字体不支持
统一 GBK源文件存 GBK,代码页保持 936只跑 Windows 老系统版本控制里 diff 难看
宽字符输出用std::wcout+_setmode(_O_U8TEXT)需要精确控制和std::cout混用会乱序

对这类小游戏,我选第一种。一行调用就够了:

#ifdef _WIN32 #include <windows.h> #endif void initConsole() { #ifdef _WIN32 SetConsoleOutputCP(CP_UTF8); SetConsoleCP(CP_UTF8); #endif }

要注意 MSVC 有个脾气:如果源文件是 UTF-8 但没带 BOM,它可能按系统代码页去解析源码里的中文字符串字面量,结果编译进去的就是错的字节。加/utf-8编译选项能彻底解决这个问题,比纠结 BOM 靠谱。

6.2 中文对齐:std::setw为什么不管用

这个坑特别隐蔽。控制台里一个汉字占两个字符宽度,但std::string::size()数的是字节数(UTF-8 下一个汉字三字节)。你想用std::setw(10)对齐血条,结果中英混排时永远对不齐,而且是随汉字数量随机偏移的那种不对齐,看起来像排版坏了。

解决办法是自己算显示宽度:按 UTF-8 首字节判断字符长度,ASCII 记 1,其余记 2。然后自己补空格,不要用setw。

size_t displayWidth(const std::string& s) { size_t w = 0; for (size_t i = 0; i < s.size(); ) { unsigned char c = s[i]; size_t len = (c < 0x80) ? 1 : (c < 0xE0) ? 2 : (c < 0xF0) ? 3 : 4; w += (len == 1) ? 1 : 2; i += len; } return w; }

这个简化版对全角标点、emoji 之类的判断不准(真正的宽度表要考虑 East Asian Width 属性),但对“汉字 + 英文字母 + 数字”的常见组合足够用。血条类的界面靠它就能对齐得像那么回事。

6.3 清屏与按键读取的可移植封装

system("cls")和system("clear")能用,但每次调用都要启动一个子进程,滚动频繁的时候肉眼可见地卡。更麻烦的是它不可控,有些环境里会闪一下。Windows 上可以用SetConsoleCursorPosition把光标挪回左上角,配合覆盖输出,闪烁会轻很多;跨平台的话就老老实实封装一层。

按键读取更麻烦:Windows 的_getch()是即时读取、不需要回车的,POSIX 上要改 termios 关掉行缓冲。这类代码必须隔离在一个编译单元里,用#ifdef分平台实现,暴露一个统一的int readKey()出来,业务代码永远不碰平台 API。

#ifdef _WIN32 #include <conio.h> int readKey() { return _getch(); } #else #include <termios.h> #include <unistd.h> int readKey() { termios oldt, newt; tcgetattr(STDIN_FILENO, &oldt); newt = oldt; newt.c_lflag &= ~(ICANON | ECHO); tcsetattr(STDIN_FILENO, TCSANOW, &newt); int ch = getchar(); tcsetattr(STDIN_FILENO, TCSANOW, &oldt); // 一定要恢复 return ch; } #endif

tcsetattr恢复那行绝对不能漏。漏了的话程序异常退出后,终端会保持无回显状态,用户得敲reset才能恢复正常——这个“后遗症”我在早期项目里制造过不止一次,用户的反馈是“跑完你的程序我的命令行就废了”。

不过话说回来,这类游戏大部分交互是菜单选择,用整行读取就够了,即时按键只在“翻页查看图鉴”“按住加速对话”这类场景才需要。

7. 一个人维护小游戏的工程习惯

7.1 编译选项是免费的质量检查

-Wall -Wextra这两个开关能抓出来的问题比大多数人的想象多得多:未使用的变量背后往往是被写错名字的变量,有符号无符号比较往往藏着负数下标,switch漏掉枚举分支在加新状态时几乎必然发生。我在项目里把警告开到-Wall -Wextra -Wshadow -Wconversion,其中-Wshadow抓的是“内层变量遮蔽外层变量”,这类 bug 表现为“改了但没生效”,查起来极其痛苦。

g++ -std=c++17 -O2 -Wall -Wextra -Wshadow -o game src/*.cpp

调试构建再额外挂上地址消毒器,越界和悬垂指针会被当场抓住,而不是等到某次战斗莫名其妙崩掉:

g++ -std=c++17 -g -fsanitize=address,undefined -o game_dbg src/*.cpp

-fsanitize=undefined对这类数值计算密集的代码特别有用,整数溢出、除零、非法的浮点转整数都会被报出来。曾经有一次敌方血量算出来是负数导致直接判负,就是靠它定位到某个防御力减伤公式里没做除数保护。

7.2 用重定向输入做冒烟测试

控制台程序有个天然优势:它的输入输出都是标准流,可以用文件重定向来“回放”一整局游戏。把一串菜单选择写进文本文件,然后./game < script.txt > out.txt,跑完检查输出里有没有出现“错误”“异常”或者血量变成负数。

#!/bin/sh # smoke.sh —— 每次改动后跑一遍 ./game < cases/basic.txt > out.txt 2>&1 || { echo "退出码异常"; exit 1; } grep -q "hp=-" out.txt && { echo "出现负血量"; exit 1; } grep -q "段错误" out.txt && exit 1 echo "冒烟通过"

这套东西看着土,但它能挡住 80% 的低级回归:改完战斗公式忘了同步界面显示、加了新菜单项导致编号错位、存档读完之后字段顺序变了。真正的单元测试在小游戏里性价比不高(逻辑和输出耦合太紧),但确定性回放的性价比极高,因为它模拟的是玩家真实的操作序列。

为了让回放确定性成立,随机数必须可控:加一个环境变量或者命令行参数,让随机引擎用固定种子初始化。这一条在调试数值时几乎是必需品,否则同一个 bug 每次复现的现象都不一样,你会怀疑人生。

7.3 发版前的那份清单

一个人维护项目,最容易忘的不是写代码,而是发版前的杂事。我现在的清单大概是这样的:

检查项原因
存档版本号是否已递增决定老存档能不能读
默认配置表是否随包发出缺了会退回内置表,内容变少但不报错
调试快捷键是否已关闭一键加钱加经验的东西不能流出去
固定种子的调试开关是否默认关关了之后每局随机才正常
版本号字符串是否已同步界面上显示的版本和文件名对不上很尴尬
是否在干净目录跑过一次开发机上有一堆残留文件,容易漏打包

最后一条是我吃过亏的:在自己机器上测试一切正常,换台机器发现配置文件路径是硬编码的绝对路径,游戏直接找不到数据。现在所有资源路径都相对于可执行文件目录解析,实在要用绝对路径的地方至少给一个清晰报错。

另外值得提一句“调试快捷键”这个事。开发期为了快速验证后期内容,加一个按~直接跳到 50 级的功能非常合理,但一定要用编译期宏包起来,#ifdef DEBUG_CHEAT,发布构建里根本不存在这段代码。用运行时的if (debugMode)是危险的,因为总有一天你会忘记把那个开关关掉,然后玩家发现了它,游戏的核心循环就废了。

最后分享一个小技巧:给每一次发版都在源码里留一个CHANGELOG.md,哪怕只写一行“2.10.6:修正突破丹药在满级时被消耗但不生效的问题”。三个月后你自己回头看,能立刻想起当时为什么改了那行看起来毫无意义的判断——这种上下文比任何注释都值钱,因为它记录的是“意图”,而代码只能记录“结果”。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询