From d46bb4dae045e08cc97d51c42008ec8f6c395689 Mon Sep 17 00:00:00 2001
From: 1165565692-lang <1165565692@qq.com>
Date: Wed, 10 Jun 2026 00:54:05 +0800
Subject: [PATCH] Improve map editor graphic imports
---
game/docs/modules/map_editor.md | 24 ++-
tools/map_editor/README.md | 20 +++
tools/map_editor/app.js | 258 +++++++++++++++++++++++++++++++-
tools/map_editor/index.html | 4 +
4 files changed, 297 insertions(+), 9 deletions(-)
diff --git a/game/docs/modules/map_editor.md b/game/docs/modules/map_editor.md
index e3035fe..0cadea7 100644
--- a/game/docs/modules/map_editor.md
+++ b/game/docs/modules/map_editor.md
@@ -94,8 +94,13 @@ http://127.0.0.1:8787/tools/map_editor/
- Canvas 可视化 world、camera、player spawn、collision、rect、tile_rect、battle zone、spawn。
- 对象列表选择和画布点击选择。
- 网格吸附拖动对象。
+- 通过“编辑目标”过滤画布命中目标,默认优先编辑 `collision`。
+- 矩形对象支持右下角手柄缩放。
- 属性面板编辑选中对象的数值或文本。
- 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。
+- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。
+- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
+- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过 Download 导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
@@ -112,6 +117,14 @@ http://127.0.0.1:8787/tools/map_editor/
素材路径仍由 `.map` 和 `game/assets/stage/stage_01/ASSETS.md` 记录。
+导入图形时,静态网页不能直接复制图片文件到仓库。编辑器默认把贴图路径写为:
+
+```text
+assets/stage/stage_01/props/<图片文件名>
+```
+
+实际运行游戏前,需要把对应图片素材放到该目录。编辑器内的图形-碰撞绑定是工具层能力,导出的 `.map` 仍保持运行时兼容格式,不额外写入私有字段。
+
## 首版验收
完成时应达到:
@@ -120,15 +133,18 @@ http://127.0.0.1:8787/tools/map_editor/
2. 已完成:能看到地面、平台、battle zone、spawn point。
3. 已完成:能新增/移动/删除一个 `collision`。
4. 已完成:能新增/移动/删除一个 `tile_rect`。
-5. 已完成:能导出 `.map`。
-6. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
-7. 待后续地图修改后复测:Windows 构建通过,短启动通过。
-8. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
+5. 已完成:能导入图形,生成 `rect_sprite` 和同尺寸 `collision`。
+6. 已完成:导入图形支持移动、缩放和同步调整绑定碰撞块。
+7. 已完成:能导出 `.map`。
+8. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
+9. 待后续地图修改后复测:Windows 构建通过,短启动通过。
+10. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
## 后续增强
- tile palette。
- tileset 图片预览。
+- 保存接口:部署到服务器后,把 `.map` 文本写回服务端文件或数据库。
- battle zone 清场出口配置。
- props 图层编辑。
- 自动检查视觉 tile 与碰撞误差。
diff --git a/tools/map_editor/README.md b/tools/map_editor/README.md
index c8a98b6..dd27f55 100644
--- a/tools/map_editor/README.md
+++ b/tools/map_editor/README.md
@@ -26,6 +26,8 @@ http://127.0.0.1:8787/tools/map_editor/
- 在画布上拖动对象,支持网格吸附。
- 通过“编辑目标”过滤画布选择对象,默认优先编辑碰撞块,避免点到重叠的铺砖区。
- 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。
+- 通过“导入图形”选择图片,自动生成一个 `rect_sprite` 图形对象和一个同尺寸 `collision`。
+- 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。
- 编辑选中对象的数值和文本属性。
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
- 下载兼容 `StageMapLoader` 的 `.map` 文本。
@@ -38,8 +40,26 @@ http://127.0.0.1:8787/tools/map_editor/
4. 拖动右下角蓝色手柄可以调整宽高。
5. 也可以在右侧属性面板直接修改 `X`、`Y`、`W`、`H`。
+## 导入和调整图形
+
+1. 点击左侧“导入图形”,选择一个图片素材。
+2. 编辑器会把它添加到 `PropsBack` 图层,并在同一位置创建同尺寸碰撞块。
+3. 新图形会自动选中,左侧“编辑目标”会切到“场景矩形”。
+4. 拖动图形本体可以移动图形和绑定碰撞块。
+5. 拖动右下角蓝色手柄可以调整图形和绑定碰撞块尺寸。
+6. 也可以在右侧属性面板修改 `X`、`Y`、`W`、`H`、颜色和贴图路径。
+
+静态网页不能自动把图片复制进项目目录。导入后 `.map` 默认写入:
+
+```text
+assets/stage/stage_01/props/<图片文件名>
+```
+
+实际运行游戏前,需要把图片素材放到这个路径下。
+
## 当前限制
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
+- 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示。
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
- 暂未实现撤销/重做和多选。
diff --git a/tools/map_editor/app.js b/tools/map_editor/app.js
index 5c0bb18..47dd543 100644
--- a/tools/map_editor/app.js
+++ b/tools/map_editor/app.js
@@ -6,6 +6,7 @@ const state = {
view: { x: -80, y: -40, scale: 0.35 },
gridSize: 10,
hitFilter: "collision",
+ imagePreviews: new Map(),
dragging: null,
};
@@ -21,6 +22,7 @@ document.addEventListener("DOMContentLoaded", () => {
function bindElements() {
for (const id of [
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
+ "graphicFileInput",
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
"hitFilterInput",
"objectList", "propertyPanel", "selectionSummary", "deleteButton",
@@ -36,6 +38,7 @@ function bindEvents() {
window.addEventListener("resize", resizeCanvas);
elements.loadSampleButton.addEventListener("click", loadSampleMap);
elements.fileInput.addEventListener("change", openFile);
+ elements.graphicFileInput.addEventListener("change", importGraphicAsset);
elements.downloadButton.addEventListener("click", downloadMap);
elements.deleteButton.addEventListener("click", deleteSelected);
@@ -96,6 +99,32 @@ function openFile(event) {
reader.readAsText(file);
}
+async function importGraphicAsset(event) {
+ const file = event.target.files[0];
+ if (!file) return;
+ try {
+ const previewUrl = URL.createObjectURL(file);
+ const image = await loadImage(previewUrl);
+ const texturePath = defaultGraphicTexturePath(file.name);
+ state.imagePreviews.set(texturePath, image);
+ addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64);
+ setStatus(`已导入图形 ${file.name},并自动创建同尺寸碰撞块。请把素材放到 ${texturePath}`);
+ } catch (error) {
+ setStatus(`导入图形失败:${error.message}`);
+ } finally {
+ event.target.value = "";
+ }
+}
+
+function loadImage(url) {
+ return new Promise((resolve, reject) => {
+ const image = new Image();
+ image.onload = () => resolve(image);
+ image.onerror = () => reject(new Error("图片读取失败"));
+ image.src = url;
+ });
+}
+
function loadMapText(text, status) {
try {
state.map = parseMap(text);
@@ -214,6 +243,7 @@ function parseMap(text) {
}
}
+ rebuildGraphicCollisionLinks(map);
return map;
}
@@ -398,11 +428,40 @@ function drawGrid(ctx) {
function drawLayers(ctx) {
for (const layer of sortLayers(state.map.layers)) {
- for (const rect of layer.rects) drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794");
+ for (const rect of layer.rects) drawLayerRect(ctx, rect);
for (const tileRect of layer.tileRects) drawTileRect(ctx, tileRect);
}
}
+function drawLayerRect(ctx, rect) {
+ const image = rect.texture ? state.imagePreviews.get(rect.texture) : null;
+ if (image) {
+ ctx.save();
+ ctx.globalAlpha = rect.color.a === undefined ? 1 : rect.color.a;
+ if (rect.source) {
+ ctx.drawImage(
+ image,
+ rect.source.x,
+ rect.source.y,
+ rect.source.width,
+ rect.source.height,
+ rect.bounds.x,
+ rect.bounds.y,
+ rect.bounds.width,
+ rect.bounds.height
+ );
+ } else {
+ ctx.drawImage(image, rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height);
+ }
+ ctx.restore();
+ ctx.strokeStyle = "#7d8794";
+ ctx.lineWidth = 1 / state.view.scale;
+ ctx.strokeRect(rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height);
+ return;
+ }
+ drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794");
+}
+
function drawTileRect(ctx, tileRect) {
const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset);
const tw = tileset ? tileset.tileWidth : 10;
@@ -511,7 +570,7 @@ function collectObjects() {
state.map.collisions.forEach((rect, index) => objects.push(objectForRect(`collision:${index}`, `碰撞块 ${index + 1}`, rect, true, null, "collision")));
state.map.layers.forEach((layer, layerIndex) => {
objects.push(objectForLayer(`layer:${layerIndex}`, `图层 ${layer.id}`, layer, true));
- layer.rects.forEach((rect, rectIndex) => objects.push(objectForRect(`rect:${layerIndex}:${rectIndex}`, `${layer.id} 矩形 ${rectIndex + 1}`, rect.bounds, true, null, "rect")));
+ layer.rects.forEach((rect, rectIndex) => objects.push(objectForLayerRect(`rect:${layerIndex}:${rectIndex}`, `${layer.id} 图形 ${rectIndex + 1}`, rect, true)));
layer.tileRects.forEach((tileRect, tileIndex) => objects.push(objectForTileRect(`tile:${layerIndex}:${tileIndex}`, `${layer.id} 铺砖区 ${tileIndex + 1}`, tileRect, true)));
});
state.map.battleZones.forEach((zone, zoneIndex) => {
@@ -569,6 +628,28 @@ function objectForRect(id, title, rect, deletable = false, meta = null, type = "
};
}
+function objectForLayerRect(id, title, item, deletable) {
+ return {
+ key: id,
+ type: "rect",
+ title,
+ meta: item.texture ? item.texture : `${fmt(item.bounds.x)}, ${fmt(item.bounds.y)}, ${fmt(item.bounds.width)}x${fmt(item.bounds.height)}`,
+ deletable,
+ bounds: () => item.bounds,
+ move(dx, dy) {
+ item.bounds.x += dx;
+ item.bounds.y += dy;
+ moveLinkedCollision(item, dx, dy);
+ },
+ resize(dw, dh) {
+ item.bounds.width = Math.max(1, item.bounds.width + dw);
+ item.bounds.height = Math.max(1, item.bounds.height + dh);
+ resizeLinkedCollision(item, item.bounds.width, item.bounds.height);
+ },
+ fields: () => layerRectFields(item),
+ };
+}
+
function objectForPoint(id, title, point, deletable = false) {
return {
key: id,
@@ -625,6 +706,11 @@ function onCanvasMouseDown(event) {
setCanvasCursor("nwse-resize");
return;
}
+ if (selected && selected.move && isInsideBounds(selected, world.x, world.y)) {
+ state.dragging = { last: snapPoint(world) };
+ setCanvasCursor("move");
+ return;
+ }
const object = hitTest(world.x, world.y);
if (object) {
@@ -691,6 +777,12 @@ function hitTest(x, y) {
return null;
}
+function isInsideBounds(object, x, y) {
+ const bounds = object.bounds && object.bounds();
+ if (!bounds) return false;
+ return x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height;
+}
+
function isHitFilterEnabled(object) {
if (state.hitFilter === "all") return true;
if (state.hitFilter === "zone") return object.type === "zone";
@@ -704,6 +796,10 @@ function updateCanvasCursor(event) {
setCanvasCursor("nwse-resize");
return;
}
+ if (selected && selected.move && isInsideBounds(selected, world.x, world.y)) {
+ setCanvasCursor("move");
+ return;
+ }
if (hitTest(world.x, world.y)) {
setCanvasCursor("move");
return;
@@ -722,6 +818,30 @@ function addCollision() {
refresh();
}
+function addGraphicSprite(texturePath, width, height) {
+ const layerIndex = ensurePropsBackLayer();
+ const layer = state.map.layers[layerIndex];
+ const point = snapPoint(viewCenter());
+ const bounds = {
+ x: point.x,
+ y: point.y,
+ width: Math.max(1, Math.round(width)),
+ height: Math.max(1, Math.round(height)),
+ };
+ const collision = { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
+ state.map.collisions.push(collision);
+ layer.rects.push({
+ bounds,
+ color: { r: 1, g: 1, b: 1, a: 1 },
+ texture: texturePath,
+ source: null,
+ linkedCollisionIndex: state.map.collisions.length - 1,
+ });
+ state.selected = `rect:${layerIndex}:${layer.rects.length - 1}`;
+ state.hitFilter = "rect";
+ refresh();
+}
+
function addTileset() {
const index = state.map.tilesets.length + 1;
state.map.tilesets.push({
@@ -786,7 +906,7 @@ function addSpawn() {
function deleteSelected() {
if (!state.selected) return;
const parts = state.selected.split(":");
- if (parts[0] === "collision") state.map.collisions.splice(Number(parts[1]), 1);
+ if (parts[0] === "collision") deleteCollision(Number(parts[1]));
if (parts[0] === "tileset") {
const tileset = state.map.tilesets[Number(parts[1])];
if (tileset && isTilesetUsed(tileset.id)) {
@@ -795,8 +915,8 @@ function deleteSelected() {
}
state.map.tilesets.splice(Number(parts[1]), 1);
}
- if (parts[0] === "layer") state.map.layers.splice(Number(parts[1]), 1);
- if (parts[0] === "rect") state.map.layers[Number(parts[1])].rects.splice(Number(parts[2]), 1);
+ if (parts[0] === "layer") deleteLayer(Number(parts[1]));
+ if (parts[0] === "rect") deleteLayerRect(Number(parts[1]), Number(parts[2]));
if (parts[0] === "tile") state.map.layers[Number(parts[1])].tileRects.splice(Number(parts[2]), 1);
if (parts[0] === "zone") state.map.battleZones.splice(Number(parts[1]), 1);
if (parts[0] === "spawn") state.map.battleZones[Number(parts[1])].spawns.splice(Number(parts[2]), 1);
@@ -844,6 +964,35 @@ function rectFields(rect) {
];
}
+function layerRectFields(item) {
+ return [
+ numberField("X", () => item.bounds.x, (v) => {
+ const next = Number.isFinite(v) ? v : item.bounds.x;
+ moveLinkedCollision(item, next - item.bounds.x, 0);
+ item.bounds.x = next;
+ }),
+ numberField("Y", () => item.bounds.y, (v) => {
+ const next = Number.isFinite(v) ? v : item.bounds.y;
+ moveLinkedCollision(item, 0, next - item.bounds.y);
+ item.bounds.y = next;
+ }),
+ numberField("W", () => item.bounds.width, (v) => {
+ item.bounds.width = Math.max(1, v);
+ resizeLinkedCollision(item, item.bounds.width, item.bounds.height);
+ }),
+ numberField("H", () => item.bounds.height, (v) => {
+ item.bounds.height = Math.max(1, v);
+ resizeLinkedCollision(item, item.bounds.width, item.bounds.height);
+ }),
+ textField("贴图路径", () => item.texture || "", (v) => {
+ const next = String(v || "").trim();
+ if (next) item.texture = next;
+ else delete item.texture;
+ }),
+ ...colorFields(item.color, true),
+ ];
+}
+
function pointFields(point) {
return [
numberField("X", () => point.x, (v) => { point.x = v; }),
@@ -875,6 +1024,43 @@ function ensureLevelVisualLayer() {
}
}
+function ensurePropsBackLayer() {
+ let index = state.map.layers.findIndex((layer) => layer.role === "PropsBack");
+ if (index >= 0) return index;
+ state.map.layers.push({ id: "props_back", role: "PropsBack", parallax: 1, rects: [], tileRects: [] });
+ index = state.map.layers.length - 1;
+ return index;
+}
+
+function defaultGraphicTexturePath(fileName) {
+ return `assets/stage/stage_01/props/${safeAssetFileName(fileName)}`;
+}
+
+function safeAssetFileName(fileName) {
+ const clean = String(fileName || "graphic.png").replace(/\\/g, "/").split("/").pop();
+ return clean.replace(/[^A-Za-z0-9._-]/g, "_") || "graphic.png";
+}
+
+function linkedCollision(item) {
+ const index = Number(item.linkedCollisionIndex);
+ if (!Number.isInteger(index)) return null;
+ return state.map.collisions[index] || null;
+}
+
+function moveLinkedCollision(item, dx, dy) {
+ const collision = linkedCollision(item);
+ if (!collision) return;
+ collision.x += dx;
+ collision.y += dy;
+}
+
+function resizeLinkedCollision(item, width, height) {
+ const collision = linkedCollision(item);
+ if (!collision) return;
+ collision.width = Math.max(1, width);
+ collision.height = Math.max(1, height);
+}
+
function sortLayers(layers) {
return [...layers].sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role));
}
@@ -987,6 +1173,68 @@ function isTilesetUsed(id) {
return state.map.layers.some((layer) => layer.tileRects.some((tileRect) => tileRect.tileset === id));
}
+function deleteLayer(layerIndex) {
+ const layer = state.map.layers[layerIndex];
+ if (!layer) return;
+ const linkedIndexes = layer.rects
+ .map((item) => Number(item.linkedCollisionIndex))
+ .filter((index) => Number.isInteger(index))
+ .sort((a, b) => b - a);
+ state.map.layers.splice(layerIndex, 1);
+ for (const index of linkedIndexes) deleteCollision(index);
+}
+
+function deleteLayerRect(layerIndex, rectIndex) {
+ const layer = state.map.layers[layerIndex];
+ if (!layer) return;
+ const item = layer.rects[rectIndex];
+ if (!item) return;
+ const linkedIndex = Number(item.linkedCollisionIndex);
+ layer.rects.splice(rectIndex, 1);
+ if (Number.isInteger(linkedIndex)) deleteCollision(linkedIndex);
+}
+
+function deleteCollision(index) {
+ if (index < 0 || index >= state.map.collisions.length) return;
+ state.map.collisions.splice(index, 1);
+ for (const layer of state.map.layers) {
+ for (const item of layer.rects) {
+ const linkedIndex = Number(item.linkedCollisionIndex);
+ if (!Number.isInteger(linkedIndex)) continue;
+ if (linkedIndex === index) delete item.linkedCollisionIndex;
+ else if (linkedIndex > index) item.linkedCollisionIndex = linkedIndex - 1;
+ }
+ }
+}
+
+function rebuildGraphicCollisionLinks(map) {
+ const used = new Set();
+ for (const layer of map.layers) {
+ for (const item of layer.rects) {
+ delete item.linkedCollisionIndex;
+ if (!item.texture) continue;
+ const index = map.collisions.findIndex((collision, collisionIndex) => {
+ return !used.has(collisionIndex) && sameRect(collision, item.bounds);
+ });
+ if (index >= 0) {
+ item.linkedCollisionIndex = index;
+ used.add(index);
+ }
+ }
+ }
+}
+
+function sameRect(a, b) {
+ return nearlyEqual(a.x, b.x) &&
+ nearlyEqual(a.y, b.y) &&
+ nearlyEqual(a.width, b.width) &&
+ nearlyEqual(a.height, b.height);
+}
+
+function nearlyEqual(a, b) {
+ return Math.abs(Number(a) - Number(b)) < 0.001;
+}
+
function defaultMapText() {
return `map stage\nworld 1280 720\ncamera 0 0 1280 720\nplayer_spawn 120 520\n\ncollision 0 600 1280 80\n\ntileset graybox assets/stage/stage_01/tiles/graybox.png 10 10 0.62 0.68 0.73 1\n\nlayer level_visual LevelVisual 1\ntile_rect graybox 0 600 128 8 0 0.62 0.68 0.73\nendlayer\n`;
}
diff --git a/tools/map_editor/index.html b/tools/map_editor/index.html
index 325cb38..7edf416 100644
--- a/tools/map_editor/index.html
+++ b/tools/map_editor/index.html
@@ -64,6 +64,10 @@
+