Improve map editor image previews

This commit is contained in:
2026-06-10 10:17:25 +08:00
parent 7d0f115fcb
commit bc300f4ff9
4 changed files with 105 additions and 7 deletions
+9
View File
@@ -104,6 +104,8 @@ http://127.0.0.1:8787/tools/map_editor/
- 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。 - 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。
- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision` - 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`
- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。 - 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
- 从仓库根目录启动静态服务时,`.map``assets/...` 贴图路径会自动映射到 `game/assets/...` 并加载项目内已有图片预览。
- `rect_sprite_src` 图形支持在属性面板编辑源图裁切区域。
- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。 - 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
- 导出兼容 `StageMapLoader``.map` 文本。 - 导出兼容 `StageMapLoader``.map` 文本。
@@ -129,6 +131,13 @@ assets/stage/stage_01/props/<图片文件名>
实际运行游戏前,需要把对应图片素材放到该目录。编辑器内的图形-碰撞绑定是工具层能力,导出的 `.map` 仍保持运行时兼容格式,不额外写入私有字段。 实际运行游戏前,需要把对应图片素材放到该目录。编辑器内的图形-碰撞绑定是工具层能力,导出的 `.map` 仍保持运行时兼容格式,不额外写入私有字段。
项目内已有素材预览规则:
- `assets/...` 会映射到仓库内 `game/assets/...`
- `game/assets/...` 会直接映射到仓库同名路径。
- 如果文件存在,编辑器重新打开 `.map` 后也会自动预览。
- 如果文件不存在,编辑器使用颜色块 fallback。
## 首版验收 ## 首版验收
完成时应达到: 完成时应达到:
+6 -1
View File
@@ -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. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。 1. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。
@@ -205,7 +209,8 @@ spawn
- 不能直接覆盖本地源文件,只能下载 `.map` - 不能直接覆盖本地源文件,只能下载 `.map`
- 导入图片的真实文件仍需手动放入项目资源目录。 - 导入图片的真实文件仍需手动放入项目资源目录。
- 已导入图片的浏览器预览只在当前会话有效;重新打开 `.map` 后,如果没有重新选择图片,会显示 fallback 色块 - `.map` 中存在于 `game/assets/...``assets/...` 贴图路径会自动预览
- 如果导入图片后没有把真实文件放到 `game/assets/...` 对应路径,重新打开 `.map` 后会显示 fallback 色块。
- 暂未实现重做。 - 暂未实现重做。
- 暂未实现多选和批量移动。 - 暂未实现多选和批量移动。
- 暂未实现 tile palette 和 tileset 图片预览。 - 暂未实现 tile palette 和 tileset 图片预览。
+6 -2
View File
@@ -33,7 +33,9 @@ http://127.0.0.1:8787/tools/map_editor/
- 通过“编辑目标”过滤画布选择对象,默认“智能选择”把碰撞块当作上层覆盖;同位置对象第一次点选碰撞块,再点一次切到下层铺砖区或图形。 - 通过“编辑目标”过滤画布选择对象,默认“智能选择”把碰撞块当作上层覆盖;同位置对象第一次点选碰撞块,再点一次切到下层铺砖区或图形。
- 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。 - 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。
- 通过“导入图形”选择图片,自动生成一个 `rect_sprite` 图形对象和一个同尺寸 `collision` - 通过“导入图形”选择图片,自动生成一个 `rect_sprite` 图形对象和一个同尺寸 `collision`
- 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块 - 从仓库根目录启动静态服务时,`.map``assets/...` 贴图路径会自动映射到 `game/assets/...` 并显示预览
- 导入的图形会在浏览器内预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。
- 已有 `rect_sprite_src` 图形可以在属性面板调整源图裁切区域。
- 编辑选中对象的数值和文本属性。 - 编辑选中对象的数值和文本属性。
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。 - 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
- 支持撤回最近编辑操作,也可以使用 `Ctrl+Z` - 支持撤回最近编辑操作,也可以使用 `Ctrl+Z`
@@ -71,6 +73,7 @@ http://127.0.0.1:8787/tools/map_editor/
4. 拖动图形本体可以移动图形和绑定碰撞块。 4. 拖动图形本体可以移动图形和绑定碰撞块。
5. 拖动右下角蓝色手柄可以调整图形和绑定碰撞块尺寸。 5. 拖动右下角蓝色手柄可以调整图形和绑定碰撞块尺寸。
6. 也可以在右侧属性面板修改 `X``Y``W``H`、颜色和贴图路径。 6. 也可以在右侧属性面板修改 `X``Y``W``H`、颜色和贴图路径。
7. 如果图形来自 `rect_sprite_src`,属性面板还会显示 `源 X``源 Y``源 W``源 H`,用于调整图集裁切区域。
静态网页不能自动把图片复制进项目目录。导入后 `.map` 默认写入: 静态网页不能自动把图片复制进项目目录。导入后 `.map` 默认写入:
@@ -89,6 +92,7 @@ assets/stage/stage_01/props/<图片文件名>
## 当前限制 ## 当前限制
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。 - 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
- 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示 - 如果 `.map` 里的贴图路径指向 `assets/...` 且文件存在于 `game/assets/...`,重新打开后会自动预览图片
- 如果导入图片后没有把真实文件放入 `game/assets/...` 对应路径,重新打开后会回退为颜色块。
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。 - 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
- 暂未实现重做和多选。 - 暂未实现重做和多选。
+84 -4
View File
@@ -7,6 +7,8 @@ const state = {
gridSize: 10, gridSize: 10,
hitFilter: "all", hitFilter: "all",
imagePreviews: new Map(), imagePreviews: new Map(),
imageLoading: new Set(),
imageFailures: new Set(),
history: [], history: [],
dragging: null, dragging: null,
}; };
@@ -121,6 +123,7 @@ async function importGraphicAsset(event) {
const image = await loadImage(previewUrl); const image = await loadImage(previewUrl);
const texturePath = defaultGraphicTexturePath(file.name); const texturePath = defaultGraphicTexturePath(file.name);
state.imagePreviews.set(texturePath, image); state.imagePreviews.set(texturePath, image);
state.imageFailures.delete(texturePath);
pushUndo("导入图形"); pushUndo("导入图形");
addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64); addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64);
setStatus(`已导入图形 ${file.name},并自动创建同尺寸碰撞块。请把素材放到 ${texturePath}`); 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) { function loadMapText(text, status) {
try { try {
state.map = parseMap(text); state.map = parseMap(text);
state.selected = null; state.selected = null;
state.history = []; state.history = [];
state.imageFailures.clear();
preloadMapImages(state.map);
fitWorldToCanvas(); fitWorldToCanvas();
setStatus(status); setStatus(status);
refresh(); refresh();
@@ -489,6 +539,7 @@ function drawLayerRect(ctx, rect) {
ctx.strokeRect(rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height); ctx.strokeRect(rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height);
return; return;
} }
if (rect.texture) queueImagePreview(rect.texture);
drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794"); drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794");
} }
@@ -1108,7 +1159,7 @@ function rectFields(rect) {
} }
function layerRectFields(item) { function layerRectFields(item) {
return [ const fields = [
numberField("X", () => item.bounds.x, (v) => { numberField("X", () => item.bounds.x, (v) => {
const next = Number.isFinite(v) ? v : item.bounds.x; const next = Number.isFinite(v) ? v : item.bounds.x;
moveLinkedCollision(item, next - item.bounds.x, 0); moveLinkedCollision(item, next - item.bounds.x, 0);
@@ -1129,11 +1180,27 @@ function layerRectFields(item) {
}), }),
textField("贴图路径", () => item.texture || "", (v) => { textField("贴图路径", () => item.texture || "", (v) => {
const next = String(v || "").trim(); const next = String(v || "").trim();
if (next) item.texture = next; if (next) {
else delete item.texture; 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) { function pointFields(point) {
@@ -1161,6 +1228,19 @@ function textField(label, get, set, options = null) {
return { label, type: "text", options, get, set }; 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() { function ensureLevelVisualLayer() {
if (!state.map.layers.some((layer) => layer.role === "LevelVisual")) { if (!state.map.layers.some((layer) => layer.role === "LevelVisual")) {
state.map.layers.push({ id: "level_visual", role: "LevelVisual", parallax: 1, rects: [], tileRects: [] }); state.map.layers.push({ id: "level_visual", role: "LevelVisual", parallax: 1, rects: [], tileRects: [] });