Yuzhe's Blog

yuzhes

MapForge:构建一个浏览器端 Minecraft 地图画生成器

MapForge:构建一个浏览器端 Minecraft 地图画生成器

TL;DR:MapForge 可以将任意图片转换为 Minecraft 地图画布局——完全在浏览器内运行,无需服务器,无需安装。它将像素量化到 Minecraft 方块调色板,支持 Atkinson/蓝噪声抖色,导出 .schem.litematic 文件,并内置裁剪工作区。基于 SvelteKit + Web Workers 构建。

在线体验:mf.yuzhes.com · 源码:bkmashiro/mapforge


什么是地图画?

在 Minecraft 中,一张填充好的地图道具会渲染玩家脚下 128×128 像素的地面视图。通过在特定坐标精心放置彩色方块,可以制作出在玩家持有地图时显示的像素艺术。这是个老技巧,但准备工作——将任意图片转换为有效的方块布局——手动操作极其繁琐。

MapForge 完全在浏览器内自动化这一转换过程。


架构

核心流水线:

图片 → 裁剪 → 预处理 → 颜色量化 → 抖色 → 预览 → 导出

全部在客户端运行,数据不离开浏览器。

Web Worker 承担繁重计算

在约 200 种方块色调的调色板上对 128×128 图片进行颜色量化,是 CPU 密集型任务。在主线程上运行会在约 1-2 秒的转换期间冻结 UI。解决方案显而易见:移到 Web Worker 中。

// +page.svelte
worker = new Worker(
  new URL('$lib/workers/converter.worker.ts', import.meta.url),
  { type: 'module' }
);
worker.postMessage({ type: 'convert', imageData, width, height, options, coloursJSON });
worker.onmessage = (e) => {
  if (e.data.type === 'progress') { progress = e.data.progress; return; }
  resultPixels = e.data.pixels;
  // ...
};

Worker 接收原始 ImageData,从 Minecraft 方块数据构建调色板,逐行运行量化(发送进度更新),并返回量化后的像素矩阵和材料用量统计。

颜色匹配:LAB vs RGB

RGB 欧式距离计算快,但感知一致性差——人眼看起来相似的颜色在 RGB 距离上可能很大(反之亦然)。MapForge 默认使用 CIE LAB 色彩空间,并对 L、A、B 通道提供可配置的权重。

function deltaLab(a: LabColor, b: LabColor, weights: LabWeights): number {
  const dl = (a.L - b.L) * weights.L;
  const da = (a.a - b.a) * weights.a;
  const db = (a.b - b.b) * weights.b;
  return dl * dl + da * da + db * db;
}

对每个输入像素,通过暴力最近邻搜索找到最近的调色板条目(调色板足够小——约 200 个条目——KD 树在这里没有必要)。

抖色算法

实现了三种模式:

Atkinson 抖色(默认)——将 75% 的量化误差扩散到 6 个相邻像素。产生典型的经典 Mac OS 图形风格的锐利、高对比度效果。部分扩散(相比 Floyd-Steinberg 的 100%)故意丢弃一些误差,使暗部区域保持更暗。

蓝噪声抖色——使用直接内嵌在 Worker 中的 IGN(交错梯度噪声)64×64 阈值矩阵,无需文件加载,无需异步。每个像素的量化误差阈值从矩阵中查找,产生比随机抖色视觉上更令人愉悦的结构化噪声,代价是一定的精度损失。

无抖色——直接最近邻量化。最快,但渐变上会出现明显色带。

MapForge 的调色板基于 61 种基础地图颜色,每种颜色对应 3 种色调(明、中、暗,来自阶梯方块机制),共 183 种可用颜色。方块颜色数据来自 mapartcraft(GPL-3 授权)。


内联裁剪工作区

早期版本有一个独立的上传流程,后面跟着静态预览。体验很笨拙。现在的设计将交互式裁剪工作区直接嵌入主页面:上传图片后,立即在可平移/缩放的画布上看到它,带有一个表示地图网格的固定大小选择框。

示例:演示图片使用了来自萌娘百科的 Hazard Creeper 图像,最终效果相当不错。

裁剪工作区分为两个组件:

function extractCroppedImageData(source: HTMLImageElement, rect: SelectionRect): ImageData {
  const width = mapWidth * 128;
  const height = mapHeight * 128;
  // 先填充背景色(处理超出边界的区域)
  context.fillStyle = bgColor;
  context.fillRect(0, 0, width, height);
  // 将图片区域缩放绘制到目标尺寸
  context.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, width, height);
  return context.getImageData(0, 0, width, height);
}

当选择框超出图片范围时(例如放大太多),超出部分用用户配置的背景色填充(默认白色,对应 Minecraft 的白色羊毛)。


我踩到的 Svelte 4 响应式 Bug

这是文章里说实话的部分。

Bug 1:响应式块读取被调用函数写入的变量

渲染流水线由一个响应式签名驱动:

$: renderSignature = JSON.stringify({ mapWidth, mapHeight, ditherMethod, /* ... */ });

$: if (renderSignature && hasEnabledColours) {
  worker?.terminate(); // ← 读取 `worker`
  worker = null;       // ← 写入 `worker`
  isRenderQueued = true;
  // 防抖后调度 convert()
}

convert() 里:

worker = new Worker(...); // ← 写入 `worker`

Bug 所在:worker 在响应式块内被读取worker?.terminate())。当 convert() 写入 worker = new Worker(...) 时,Svelte 检测到变化并重新运行响应式块。该块终止新 Worker,重置 isRenderQueued,并重新调度防抖。这会无限循环。

修复: 将所有 worker 的读写从响应式块中移除,Worker 生命周期管理完全放在 convert() 内部。

Bug 2:响应式块因自身副作用触发重新执行

即使修复了 Bug 1,「渲染中…」指示器仍然在闪烁。根本原因:响应式块赋值了 isRenderQueued = true。在 Svelte 4 中,响应式块内的赋值会被追踪,任何读取这些变量的下游响应式语句(如 $: isRendering = isRenderQueued || isConverting)都会被重新调度。实际上,这导致整个响应式更新周期重新运行该块。

修复: 将响应式块替换为普通函数调用:

let _lastScheduledSig = '';
function scheduleConvertIfChanged(sig: string, hasColours: boolean) {
  if (!sig || !hasColours) { /* 重置 */ return; }
  if (sig === _lastScheduledSig) return; // 未变化则跳过
  _lastScheduledSig = sig;
  isRenderQueued = true;
  // 防抖 → convert()
}
$: scheduleConvertIfChanged(renderSignature, hasEnabledColours);

手动维护的 _lastScheduledSig 守卫,即使 Svelte 因无关状态变化重新调用该函数也能防止重复执行。

教训: 在 Svelte 4 中,当 sideEffects() 会修改响应式状态时,$: if (x) { sideEffects() } 是脆弱的。在 fn 内部带有显式守卫的 $: fn(dep) 模式更安全。

Bug 3:renderSignature 未追踪裁剪内容

修复循环后,移动选择框不会重新触发渲染。renderSignature 包含了 croppedImageData.widthcroppedImageData.height——但这些值始终是 mapWidth * 128mapHeight * 128。移动选择框会产生一个新的 ImageData,尺寸完全相同。

修复:onCrop 处理器中添加一个 cropGeneration 计数器并自增,将其包含在 renderSignature 中。简单,零开销。


导出:.schem 和 .litematic

MapForge 支持两种格式导出:

.schematic(MCEdit/WorldEdit) — NBT 编码,将方块数据存储在扁平的 Blocks 字节数组中,配以单独的 Data 半字节数组。格式简单,兼容性广泛。

.litematic(Litematica) — 更复杂。使用可变位宽(⌈log₂(调色板大小)⌉位/方块)的压缩位数组,存储区域元数据、方块实体数据和实体数据。该格式通过逆向工程 mod 源码进行了文档化。

两者都完全在浏览器中通过手工实现的 NBT 编码器生成。没有 WASM,没有原生代码——只有类型化数组和位操作。


部署

SvelteKit 配合 adapter-cloudflare。整个应用编译为单个 Worker 脚本 + 静态资源,部署在 Cloudflare Pages 上。构建时间约 2 秒,冷启动时间实际上为零,因为 CF Workers 在边缘运行。

注意:adapter-cloudflare 的输出在 .svelte-kit/cloudflare/ 而不是 dist/。如果手动配置 CF Pages,输出目录很重要。


近期新增功能(2026 年 3 月)

旋转支持

呼声最高的功能是在裁剪前旋转源图片。添加 ↺ 和 ↻ 按钮本身很简单——有趣的是让旋转真正端到端正确工作。

第一次尝试是对预览图片元素应用 CSS transform: rotate(Ndeg),然后就觉得完事了。预览看起来没问题。但导出的瓦片是错的:CSS transform 纯粹是视觉效果;底层的 ImageData 提取仍然从未旋转的图片读取。顶部裁剪预览和底部瓦片网格不同步。

正确做法:不使用 CSS 后处理,而是将图片渲染到临时 canvas,以编程方式应用相同的变换,然后从该 canvas 提取 ImageData

function extractWithRotation(
  source: HTMLImageElement,
  rect: SelectionRect,
  rotation: 0 | 90 | 180 | 270
): ImageData {
  const canvas = document.createElement('canvas');
  const ctx = canvas.getContext('2d')!;
  const w = targetWidth, h = targetHeight;

  canvas.width = w;
  canvas.height = h;

  ctx.save();
  ctx.translate(w / 2, h / 2);
  ctx.rotate((rotation * Math.PI) / 180);
  ctx.translate(-w / 2, -h / 2);
  ctx.drawImage(source, rect.x, rect.y, rect.width, rect.height, 0, 0, w, h);
  ctx.restore();

  return ctx.getImageData(0, 0, w, h);
}

通过将 canvas 渲染为与 CSS 预览相同的平移/旋转,提取的 ImageData 始终与用户所见一致。预览元素上的 CSS transform 被完全移除——canvas 现在同时驱动预览和导出,保持同步。


方块中文本地化

材料列表以前只显示 minecraft:block_id。对于大多数玩家——尤其是中文玩家——这可读性不高。我希望在命名空间 ID 旁边显示方块的正式中文名称。

Minecraft 将 zh_cn.json 翻译文件作为游戏资源的一部分发布。我获取了 1.20 版本的文件,其中包含约 9000 个翻译键。方块条目遵循 block.minecraft.<id> 的模式。我编写了一个小脚本,将 MapForge 使用的方块列表与这个 JSON 文件进行交叉引用。

结果:324 个方块中有 323 个匹配(有一个方块 ID 在 MapForge 内部名称和翻译键格式之间存在差异)。匹配数据被烘焙到生成的 blockMeta.ts 文件中:

export const blockMeta: Record<string, { zhName: string }> = {
  "minecraft:stone": { zhName: "石头" },
  "minecraft:grass_block": { zhName: "草方块" },
  // ... 另外 321 个
};

材料列表现在渲染为:

草方块 (minecraft:grass_block) × 42
石头 (minecraft:stone) × 17

英文名称已经从方块颜色数据中获得,所以这是附加的——两者都显示,当 locale 为 zh-CN 时中文名称在前。


i18n 完善

MapForge 从一开始就有部分 i18n 支持(英文和中文 locale 文件),但一些 UI 字符串仍硬编码在组件模板中。本次更新新增了 16 个翻译键:

所有 UI 字符串现在都通过 i18n 系统路由。运行时切换 locale 可以在不重新加载页面的情况下更新完整界面。


README 重写

README 以前是一大堵文字。我重写了它,包含:

中文 README 与英文保持相同结构,目前手动同步维护。如果 README 有较大改动,可以考虑添加翻译 CI 步骤。


用一个周末构建,又调试了两个周末。