Improve map editor image previews
This commit is contained in:
@@ -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。
|
||||
|
||||
## 首版验收
|
||||
|
||||
完成时应达到:
|
||||
|
||||
@@ -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 图片预览。
|
||||
|
||||
@@ -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 颜色显示。
|
||||
- 暂未实现重做和多选。
|
||||
|
||||
+84
-4
@@ -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: [] });
|
||||
|
||||
Reference in New Issue
Block a user