自研战棋地图编辑器:重画火纹初代25张地图的数据结构与工程实践

发布时间:2026/9/7 7:14:51
自研战棋地图编辑器:重画火纹初代25张地图的数据结构与工程实践 把火纹初代全部25张地图重画一遍最花时间的不是画格子而是想清楚地图数据到底怎么组织。我花了两周左右先做了一个自研地图编辑器再基于它把25张地图按原始结构重画并导出。这篇文章不聊具体关卡剧情只讲重画地图这件事里的数据结构、绘制流程、校验方法和工程坑点适合对战棋关卡编辑器、像素地图数据结构、批量地图生产感兴趣的人。先说结论如果你只是想画一张看起来像火纹的地图用 Tiled 这类瓦片工具就够了。但如果你想还原的是“能跑逻辑的战棋地图”也就是每个格子的地形、移动消耗、敌我初始位置、增援点和事件触发点都必须对得上那么普通的瓦片编辑器会越用越别扭。这也是我选择自研编辑器的根本原因。1. 先推翻“用现成瓦片工具重画一遍”的偷懒方案1.1 普通瓦片地图工具解决不了战棋数据Tiled 这类工具可以画瓦片、设置碰撞区域做平台动作游戏的地图非常顺手。Pyxel Edit 这类像素工具更适合画美术素材。但战棋地图的核心不是“瓦片长什么样”而是“格子数据是什么”。火纹初代是回合制方格战棋地图本质上是行和列组成的二维网格。每个格子不是一张普通瓦片图片而是一个带属性状态的数据节点。同一格表面看上去是“森林”里面可能还要记录步兵移动消耗是多少骑兵能不能进入飞行单位消耗是不是 1森林里是否藏有增援点是普通树林还是需要特殊触发的地形普通瓦片编辑器只能把森林画成深绿色方块但无法天然地管理“森林地形 移动消耗 增援标记”这一整组关联属性。你可以用自定义属性硬塞进去但到了批量绘制、批量校验、批量导出的时候工具本身不约束数据结构所有规则都得靠人肉记出错的概率会越来越高。所以我说的“自研编辑器”重点不是做一个比 Tiled 更强的像素绘制软件而是做一个能理解战棋地图数据结构的轻量工具。1.2 自研编辑器的设计目标小而专这个编辑器的设计目标非常窄能打开 25 张地图的数据能按格子绘制和修改地形能放置建筑、村庄、城门、王座等物体能标注玩家初始位置、敌人初始位置、增援点和事件触发点能实时预览能导出统一格式能配合校验脚本做数据检查不做动画不做特效不做脚本编辑不做复杂图块自动拼接。功能缩小之后技术难度会明显下降出问题也好定位。我见过不少自研编辑器项目最终卡死往往不是因为功能不够而是加需求太猛。今天加一个图层特效明天加一个插件接口后天又想支持多人协作结果编辑器本身越来越不稳定地图没画多少时间全花在维护工具上。地图编辑器的核心价值是约束数据结构不是做一个通用的游戏编辑器。2. 自研编辑器到底在编辑什么格子、图层和属性2.1 格子是地图的最小单元不是像素火纹初代这类战棋游戏里单位移动、攻击范围、地形效果、事件触发全都建立在格子坐标上。所以编辑器内部不能把地图当作一张大图片来存而应该把地图看作一个二维数组。以 TypeScript 类型为例我当时先定义了这样一套核心结构type TerrainType | plain // 平原 | grass // 草地 | forest // 森林 | mountain // 山 | water // 水 | wall // 城墙 | castle // 城堡 | village // 村庄 | gate; // 城门 type MoveType foot | horse | fly | armor; interface MapCell { x: number; y: number; terrain: TerrainType; object?: village | house | chest | throne; walkable: boolean; moveCost: RecordMoveType, number; tags: string[]; // 出生点、增援点、剧情点等 } interface FireEmblemMap { mapId: string; name: string; width: number; height: number; cells: MapCell[][]; playerSpawns: SpawnPoint[]; enemySpawns: SpawnPoint[]; events: MapEvent[]; }这个结构不是某个现成引擎的标准只是我自己为了重画地图时方便管理数据而设计的示例。它表达了一个关键思路格子必须同时拥有“视觉表现”和“逻辑属性”。画图时你看到的是森林但保存后程序读到的是一串包含地形类型、移动消耗、是否可通行的数据。这才是重画地图真正要做的事。2.2 地形层、物体层、标注层三层分开存绘制界面虽然看着是一张地图但内部逻辑一定要分层。我把数据分成三层地形层基础地面包括草地、平原、森林、山、水、荒地、城堡内部地面。物体层建筑和交互对象包括村庄、房屋、城门、王座、宝箱、墙壁。标注层只存在于逻辑中不对应具体图块的标记包括玩家出生点、敌人出生点、增援点、剧情触发点。三层分开的最大好处是修改范围可控。比如某张地图只需调整敌人初始位置那我只动标注层地形层和物体层完全不用碰。自动校验时也可以逐层检查地形层看移动消耗表物体层看交互对象是否重叠标注层看出生点是否越界。如果所有信息都堆在同一个图层里画到第 10 张时想改一个增援点位置很有可能会误碰地形数据。2.3 导出格式要能同时被编辑器、校验脚本和战斗模块读取地图数据不是给人看的图片而是给程序读的数据。保存格式要稳定字段含义要明确。我用的是 JSON 格式。{ mapId: chapter01, name: 第一章示例, width: 24, height: 20, cells: [ { x: 0, y: 0, terrain: plain, walkable: true, moveCost: { foot: 1, horse: 1, fly: 1 } } ], playerSpawns: [{ x: 3, y: 18 }], enemySpawns: [{ x: 18, y: 2 }] }这样一个文件既可以被编辑器读取也可以被校验脚本解析还能被战斗模块加载。调试时直接用文本方式打开哪个格子写错了一眼就能看到。比 JSON 更省空间的方案是二进制格式但对于学习和研究来说没必要一开始就上二进制。JSON 在调试时的优势非常明显肉眼可读Git diff 友好出现解析问题也容易定位。3. 从第一张图到第 25 张图实际重画流程3.1 从一张最简单的地图建立最小闭环我没有一上来就处理 25 张图而是先用程序生成一张 10×10 的空地图在上面随便摆几种地形导出 JSON再写一个简单的加载脚本把地图打印到控制台。这一步看起来很简单但意义很大它验证了“编辑 → 导出 → 加载 → 检查 → 调整”整个链路是通的。如果直接进入正式地图绘制有可能画到第 5 张时才发现导出的数据少了一个字段或者加载脚本读不到出生点坐标那才是真正的返工灾难。所以我的建议是先花半天时间做最小样例再决定要不要画正式 25 张图。最小闭环跑通之后后面的工作只是重复填充数据而不是反复改工具。3.2 绘制顺序先底图再建筑最后标出生点重画地图时我固定按这个顺序操作根据参考地图确定宽度和高度。用大面积填充铺出基础地形草地、荒地、山、水。再画高细节地形森林、道路、桥梁。接着放置建筑物体村庄、城门、王座、城墙。最后标注玩家初始位置、敌人初始位置、增援点和事件触发点。这个顺序背后的逻辑是数据依赖。先确定可行走区域才能判断建筑能不能放建筑位置确定之后出生点才不会和墙壁重叠。如果顺序反过来先标了出生点再画地形很容易出现单位被山或水包围的尴尬局面。我一般会把“出生点是否可到达”作为单张地图的完成标准之一而不是只看视觉上像不像。3.3 分批推进比一口气刷完效率高得多25 张地图如果一张一张排着画人很容易疲劳而且越到后面越容易和前面的地图格式不一致。我按场景复杂度把地图分成几批每批 3 到 5 张。每批画完之后先做一次自动校验和格式检查确认这 5 张地图的数据结构完全统一再进入下一批。这样如果编辑器的数据结构需要调整最多只改一批而不是 25 张全部返工。这个分批思路和写代码时的小步提交很像不要一次提交几万行代码而是分成几个逻辑完整的 commit每个 commit 都能运行。地图生产也是一样每批都应该是一个可用的状态。3.4 单图校验清单尺寸、出口、村庄、出生点我给自己定了一张单图完成前的必查清单宽度和高度与参考数据一致。地图出口和连接点存在。村庄数量、位置正确且周边有可通行格子。玩家出生点有空格敌人出生点没有重叠。所有宝箱都有可到达路径。移动消耗表没有漏填。不校验就导出是地图工程里最危险的节奏。前期我吃过亏有一张图导出后才发现某个村庄被城墙完全围住角色根本走不进去。视觉上看起来没问题但放进逻辑里就是错误地图。4. 重画地图时最容易出错的 4 类数据问题4.1 地形画对了通行规则未必对这是最容易翻车的地方。森林格子在地图上画成深绿色块但数据层必须对应一个移动消耗表。如果只填了地形类型移动消耗没有填战斗模块读取数据时可能会把这个格子当作默认地形处理导致骑兵轻松穿过森林。重画时不能只看“长得像”要看数据模型是否完整。以通用战棋规则为参考我给每种地形配一张移动成本表地形类型步兵骑兵飞行单位备注平原/草地111基础可通行森林2不可通行1可能隐藏增援山地331高处有地形加成水不可通行不可通行1只能飞行通过城门/墙取决于开关状态同左视配置与事件绑定这不是火纹初代精确到小数点的原版数值而是我为了重画时统一规则做的通用示例。真正落到你自己的项目时移动消耗表必须按实际游戏逻辑来配置。4.2 出生点、增援点、村庄入口的空间重叠重画过程中很容易把两个逻辑点放到同一格。比如玩家初始位置和增援点重叠事件加载时单位会卡在同一格。或者敌人出生点刚好放在村庄入口格村子就访问不了。解决这个问题不能只靠眼睛看。编辑器里颜色高亮可以提示最后还是要靠校验脚本检查。保存地图时如果检测到多个逻辑点共占一格直接报错比事后加载再发现问题节省大量时间。4.3 村庄、城门、宝箱不能只当装饰画村庄需要绑定访问事件城门要控制开和关宝箱要记录里面放的物品。如果编辑器只支持“画一个橙色方块”不维护属性那后续就必须在脚本里手工对坐标非常容易错。我给物体层加了一个简单的属性面板哪怕先只做只读字段也比纯画图强。比如点选一个宝箱格子至少能看到“道具编号”“是否已开启”“所在坐标”这些字段。这样地图数据和逻辑数据在源头上就是一致的。4.4 越界、断头路和不可达角落重画时偶尔会把某个格子误设为不可通行导致本应连接到下一段道路的区域出现断头路。或者把出生点放在地图外加载时坐标数组校验直接失败。建议在做完一批地图后跑一次简单连通性检查。从玩家初始位置出发用 BFS 遍历所有可通行格子看村庄、宝箱、地图出口是否都在遍历结果里。这一步不需要复杂算法十几行代码就能完成但能拦住大部分“看着正常实际不能玩”的地图。5. 25 张图的批量工程命名、自动校验和版本管理5.1 地图文件命名少用“终版”多用编号和场景25 张地图导出后文件命名直接影响后续管理。我建议用固定前缀加语义场景比如chapter01_plain.jsonchapter02_castle.jsonchapter03_mountain.json而不是用 chapter01_final.json、chapter01_new2.json 这类名字。“最终版”“改3”“最后版”这种命名方式在项目进入第 5 天之后一定会让你后悔。文件命名本身也是数据规范的一部分。名称确定后加载脚本可以直接从文件名推断章节编号减少手工配置。5.2 自动校验脚本要检查什么地图数量一多人工检查就会失效。我写了一个非常朴素的校验函数思路大概是function validateMap(map: FireEmblemMap): string[] { const errors: string[] []; // 1. 检查尺寸 if (map.cells.length ! map.height) { errors.push(height mismatch: ${map.cells.length} vs ${map.height}); } for (const row of map.cells) { if (row.length ! map.width) { errors.push(width mismatch); } } // 2. 检查出生点是否越界或重叠 const spawns [...map.playerSpawns, ...map.enemySpawns]; const spawnKeySet new Setstring(); for (const spawn of spawns) { const key ${spawn.x},${spawn.y}; if (spawnKeySet.has(key)) { errors.push(duplicate spawn: ${key}); } spawnKeySet.add(key); } // 3. 检查基础地形必须可通行 for (const cell of map.cells.flat()) { if (cell.terrain plain !cell.walkable) { errors.push(plain cell not walkable at ${cell.x},${cell.y}); } } return errors; }这段代码是简化示例不是完整的校验实现但检查思路是通用的。真正落地时你还可以加入“村庄是否可达”“宝箱是否越界”“移动消耗表是否存在”等检查项。校验脚本要在编辑器里一键触发而不是每张图手工跑一遍命令。把校验嵌入工作流人才会用。如果每次都要额外开终端、输命令、找文件你大概率会在画到第 15 张时放弃检查。5.3 用 Git 管理地图数据能减少大量返工地图数据结构化之后非常适合放进 Git。JSON 是文本文件Git 可以对它做逐行 diff。某张地图上周改过哪个格子的地形一查历史记录就能看出来。如果你把地图直接保存成 PNGGit 只能看到二进制变化定位问题就很难。还有一个必须提前统一的点坐标系约定。地图坐标从左上角开始还是从左下角开始必须全局统一。不同地图如果坐标系不一致战斗模块寻路时会出现整张图上下颠倒的现象这种 bug 排查起来相当痛苦。我的做法是在项目根目录写一个 README记录坐标约定、地形枚举、导出格式。新加入这张地图数据的任何工具都以这份文档为准。5.4 加载失败时先查数据还是先查代码遇到地图加载失败不要一上来就去改渲染代码。先用排除法确定问题层面。先用脚本直接打开导出的 JSON确认数据文件本身能解析。检查 JSON 里字段名和加载器读取的字段是否一致。检查坐标是否越界出生点是否重复。对比编辑器里的预览效果和实际加载效果是否一致。如果以上都正常再查渲染层的高亮颜色和图块映射。最常见的原因是字段名不一致。编辑器导出的是 mapId加载脚本读的是 map_id不仔细看根本发现不了。先看数据再改代码这个顺序能省下大量调试时间。6. 全部重画完之后我对战棋地图和编辑器工程的复盘6.1 编辑器真正的价值是约束数据结构很多人觉得自研编辑器是为了“提高画图速度”实际上它更大的作用是让数据不乱。手写 JSON 也能完成 25 张地图但很容易出现字段不一致、忘了填移动消耗、出生点写错的问题。编辑器把常用操作变成控件点选地形刷子一刷拖一个出生点标记坐标自动写入。数据结构由程序维护天然稳定。人只需要做选择和判断不需要记忆字段名。这才是自研编辑器最有价值的地方。6.2 经典战棋地图设计的几个共性25 张地图全部重画完之后我发现经典战棋地图有几条很明显的设计规律初始敌我位置会留出安全距离不会让你第一回合就被包围。地形不是随意摆放而是为了引导行动路线。每条推进路线都至少保留一种可通行方案。重要建筑周围会预留相邻格子方便事件交互。山地和水域会自然分割战场让不同兵种都有发挥空间。这些规律对后来的自建关卡设计很有参考价值。地图不只是“把格子填满”而是在有限的空间里规划节奏和对抗。6.3 如果从头再做一次我会先搭校验再写绘制界面这个项目完成之后我最想调整的部分不是编辑器布局也不是渲染效果而是开发顺序。当初我先写了编辑器的绘制界面后补校验脚本导致前期有一批地图数据不干净。如果重来一次我会先把数据结构定义好再写校验脚本最后才做绘制界面。绘制界面只是数据输入的前端校验脚本才是最后一道安全网。你可以先用一个命令行工具生成测试数据、运行校验、再打印结果。整个数据链路稳定之后再把操作封装成可视化界面。这样编辑器做出来的第一版就已经是能生产可用地图的工具而不是一个只能画着玩的原型。这个项目做完之后我的地图文件从最初的目录混乱、字段不一致慢慢收敛成一套相对稳定的结构。如果你也想做类似的经典战棋地图重绘或者想给自己游戏写一个关卡编辑器我建议你先别急着画第 25 张图先把第 1 张图的数据模型和校验跑通。地图重画的重点从来不是像素像不像而是那张图放进游戏逻辑里能不能跑。