From bc300f4ff976500e975742beac1f2e93a641add8 Mon Sep 17 00:00:00 2001 From: 1165565692-lang <1165565692@qq.com> Date: Wed, 10 Jun 2026 10:17:25 +0800 Subject: [PATCH] Improve map editor image previews --- game/docs/modules/map_editor.md | 9 ++++ tools/map_editor/DOCUMENTATION.md | 7 ++- tools/map_editor/README.md | 8 ++- tools/map_editor/app.js | 88 +++++++++++++++++++++++++++++-- 4 files changed, 105 insertions(+), 7 deletions(-) diff --git a/game/docs/modules/map_editor.md b/game/docs/modules/map_editor.md index d1b560b..6a6965b 100644 --- a/game/docs/modules/map_editor.md +++ b/game/docs/modules/map_editor.md @@ -104,6 +104,8 @@ http://127.0.0.1:8787/tools/map_editor/ - 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。 - 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。 - 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。 +- 从仓库根目录启动静态服务时,`.map` 内 `assets/...` 贴图路径会自动映射到 `game/assets/...` 并加载项目内已有图片预览。 +- `rect_sprite_src` 图形支持在属性面板编辑源图裁切区域。 - 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。 - 导出兼容 `StageMapLoader` 的 `.map` 文本。 @@ -129,6 +131,13 @@ assets/stage/stage_01/props/<图片文件名> 实际运行游戏前,需要把对应图片素材放到该目录。编辑器内的图形-碰撞绑定是工具层能力,导出的 `.map` 仍保持运行时兼容格式,不额外写入私有字段。 +项目内已有素材预览规则: + +- `assets/...` 会映射到仓库内 `game/assets/...`。 +- `game/assets/...` 会直接映射到仓库同名路径。 +- 如果文件存在,编辑器重新打开 `.map` 后也会自动预览。 +- 如果文件不存在,编辑器使用颜色块 fallback。 + ## 首版验收 完成时应达到: diff --git a/tools/map_editor/DOCUMENTATION.md b/tools/map_editor/DOCUMENTATION.md index 4109150..3376446 100644 --- a/tools/map_editor/DOCUMENTATION.md +++ b/tools/map_editor/DOCUMENTATION.md @@ -133,6 +133,10 @@ assets/stage/stage_01/props/<图片文件名> 静态网页不能自动复制图片到项目目录。实际运行游戏前,需要手动把对应图片素材放到该路径。 +如果从仓库根目录启动静态服务,编辑器会把 `.map` 里的 `assets/...` 贴图路径映射到 `game/assets/...` 并自动加载预览。也就是说,项目内已经存在的 Mossy Cavern 等 PNG 会在重新打开地图后直接显示;只有文件没有放到对应目录时才会回退为颜色块。 + +`rect_sprite_src` 图形会在属性面板显示 `源 X`、`源 Y`、`源 W`、`源 H`,可以直接调整大图集的裁切区域。普通 `rect_sprite` 仍按整张图绘制,不会因为打开属性面板而自动变成 `rect_sprite_src`。 + ### 调整图层顺序 1. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。 @@ -205,7 +209,8 @@ spawn - 不能直接覆盖本地源文件,只能下载 `.map`。 - 导入图片的真实文件仍需手动放入项目资源目录。 -- 已导入图片的浏览器预览只在当前会话有效;重新打开 `.map` 后,如果没有重新选择图片,会显示 fallback 色块。 +- `.map` 中存在于 `game/assets/...` 的 `assets/...` 贴图路径会自动预览。 +- 如果导入图片后没有把真实文件放到 `game/assets/...` 对应路径,重新打开 `.map` 后会显示 fallback 色块。 - 暂未实现重做。 - 暂未实现多选和批量移动。 - 暂未实现 tile palette 和 tileset 图片预览。 diff --git a/tools/map_editor/README.md b/tools/map_editor/README.md index 23056c8..8f0fde6 100644 --- a/tools/map_editor/README.md +++ b/tools/map_editor/README.md @@ -33,7 +33,9 @@ http://127.0.0.1:8787/tools/map_editor/ - 通过“编辑目标”过滤画布选择对象,默认“智能选择”把碰撞块当作上层覆盖;同位置对象第一次点选碰撞块,再点一次切到下层铺砖区或图形。 - 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。 - 通过“导入图形”选择图片,自动生成一个 `rect_sprite` 图形对象和一个同尺寸 `collision`。 -- 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。 +- 从仓库根目录启动静态服务时,`.map` 内 `assets/...` 贴图路径会自动映射到 `game/assets/...` 并显示预览。 +- 导入的图形会在浏览器内预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。 +- 已有 `rect_sprite_src` 图形可以在属性面板调整源图裁切区域。 - 编辑选中对象的数值和文本属性。 - 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。 - 支持撤回最近编辑操作,也可以使用 `Ctrl+Z`。 @@ -71,6 +73,7 @@ http://127.0.0.1:8787/tools/map_editor/ 4. 拖动图形本体可以移动图形和绑定碰撞块。 5. 拖动右下角蓝色手柄可以调整图形和绑定碰撞块尺寸。 6. 也可以在右侧属性面板修改 `X`、`Y`、`W`、`H`、颜色和贴图路径。 +7. 如果图形来自 `rect_sprite_src`,属性面板还会显示 `源 X`、`源 Y`、`源 W`、`源 H`,用于调整图集裁切区域。 静态网页不能自动把图片复制进项目目录。导入后 `.map` 默认写入: @@ -89,6 +92,7 @@ assets/stage/stage_01/props/<图片文件名> ## 当前限制 - 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。 -- 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示。 +- 如果 `.map` 里的贴图路径指向 `assets/...` 且文件存在于 `game/assets/...`,重新打开后会自动预览图片。 +- 如果导入图片后没有把真实文件放入 `game/assets/...` 对应路径,重新打开后会回退为颜色块。 - 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。 - 暂未实现重做和多选。 diff --git a/tools/map_editor/app.js b/tools/map_editor/app.js index e105c59..83425a9 100644 --- a/tools/map_editor/app.js +++ b/tools/map_editor/app.js @@ -7,6 +7,8 @@ const state = { gridSize: 10, hitFilter: "all", imagePreviews: new Map(), + imageLoading: new Set(), + imageFailures: new Set(), history: [], dragging: null, }; @@ -121,6 +123,7 @@ async function importGraphicAsset(event) { const image = await loadImage(previewUrl); const texturePath = defaultGraphicTexturePath(file.name); state.imagePreviews.set(texturePath, image); + state.imageFailures.delete(texturePath); pushUndo("导入图形"); addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64); setStatus(`已导入图形 ${file.name},并自动创建同尺寸碰撞块。请把素材放到 ${texturePath}`); @@ -140,11 +143,58 @@ function loadImage(url) { }); } +function preloadMapImages(map) { + for (const layer of map.layers) { + for (const rect of layer.rects) { + if (rect.texture) queueImagePreview(rect.texture); + } + } +} + +async function queueImagePreview(texturePath) { + if (!texturePath || + state.imagePreviews.has(texturePath) || + state.imageLoading.has(texturePath) || + state.imageFailures.has(texturePath)) { + return; + } + + const url = previewUrlForTexture(texturePath); + if (!url) { + state.imageFailures.add(texturePath); + return; + } + + state.imageLoading.add(texturePath); + try { + const image = await loadImage(url); + state.imagePreviews.set(texturePath, image); + state.imageFailures.delete(texturePath); + refresh(); + } catch (error) { + state.imageFailures.add(texturePath); + setStatus(`贴图预览加载失败:${texturePath}`); + } finally { + state.imageLoading.delete(texturePath); + } +} + +function previewUrlForTexture(texturePath) { + const clean = String(texturePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); + if (!clean) return ""; + if (/^(blob:|data:|https?:\/\/)/i.test(clean)) return clean; + if (clean.startsWith("assets/")) return `../../game/${clean}`; + if (clean.startsWith("game/assets/")) return `../../${clean}`; + return clean; +} + function loadMapText(text, status) { try { state.map = parseMap(text); state.selected = null; state.history = []; + state.imageFailures.clear(); + preloadMapImages(state.map); fitWorldToCanvas(); setStatus(status); refresh(); @@ -489,6 +539,7 @@ function drawLayerRect(ctx, rect) { ctx.strokeRect(rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height); return; } + if (rect.texture) queueImagePreview(rect.texture); drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794"); } @@ -1108,7 +1159,7 @@ function rectFields(rect) { } function layerRectFields(item) { - return [ + const fields = [ numberField("X", () => item.bounds.x, (v) => { const next = Number.isFinite(v) ? v : item.bounds.x; moveLinkedCollision(item, next - item.bounds.x, 0); @@ -1129,11 +1180,27 @@ function layerRectFields(item) { }), textField("贴图路径", () => item.texture || "", (v) => { const next = String(v || "").trim(); - if (next) item.texture = next; - else delete item.texture; + if (next) { + item.texture = next; + state.imageFailures.delete(next); + queueImagePreview(next); + } else { + delete item.texture; + } }), - ...colorFields(item.color, true), ]; + + if (item.texture && item.source) { + fields.push( + numberField("源 X", () => sourceRectFor(item).x, (v) => { sourceRectFor(item).x = Math.max(0, v); }), + numberField("源 Y", () => sourceRectFor(item).y, (v) => { sourceRectFor(item).y = Math.max(0, v); }), + numberField("源 W", () => sourceRectFor(item).width, (v) => { sourceRectFor(item).width = Math.max(1, v); }), + numberField("源 H", () => sourceRectFor(item).height, (v) => { sourceRectFor(item).height = Math.max(1, v); }) + ); + } + + fields.push(...colorFields(item.color, true)); + return fields; } function pointFields(point) { @@ -1161,6 +1228,19 @@ function textField(label, get, set, options = null) { return { label, type: "text", options, get, set }; } +function sourceRectFor(item) { + if (!item.source) { + const image = item.texture ? state.imagePreviews.get(item.texture) : null; + item.source = { + x: 0, + y: 0, + width: image ? (image.naturalWidth || image.width || Math.max(1, item.bounds.width)) : Math.max(1, item.bounds.width), + height: image ? (image.naturalHeight || image.height || Math.max(1, item.bounds.height)) : Math.max(1, item.bounds.height), + }; + } + return item.source; +} + function ensureLevelVisualLayer() { if (!state.map.layers.some((layer) => layer.role === "LevelVisual")) { state.map.layers.push({ id: "level_visual", role: "LevelVisual", parallax: 1, rects: [], tileRects: [] });