Improve map editor graphic imports
This commit is contained in:
@@ -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。
|
- Canvas 可视化 world、camera、player spawn、collision、rect、tile_rect、battle zone、spawn。
|
||||||
- 对象列表选择和画布点击选择。
|
- 对象列表选择和画布点击选择。
|
||||||
- 网格吸附拖动对象。
|
- 网格吸附拖动对象。
|
||||||
|
- 通过“编辑目标”过滤画布命中目标,默认优先编辑 `collision`。
|
||||||
|
- 矩形对象支持右下角手柄缩放。
|
||||||
- 属性面板编辑选中对象的数值或文本。
|
- 属性面板编辑选中对象的数值或文本。
|
||||||
- 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。
|
- 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。
|
||||||
|
- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。
|
||||||
|
- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
|
||||||
|
- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
|
||||||
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
|
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
|
||||||
|
|
||||||
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过 Download 导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
|
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过 Download 导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
|
||||||
@@ -112,6 +117,14 @@ http://127.0.0.1:8787/tools/map_editor/
|
|||||||
|
|
||||||
素材路径仍由 `.map` 和 `game/assets/stage/stage_01/ASSETS.md` 记录。
|
素材路径仍由 `.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。
|
2. 已完成:能看到地面、平台、battle zone、spawn point。
|
||||||
3. 已完成:能新增/移动/删除一个 `collision`。
|
3. 已完成:能新增/移动/删除一个 `collision`。
|
||||||
4. 已完成:能新增/移动/删除一个 `tile_rect`。
|
4. 已完成:能新增/移动/删除一个 `tile_rect`。
|
||||||
5. 已完成:能导出 `.map`。
|
5. 已完成:能导入图形,生成 `rect_sprite` 和同尺寸 `collision`。
|
||||||
6. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
|
6. 已完成:导入图形支持移动、缩放和同步调整绑定碰撞块。
|
||||||
7. 待后续地图修改后复测:Windows 构建通过,短启动通过。
|
7. 已完成:能导出 `.map`。
|
||||||
8. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
|
8. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
|
||||||
|
9. 待后续地图修改后复测:Windows 构建通过,短启动通过。
|
||||||
|
10. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
|
||||||
|
|
||||||
## 后续增强
|
## 后续增强
|
||||||
|
|
||||||
- tile palette。
|
- tile palette。
|
||||||
- tileset 图片预览。
|
- tileset 图片预览。
|
||||||
|
- 保存接口:部署到服务器后,把 `.map` 文本写回服务端文件或数据库。
|
||||||
- battle zone 清场出口配置。
|
- battle zone 清场出口配置。
|
||||||
- props 图层编辑。
|
- props 图层编辑。
|
||||||
- 自动检查视觉 tile 与碰撞误差。
|
- 自动检查视觉 tile 与碰撞误差。
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ http://127.0.0.1:8787/tools/map_editor/
|
|||||||
- 在画布上拖动对象,支持网格吸附。
|
- 在画布上拖动对象,支持网格吸附。
|
||||||
- 通过“编辑目标”过滤画布选择对象,默认优先编辑碰撞块,避免点到重叠的铺砖区。
|
- 通过“编辑目标”过滤画布选择对象,默认优先编辑碰撞块,避免点到重叠的铺砖区。
|
||||||
- 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。
|
- 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。
|
||||||
|
- 通过“导入图形”选择图片,自动生成一个 `rect_sprite` 图形对象和一个同尺寸 `collision`。
|
||||||
|
- 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。
|
||||||
- 编辑选中对象的数值和文本属性。
|
- 编辑选中对象的数值和文本属性。
|
||||||
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
|
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
|
||||||
- 下载兼容 `StageMapLoader` 的 `.map` 文本。
|
- 下载兼容 `StageMapLoader` 的 `.map` 文本。
|
||||||
@@ -38,8 +40,26 @@ http://127.0.0.1:8787/tools/map_editor/
|
|||||||
4. 拖动右下角蓝色手柄可以调整宽高。
|
4. 拖动右下角蓝色手柄可以调整宽高。
|
||||||
5. 也可以在右侧属性面板直接修改 `X`、`Y`、`W`、`H`。
|
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`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
|
||||||
|
- 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示。
|
||||||
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
|
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
|
||||||
- 暂未实现撤销/重做和多选。
|
- 暂未实现撤销/重做和多选。
|
||||||
|
|||||||
+253
-5
@@ -6,6 +6,7 @@ const state = {
|
|||||||
view: { x: -80, y: -40, scale: 0.35 },
|
view: { x: -80, y: -40, scale: 0.35 },
|
||||||
gridSize: 10,
|
gridSize: 10,
|
||||||
hitFilter: "collision",
|
hitFilter: "collision",
|
||||||
|
imagePreviews: new Map(),
|
||||||
dragging: null,
|
dragging: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
function bindElements() {
|
function bindElements() {
|
||||||
for (const id of [
|
for (const id of [
|
||||||
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
|
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
|
||||||
|
"graphicFileInput",
|
||||||
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
||||||
"hitFilterInput",
|
"hitFilterInput",
|
||||||
"objectList", "propertyPanel", "selectionSummary", "deleteButton",
|
"objectList", "propertyPanel", "selectionSummary", "deleteButton",
|
||||||
@@ -36,6 +38,7 @@ function bindEvents() {
|
|||||||
window.addEventListener("resize", resizeCanvas);
|
window.addEventListener("resize", resizeCanvas);
|
||||||
elements.loadSampleButton.addEventListener("click", loadSampleMap);
|
elements.loadSampleButton.addEventListener("click", loadSampleMap);
|
||||||
elements.fileInput.addEventListener("change", openFile);
|
elements.fileInput.addEventListener("change", openFile);
|
||||||
|
elements.graphicFileInput.addEventListener("change", importGraphicAsset);
|
||||||
elements.downloadButton.addEventListener("click", downloadMap);
|
elements.downloadButton.addEventListener("click", downloadMap);
|
||||||
elements.deleteButton.addEventListener("click", deleteSelected);
|
elements.deleteButton.addEventListener("click", deleteSelected);
|
||||||
|
|
||||||
@@ -96,6 +99,32 @@ function openFile(event) {
|
|||||||
reader.readAsText(file);
|
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) {
|
function loadMapText(text, status) {
|
||||||
try {
|
try {
|
||||||
state.map = parseMap(text);
|
state.map = parseMap(text);
|
||||||
@@ -214,6 +243,7 @@ function parseMap(text) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rebuildGraphicCollisionLinks(map);
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,11 +428,40 @@ function drawGrid(ctx) {
|
|||||||
|
|
||||||
function drawLayers(ctx) {
|
function drawLayers(ctx) {
|
||||||
for (const layer of sortLayers(state.map.layers)) {
|
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);
|
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) {
|
function drawTileRect(ctx, tileRect) {
|
||||||
const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset);
|
const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset);
|
||||||
const tw = tileset ? tileset.tileWidth : 10;
|
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.collisions.forEach((rect, index) => objects.push(objectForRect(`collision:${index}`, `碰撞块 ${index + 1}`, rect, true, null, "collision")));
|
||||||
state.map.layers.forEach((layer, layerIndex) => {
|
state.map.layers.forEach((layer, layerIndex) => {
|
||||||
objects.push(objectForLayer(`layer:${layerIndex}`, `图层 ${layer.id}`, layer, true));
|
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)));
|
layer.tileRects.forEach((tileRect, tileIndex) => objects.push(objectForTileRect(`tile:${layerIndex}:${tileIndex}`, `${layer.id} 铺砖区 ${tileIndex + 1}`, tileRect, true)));
|
||||||
});
|
});
|
||||||
state.map.battleZones.forEach((zone, zoneIndex) => {
|
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) {
|
function objectForPoint(id, title, point, deletable = false) {
|
||||||
return {
|
return {
|
||||||
key: id,
|
key: id,
|
||||||
@@ -625,6 +706,11 @@ function onCanvasMouseDown(event) {
|
|||||||
setCanvasCursor("nwse-resize");
|
setCanvasCursor("nwse-resize");
|
||||||
return;
|
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);
|
const object = hitTest(world.x, world.y);
|
||||||
if (object) {
|
if (object) {
|
||||||
@@ -691,6 +777,12 @@ function hitTest(x, y) {
|
|||||||
return null;
|
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) {
|
function isHitFilterEnabled(object) {
|
||||||
if (state.hitFilter === "all") return true;
|
if (state.hitFilter === "all") return true;
|
||||||
if (state.hitFilter === "zone") return object.type === "zone";
|
if (state.hitFilter === "zone") return object.type === "zone";
|
||||||
@@ -704,6 +796,10 @@ function updateCanvasCursor(event) {
|
|||||||
setCanvasCursor("nwse-resize");
|
setCanvasCursor("nwse-resize");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (selected && selected.move && isInsideBounds(selected, world.x, world.y)) {
|
||||||
|
setCanvasCursor("move");
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (hitTest(world.x, world.y)) {
|
if (hitTest(world.x, world.y)) {
|
||||||
setCanvasCursor("move");
|
setCanvasCursor("move");
|
||||||
return;
|
return;
|
||||||
@@ -722,6 +818,30 @@ function addCollision() {
|
|||||||
refresh();
|
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() {
|
function addTileset() {
|
||||||
const index = state.map.tilesets.length + 1;
|
const index = state.map.tilesets.length + 1;
|
||||||
state.map.tilesets.push({
|
state.map.tilesets.push({
|
||||||
@@ -786,7 +906,7 @@ function addSpawn() {
|
|||||||
function deleteSelected() {
|
function deleteSelected() {
|
||||||
if (!state.selected) return;
|
if (!state.selected) return;
|
||||||
const parts = state.selected.split(":");
|
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") {
|
if (parts[0] === "tileset") {
|
||||||
const tileset = state.map.tilesets[Number(parts[1])];
|
const tileset = state.map.tilesets[Number(parts[1])];
|
||||||
if (tileset && isTilesetUsed(tileset.id)) {
|
if (tileset && isTilesetUsed(tileset.id)) {
|
||||||
@@ -795,8 +915,8 @@ function deleteSelected() {
|
|||||||
}
|
}
|
||||||
state.map.tilesets.splice(Number(parts[1]), 1);
|
state.map.tilesets.splice(Number(parts[1]), 1);
|
||||||
}
|
}
|
||||||
if (parts[0] === "layer") state.map.layers.splice(Number(parts[1]), 1);
|
if (parts[0] === "layer") deleteLayer(Number(parts[1]));
|
||||||
if (parts[0] === "rect") state.map.layers[Number(parts[1])].rects.splice(Number(parts[2]), 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] === "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] === "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);
|
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) {
|
function pointFields(point) {
|
||||||
return [
|
return [
|
||||||
numberField("X", () => point.x, (v) => { point.x = v; }),
|
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) {
|
function sortLayers(layers) {
|
||||||
return [...layers].sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role));
|
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));
|
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() {
|
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`;
|
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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,10 @@
|
|||||||
<button id="addLayerButton" type="button">图层</button>
|
<button id="addLayerButton" type="button">图层</button>
|
||||||
<button id="addCollisionButton" type="button">碰撞块</button>
|
<button id="addCollisionButton" type="button">碰撞块</button>
|
||||||
<button id="addTileRectButton" type="button">铺砖区</button>
|
<button id="addTileRectButton" type="button">铺砖区</button>
|
||||||
|
<label class="file-button">
|
||||||
|
导入图形
|
||||||
|
<input id="graphicFileInput" type="file" accept="image/*">
|
||||||
|
</label>
|
||||||
<button id="addBattleZoneButton" type="button">战斗区</button>
|
<button id="addBattleZoneButton" type="button">战斗区</button>
|
||||||
<button id="addSpawnButton" type="button">出生点</button>
|
<button id="addSpawnButton" type="button">出生点</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user