
实际制作战棋游戏时最容易低估的是地图数据本身。地图不是一张背景图而是由网格、地形、单位、门、宝箱、事件触发区组成的结构化数据。为了把《火焰之纹章暗黑龙与光之剑》初代全部25张地图重新绘制出来我实现了一款自研编辑器并在这套编辑器里完成了逐关复刻、校验和批量导出。这篇文章会从架构设计、数据结构、绘制流程、校验方法和排错路径几个方面完整记录这条项目链路。如果你也在做战棋关卡工具、瓦片地图编辑器或者准备把老游戏地图拆解成可复用数据本文可以作为一套可直接落地的工程参考。需要提前说明一点这里“重画”指的是制作一套用于学习研究的地图数据与编辑器工程不涉及从原版游戏安装包或磁碟中提取素材。制作过程中需要的手绘像素图、瓦片素材要么是自己画的要么来自可商用或已授权素材。不要使用原版贴图直接导出并公开分发这属于版权风险区域。1. 先从需求出发为什么不自接改地图而要自研编辑器1.1 自研编辑器要解决的核心问题市面上有现成的瓦片地图编辑器最典型的是 Tiled。它的图层、对象、碰撞区域和自定义属性都做得不错。那为什么还要自己写一个编辑器因为火纹这类战棋地图带有一批强业务语义。普通 tilemap 编辑器只负责“把瓦片摆到格子上”但战棋关卡需要理解这些东西哪些地形可以站人哪些不能站。不同移动类型经过森林、山、水、桥时消耗不同。门需要钥匙才能打开宝箱会产出物品王座通常和胜利条件绑定。敌人有固定的待机点、增援点和巡逻逻辑。剧情触发区域、访问村庄事件、对话事件需要一块块矩形区域来标记。战斗部署区域要限定玩家单位可摆放的位置。如果所有内容都用 Tiled 的自定义属性写项目一开始还能撑住但做到25章时会非常痛苦。你无法快速知道某张地图有多少个不可通行点、所有事件区域是否越界、敌人出生点是否落在不可行走地形上。这些检查必须变成编辑器内置的自动化能力而不是靠人眼手工核对。自研编辑器的目标不是“做一个比 Tiled 更好的通用地图工具”而是“做一个懂战棋规则的地图工具”。1.2 25张地图拆成多少种要素把25张地图当作数据处理时不能只看到瓦片。每一章地图至少包含六类要素地形网格大地块上的平原、森林、山峦、河流、道路、桥梁、墙壁、柱子、王座。建筑物与机关门、宝箱、村庄、堡垒、破损墙。单位对象玩家单位、敌方单位、NPC单位、当前不出场单位。部署区域玩家入场时可以摆放单位的位置。事件区域剧情对话、访问村庄、敌人增援、胜利与败北判断。脚本参数某个区域触发后刷出哪批敌人门被打开后是否触发对话。这些要素混合在一张平面图上。编辑器必须把这六类数据分开存储、分开绘制、分别校验否则后续做战斗逻辑时极难维护。1.3 编辑器能力清单正式写代码前我先定了能力边界。第一版编辑器不追求做成完整游戏只做地图编辑与数据产出。能力清单如下新建、打开、保存地图工程支持多个章节文件切换。地形绘制笔刷、矩形填充、橡皮擦支持覆盖层。对象放置单位、宝箱、门、传送点、事件区域。属性面板选中任意瓦片或对象后能查看和修改属性。撤销重做地形绘制和对象操作支持多步撤销。地图校验越界、重复ID、不可通行地形上放单位、事件区域为空。预览模式隐藏网格查看当前地图的实际显示效果。批量导出将所有章节导出为游戏引擎可读取的 JSON 和 CSV。做编辑器不是先写界面而是先定数据结构。数据结构定错了后面所有功能都会返工。2. 底层数据结构地图不是一张图而是一份数据2.1 地图文件整体结构地图文件我采用 JSON 作为主格式。第一层结构分四个区块meta保存地图元信息terrainLayer保存地形网格objects保存单位与机关对象events保存事件区域。{ meta: { id: chapter_01, name: 第1章, mapWidth: 30, mapHeight: 22, tileSize: 16 }, terrainLayer: [ [plain, plain, wall, plain], [forest, plain, wall, plain] ], objects: [ { id: unit_001, type: unit, name: 主角, x: 5, y: 3, team: player, level: 1, sprite: marth } ], events: [ { id: event_01, type: zone, x: 10, y: 4, width: 2, height: 2, trigger: enter, script: open_door } ] }terrainLayer直接用二维数组二维下标就是地图坐标值就是地形类型。这个结构在编辑和运行时都最好查要判断某个格子是什么地形一次数组下标访问即可。objects使用数组而不是字典主要原因是对象最终要按绘制顺序渲染并且地图里对象数量不多不需要用字典加速。events同样使用数组事件区域允许重叠重叠时按数组顺序依次判断。2.2 地形层细分配置而不是只存一个名字火纹地图的地形不是简单一个字符串。同一个“森林”在显示、移动消耗、命中回避、是否可通行几个维度上都有不同表现。如果地图文件只存forest那么移动规则和渲染规则就得在游戏代码里写死。更好的做法是让地形类型成为唯一标识具体规则放在一份独立配置里。type TerrainType | plain | forest | mountain | fort | throne | door | chest | wall | water | bridge | road | pillar; interface TerrainRule { type: TerrainType; name: string; moveCost: number; passable: boolean; flyable: boolean; heal: boolean; defenseBonus: number; avoidBonus: number; }一份地形规则配置如下地形类型中文名移动消耗默认可通行说明plain平原1是最常见地形road道路1是视觉上与平原不同规则可一致forest森林2是通常提供回避加成mountain山3视移动类型而定飞行单位可通过步兵通常不能water水无法步行视移动类型而定飞行单位可通行bridge桥1是跨水道路wall墙壁无法通行否阻挡移动与视线pillar柱子无法通行否阻挡移动door门0开启后通行需要钥匙或开锁指令chest宝箱0是可被角色打开fort堡垒1是站在堡垒上每回合回复throne王座1是通常与胜利条件关联注意这张表是编辑器里的规则元数据示例不是原版数据。真正复刻每一章前应该逐章用原版资料核对地形数值因为不同作品、不同版本的森林回避率、门钥匙规则不一定相同。2.3 对象层单位、宝箱、门、传送点怎么存地形只是底子战棋地图的核心是对象。单位对象的字段设计会直接影响后续战斗逻辑。保存时要包含队伍归属、等级、职业、当前坐标、巡逻路径、携带物品等。interface MapUnit { id: string; type: unit; name: string; x: number; y: number; team: player | enemy | npc; classId: string; level: number; hp: number; items: string[]; aiMode?: stand | guard | attack | move; moveRange?: number; dialogueOnEncounter?: string; }门和宝箱属于可交互对象也应该放到objects而不是地形层。门虽然占据一个格子但它可以被打开宝箱也是格子上的一件可打开对象。把它们放对象层的好处是地图校验可以直接遍历对象数组而不需要分析地形层里某个特殊字符串。interface InteractiveObject { id: string; type: door | chest | village | stair; x: number; y: number; state: closed | open | broken | collected; keyId?: string; rewardItem?: string; eventId?: string; }事件区域用矩形记录不写复杂多边形。火纹这类 SRPG 的事件触发绝大多数是矩形区域玩家走到区域内即触发。矩形对人工编辑更友好导出和判定也简单。interface EventZone { id: string; type: zone; x: number; y: number; width: number; height: number; trigger: enter | turnStart | afterKill | visit; scriptId: string; params: Recordstring, unknown; }事件区域不参与地形渲染但在编辑器里必须显示成半透明色块否则关卡设计者无法确认触发范围。2.4 坐标体系先统一网格坐标和像素坐标编辑器和游戏引擎里最容易乱的是坐标。地图数据统一使用网格坐标也就是(tileX, tileY)其中(0,0)是地图左上角。渲染层需要把网格坐标换算成像素坐标。function tileToPixel(tileX: number, tileY: number, tileSize: number) { return { x: tileX * tileSize, y: tileY * tileSize }; }function pixelToTile(pixelX: number, pixelY: number, tileSize: number) { return { x: Math.floor(pixelX / tileSize), y: Math.floor(pixelY / tileSize) }; }鼠标交互时要把画布像素坐标转成网格坐标这一步必须做边界保护。像素坐标可能为负数也可能超出地图宽度负坐标Math.floor之后可能得到-1直接访问数组会拿到undefined。所以转换后要立即判断是否在地图范围内。编辑器内部一律使用网格坐标存储像素坐标只出现在渲染层和鼠标事件层。这样导出的数据不依赖具体屏幕分辨率游戏引擎在不同屏幕上缩放时也不用改数据。2.5 用 JSON 还是二进制编辑阶段和运行阶段要分开初版编辑器数据直接用 JSON因为可读性好Git 对比方便出问题容易排查。但 JSON 有缺点文件体积偏大、解析比二进制慢、字符串容易写错。25张地图每张几十到几百个对象JSON 完全够用。真正到移动端热更新或大量关卡包时再考虑二进制。格式可读性体积解析速度适合阶段JSON高中中编辑器、调试、测试、早期版本CSV中小中只导出地形层时使用二进制低小高正式包体、热更新如果项目后期需要二进制不要手写解析器而应该用 idl 或 flatbuffers 这类工具生成代码避免字段顺序和字节对齐问题。3. 编辑器架构模型、渲染、交互三部分分开3.1 技术选型Electron React Canvas 的取舍自研编辑器我选用了 Electron React TypeScript渲染层使用 Canvas 2D。这个组合的好处是开发速度快、跨平台、前端工程经验可以复用。为什么不直接用 DOM 来绘制地图瓦片25张火纹地图每张大约 30x22 到 40x30 格一格一个 div 会产生上千个 DOM 节点滚动和绘制时性能很容易劣化。Canvas 2D 只需要在帧内画所有可见瓦片即可性能余量充足。为什么不一开始就上 WebGL地图瓦片属于低频绘制操作每一帧只画几百到两三千个精灵。Canvas 2D 足够WebGL 会增加大量着色器和纹理管理代码第二版再升级也不迟。React 只负责编辑器面板部分包括图层列表、对象属性、地形选择、日志输出。Canvas 部分不直接由 React 管理而是由独立渲染模块负责。React 状态变化后把需要重绘的数据传给渲染模块避免每次鼠标移动都触发 React 重渲染。3.2 目录结构模型层和 UI 层隔离工程目录如下editor/ src/ main/ index.ts renderer/ App.tsx components/ Toolbar.tsx LayerPanel.tsx PropertyPanel.tsx MapCanvas.tsx canvas/ renderMap.ts drawTile.ts drawObject.ts drawSelection.ts camera.ts core/ MapDocument.ts TerrainRule.ts MapObject.ts EventZone.ts commands/ PaintCommand.ts ClearCommand.ts AddObjectCommand.ts UndoStack.ts validation/ validateMap.ts checkConnectivity.ts checkObjects.ts io/ saveJson.ts loadJson.ts exportCsv.ts exportAll.ts projects/ chapters/ chapter_01.json chapter_02.jsoncore目录放纯数据模型不依赖 Electron、React、Canvas。这样最核心的数据逻辑可以单测也可以被 Node 脚本直接调用。批量导出就是通过 Node 脚本读取projects/chapters下的 JSON 文件调用io模块输出游戏引擎需要的数据完全不打开图形界面。commands目录实现撤销重做。所有修改地图的操作都封装成命令对象每个命令包含do()和undo()方法。interface EditCommand { name: string; do(document: MapDocument): void; undo(document: MapDocument): void; }撤销栈只记录命令列表和当前指针。每次执行命令时把快照或者把逆操作压入栈。地形笔刷这种高频操作逐格记录命令会撑爆内存所以笔刷命令需要按“一次拖拽一次命令”合并而不是每次 mouse move 都入栈。3.3 渲染引擎先画瓦片再画对象最后画选区地图 Canvas 的绘制顺序决定了层叠关系。渲染顺序是地形底图、地面覆盖物、对象、事件区域、网格线、当前选中框。function renderMap(ctx, map, camera) { const startCol Math.max(0, Math.floor(camera.x / map.tileSize)); const startRow Math.max(0, Math.floor(camera.y / map.tileSize)); const endCol Math.min(map.width, Math.ceil((camera.x camera.width) / map.tileSize)); const endRow Math.min(map.height, Math.ceil((camera.y camera.height) / map.tileSize)); for (let row startRow; row endRow; row) { for (let col startCol; col endCol; col) { drawTile(ctx, map.terrain[row][col], col, row, map.tileSize); } } for (const obj of map.objects) { drawObject(ctx, obj, map.tileSize); } for (const eventZone of map.events) { drawEventZone(ctx, eventZone, map.tileSize); } if (camera.showGrid) { drawGrid(ctx, startCol, startRow, endCol, endRow, map.tileSize); } }只绘制可视区域是地图编辑器性能优化的关键。整张地图 30x22 格虽然不多但编辑器支持缩放和滚动后数据量会成倍增加。通过camera算出可视范围内的行列区间只对这部分做绘制任何时候帧循环都很稳定。事件区域必须半透明绘制常见做法是给色块填充一个带 alpha 的颜色并在鼠标悬浮时加边框。这部分只有编辑器需要导出地图数据时不会写入颜色变量。3.4 编辑操作笔刷、碰撞、撤销重做怎么设计地形笔刷的输入是鼠标拖拽轨迹。每帧鼠标移动都会产生大量位置不能每格都写一条命令。正确做法是记录当前拖拽开始前的地形快照拖拽过程中实时修改内存数据鼠标释放时才生成一条PaintCommand。撤销时会恢复拖拽前的整块地形。对象放置与地形绘制不同每次放置是一个离散动作。放置对象时要立即做基础合法性检查坐标是否越界。该地形是否允许放单位。当前格子是否已经存在同类对象。对象 ID 是否重复。只有通过检查才允许落点。落点后生成一条AddObjectCommand。禁用空格放置错误的原子性很重要。很多地图编辑器的对象放置失败时已经改了内存导致撤销和重做不一致。我的做法是先做完整校验再创建命令最后统一执行。命令执行时默认已经通过校验撤销时不做二次校验。4. 绘制25张地图的生产流程4.1 先建立原版地图核对流程编辑器和数据结构准备好之后不要急着开画。25张地图是一批长期任务如果不按照统一流程生产后面章节的风格和规则会漂移。绘制前需要为每章准备一份资料包包含原版地图的清晰截图或扫描图。已确认的地形规则表。本章单位清单。本章事件说明。胜利条件和败北条件。资料包只作为人工对照参考不放进编辑器工程。因为截图类文件一旦进入 Git仓库体积会迅速变大而且原始截图不一定有分发权限。每张地图开始制作前先把资料包里的关键信息录入章节日记字段内容章节编号chapter_01地图尺寸待核对主要地形平原、森林、山地标对象王座、门、宝箱、村庄特殊规则门需要钥匙王座胜利条件资料来源原版截图 已购资料书这个步骤的核心目的是把模糊印象转成可执行任务。4.2 四步复刻法底图 - 地形 - 对象 - 事件单章复刻流程固定为四步每一步完成标准不一样。第一步是底图对齐。将参考截图导入编辑器作为半透明背景图层。设置截图在画布上的偏移量和缩放比例让截图的网格与编辑器网格对齐。这一步不对齐后面每一个格子都会偏。检查点放大到 400%让截图里的每个格子和编辑器网格线重合至少检查地图四个角和中心点。第二步是铺地形。先用矩形填充把大块地形画出来再处理边界和细节。先画大面积的水、墙、山再画道路和森林最后补特殊地形。不要一开始就精修建筑和树木边缘否则反复调整时成本很高。检查点逐行扫描截图和编辑器地形层重点看墙与门口是否对齐。第三步是放对象。按“王座/门/宝箱/村庄/传送点”到“玩家部署区域”再到“敌我单位”的顺序放置。对象放置要统一命名规则比如单位 ID 用unit_001连续编号敌我使用不同前缀便于过滤。检查点对象总数、队伍数量、玩家单位初始位置是否与资料一致。第四步是事件区域。把剧情对话、增援、访问村庄、门开关等区域画上去。事件区域即使没有绑定脚本也先占位保证所有标红位置在游戏逻辑里可见。检查点每个事件区域坐标、宽高是否在地图范围内重叠区域是否有明确优先级。4.3 生产台账与进度管理25张地图不能靠脑子记进度。我在项目根目录维护了一份production_status.md同时用脚本自动扫描章节 JSON 的校验结果生成进度表。下面是一个台账字段示例具体数值以实际项目为准章节序号地图编号地形完成对象完成事件完成连通性校验最终导出第1章chapter_01是是是通过完成第2章chapter_02是是否未通过未导出第3章chapter_03否否否未执行未导出这个表格的用途不是装饰而是让任何协作者打开项目都能立刻知道当前阻塞点。比如第二章事件未完成就不应该导出到游戏引擎避免测试时踩空。4.4 批量导出与游戏引擎集成单张地图编辑完成后在编辑器里手动导出 JSON 可以用于调试。全部25章完成后要使用命令行脚本批量导出避免人为漏导。npm run export:all \ -- --input ./projects/chapters \ --output ./dist/levels \ --format json,csvexport 脚本做的事包括遍历输入目录下的所有章节 JSON。逐个调用validateMap校验失败就终止或列出警告。将地形层导出为 CSV。将完整地图对象和事件区域导出为统一 JSON。生成一个index.json记录地图编号、文件名、校验时间。游戏引擎侧只读取dist目录里的导出结果不直接读取编辑器工程文件。这样编辑器以后怎么改数据结构都不影响已发布的关卡包只要导出层保持兼容。5. 验证和排查数据能保存不等于地图正确5.1 自检检查项地图制作完成后第一件事不是导出而是运行完整性校验。每次保存前编辑器会执行一组自动检查地图文件和元信息是否完整。所有对象坐标是否在地图范围内。所有事件区域是否在地图范围内。单位是否放在可通行地形上。门、宝箱是否重叠。对象 ID 是否重复。至少存在一个玩家部署区域。王座或胜利目标对象是否存在。校验结果分 error 和 warning。error 会阻止导出warning 只记录。比如“门周围没有钥匙”可以算 warning因为某些关卡可能允许盗贼开锁。5.2 连通性校验BFS 检查角色可达性比单点属性校验更重要的是地图连通性。火纹地图里玩家单位必须能通过移动到达关键区域。如果某个区域被墙或山完全封死玩家单位永远无法进入关卡就卡住了。连通性校验使用 BFS。从玩家部署区域开始按单位移动力向四周扩展检查能到达哪些格子。type Pos { x: number; y: number }; function checkConnectivity(map, startPositions: Pos[], movePower: number, canPass: (terrain: string) boolean) { const queue: Pos[] []; const visited new Setstring(); for (const start of startPositions) { const key ${start.x},${start.y}; if (!visited.has(key)) { visited.add(key); queue.push(start); } } const directions [{ x: 0, y: -1 }, { x: 0, y: 1 }, { x: -1, y: 0 }, { x: 1, y: 0 }]; while (queue.length 0) { const current queue.shift()!; for (const dir of directions) { const next { x: current.x dir.x, y: current.y dir.y }; const key ${next.x},${next.y}; if (visited.has(key)) continue; if (!isInsideMap(map, next)) continue; if (!canPass(getTerrain(map, next.x, next.y))) continue; visited.add(key); queue.push(next); } } return visited; }这个 BFS 没有计算移动力递减而是按“是否可达”来判断。更精确的做法是把每个格子记录剩余移动力使用带权 BFS 或 Dijkstra。但作为编辑器校验先跑一个不考虑移动消耗的连通性检查成本低能立刻发现地图被彻底切断的问题。校验通过后还要把那些不可达但存在单位、宝箱或事件区域的关键点单独列出来输出警告。5.3 属性校验固有点位不能穿透地图对象校验需要覆盖几种高频错误单位放在wall或water上。宝箱放在不可行走地形上。事件区域宽高写成 0 或负数。门和宝箱的 ID 与脚本引用不一致。玩家部署区域与敌方单位出生点重叠。这些错误在视觉上很难发现因为单位小头像和地形叠加后人眼不容易判断脚下是什么地形。自动校验可以逐对象检查。function validateObjects(map): ValidationResult[] { const errors: ValidationResult[] []; for (const obj of map.objects) { if (!isInsideMap(map, obj.x, obj.y)) { errors.push({ level: error, message: ${obj.id} 坐标越界 }); continue; } const terrain getTerrain(map, obj.x, obj.y); if (obj.type unit !terrainRule.canStand(terrain, obj.team)) { errors.push({ level: error, message: ${obj.id} 位于不可通行地形 ${terrain} }); } } return errors; }校验结果集合要在编辑器底部的日志面板实时展示。点选某一条错误时编辑器自动把视角移动到出错的格子并高亮该位置。这个交互能大幅降低25张地图的检查成本。5.4 常见问题和解决方式问题现象常见原因检查方式处理建议保存后重新打开地图瓦片错位保存时用了像素坐标打开时按网格坐标解析查看 JSON 里坐标是否是整数是否超过地图宽高统一使用网格坐标增加保存前校验单位无法进入某个区域门口被墙堵死或桥没有连接两岸运行连通性校验查看不可达区域调整墙体和门的位置重新导出撤销后地形没有恢复笔刷命令记录的是整条轨迹但撤销逻辑写成了逐格检查命令栈里是否只有一条命令一次拖拽合并为一条命令记录拖拽前快照导出的 CSV 行列转置CSV 按列输出而没有按行输出用表格工具打开检查形状明确导出循环顺序写出单测确认行列数事件区域看不见事件层被当作对象层过滤掉检查渲染顺序和图层显隐状态渲染时单独绘制事件层并设置开关对象 ID 冲突复制对象后忘记修改 ID跑唯一性校验保存前自动检查冲突时自动生成新 ID上面最后一行最关键不要依赖人手工保证 ID 唯一。地图对象多了之后复制粘贴很容易造成重复 ID脚本引用会串。6. 生产级编辑器还需要什么6.1 发布前检查清单25张地图全部画完后不能直接交付。导出前应该再过一遍清单所有章节是否跑通validateMaperror 数量为 0。所有章节是否跑通连通性校验关键目标区域可达。所有门、宝箱、村庄的事件脚本 ID 是否存在。玩家部署区域数量和位置是否满足关卡设计。敌方单位数量、队伍归属、AI 类型是否填写完整。地图元信息中的章节编号是否正确。导出目录是否干净没有残留旧版本文件。游戏引擎是否读取新版本导出包而不是旧缓存。这个清单可以写到脚本里。只要有一项失败批量导出命令就返回非零状态码CI 流程直接拦截。6.2 自研编辑器的工程化边界自研编辑器最大的风险是“做成了玩具”。要变成生产工具必须补充几件事版本管理地图工程文件一定要纳入 Git便于回溯和多人协作。自动化测试核心的数据模型、校验逻辑、导出脚本都要有单测。错误上报编辑器自身崩溃时至少保存当前地图的临时文件避免一整天工作丢失。兼容迁移地图数据结构升级时要写迁移脚本不能让老章节打不开。性能监控编辑大尺寸地图时帧率下降要能定位渲染层只画可视区域是最基本的要求。不要把这些当额外工作。25章地图没有版本回滚和自动校验一旦数据损坏损失的是大量手工时间。6.3 从地图编辑器继续扩展地图数据稳定后下一步自然扩展方向是关卡脚本编辑器。目前事件区域只是矩形占位真正的刷兵逻辑、对话逻辑、胜利败北条件都写在脚本系统里。可以做一个节点式事件编辑器把事件区域和脚本节点连接起来。再往后可以开发战斗预览模式。在编辑器内部直接模拟单位移动和攻击范围验证地形规则是否正确。这个功能对复刻类项目和原创战棋项目都非常有价值因为它能把“规则配置”和“地图数据”的配合问题在编辑阶段就暴露出来。如果是新手想从零练习建议不要一上来就复刻25张地图。先做一张 20x20 的小地图跑通“编辑器 - 校验 - 导出 - 游戏引擎读取”的完整链路再逐步扩充张数。数据结构、校验逻辑和导出流程比地图数量重要得多。自研编辑器不是目的能稳定产出25张可复用、可校验、可扩展的地图数据才是目的。把编辑器当成数据生产线来设计后面的绘制和集成工作才会越来越顺。