简介:《VC版重装机兵(MetalMax)(游戏+源码)》面向想研究FC经典游戏复刻与C/C++游戏开发的读者,基于VC8.0和DirectX9.0 SDK实现,提供可运行程序、存档、音效、纹理及完整源码,便于拆解地图、战斗、UI与资源加载模块。压缩包共545个文件,约6.97MB,以png纹理、h/cpp源码、db数据、wav/mp3音频和exe、dll运行文件为主,目录按Release、Save、Sound、Texture、Src划分,结构清晰。已有3076人学习下载,可对照理解小型2D角色扮演游戏的工程组织、场景切换、存档读取与战斗流程,也适合作为VC++与DirectX实践参考。
1. 一台战车RPG的VC复刻,为什么值得拆
前阵子整理老工程,翻到一个 VC 版重装机兵(MetalMax)的游戏加源码包,双击 exe 能直接进标题画面,坦克开出去、遇敌、进战斗,一套流程不缺。很多人第一反应是「VC 写游戏早就过时了」,但真把源码摊开看,Win32 消息循环、GDI 双缓冲、瓦片地图、回合战斗、数据表驱动,该有的都有,而且没有引擎黑盒,每一行都能改。它适合两类人:想看懂一个完整小体量 RPG 怎么从零搭起来的新手,和想拿一套能跑的骨架做自己战车或冒险游戏的熟手。源码结构不绕,但细节里埋了不少老工程的坑,下面按「结构—地图—战斗—跑起来—改造」的顺序拆。
2. 工程结构与技术选型:一个 Win32 程序怎么撑起整台战车
2.1 目录拆开看:源码、资源、数据三块分得很清
拿到源码包先别急着编译,先把目录扫一遍,判断哪部分是逻辑、哪部分是美术、哪部分是可以直接改的数值。这个项目的分法很典型,src 只放 C++ 逻辑,res 放位图,data 放可编辑的数据文件,save 是运行时才生成的存档目录。好处是改敌人血量不用碰代码,换一张地图瓦片也不用重新编译,对二次开发很友好。
MetalMax_VC/ ├── src/ # 全部 C++ 源码 │ ├── main.cpp # WinMain 入口,消息循环 │ ├── GameApp.cpp/.h # 应用层,场景切换与全局状态 │ ├── Scene.h # 场景基类 │ ├── MapScene.cpp/.h # 大地图/城镇场景 │ ├── BattleScene.cpp/.h # 回合战斗场景 │ ├── MenuScene.cpp/.h # 菜单、装备、存档界面 │ ├── Player.cpp/.h # 角色与队伍 │ ├── Vehicle.cpp/.h # 战车改装与底盘 │ ├── ResourceMgr.cpp/.h # 位图资源加载与缓存 │ ├── DataTable.cpp/.h # CSV 数据表读取 │ └── Trigger.cpp/.h # 地图事件触发 ├── res/ │ ├── tiles/ # 地形瓦片 BMP │ ├── sprites/ # 角色、敌人、战车 │ └── ui/ # 对话框、菜单框 ├── data/ │ ├── map_*.dat # 地图二进制 │ ├── enemies.csv # 敌人属性 │ ├── items.csv # 道具 │ └── vehicles.csv # 车辆底盘与改装 ├── save/ # 存档输出目录 └── MetalMax.dsw # VC6 工程文件这个结构里最值得学的是ResourceMgr的位置。它被做成一个全局单例,负责把 BMP 加载成HBITMAP并缓存,其他模块只拿句柄,不直接碰文件。听起来简单,但如果没有这一层,每个场景各自LoadImage,GDI 对象数量很快就会失控,程序跑十几分钟就开始掉帧甚至句柄耗尽。老工程里这类隐性问题不少,读源码时先找资源管理入口,比从头啃WinMain效率高得多。
工程文件是.dsw/.dsp的老结构,用新版 Visual Studio 打开会提示升级,按向导走一般能转过去。转完第一件事是看项目属性的字符集设置,这个后面排查章节会细说,先记住它是个高频翻车点。
2.2 技术选型:GDI 双缓冲加场景栈,够用就好
这个项目没有用 DirectX,也没有引第三方引擎,纯 Win32 API 加 GDI 绘制。放在今天看好像很原始,但放到这个体量上非常合理:分辨率 800×600,同屏瓦片几百个,角色和敌人都是小位图,GDI 的BitBlt完全扛得住,而且不需要额外运行库,拿到机器上双击就能跑。
真正决定体验的不是绘图 API,而是有没有做双缓冲。直接在窗口 DC 上画,每一帧擦背景再画内容,玩家看到的就是持续闪烁。项目的做法是先在内存 DC 上画完整一帧,再一次性贴到窗口,这是老 Windows 游戏的标准解法。场景管理则用了一个简单的栈:地图场景在底下,进战斗就压一个战斗场景上去,战斗结束弹栈回到地图,队伍状态原样保留。
// Scene.h:所有场景的统一接口 class Scene { public: virtual ~Scene() {} virtual void Update(float dt) = 0; // dt 单位是秒 virtual void Render(HDC backDC) = 0; // 画到后备缓冲,不是窗口 DC virtual void OnKeyDown(int vk) {} // 键盘按下 virtual void OnEnter() {} // 进入场景时初始化 virtual void OnExit() {} // 离开时释放临时资源 };接口只有五个函数,职责划得很干净:Update只算状态不画图,Render只画图不改状态,键盘事件单独走。这样战斗场景可以完全复用地图场景的输入处理方式,不用为每个场景重复写消息分支。Render的参数是后备 DC 而不是窗口 DC,这一条是双缓冲能成立的前提,如果哪个场景偷偷拿了窗口 DC 直接画,闪烁就会回来。
场景栈的压入和弹出也值得看一眼:
// GameApp.cpp:战斗结束回地图,本质就是一次 Pop void GameApp::PushScene(Scene* s) { if (!m_scenes.empty()) m_scenes.back()->OnExit(); m_scenes.push_back(s); s->OnEnter(); } void GameApp::PopScene() { if (m_scenes.empty()) return; m_scenes.back()->OnExit(); delete m_scenes.back(); m_scenes.pop_back(); if (!m_scenes.empty()) m_scenes.back()->OnEnter(); }注意PushScene里旧场景只调OnExit,对象并不销毁,所以OnExit里不能把地图数据释放掉,否则战斗打完回来地图就空了。这是读源码时很容易误解的一处:OnExit的语义是「暂时离开」,不是「销毁」。真正释放放在析构函数里。我一般会在OnExit里只做三件事:停计时器、清临时动画、把输入状态复位,地图和队伍这种长期数据一律不动。
2.3 主循环:把 WM_TIMER 换成固定步长
很多 VC 小游戏用SetTimer驱动逻辑,WM_TIMER的精度受消息队列影响,实际间隔可能在 15 到 50 毫秒之间飘,角色移动速度会忽快忽慢,战斗动画也会抖。这个项目的main.cpp用的是PeekMessage加高精度计数器,逻辑更新和渲染分开,算是这类项目里比较讲究的写法。
// main.cpp:固定步长主循环 int WINAPI WinMain(HINSTANCE hInst, HINSTANCE, LPSTR, int) { GameApp app; if (!app.Init(hInst, 800, 600)) return -1; LARGE_INTEGER freq, last, now; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&last); double acc = 0.0; const double STEP = 1.0 / 60.0; // 逻辑固定 60Hz MSG msg = {0}; while (msg.message != WM_QUIT) { if (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE)) { TranslateMessage(&msg); DispatchMessage(&msg); continue; // 有消息先处理消息 } QueryPerformanceCounter(&now); double dt = double(now.QuadPart - last.QuadPart) / freq.QuadPart; last = now; if (dt > 0.25) dt = 0.25; // 拖动窗口后别一次补太多帧 acc += dt; while (acc >= STEP) { app.Update((float)STEP); // 逻辑按固定步长推进 acc -= STEP; } app.Render(); // 渲染每帧一次 } app.Shutdown(); return 0; }参数含义:STEP决定游戏世界的时间刻度,改成1.0/30.0逻辑更新变慢但 CPU 占用下降;dt上限 0.25 秒是防止玩家拖动窗口或切出桌面再回来时,累积时间过大导致一次补上百帧,俗称死亡螺旋,表现出来就是卡死几秒然后角色瞬移。acc是时间累加器,只有攒够一个STEP才推一次逻辑,这样即使渲染帧率浮动,移动速度和动画节奏也是稳定的。
这套循环还有个隐含好处:逻辑和渲染解耦之后,做回放或者自动化测试会容易很多。我在别的项目里试过把Update单独抽出来按固定次数跑,不启动窗口也能验证战斗结算,这个项目虽然没写测试,但结构上留了这个口子。
3. 地图与移动:从瓦片数组到格子对齐的行走手感
3.1 地图文件:地形、碰撞、事件三层分开存
地图不是一张大图,而是按瓦片切开的索引数组。项目给地图文件定义了一个很紧凑的头结构,后面依次跟地形层、碰撞层、事件表。这种「头 + 多段数据」的二进制格式在老游戏里很常见,读起来快,缺点是改地图必须用工具或者手写脚本,不能直接拿文本编辑器开。
// MapScene.cpp:读取自定义地图文件 #pragma pack(push, 1) struct MapHeader { int width; // 横向瓦片数 int height; // 纵向瓦片数 int tileSize; // 单瓦片像素,通常是 32 int eventCount; // 事件触发器数量 }; #pragma pack(pop) bool MapScene::LoadMap(const char* path) { FILE* fp = fopen(path, "rb"); if (!fp) return false; MapHeader hdr; fread(&hdr, sizeof(hdr), 1, fp); m_width = hdr.width; m_height = hdr.height; m_tile = hdr.tileSize; int n = hdr.width * hdr.height; m_terrain.resize(n); m_collision.resize(n); fread(&m_terrain[0], sizeof(int), n, fp); // 地形索引 fread(&m_collision[0], sizeof(unsigned char), n, fp); // 0 可走 1 阻挡 m_triggers.resize(hdr.eventCount); fread(&m_triggers[0], sizeof(Trigger), hdr.eventCount, fp); fclose(fp); return true; }#pragma pack(1)是关键,它告诉编译器结构体不要做字节对齐填充,四个int就是 16 字节,换台机器、换个编译器读同一份文件也不会错位。如果不加,结构体可能被填成 16 字节或更多,读出来的宽高就是乱码。碰撞层用unsigned char而不是int,一张 200×200 的地图能省下 120KB 内存,对老机器来说是实打实的优化。
地形索引指向res/tiles/下的一组位图,绘制时按「行优先」遍历,只画摄像机范围内的瓦片。这里有个小细节:地图边缘通常留一圈阻挡格,防止角色走到数组外面去。读源码时如果发现角色能走出地图然后崩溃,先查碰撞层最外圈是不是全 0。
3.2 移动与碰撞:格子对齐加插值,手感才不飘
角色移动是这类游戏最容易做糙的地方。纯像素自由移动会有两个问题:一是贴墙时角色能卡进半格,二是斜向走位能穿过墙角。项目的解法是「格子对齐 + 目标格插值」:按键时先判断目标格能不能走,能走才启动一段从当前格到目标格的平滑移动,到达后吸附到整数格。
// Player.cpp:格子对齐移动,避免半格卡墙 void Player::Update(float dt, const MapScene& map) { if (m_moving) { m_px += m_dirX * SPEED * dt; m_py += m_dirY * SPEED * dt; // 到达目标格就吸附,防止浮点误差累积 if (fabs(m_px - m_targetPx) < 1.0f && fabs(m_py - m_targetPy) < 1.0f) { m_px = m_targetPx; m_py = m_targetPy; m_gridX = m_targetGX; m_gridY = m_targetGY; m_moving = false; } return; } int dx = 0, dy = 0; if (m_keyUp) dy = -1; else if (m_keyDown) dy = 1; else if (m_keyLeft) dx = -1; else if (m_keyRight) dx = 1; if (dx == 0 && dy == 0) return; int nx = m_gridX + dx; int ny = m_gridY + dy; if (!map.IsWalkable(nx, ny)) return; // 撞墙就不启动移动 m_targetGX = nx; m_targetGY = ny; m_targetPx = float(nx * map.TileSize()); m_targetPy = float(ny * map.TileSize()); m_dirX = float(dx); m_dirY = float(dy); m_moving = true; }SPEED是像素每秒,一格 32 像素、SPEED设 160 的话每格正好 0.2 秒,走起来干脆利落;设 80 就慢一倍,适合大地图探索。按键判断用else if而不是并列if,是为了避免同时按两个方向时出现斜向移动,斜向移动会让碰撞判断变成两个格子的组合,穿墙角的问题就是这么来的。
吸附那两行别小看。浮点数累加几十次之后误差会累积到零点几像素,角色看起来会「陷进」地面或者贴图偏移一格。每次到达目标格强制赋值回整数坐标,误差就永远不会滚雪球。这是血泪经验,早期版本没做吸附,走十分钟地图角色就歪了。
IsWalkable的实现很简单,先做边界检查,再查碰撞层:
bool MapScene::IsWalkable(int gx, int gy) const { if (gx < 0 || gy < 0 || gx >= m_width || gy >= m_height) return false; return m_collision[gy * m_width + gx] == 0; }索引是y * width + x,行优先,和地图文件的存储顺序一致。如果写反成x * height + y,表现是角色在某些位置莫名其妙被挡住,排查时优先怀疑这一行。
3.3 事件触发器:对话、传送、遇敌都挂在格子上
地图上的门、宝箱、NPC、随机遇敌区域,本质都是「踩到某一格触发某个动作」。项目把这类逻辑抽成独立的触发器表,和地图一起加载,移动结束后统一检测,避免移动过程中反复触发。
// Trigger.h:事件表的一行 struct Trigger { short x, y; // 触发格坐标 unsigned char type; // 0 对话 1 传送 2 遇敌 3 拾取 short param1; // 对话 ID / 目标地图 / 敌人组 ID short param2; // 附加参数,比如传送后的坐标 };type决定走哪个分支,param1和param2的含义跟着type变,这种「标签 + 两个参数」的设计在资源受限的年代很常见,省内存也够用。检测时机放在移动结束、角色吸附到新格子之后:
// MapScene.cpp:移动结束时检查当前格有没有事件 void MapScene::CheckTrigger(Player& p) { for (size_t i = 0; i < m_triggers.size(); ++i) { const Trigger& t = m_triggers[i]; if (t.x != p.GridX() || t.y != p.GridY()) continue; switch (t.type) { case 0: StartDialog(t.param1); break; case 1: ChangeMap(t.param1, t.param2); break; case 2: StartBattle(t.param1); break; case 3: PickItem(t.param1); RemoveTrigger(i); break; } break; // 一格只处理一个事件 } }拾取类事件处理完要把触发器删掉,不然宝箱能无限开。这里用RemoveTrigger(i)直接把第 i 项抹掉,数组顺序会变,但触发器之间没有顺序依赖,所以问题不大。如果要做「开关式」事件,比如踩一次机关打开某扇门,可以在type的高位加一个标志位,或者单独存一张全局开关表,项目里用的是后者,param2里存开关编号。
对话内容本身没有硬编码在代码里,而是按 ID 存在data/下的脚本文件,最简单的实现就是「ID + 文本行」的纯文本,读进来按行显示。想加剧情不用改代码,往脚本里追加就行。
4. 战斗与数据表:回合结算怎么写成可配置的
4.1 数据表:CSV 让改数值不用重编译
敌人、道具、车辆底盘这些数值,如果写死在switch里,调平衡就是一场灾难。项目把它们抽到 CSV,程序启动时读进内存的map,用 ID 索引。CSV 的优点是人能直接看懂,Excel 也能开,缺点是解析慢、没有类型校验,但对这种几千行的数据量来说完全够用。
// DataTable.cpp:极简 CSV 读取,第一行是表头 bool DataTable::LoadEnemies(const char* path) { FILE* fp = fopen(path, "r"); if (!fp) return false; char line[512]; fgets(line, sizeof(line), fp); // 跳过表头 while (fgets(line, sizeof(line), fp)) { std::vector<std::string> cols; char* p = strtok(line, ","); while (p) { cols.push_back(p); p = strtok(NULL, ","); } if (cols.size() < 6) continue; // 列数不对就跳过 Enemy e; e.id = atoi(cols[0].c_str()); e.hp = atoi(cols[1].c_str()); e.atk = atoi(cols[2].c_str()); e.def = atoi(cols[3].c_str()); e.agi = atoi(cols[4].c_str()); e.exp = atoi(cols[5].c_str()); m_enemies[e.id] = e; } fclose(fp); return true; }strtok会修改传入的字符串,把逗号替换成\0,所以必须按行读、按行解析,不能一次性把整个文件读进来再切。cols.size() < 6是防御性判断,防止有人手改 CSV 少写了一列导致越界。字段含义如下:
| 列 | 字段 | 作用 |
|---|---|---|
| 0 | id | 敌人编号,触发器和战斗配置都引用它 |
| 1 | hp | 血量,决定能扛几回合 |
| 2 | atk | 攻击力,进伤害公式 |
| 3 | def | 防御力,减伤用 |
| 4 | agi | 敏捷,决定出手顺序 |
| 5 | exp | 击败后给的经验 |
这套表结构简单到有点土,但改起来是真的快。想把前期难度降一点,把前五个敌人的hp和atk各乘 0.8,保存重开游戏就生效,不用等编译。
4.2 战斗状态机:五个状态把回合流程串起来
回合战斗最怕写成一坨「按键就结算、结算完立刻切下一回合」的同步代码,玩家根本看不清发生了什么。项目用了一个显式的状态机,每个状态停留一小段时间,把「进场—选指令—执行—结算—结束」拆开,节奏感就出来了。
// BattleScene.cpp:回合战斗的状态机骨架 enum BattleState { BS_INTRO, BS_INPUT, BS_EXECUTE, BS_RESULT, BS_END }; void BattleScene::Update(float dt) { m_timer += dt; switch (m_state) { case BS_INTRO: // 进场动画 if (m_timer > 0.8f) EnterState(BS_INPUT); break; case BS_INPUT: // 等玩家选攻击/道具/防御/逃跑 if (m_cmdReady) EnterState(BS_EXECUTE); break; case BS_EXECUTE: // 播放行动 if (!m_actionPlayed && m_timer > 0.3f) { ResolveTurn(); // 真正结算这一回合 m_actionPlayed = true; } if (m_timer > 1.2f) EnterState(IsBattleOver() ? BS_END : BS_INPUT); break; case BS_RESULT: // 伤害数字、掉落提示 if (m_timer > 0.8f) EnterState(BS_END); break; case BS_END: if (m_timer > 1.0f) PopSelf(); // 弹栈回地图 break; } UpdateAnimations(dt); // 动画和状态机解耦 }m_timer每个状态入口清零,0.3f是行动前摇,1.2f是整个执行阶段的时长。这些数字没有魔法,就是试出来的,改大改小直接影响战斗节奏,觉得拖沓就把1.2f降到0.8f。m_actionPlayed保证ResolveTurn一个回合只跑一次,否则Update每帧都调,伤害会翻几十倍。
ResolveTurn里按敏捷排序决定出手顺序,速度快的一方先打,打死对方就直接结束,不再执行后面的行动。这个顺序逻辑写在结算函数里而不是状态机里,是为了让状态机保持「只管流程不管业务」。
4.3 伤害公式:能调、能解释、不出现负数
伤害公式是数值手感的核心。项目用的是最朴素的「攻击加技能威力减半防御,再乘随机浮动」,看起来简单,但已经足够支撑前期到后期的成长曲线。
// BattleScene.cpp:伤害计算,最低保底 1 点 int CalcDamage(int atk, int def, int power) { int base = atk + power - def / 2; if (base < 1) base = 1; // 保底伤害,避免完全打不动 int rand100 = rand() % 100; // 0~99 return base * (90 + rand100 % 21) / 100; // 90%~110% 浮动 }def / 2而不是直接减def,是为了避免高防御敌人变成无敌,前期玩家攻击力低的时候也不会被完全挡死。保底 1 点是必须的,不然会出现「打了半天零伤害」的挫败感。浮动范围 ±10% 让同样的攻击有轻微变化,战斗日志不至于每行都一样。
想加暴击,在这个函数外面套一层:先算命中,命中后按暴击率决定是否把结果乘 1.5,再走浮动。想加属性克制,把power换成「基础威力 × 克制系数」,系数查表得到。公式本身不用动,扩展点都在参数上。
5. 编译与排查:从工程转换到运行时崩溃的五个坑
5.1 从源码到可执行:按顺序走一遍
第一步打开工程。.dsw是老 VC6 的解决方案格式,新版 Visual Studio 打开会提示升级,按向导确认即可,转换后生成.sln。转换完先别编译,进项目属性确认三件事:字符集、输出目录、工作目录。字符集在老工程里通常是「使用多字节字符集」,如果被改成 Unicode,所有char*和fopen相关的代码都可能出问题。
第二步检查资源路径。源码里读资源大量使用相对路径,比如fopen("data/enemies.csv", "r"),这个路径是相对于「工作目录」而不是 exe 所在目录。在 IDE 里调试时工作目录默认是项目目录,能读到;直接双击 exe 时工作目录变成 exe 所在目录,如果 exe 被输出到Debug/而data/在上一层,就会读不到文件,表现为黑屏或者一进游戏就退出。稳妥做法是把res/、data/一起拷到 exe 旁边,或者代码里用GetModuleFileName拼绝对路径。
第三步编译。老工程常见的报错是LNK2005重复定义和LNK2019找不到符号,前者多半是头文件里直接定义了全局变量,后者通常是某个.cpp没加进项目。解决完链接错误,先跑标题画面,再进地图,最后进战斗,按这个顺序验证,能快速定位是哪一层没初始化好。
5.2 五个真实翻车现场
现象一:编译报一堆LNK2005,说某个变量已经在别的 obj 里定义。原因是在头文件里写了int g_score;这种定义,多个.cpp包含该头文件后各自生成一份符号。解决方式是在头文件里改成extern int g_score;,然后只在一个.cpp里写真正的定义。老工程里这种写法很常见,尤其是全局状态变量。
现象二:程序启动后窗口全黑,或者一闪就退出。先看是不是资源没读到。在LoadMap和LoadEnemies的fopen失败分支加一句OutputDebugString,用调试器挂上去看输出。十有八九是工作目录问题,其次是文件名大小写,Windows 不敏感但有些解压工具会把文件名改掉。还有一种可能是Init里创建后备 DC 失败,检查CreateCompatibleDC的返回值。
现象三:画面持续闪烁,角色移动像在抖。原因是没做双缓冲,或者做了但某个绘制函数拿了窗口 DC。检查WM_ERASEBKGND消息有没有返回非零,返回 0 可以阻止系统擦背景,减少一次全屏填充。再检查所有Render调用传的是不是后备 DC,只要有一处直接往窗口画,闪烁就会回来。
现象四:中文对话显示成乱码或者问号。这是字符集和文件编码不一致。源码文件如果存成 UTF-8,而工程用多字节字符集编译,char*里的中文会按本地代码页解释,显示就乱。统一成一种:要么源码存 ANSI/GBK,要么工程切 Unicode 并把字符串改成L"..."。改之前先备份,这个改动会牵连一大片。
现象五:存档能存能读,但读出来的数值是错的。多半是结构体对齐或者版本不匹配。直接把Player结构体fwrite到文件,如果结构体里有bool、short混排,不同编译选项下填充字节不一样,读回来就错位。解决办法是给存档结构体加#pragma pack(1),并在文件头写一个版本号,读的时候先比对版本,不一致就拒绝加载而不是硬读。
6. 二次开发:改数值、加地图、换素材的落点
想快速看到改动效果,从数值下手最划算。data/enemies.csv改完保存,重启游戏就生效,适合调难度曲线。想加一件新道具,在items.csv末尾追加一行,给个没用过的 ID,再在地图触发器里把type=3的param1指向这个 ID,宝箱里就能开出来。这套流程不用碰一行 C++。
加地图稍微麻烦一点,因为地图是二进制格式。最省事的做法是复制一张现有的map_*.dat,用脚本改文件头里的宽高和后面的数组长度,再手工填地形和碰撞。我一般会写个几十行的 Python 脚本,把一张 CSV 描述的地图转成二进制,改起来比手搓十六进制快得多。转换脚本重点保证三段的顺序和长度严格对应:地形width*height*4字节,碰撞width*height字节,触发器按sizeof(Trigger)对齐。
换素材是最容易翻车的一步。res/下的 BMP 文件名被代码硬编码引用,替换时文件名必须完全一致,尺寸也要一致,否则BitBlt会画歪或者只画一部分。替换前先看原图的位深,24 位和 32 位在 GDI 下的处理不一样,带透明通道的图需要额外的掩码处理,直接换会变成黑底。
验证改动是否生效,我习惯在Update里加一个调试开关,按 F1 打印当前地图坐标、队伍血量和最近一次伤害值。这个开关不删,留着给后面改数值的人用。有一次我改完敌人血量忘了同步经验值,打一场升三级,就是靠这个打印发现的。
从那以后我每次动数据表之前,都强制先把原始 CSV 备份一份,改完跑三场战斗对比数值,确认曲线没崩再继续。希望帮到你。
本文还有配套的精品资源,点击获取