把火纹初代全部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: Record<MoveType, 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 地形画对了,通行规则未必对
这是最容易翻车的地方。森林格子在地图上画成深绿色块,但数据层必须对应一个移动消耗表。如果只填了地形类型,移动消耗没有填,战斗模块读取数据时,可能会把这个格子当作默认地形处理,导致骑兵轻松穿过森林。
重画时不能只看“长得像”,要看数据模型是否完整。以通用战棋规则为参考,我给每种地形配一张移动成本表:
| 地形类型 | 步兵 | 骑兵 | 飞行单位 | 备注 |
|---|---|---|---|---|
| 平原/草地 | 1 | 1 | 1 | 基础可通行 |
| 森林 | 2 | 不可通行 | 1 | 可能隐藏增援 |
| 山地 | 3 | 3 | 1 | 高处有地形加成 |
| 水 | 不可通行 | 不可通行 | 1 | 只能飞行通过 |
| 城门/墙 | 取决于开关状态 | 同左 | 视配置 | 与事件绑定 |
这不是火纹初代精确到小数点的原版数值,而是我为了重画时统一规则做的通用示例。真正落到你自己的项目时,移动消耗表必须按实际游戏逻辑来配置。
4.2 出生点、增援点、村庄入口的空间重叠
重画过程中,很容易把两个逻辑点放到同一格。比如玩家初始位置和增援点重叠,事件加载时单位会卡在同一格。或者敌人出生点刚好放在村庄入口格,村子就访问不了。
解决这个问题不能只靠眼睛看。编辑器里颜色高亮可以提示,最后还是要靠校验脚本检查。保存地图时如果检测到多个逻辑点共占一格,直接报错,比事后加载再发现问题节省大量时间。
4.3 村庄、城门、宝箱不能只当装饰画
村庄需要绑定访问事件,城门要控制开和关,宝箱要记录里面放的物品。如果编辑器只支持“画一个橙色方块”,不维护属性,那后续就必须在脚本里手工对坐标,非常容易错。
我给物体层加了一个简单的属性面板,哪怕先只做只读字段,也比纯画图强。比如点选一个宝箱格子,至少能看到“道具编号”“是否已开启”“所在坐标”这些字段。这样地图数据和逻辑数据在源头上就是一致的。
4.4 越界、断头路和不可达角落
重画时偶尔会把某个格子误设为不可通行,导致本应连接到下一段道路的区域出现断头路。或者把出生点放在地图外,加载时坐标数组校验直接失败。
建议在做完一批地图后,跑一次简单连通性检查。从玩家初始位置出发,用 BFS 遍历所有可通行格子,看村庄、宝箱、地图出口是否都在遍历结果里。这一步不需要复杂算法,十几行代码就能完成,但能拦住大部分“看着正常实际不能玩”的地图。
5. 25 张图的批量工程:命名、自动校验和版本管理
5.1 地图文件命名:少用“终版”,多用编号和场景
25 张地图导出后,文件命名直接影响后续管理。我建议用固定前缀加语义场景,比如:
- chapter01_plain.json
- chapter02_castle.json
- chapter03_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 Set<string>(); 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。某张地图上周改过哪个格子的地形,一查历史记录就能看出来。如果你把地图直接保存成 PNG,Git 只能看到二进制变化,定位问题就很难。
还有一个必须提前统一的点:坐标系约定。地图坐标从左上角开始还是从左下角开始,必须全局统一。不同地图如果坐标系不一致,战斗模块寻路时会出现整张图上下颠倒的现象,这种 bug 排查起来相当痛苦。
我的做法是在项目根目录写一个 README,记录坐标约定、地形枚举、导出格式。新加入这张地图数据的任何工具,都以这份文档为准。
5.4 加载失败时先查数据,还是先查代码
遇到地图加载失败,不要一上来就去改渲染代码。先用排除法确定问题层面。
- 先用脚本直接打开导出的 JSON,确认数据文件本身能解析。
- 检查 JSON 里字段名和加载器读取的字段是否一致。
- 检查坐标是否越界,出生点是否重复。
- 对比编辑器里的预览效果和实际加载效果是否一致。
- 如果以上都正常,再查渲染层的高亮颜色和图块映射。
最常见的原因是字段名不一致。编辑器导出的是 mapId,加载脚本读的是 map_id,不仔细看根本发现不了。先看数据,再改代码,这个顺序能省下大量调试时间。
6. 全部重画完之后,我对战棋地图和编辑器工程的复盘
6.1 编辑器真正的价值是约束数据结构
很多人觉得自研编辑器是为了“提高画图速度”,实际上它更大的作用是让数据不乱。手写 JSON 也能完成 25 张地图,但很容易出现字段不一致、忘了填移动消耗、出生点写错的问题。
编辑器把常用操作变成控件:点选地形,刷子一刷;拖一个出生点标记,坐标自动写入。数据结构由程序维护,天然稳定。人只需要做选择和判断,不需要记忆字段名。这才是自研编辑器最有价值的地方。
6.2 经典战棋地图设计的几个共性
25 张地图全部重画完之后,我发现经典战棋地图有几条很明显的设计规律:
- 初始敌我位置会留出安全距离,不会让你第一回合就被包围。
- 地形不是随意摆放,而是为了引导行动路线。
- 每条推进路线都至少保留一种可通行方案。
- 重要建筑周围会预留相邻格子,方便事件交互。
- 山地和水域会自然分割战场,让不同兵种都有发挥空间。
这些规律对后来的自建关卡设计很有参考价值。地图不只是“把格子填满”,而是在有限的空间里规划节奏和对抗。
6.3 如果从头再做一次,我会先搭校验,再写绘制界面
这个项目完成之后,我最想调整的部分不是编辑器布局,也不是渲染效果,而是开发顺序。
当初我先写了编辑器的绘制界面,后补校验脚本,导致前期有一批地图数据不干净。如果重来一次,我会先把数据结构定义好,再写校验脚本,最后才做绘制界面。绘制界面只是数据输入的前端,校验脚本才是最后一道安全网。
你可以先用一个命令行工具生成测试数据、运行校验、再打印结果。整个数据链路稳定之后,再把操作封装成可视化界面。这样编辑器做出来的第一版,就已经是能生产可用地图的工具,而不是一个只能画着玩的原型。
这个项目做完之后,我的地图文件从最初的目录混乱、字段不一致,慢慢收敛成一套相对稳定的结构。如果你也想做类似的经典战棋地图重绘,或者想给自己游戏写一个关卡编辑器,我建议你先别急着画第 25 张图,先把第 1 张图的数据模型和校验跑通。地图重画的重点从来不是像素像不像,而是那张图放进游戏逻辑里能不能跑。