ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

地图编辑器第四阶段:撤销重做、区域选择与数据序列化实战

地图编辑器第四阶段:撤销重做、区域选择与数据序列化实战

1. 从"能画"到"好用":第四阶段到底在解决什么问题

做地图编辑器的前三篇里,我们基本把"能画出来"这件事搞定了:网格系统、地块绘制、图层管理、基础存档。但如果你真的拿前三篇的成果去画一张稍微复杂点的地图,大概率会在半小时内骂人——画错一格要手动擦半天,想复制一片区域只能一格一格点,图层一多就分不清谁盖着谁,保存完关掉再打开发现数据对不上。

这就是第四阶段要解决的核心矛盾:从"功能可用"跨到"操作顺手"。地图编辑器本质上是一个生产力工具,用户在里面待的时间可能是几小时甚至几天,任何一次多余的点击、任何一个不清晰的视觉反馈,都会被时间放大成巨大的效率损耗。我见过太多个人开发者做的编辑器,功能列表拉出来挺唬人,实际用起来像在受刑,最后自己都不想打开第二次。

这一篇我会围绕四个方向展开:撤销重做系统的正确实现方式、区域选择与批量操作、图层可见性与锁定机制、数据序列化的健壮性处理。这四个点看起来各自独立,实际上它们共享同一套底层数据结构设计,如果一开始没想清楚,后面每加一个功能都是在还债。

适合谁看?如果你已经有一个能跑的地图编辑器雏形,或者正在做任何涉及"二维网格编辑"的工具(关卡编辑器、像素画工具、塔防布阵界面),这篇的内容可以直接抄作业。如果你还在纠结用什么引擎画格子,建议先回去看前三篇。

提示:本篇所有代码示例基于 TypeScript + Canvas 2D 的技术栈,但核心思路与具体渲染方案无关,换成 WebGL 或任何游戏引擎的 Tilemap 组件同样适用。

2. 撤销重做:为什么你的 Ctrl+Z 总是"差一点"

2.1 命令模式不是唯一解,但它是这个场景下最不坏的解

几乎所有教程讲到撤销重做,第一反应都是"用命令模式"。这个方向没错,但很多人实现出来的效果是:撤销一步,地图状态回到了上一步,但选中框位置乱了、图层高亮没了、右侧属性面板还显示着旧数据。问题出在把"撤销"理解成了"回滚数据",而实际上用户感知的"撤销"是回滚整个编辑上下文。

我的做法是把每一次用户操作封装成一个EditAction,它包含三个部分:

interface EditAction { type: string; // 操作类型标识 undoData: unknown; // 撤销所需的数据快照 redoData: unknown; // 重做所需的数据快照 context: EditorContextSnapshot; // 编辑上下文(选中状态、当前图层等) apply: (target: MapModel) => void; // 正向执行 revert: (target: MapModel) => void; // 反向执行 }

关键在于context这一层。用户画了一格然后撤销,他期望的不只是那一格消失,还期望"当前选中的工具还是画笔、当前图层还是地面层、光标还停在刚才那个位置"。如果这些上下文丢了,用户就得重新点一遍工具、重新找位置,撤销反而变成了负担。

2.2 快照 vs 增量:什么时候该用哪种

这里有个绕不开的取舍。全量快照实现简单,但一张 256×256 的地图,每层存一个Uint16Array,单层就是 128KB,四层就是 512KB,撤销栈存 50 步就是 25MB 内存。增量记录省内存,但实现复杂,尤其是"批量填充"这种一次改动几千格的操作,增量记录反而可能比快照还大。

我的经验分界线是这样的:

操作类型推荐策略理由
单格绘制/擦除增量记录只存坐标和前后值,几十字节
矩形填充增量记录(存区域范围+前后值)区域描述比逐格存小得多
魔棒选区填充全量快照(仅该图层)选区形状不规则,增量描述成本高
图层增删/排序全量快照(仅图层元数据)元数据本身很小
整图导入/重置全量快照(整图)低频操作,内存可接受

注意最后两行——快照的粒度是可以按图层、按元数据细分的,不是只有"全存"和"全不存"两个选项。我早期就是没想通这点,要么全存导致内存爆炸,要么全增量导致代码里到处是特判,后来按上表拆分,代码量和内存都降下来了。

2.3 撤销栈的合并策略:连续同类操作要"粘"在一起

用户按住鼠标拖拽画了一笔,松手。这在底层可能是 30 次单格绘制。如果每一步都进撤销栈,用户按一次 Ctrl+Z 只撤销一格,他会疯掉。所以需要一个操作合并窗口:同一类型、同一图层、时间间隔小于 300ms 的连续操作,合并成一个EditAction。

class UndoStack { private stack: EditAction[] = []; private mergeWindowMs = 300; private lastPushTime = 0; push(action: EditAction, mergeable: boolean) { const now = performance.now(); const last = this.stack[this.stack.length - 1]; if ( mergeable && last && last.type === action.type && now - this.lastPushTime < this.mergeWindowMs ) { // 合并:扩展 last 的 redoData,保留最早的 undoData this.mergeInto(last, action); } else { this.stack.push(action); } this.lastPushTime = now; } }

这里有个坑我踩过:合并窗口的时间判断不能用Date.now(),因为系统时间可能被调整,用performance.now()单调时钟更稳。另外合并的边界条件要小心——如果用户画一笔、停 400ms、再画一笔,这是两次独立操作,不能合并;但如果他画一笔、停 200ms、在同一格上又画一次,这其实应该合并成"最终值"而不是两次操作,否则撤销一次只是回到中间态,用户会觉得莫名其妙。

注意:合并策略一定要做成可配置的。有些用户习惯"每画一笔就是一个独立步骤",尤其是做像素画的人,他们需要精确控制撤销粒度。给个设置项,别替用户做死决定。

3. 区域选择与批量操作:让用户少点几百次鼠标

3.1 选择工具的本质是"一组坐标的集合"

矩形选择、椭圆选择、魔棒选择,看起来是三种工具,底层都是同一个东西:一个Set<number>,里面存的是y * width + x编码后的格子索引。用编码索引而不是{x, y}对象,是因为 Set 对数字的哈希效率远高于对象,而且判断"某格是否被选中"时不需要遍历。

class Selection { private cells = new Set<number>(); private width: number; constructor(mapWidth: number) { this.width = mapWidth; } add(x: number, y: number) { this.cells.add(y * this.width + x); } has(x: number, y: number): boolean { return this.cells.has(y * this.width + x); } // 矩形选择:直接双重循环填充 static rect(x0: number, y0: number, x1: number, y1: number, width: number): Selection { const sel = new Selection(width); const minX = Math.min(x0, x1), maxX = Math.max(x0, x1); const minY = Math.min(y0, y1), maxY = Math.max(y0, y1); for (let y = minY; y <= maxY; y++) { for (let x = minX; x <= maxX; x++) { sel.add(x, y); } } return sel; } }

魔棒选择用洪水填充(Flood Fill)实现,但这里有个性能陷阱:如果地图很大且目标区域连通性高,递归实现会爆栈,必须用显式栈或队列。而且每次判断"颜色是否相同"时,要基于当前图层的原始数据而不是渲染后的像素,否则半透明叠加会让判断失准。

3.2 批量操作的原子性:要么全成,要么全不成

选中一片区域后,用户可能执行:填充、删除、复制、移动、翻转。这些操作必须作为一个原子EditAction进撤销栈。我见过有人把"填充 500 格"拆成 500 个 action,结果用户按一次撤销只回退一格,体验灾难。

正确做法是在操作开始前记录整个选区的旧值,操作结束后记录新值,打包成一个 action:

function batchFill( model: MapModel, layer: number, selection: Selection, newTileId: number ): EditAction { const oldValues = new Map<number, number>(); const newValues = new Map<number, number>(); for (const idx of selection.cells) { oldValues.set(idx, model.getTileByIndex(layer, idx)); newValues.set(idx, newTileId); } return { type: 'batch-fill', undoData: oldValues, redoData: newValues, context: captureContext(), apply: (m) => { for (const [idx, v] of newValues) m.setTileByIndex(layer, idx, v); }, revert: (m) => { for (const [idx, v] of oldValues) m.setTileByIndex(layer, idx, v); }, }; }

用Map而不是数组存旧值,是因为选区可能稀疏(魔棒选出来的形状往往不规则),Map 只存实际改动的格子,内存更省。

3.3 复制粘贴的坐标系陷阱

复制一片区域再粘贴,最容易出的 bug 是粘贴位置偏移。用户复制了 (10,10) 到 (20,20) 的区域,然后鼠标移到 (50,50) 点粘贴,期望的是粘贴区域左上角对齐 (50,50)。但如果你直接存的是绝对坐标,粘贴时就会把整片区域原样搬到 (10,10),用户一脸问号。

我的处理方式是:复制时记录选区的最小外接矩形和相对于该矩形左上角的偏移,粘贴时以鼠标位置为新的左上角重新计算绝对坐标。这样无论选区形状多不规则,粘贴行为都符合直觉。

还有一个细节:粘贴时如果目标区域超出地图边界,是裁剪、报错还是环绕?我选择裁剪并提示,因为环绕会让用户困惑,报错又太打断流程。裁剪后在状态栏显示"部分内容超出边界已裁剪",用户心里有数就行。

4. 图层可见性与锁定:多图层编辑器的秩序感来源

4.1 可见性、锁定、激活——三个状态别混为一谈

新手做图层管理,往往只做一个"当前图层"的概念,所有操作都作用于它。但真实编辑场景里,用户需要的是三个独立维度:

  • 可见性:这一层画不画出来。关掉后渲染跳过,但数据还在。
  • 锁定:这一层能不能被编辑。锁定后点击、框选、填充都跳过它。
  • 激活:当前操作默认作用于哪一层。同一时刻只有一个激活层。

这三个状态正交组合,能覆盖绝大多数需求。比如"我想看着参考层描图,但不想误改它"——参考层可见但锁定;"我想临时隐藏装饰层看看地面布局"——装饰层不可见但没锁定,随时可以打开继续编辑。

interface LayerState { id: string; name: string; visible: boolean; locked: boolean; opacity: number; // 0~1,用于半透明参考层 }

opacity这个字段是我后来加的,因为做地形过渡时,经常需要把草地图层调到 50% 透明度叠在沙地上看效果。Canvas 2D 里用globalAlpha就能实现,成本极低但实用性极高。

4.2 渲染顺序与命中检测必须用同一套排序

图层有顺序,渲染时从下往上画。但命中检测(用户点击某格,判断点到了哪一层)必须用相反的顺序从上往下找,找到第一个可见且未锁定的层就停。如果渲染顺序和命中顺序不一致,就会出现"我明明点的是上层,结果改了下层"的诡异现象。

// 渲染:从底到顶 for (const layer of layers) { if (!layer.visible) continue; ctx.globalAlpha = layer.opacity; renderLayer(ctx, layer); } // 命中:从顶到底 function hitTest(x: number, y: number): LayerState | null { for (let i = layers.length - 1; i >= 0; i--) { const layer = layers[i]; if (!layer.visible || layer.locked) continue; if (layerHasContentAt(layer, x, y)) return layer; } return null; }

这里layerHasContentAt的判断要小心:如果某层是"空白层"(该格没有地块),是否算命中?我的选择是不算,继续往下找。否则用户点一个空白区域,结果选中了一个空图层,然后画上去发现被上层挡住了,会很困惑。只有该格确实有内容时才认为命中该层。

4.3 图层拖拽排序的边界处理

图层列表支持拖拽排序是基本需求,但实现时有两个坑。第一,拖拽过程中要实时预览插入位置,不能等松手才更新,否则用户不知道会插到哪。第二,拖拽到列表顶部或底部时,要能正确插入到最前或最后,而不是卡在倒数第二的位置。

我的做法是用一个dropIndicator状态记录当前插入线位置,拖拽时根据鼠标 Y 坐标计算它落在哪两个图层之间,松手时按这个位置做数组 splice。计算插入位置时用每个列表项的中点作为分界,而不是上边缘,这样手感更自然。

提示:图层数量多的时候(超过 20 层),列表要支持折叠分组。我一般按"地形层组""装饰层组""事件层组"分三大类,每组可折叠。这个功能看起来不起眼,但图层一多就是救命稻草。

5. 数据序列化:别让用户的心血毁在保存上

5.1 存档格式的选择:JSON 还是二进制

地图数据用 JSON 存,可读性好,调试方便,但体积大。一张 256×256 四层的地图,JSON 化后可能好几 MB,加载时JSON.parse也要几百毫秒。二进制存,体积小、解析快,但不可读,版本迁移麻烦。

我的折中方案是:运行时用二进制(TypedArray),存档时用 JSON + 可选压缩。具体来说,每层的地块数据用Uint16Array存,序列化时转成 Base64 字符串塞进 JSON。这样既保留了 JSON 的元数据可读性(图层名、顺序、属性都在),又避免了逐格数字的 JSON 膨胀。

function serializeLayer(layer: Layer): SerializedLayer { const bytes = new Uint8Array(layer.data.buffer); let binary = ''; const chunkSize = 0x8000; // 分块避免参数过多 for (let i = 0; i < bytes.length; i += chunkSize) { binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)); } return { id: layer.id, name: layer.name, visible: layer.visible, locked: layer.locked, opacity: layer.opacity, width: layer.width, height: layer.height, data: btoa(binary), }; }

注意String.fromCharCode(...bytes)在数据量大时会因为参数过多报错,必须分块。这个坑我在一张 512×512 的地图上踩过,浏览器直接抛RangeError,排查了半天才发现是展开运算符的参数上限问题。

5.2 版本号与向后兼容:从第一天就要留的余地

存档格式一定会变。今天你存的是 v1,明天加了"地块旋转"字段,后天加了"自定义属性",老存档怎么办?如果第一天没留版本号,后面就是灾难。

我的做法是在存档根对象里放一个formatVersion,每次格式变更就递增,加载时按版本号走不同的迁移函数:

function migrate(raw: any): MapModel { let data = raw; if (data.formatVersion < 2) { data = migrateV1ToV2(data); // 补上默认的 opacity 字段 } if (data.formatVersion < 3) { data = migrateV2ToV3(data); // 地块 ID 重映射 } return data as MapModel; }

迁移函数要写成纯函数,输入旧格式输出新格式,不要在里面直接改全局状态。这样每个迁移步骤都可以单独测试,出问题也好定位。

5.3 自动保存与崩溃恢复

用户画了两小时没保存,浏览器崩了,这是最伤人的场景。自动保存必须做,但频率要控制好——太频繁会卡顿(序列化本身有成本),太稀疏又起不到保护作用。我的策略是脏标记 + 定时器:任何修改操作把isDirty置 true,一个 30 秒的定时器检查,如果 dirty 就序列化写入localStorage或 IndexedDB,然后清标记。

let isDirty = false; let autoSaveTimer: number | null = null; function markDirty() { isDirty = true; if (autoSaveTimer === null) { autoSaveTimer = window.setTimeout(async () => { if (isDirty) { await saveToIndexedDB(serialize(currentModel)); isDirty = false; } autoSaveTimer = null; }, 30000); } }

用 IndexedDB 而不是 localStorage,是因为 localStorage 有 5MB 左右的限制,大地图存不下。IndexedDB 容量大得多,而且支持异步,不会阻塞主线程。启动时检查有没有未正常关闭的自动存档,有的话提示用户"检测到上次编辑未正常保存,是否恢复",这个体验能救回很多人的工作成果。

注意:自动保存的存档要和用户手动保存的存档分开存,别互相覆盖。用户手动保存的是"正式版",自动保存的是"草稿版",恢复时让用户选。

6. 那些文档里不会写的实操心得

6.1 光标反馈比你想的重要

用户把鼠标移到地图上,当前会画什么、画在哪一格,必须有清晰的预览。我早期只画了一个细边框,结果用户反馈"不知道点下去会画成什么样"。后来改成:半透明预览当前选中的地块 + 高亮边框 + 坐标显示,体验立刻上了一个台阶。

预览的实现要注意性能:鼠标移动事件触发频率很高,不能每次都重绘整个画布。我的做法是维护一个独立的"预览层"画布,叠在主画布上方,鼠标移动时只清空并重绘预览层,主画布不动。这样即使地图很大,预览也丝滑。

6.2 快捷键要可配置,且要有冲突检测

Ctrl+Z 撤销、Ctrl+Y 重做、Ctrl+C/V 复制粘贴、Delete 删除选区,这些是标配。但用户可能想改成自己习惯的键位,所以快捷键表要可配置。配置时最烦的是冲突——用户把两个功能绑到同一个键上,你得检测出来并提示。

我的做法是维护一个Map<string, string>(键位组合到功能 ID),注册新键位时检查是否已被占用,占用就弹提示让用户先解绑。键位组合的规范化要注意:Ctrl+Shift+Z和Shift+Ctrl+Z应该视为同一个,所以注册前先把修饰键按固定顺序排序。

6.3 大选区操作的性能兜底

魔棒选中一大片区域(比如整张地图同色区域),然后填充,这个操作可能涉及几万格。如果每格都触发一次重绘,浏览器直接卡死。必须做批量更新 + 单次重绘:先把所有数据改完,再统一调一次requestAnimationFrame重绘。

更进一步,如果选区超过某个阈值(比如 10000 格),可以考虑分帧处理,每帧处理一部分,避免长时间阻塞主线程。不过实测下来,只要重绘只做一次,几万格的数据更新在现代浏览器上也就几十毫秒,用户基本无感。真正卡的是每格都重绘,这个一定要避免。

6.4 撤销栈的深度限制与内存监控

撤销栈不能无限增长。我一般限制 100 步,超过就丢弃最老的。但光限制步数不够,因为不同操作的 action 大小差异巨大——单格绘制几十字节,整图快照可能几 MB。所以还要加一个总内存上限,比如 50MB,超过就从最老的开始丢。

function trimStack(stack: EditAction[], maxSteps: number, maxBytes: number) { while (stack.length > maxSteps) stack.shift(); let total = estimateSize(stack); while (total > maxBytes && stack.length > 1) { const removed = stack.shift()!; total -= estimateSize([removed]); } }

estimateSize不用精确,粗略估算即可——快照类按数据长度算,增量类按 Map 大小乘个系数。目的是防止极端情况下内存爆掉,不需要精确到字节。

6.5 测试用例要覆盖"操作序列"而不只是"单步操作"

地图编辑器的 bug 往往不在单个功能,而在功能组合。比如"画一笔 → 撤销 → 切换图层 → 再撤销",这种序列最容易出问题。我的测试策略是写一个操作序列回放器:把一系列操作和预期状态写成脚本,跑一遍看最终状态是否一致,以及每一步撤销后是否回到预期状态。

这个回放器还能用来做回归测试——每次改了撤销逻辑,跑一遍所有序列,确保没破坏已有行为。比手动点来点去靠谱得多。

7. 下一步可以往哪走

第四阶段做完,编辑器基本达到了"自己愿意天天用"的水平。再往后,我个人的规划是三个方向:多地图项目管理(一个项目里管理多张关联地图)、自定义地块属性系统(给地块挂碰撞、触发器等元数据)、协作编辑(多人同时编辑一张图,这个复杂度陡增,需要 CRDT 或 OT 那套东西)。

但说实话,协作编辑对个人项目来说性价比不高,除非真的有团队需求。更实际的是把前两个方向做扎实,再打磨一下 UI 细节——比如地块面板的搜索、最近使用、收藏夹,这些小功能对日常效率的提升,往往比一个大而全的新模块更明显。

我在这个编辑器上投入的时间,前三个阶段占了 40%,第四阶段占了 60%。功能从 0 到 1 很快,从 1 到"好用"才是真正耗时的部分。如果你也在做类似的东西,别急着加新功能,先把撤销、选择、图层、存档这四件事做到位,用户的留存率会告诉你值不值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进