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 @@ +