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 图像,最终效果相当不错。
裁剪工作区分为两个组件:
InlineCropWorkspace.svelte— 通过 pointer 事件处理平移、缩放(滚轮)和选择拖拽,每当视图或选择移动时发出selectionChange。InlineWorkspace.svelte— 持有实际图片元素,接收选择事件,对裁剪提取进行防抖处理,并将裁剪后的ImageData向上分发。
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.width 和 croppedImageData.height——但这些值始终是 mapWidth * 128 和 mapHeight * 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 个翻译键:
imageWorkspace— 裁剪工作区的节标题previewSection— 输出预览区域标签waitingRender— 首次渲染前显示的占位文字bgColor— 背景颜色选择器标签rotation— 旋转控制组标签- ……以及 11 个涵盖工具提示、按钮标签和状态信息的翻译键
所有 UI 字符串现在都通过 i18n 系统路由。运行时切换 locale 可以在不重新加载页面的情况下更新完整界面。
README 重写
README 以前是一大堵文字。我重写了它,包含:
- 前后对比演示表格 — 并排截图对比,展示输入图片 vs 渲染地图画 vs 游戏内地图视图
- for-the-badge 徽章 — 构建状态、许可证、Cloudflare 部署状态和技术栈徽章
- 中文 README(
README.zh.md)— README 的完整中文翻译,涵盖安装、功能和技术说明
中文 README 与英文保持相同结构,目前手动同步维护。如果 README 有较大改动,可以考虑添加翻译 CI 步骤。
用一个周末构建,又调试了两个周末。