diff --git a/game/docs/modules/map_editor.md b/game/docs/modules/map_editor.md index 493a6d4..102e6fe 100644 --- a/game/docs/modules/map_editor.md +++ b/game/docs/modules/map_editor.md @@ -29,7 +29,7 @@ ### 可以暂缓 -- 撤销/重做。 +- 重做。 - 多选批量操作。 - 自动铺边缘 tile。 - 地图对象属性面板的高级校验。 @@ -98,12 +98,16 @@ http://127.0.0.1:8787/tools/map_editor/ - 矩形对象支持右下角手柄缩放。 - 属性面板编辑选中对象的数值或文本。 - 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。 +- 支持撤回最近编辑操作,包含新增、删除、拖动、缩放、属性修改、导入图形和图层移动。 +- 支持 `Ctrl+Z` 快捷撤回。 +- 支持保存并下载 `.map` 文本。 +- 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。 - 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。 - 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。 - 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。 - 导出兼容 `StageMapLoader` 的 `.map` 文本。 -浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过 Download 导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。 +浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。 ## 数据边界 @@ -136,10 +140,12 @@ assets/stage/stage_01/props/<图片文件名> 5. 已完成:`tile_rect` 与 `collision` 重叠时,默认智能选择可直接选中并移动铺砖区。 6. 已完成:能导入图形,生成 `rect_sprite` 和同尺寸 `collision`。 7. 已完成:导入图形支持移动、缩放和同步调整绑定碰撞块。 -8. 已完成:能导出 `.map`。 -9. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。 -10. 待后续地图修改后复测:Windows 构建通过,短启动通过。 -11. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。 +8. 已完成:能撤回最近编辑操作。 +9. 已完成:能保存并下载 `.map`。 +10. 已完成:能上移/下移图层,并按当前图层顺序预览和导出。 +11. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。 +12. 待后续地图修改后复测:Windows 构建通过,短启动通过。 +13. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。 ## 后续增强 diff --git a/tools/map_editor/README.md b/tools/map_editor/README.md index b8aa386..70da690 100644 --- a/tools/map_editor/README.md +++ b/tools/map_editor/README.md @@ -30,7 +30,16 @@ http://127.0.0.1:8787/tools/map_editor/ - 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。 - 编辑选中对象的数值和文本属性。 - 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。 -- 下载兼容 `StageMapLoader` 的 `.map` 文本。 +- 支持撤回最近编辑操作,也可以使用 `Ctrl+Z`。 +- 选中图层或图层内对象后,可以把对应图层上移或下移。 +- 保存并下载兼容 `StageMapLoader` 的 `.map` 文本。 + +## 保存和撤回 + +- “保存并下载”会把右侧导出文本下载成 `<地图ID>.map`。 +- 浏览器安全限制下,静态网页不能直接覆盖本地项目文件;下载后仍需要替换项目里的地图文件。 +- “撤回”会恢复最近一次地图编辑操作,包括新增、删除、拖动、缩放、属性修改、导入图形和图层移动。 +- 打开或重新加载 `.map` 会清空撤回历史。 ## 调整碰撞块 @@ -65,9 +74,15 @@ assets/stage/stage_01/props/<图片文件名> 实际运行游戏前,需要把图片素材放到这个路径下。 +## 调整图层顺序 + +1. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。 +2. 点击右侧“图层上移”或“图层下移”。 +3. 编辑器会按当前图层顺序预览和导出 `.map`。 + ## 当前限制 - 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。 - 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示。 - 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。 -- 暂未实现撤销/重做和多选。 +- 暂未实现重做和多选。 diff --git a/tools/map_editor/app.js b/tools/map_editor/app.js index 6db29d5..93f452d 100644 --- a/tools/map_editor/app.js +++ b/tools/map_editor/app.js @@ -7,6 +7,7 @@ const state = { gridSize: 10, hitFilter: "all", imagePreviews: new Map(), + history: [], dragging: null, }; @@ -21,7 +22,7 @@ document.addEventListener("DOMContentLoaded", () => { function bindElements() { for (const id of [ - "mapCanvas", "fileInput", "loadSampleButton", "downloadButton", + "mapCanvas", "fileInput", "loadSampleButton", "downloadButton", "undoButton", "graphicFileInput", "mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput", "hitFilterInput", @@ -29,6 +30,7 @@ function bindElements() { "tilesetList", "outputText", "statusText", "viewText", "addTilesetButton", "addLayerButton", "addCollisionButton", "addTileRectButton", "addBattleZoneButton", "addSpawnButton", + "moveLayerUpButton", "moveLayerDownButton", ]) { elements[id] = document.getElementById(id); } @@ -39,18 +41,30 @@ function bindEvents() { elements.loadSampleButton.addEventListener("click", loadSampleMap); elements.fileInput.addEventListener("change", openFile); elements.graphicFileInput.addEventListener("change", importGraphicAsset); - elements.downloadButton.addEventListener("click", downloadMap); + elements.downloadButton.addEventListener("click", saveAndDownloadMap); + elements.undoButton.addEventListener("click", undoLast); elements.deleteButton.addEventListener("click", deleteSelected); + elements.moveLayerUpButton.addEventListener("click", () => moveSelectedLayer(-1)); + elements.moveLayerDownButton.addEventListener("click", () => moveSelectedLayer(1)); + window.addEventListener("keydown", (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") { + event.preventDefault(); + undoLast(); + } + }); elements.mapIdInput.addEventListener("change", () => { + pushUndo("修改地图 ID"); state.map.id = elements.mapIdInput.value.trim() || "stage"; refresh(); }); elements.worldWidthInput.addEventListener("change", () => { + pushUndo("修改世界宽度"); state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width); refresh(); }); elements.worldHeightInput.addEventListener("change", () => { + pushUndo("修改世界高度"); state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height); refresh(); }); @@ -107,6 +121,7 @@ async function importGraphicAsset(event) { const image = await loadImage(previewUrl); const texturePath = defaultGraphicTexturePath(file.name); state.imagePreviews.set(texturePath, image); + pushUndo("导入图形"); addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64); setStatus(`已导入图形 ${file.name},并自动创建同尺寸碰撞块。请把素材放到 ${texturePath}`); } catch (error) { @@ -129,6 +144,7 @@ function loadMapText(text, status) { try { state.map = parseMap(text); state.selected = null; + state.history = []; fitWorldToCanvas(); setStatus(status); refresh(); @@ -263,7 +279,7 @@ function serializeMap(map) { } lines.push(""); - for (const layer of sortLayers(map.layers)) { + for (const layer of map.layers) { lines.push(`layer ${layer.id} ${layer.role} ${fmt(layer.parallax)}`); for (const rect of layer.rects) { if (rect.texture && rect.source) { @@ -298,6 +314,8 @@ function refresh() { elements.gridSizeInput.value = state.gridSize; elements.hitFilterInput.value = state.hitFilter; elements.outputText.value = serializeMap(state.map); + elements.undoButton.disabled = state.history.length === 0; + elements.undoButton.textContent = state.history.length ? `撤回 ${state.history.length}` : "撤回"; renderTilesets(); renderObjectList(); renderPropertyPanel(); @@ -342,11 +360,14 @@ function renderPropertyPanel() { if (!object) { elements.selectionSummary.textContent = "未选择对象。"; elements.deleteButton.disabled = true; + elements.moveLayerUpButton.disabled = true; + elements.moveLayerDownButton.disabled = true; return; } elements.selectionSummary.textContent = `${object.title} | ${object.meta}`; elements.deleteButton.disabled = !object.deletable; + updateLayerMoveButtons(); const fields = object.fields(); for (const field of fields) { const label = document.createElement("label"); @@ -367,7 +388,10 @@ function renderPropertyPanel() { } input.value = field.get(); input.addEventListener("change", () => { - field.set(input.type === "number" ? Number(input.value) : input.value); + const nextValue = input.type === "number" ? Number(input.value) : input.value; + if (String(field.get()) === String(nextValue)) return; + pushUndo(`修改${field.label}`); + field.set(nextValue); refresh(); }); label.appendChild(input); @@ -375,6 +399,12 @@ function renderPropertyPanel() { } } +function updateLayerMoveButtons() { + const layerIndex = selectedLayerIndex(); + elements.moveLayerUpButton.disabled = layerIndex <= 0; + elements.moveLayerDownButton.disabled = layerIndex < 0 || layerIndex >= state.map.layers.length - 1; +} + function drawCanvas() { const canvas = elements.mapCanvas; const ctx = canvas.getContext("2d"); @@ -427,7 +457,7 @@ function drawGrid(ctx) { } function drawLayers(ctx) { - for (const layer of sortLayers(state.map.layers)) { + for (const layer of state.map.layers) { for (const rect of layer.rects) drawLayerRect(ctx, rect); for (const tileRect of layer.tileRects) drawTileRect(ctx, tileRect); } @@ -741,6 +771,10 @@ function onCanvasMouseMove(event) { const dx = current.x - state.dragging.last.x; const dy = current.y - state.dragging.last.y; if (dx || dy) { + if (!state.dragging.undoPushed) { + pushUndo(state.dragging.resize ? "调整对象尺寸" : "移动对象"); + state.dragging.undoPushed = true; + } if (state.dragging.resize && object.resize) { object.resize(dx, dy); } else if (object.move) { @@ -833,6 +867,7 @@ function setCanvasCursor(cursor) { } function addCollision() { + pushUndo("新增碰撞块"); const point = snapPoint(viewCenter()); state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 }); state.selected = `collision:${state.map.collisions.length - 1}`; @@ -865,6 +900,7 @@ function addGraphicSprite(texturePath, width, height) { } function addTileset() { + pushUndo("新增图块集"); const index = state.map.tilesets.length + 1; state.map.tilesets.push({ id: `tileset_${index}`, @@ -878,6 +914,7 @@ function addTileset() { } function addLayer() { + pushUndo("新增图层"); const index = state.map.layers.length + 1; state.map.layers.push({ id: `layer_${index}`, @@ -891,6 +928,7 @@ function addLayer() { } function addTileRect() { + pushUndo("新增铺砖区"); ensureLevelVisualLayer(); if (!state.map.tilesets.length) { state.map.tilesets.push({ id: "graybox_tile", texture: "assets/stage/stage_01/tiles/graybox_tile.png", tileWidth: 10, tileHeight: 10, color: { r: 0.62, g: 0.68, b: 0.73, a: 1 } }); @@ -904,7 +942,8 @@ function addTileRect() { refresh(); } -function addBattleZone() { +function addBattleZone(recordHistory = true) { + if (recordHistory) pushUndo("新增战斗区"); const point = snapPoint(viewCenter()); state.map.battleZones.push({ id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`, @@ -917,7 +956,8 @@ function addBattleZone() { } function addSpawn() { - if (!state.map.battleZones.length) addBattleZone(); + pushUndo("新增出生点"); + if (!state.map.battleZones.length) addBattleZone(false); const zoneIndex = Math.max(0, state.map.battleZones.length - 1); const zone = state.map.battleZones[zoneIndex]; const point = snapPoint(viewCenter()); @@ -929,15 +969,16 @@ function addSpawn() { function deleteSelected() { if (!state.selected) return; const parts = state.selected.split(":"); - 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)) { setStatus(`不能删除图块集 ${tileset.id}:它仍被 tile_rect 使用。`); return; } - state.map.tilesets.splice(Number(parts[1]), 1); } + pushUndo("删除对象"); + if (parts[0] === "collision") deleteCollision(Number(parts[1])); + if (parts[0] === "tileset") state.map.tilesets.splice(Number(parts[1]), 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); @@ -947,6 +988,54 @@ function deleteSelected() { refresh(); } +function saveAndDownloadMap() { + downloadMap(); + setStatus(`已保存导出文本并下载 ${state.map.id || "stage"}.map`); +} + +function undoLast() { + const entry = state.history.pop(); + if (!entry) return; + restoreSnapshot(entry); + setStatus(`已撤回:${entry.label}`); + refresh(); +} + +function pushUndo(label) { + state.history.push({ + label, + mapText: serializeMap(state.map), + selected: state.selected, + hitFilter: state.hitFilter, + }); + if (state.history.length > 80) state.history.shift(); +} + +function restoreSnapshot(entry) { + state.map = parseMap(entry.mapText); + state.selected = entry.selected; + state.hitFilter = entry.hitFilter || "all"; + if (state.selected && !getSelectedObject()) state.selected = null; +} + +function moveSelectedLayer(direction) { + const index = selectedLayerIndex(); + const nextIndex = index + direction; + if (index < 0 || nextIndex < 0 || nextIndex >= state.map.layers.length) return; + pushUndo(direction < 0 ? "图层上移" : "图层下移"); + const [layer] = state.map.layers.splice(index, 1); + state.map.layers.splice(nextIndex, 0, layer); + state.selected = `layer:${nextIndex}`; + refresh(); +} + +function selectedLayerIndex() { + if (!state.selected) return -1; + const parts = state.selected.split(":"); + if (parts[0] === "layer" || parts[0] === "rect" || parts[0] === "tile") return Number(parts[1]); + return -1; +} + function downloadMap() { const blob = new Blob([serializeMap(state.map)], { type: "text/plain" }); const url = URL.createObjectURL(blob); diff --git a/tools/map_editor/index.html b/tools/map_editor/index.html index c6c8b4b..2e9512e 100644 --- a/tools/map_editor/index.html +++ b/tools/map_editor/index.html @@ -13,19 +13,23 @@
编辑游戏运行时使用的 Frostbite2D .map 文本地图。