Improve map editor graphic imports

This commit is contained in:
2026-06-10 00:54:05 +08:00
parent d652d576e3
commit d46bb4dae0
4 changed files with 297 additions and 9 deletions
+20
View File
@@ -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 颜色显示。
- 暂未实现撤销/重做和多选。
+253 -5
View File
@@ -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`;
}
+4
View File
@@ -64,6 +64,10 @@
<button id="addLayerButton" type="button">图层</button>
<button id="addCollisionButton" type="button">碰撞块</button>
<button id="addTileRectButton" type="button">铺砖区</button>
<label class="file-button">
导入图形
<input id="graphicFileInput" type="file" accept="image/*">
</label>
<button id="addBattleZoneButton" type="button">战斗区</button>
<button id="addSpawnButton" type="button">出生点</button>
</div>