Improve map editor workflow controls
This commit is contained in:
@@ -29,7 +29,7 @@
|
|||||||
|
|
||||||
### 可以暂缓
|
### 可以暂缓
|
||||||
|
|
||||||
- 撤销/重做。
|
- 重做。
|
||||||
- 多选批量操作。
|
- 多选批量操作。
|
||||||
- 自动铺边缘 tile。
|
- 自动铺边缘 tile。
|
||||||
- 地图对象属性面板的高级校验。
|
- 地图对象属性面板的高级校验。
|
||||||
@@ -98,12 +98,16 @@ http://127.0.0.1:8787/tools/map_editor/
|
|||||||
- 矩形对象支持右下角手柄缩放。
|
- 矩形对象支持右下角手柄缩放。
|
||||||
- 属性面板编辑选中对象的数值或文本。
|
- 属性面板编辑选中对象的数值或文本。
|
||||||
- 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。
|
- 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。
|
||||||
|
- 支持撤回最近编辑操作,包含新增、删除、拖动、缩放、属性修改、导入图形和图层移动。
|
||||||
|
- 支持 `Ctrl+Z` 快捷撤回。
|
||||||
|
- 支持保存并下载 `.map` 文本。
|
||||||
|
- 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。
|
||||||
- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。
|
- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。
|
||||||
- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
|
- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
|
||||||
- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
|
- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
|
||||||
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
|
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
|
||||||
|
|
||||||
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过 Download 导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
|
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
|
||||||
|
|
||||||
## 数据边界
|
## 数据边界
|
||||||
|
|
||||||
@@ -136,10 +140,12 @@ assets/stage/stage_01/props/<图片文件名>
|
|||||||
5. 已完成:`tile_rect` 与 `collision` 重叠时,默认智能选择可直接选中并移动铺砖区。
|
5. 已完成:`tile_rect` 与 `collision` 重叠时,默认智能选择可直接选中并移动铺砖区。
|
||||||
6. 已完成:能导入图形,生成 `rect_sprite` 和同尺寸 `collision`。
|
6. 已完成:能导入图形,生成 `rect_sprite` 和同尺寸 `collision`。
|
||||||
7. 已完成:导入图形支持移动、缩放和同步调整绑定碰撞块。
|
7. 已完成:导入图形支持移动、缩放和同步调整绑定碰撞块。
|
||||||
8. 已完成:能导出 `.map`。
|
8. 已完成:能撤回最近编辑操作。
|
||||||
9. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
|
9. 已完成:能保存并下载 `.map`。
|
||||||
10. 待后续地图修改后复测:Windows 构建通过,短启动通过。
|
10. 已完成:能上移/下移图层,并按当前图层顺序预览和导出。
|
||||||
11. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
|
11. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
|
||||||
|
12. 待后续地图修改后复测:Windows 构建通过,短启动通过。
|
||||||
|
13. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
|
||||||
|
|
||||||
## 后续增强
|
## 后续增强
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,16 @@ http://127.0.0.1:8787/tools/map_editor/
|
|||||||
- 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。
|
- 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。
|
||||||
- 编辑选中对象的数值和文本属性。
|
- 编辑选中对象的数值和文本属性。
|
||||||
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
|
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
|
||||||
- 下载兼容 `StageMapLoader` 的 `.map` 文本。
|
- 支持撤回最近编辑操作,也可以使用 `Ctrl+Z`。
|
||||||
|
- 选中图层或图层内对象后,可以把对应图层上移或下移。
|
||||||
|
- 保存并下载兼容 `StageMapLoader` 的 `.map` 文本。
|
||||||
|
|
||||||
|
## 保存和撤回
|
||||||
|
|
||||||
|
- “保存并下载”会把右侧导出文本下载成 `<地图ID>.map`。
|
||||||
|
- 浏览器安全限制下,静态网页不能直接覆盖本地项目文件;下载后仍需要替换项目里的地图文件。
|
||||||
|
- “撤回”会恢复最近一次地图编辑操作,包括新增、删除、拖动、缩放、属性修改、导入图形和图层移动。
|
||||||
|
- 打开或重新加载 `.map` 会清空撤回历史。
|
||||||
|
|
||||||
## 调整碰撞块
|
## 调整碰撞块
|
||||||
|
|
||||||
@@ -65,9 +74,15 @@ assets/stage/stage_01/props/<图片文件名>
|
|||||||
|
|
||||||
实际运行游戏前,需要把图片素材放到这个路径下。
|
实际运行游戏前,需要把图片素材放到这个路径下。
|
||||||
|
|
||||||
|
## 调整图层顺序
|
||||||
|
|
||||||
|
1. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。
|
||||||
|
2. 点击右侧“图层上移”或“图层下移”。
|
||||||
|
3. 编辑器会按当前图层顺序预览和导出 `.map`。
|
||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
|
|
||||||
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
|
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
|
||||||
- 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示。
|
- 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示。
|
||||||
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
|
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
|
||||||
- 暂未实现撤销/重做和多选。
|
- 暂未实现重做和多选。
|
||||||
|
|||||||
+98
-9
@@ -7,6 +7,7 @@ const state = {
|
|||||||
gridSize: 10,
|
gridSize: 10,
|
||||||
hitFilter: "all",
|
hitFilter: "all",
|
||||||
imagePreviews: new Map(),
|
imagePreviews: new Map(),
|
||||||
|
history: [],
|
||||||
dragging: null,
|
dragging: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,7 +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", "undoButton",
|
||||||
"graphicFileInput",
|
"graphicFileInput",
|
||||||
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
||||||
"hitFilterInput",
|
"hitFilterInput",
|
||||||
@@ -29,6 +30,7 @@ function bindElements() {
|
|||||||
"tilesetList", "outputText", "statusText", "viewText",
|
"tilesetList", "outputText", "statusText", "viewText",
|
||||||
"addTilesetButton", "addLayerButton", "addCollisionButton",
|
"addTilesetButton", "addLayerButton", "addCollisionButton",
|
||||||
"addTileRectButton", "addBattleZoneButton", "addSpawnButton",
|
"addTileRectButton", "addBattleZoneButton", "addSpawnButton",
|
||||||
|
"moveLayerUpButton", "moveLayerDownButton",
|
||||||
]) {
|
]) {
|
||||||
elements[id] = document.getElementById(id);
|
elements[id] = document.getElementById(id);
|
||||||
}
|
}
|
||||||
@@ -39,18 +41,30 @@ function bindEvents() {
|
|||||||
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.graphicFileInput.addEventListener("change", importGraphicAsset);
|
||||||
elements.downloadButton.addEventListener("click", downloadMap);
|
elements.downloadButton.addEventListener("click", saveAndDownloadMap);
|
||||||
|
elements.undoButton.addEventListener("click", undoLast);
|
||||||
elements.deleteButton.addEventListener("click", deleteSelected);
|
elements.deleteButton.addEventListener("click", deleteSelected);
|
||||||
|
elements.moveLayerUpButton.addEventListener("click", () => moveSelectedLayer(-1));
|
||||||
|
elements.moveLayerDownButton.addEventListener("click", () => moveSelectedLayer(1));
|
||||||
|
window.addEventListener("keydown", (event) => {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") {
|
||||||
|
event.preventDefault();
|
||||||
|
undoLast();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
elements.mapIdInput.addEventListener("change", () => {
|
elements.mapIdInput.addEventListener("change", () => {
|
||||||
|
pushUndo("修改地图 ID");
|
||||||
state.map.id = elements.mapIdInput.value.trim() || "stage";
|
state.map.id = elements.mapIdInput.value.trim() || "stage";
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
elements.worldWidthInput.addEventListener("change", () => {
|
elements.worldWidthInput.addEventListener("change", () => {
|
||||||
|
pushUndo("修改世界宽度");
|
||||||
state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width);
|
state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width);
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
elements.worldHeightInput.addEventListener("change", () => {
|
elements.worldHeightInput.addEventListener("change", () => {
|
||||||
|
pushUndo("修改世界高度");
|
||||||
state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height);
|
state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height);
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
@@ -107,6 +121,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);
|
||||||
|
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}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -129,6 +144,7 @@ function loadMapText(text, status) {
|
|||||||
try {
|
try {
|
||||||
state.map = parseMap(text);
|
state.map = parseMap(text);
|
||||||
state.selected = null;
|
state.selected = null;
|
||||||
|
state.history = [];
|
||||||
fitWorldToCanvas();
|
fitWorldToCanvas();
|
||||||
setStatus(status);
|
setStatus(status);
|
||||||
refresh();
|
refresh();
|
||||||
@@ -263,7 +279,7 @@ function serializeMap(map) {
|
|||||||
}
|
}
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
for (const layer of sortLayers(map.layers)) {
|
for (const layer of map.layers) {
|
||||||
lines.push(`layer ${layer.id} ${layer.role} ${fmt(layer.parallax)}`);
|
lines.push(`layer ${layer.id} ${layer.role} ${fmt(layer.parallax)}`);
|
||||||
for (const rect of layer.rects) {
|
for (const rect of layer.rects) {
|
||||||
if (rect.texture && rect.source) {
|
if (rect.texture && rect.source) {
|
||||||
@@ -298,6 +314,8 @@ function refresh() {
|
|||||||
elements.gridSizeInput.value = state.gridSize;
|
elements.gridSizeInput.value = state.gridSize;
|
||||||
elements.hitFilterInput.value = state.hitFilter;
|
elements.hitFilterInput.value = state.hitFilter;
|
||||||
elements.outputText.value = serializeMap(state.map);
|
elements.outputText.value = serializeMap(state.map);
|
||||||
|
elements.undoButton.disabled = state.history.length === 0;
|
||||||
|
elements.undoButton.textContent = state.history.length ? `撤回 ${state.history.length}` : "撤回";
|
||||||
renderTilesets();
|
renderTilesets();
|
||||||
renderObjectList();
|
renderObjectList();
|
||||||
renderPropertyPanel();
|
renderPropertyPanel();
|
||||||
@@ -342,11 +360,14 @@ function renderPropertyPanel() {
|
|||||||
if (!object) {
|
if (!object) {
|
||||||
elements.selectionSummary.textContent = "未选择对象。";
|
elements.selectionSummary.textContent = "未选择对象。";
|
||||||
elements.deleteButton.disabled = true;
|
elements.deleteButton.disabled = true;
|
||||||
|
elements.moveLayerUpButton.disabled = true;
|
||||||
|
elements.moveLayerDownButton.disabled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
elements.selectionSummary.textContent = `${object.title} | ${object.meta}`;
|
elements.selectionSummary.textContent = `${object.title} | ${object.meta}`;
|
||||||
elements.deleteButton.disabled = !object.deletable;
|
elements.deleteButton.disabled = !object.deletable;
|
||||||
|
updateLayerMoveButtons();
|
||||||
const fields = object.fields();
|
const fields = object.fields();
|
||||||
for (const field of fields) {
|
for (const field of fields) {
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
@@ -367,7 +388,10 @@ function renderPropertyPanel() {
|
|||||||
}
|
}
|
||||||
input.value = field.get();
|
input.value = field.get();
|
||||||
input.addEventListener("change", () => {
|
input.addEventListener("change", () => {
|
||||||
field.set(input.type === "number" ? Number(input.value) : input.value);
|
const nextValue = input.type === "number" ? Number(input.value) : input.value;
|
||||||
|
if (String(field.get()) === String(nextValue)) return;
|
||||||
|
pushUndo(`修改${field.label}`);
|
||||||
|
field.set(nextValue);
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
label.appendChild(input);
|
label.appendChild(input);
|
||||||
@@ -375,6 +399,12 @@ function renderPropertyPanel() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateLayerMoveButtons() {
|
||||||
|
const layerIndex = selectedLayerIndex();
|
||||||
|
elements.moveLayerUpButton.disabled = layerIndex <= 0;
|
||||||
|
elements.moveLayerDownButton.disabled = layerIndex < 0 || layerIndex >= state.map.layers.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
function drawCanvas() {
|
function drawCanvas() {
|
||||||
const canvas = elements.mapCanvas;
|
const canvas = elements.mapCanvas;
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
@@ -427,7 +457,7 @@ function drawGrid(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function drawLayers(ctx) {
|
function drawLayers(ctx) {
|
||||||
for (const layer of sortLayers(state.map.layers)) {
|
for (const layer of state.map.layers) {
|
||||||
for (const rect of layer.rects) drawLayerRect(ctx, rect);
|
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);
|
||||||
}
|
}
|
||||||
@@ -741,6 +771,10 @@ function onCanvasMouseMove(event) {
|
|||||||
const dx = current.x - state.dragging.last.x;
|
const dx = current.x - state.dragging.last.x;
|
||||||
const dy = current.y - state.dragging.last.y;
|
const dy = current.y - state.dragging.last.y;
|
||||||
if (dx || dy) {
|
if (dx || dy) {
|
||||||
|
if (!state.dragging.undoPushed) {
|
||||||
|
pushUndo(state.dragging.resize ? "调整对象尺寸" : "移动对象");
|
||||||
|
state.dragging.undoPushed = true;
|
||||||
|
}
|
||||||
if (state.dragging.resize && object.resize) {
|
if (state.dragging.resize && object.resize) {
|
||||||
object.resize(dx, dy);
|
object.resize(dx, dy);
|
||||||
} else if (object.move) {
|
} else if (object.move) {
|
||||||
@@ -833,6 +867,7 @@ function setCanvasCursor(cursor) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addCollision() {
|
function addCollision() {
|
||||||
|
pushUndo("新增碰撞块");
|
||||||
const point = snapPoint(viewCenter());
|
const point = snapPoint(viewCenter());
|
||||||
state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 });
|
state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 });
|
||||||
state.selected = `collision:${state.map.collisions.length - 1}`;
|
state.selected = `collision:${state.map.collisions.length - 1}`;
|
||||||
@@ -865,6 +900,7 @@ function addGraphicSprite(texturePath, width, height) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addTileset() {
|
function addTileset() {
|
||||||
|
pushUndo("新增图块集");
|
||||||
const index = state.map.tilesets.length + 1;
|
const index = state.map.tilesets.length + 1;
|
||||||
state.map.tilesets.push({
|
state.map.tilesets.push({
|
||||||
id: `tileset_${index}`,
|
id: `tileset_${index}`,
|
||||||
@@ -878,6 +914,7 @@ function addTileset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addLayer() {
|
function addLayer() {
|
||||||
|
pushUndo("新增图层");
|
||||||
const index = state.map.layers.length + 1;
|
const index = state.map.layers.length + 1;
|
||||||
state.map.layers.push({
|
state.map.layers.push({
|
||||||
id: `layer_${index}`,
|
id: `layer_${index}`,
|
||||||
@@ -891,6 +928,7 @@ function addLayer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addTileRect() {
|
function addTileRect() {
|
||||||
|
pushUndo("新增铺砖区");
|
||||||
ensureLevelVisualLayer();
|
ensureLevelVisualLayer();
|
||||||
if (!state.map.tilesets.length) {
|
if (!state.map.tilesets.length) {
|
||||||
state.map.tilesets.push({ id: "graybox_tile", texture: "assets/stage/stage_01/tiles/graybox_tile.png", tileWidth: 10, tileHeight: 10, color: { r: 0.62, g: 0.68, b: 0.73, a: 1 } });
|
state.map.tilesets.push({ id: "graybox_tile", texture: "assets/stage/stage_01/tiles/graybox_tile.png", tileWidth: 10, tileHeight: 10, color: { r: 0.62, g: 0.68, b: 0.73, a: 1 } });
|
||||||
@@ -904,7 +942,8 @@ function addTileRect() {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBattleZone() {
|
function addBattleZone(recordHistory = true) {
|
||||||
|
if (recordHistory) pushUndo("新增战斗区");
|
||||||
const point = snapPoint(viewCenter());
|
const point = snapPoint(viewCenter());
|
||||||
state.map.battleZones.push({
|
state.map.battleZones.push({
|
||||||
id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`,
|
id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`,
|
||||||
@@ -917,7 +956,8 @@ function addBattleZone() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addSpawn() {
|
function addSpawn() {
|
||||||
if (!state.map.battleZones.length) addBattleZone();
|
pushUndo("新增出生点");
|
||||||
|
if (!state.map.battleZones.length) addBattleZone(false);
|
||||||
const zoneIndex = Math.max(0, state.map.battleZones.length - 1);
|
const zoneIndex = Math.max(0, state.map.battleZones.length - 1);
|
||||||
const zone = state.map.battleZones[zoneIndex];
|
const zone = state.map.battleZones[zoneIndex];
|
||||||
const point = snapPoint(viewCenter());
|
const point = snapPoint(viewCenter());
|
||||||
@@ -929,15 +969,16 @@ 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") 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)) {
|
||||||
setStatus(`不能删除图块集 ${tileset.id}:它仍被 tile_rect 使用。`);
|
setStatus(`不能删除图块集 ${tileset.id}:它仍被 tile_rect 使用。`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.map.tilesets.splice(Number(parts[1]), 1);
|
|
||||||
}
|
}
|
||||||
|
pushUndo("删除对象");
|
||||||
|
if (parts[0] === "collision") deleteCollision(Number(parts[1]));
|
||||||
|
if (parts[0] === "tileset") state.map.tilesets.splice(Number(parts[1]), 1);
|
||||||
if (parts[0] === "layer") deleteLayer(Number(parts[1]));
|
if (parts[0] === "layer") deleteLayer(Number(parts[1]));
|
||||||
if (parts[0] === "rect") deleteLayerRect(Number(parts[1]), Number(parts[2]));
|
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);
|
||||||
@@ -947,6 +988,54 @@ function deleteSelected() {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveAndDownloadMap() {
|
||||||
|
downloadMap();
|
||||||
|
setStatus(`已保存导出文本并下载 ${state.map.id || "stage"}.map`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function undoLast() {
|
||||||
|
const entry = state.history.pop();
|
||||||
|
if (!entry) return;
|
||||||
|
restoreSnapshot(entry);
|
||||||
|
setStatus(`已撤回:${entry.label}`);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUndo(label) {
|
||||||
|
state.history.push({
|
||||||
|
label,
|
||||||
|
mapText: serializeMap(state.map),
|
||||||
|
selected: state.selected,
|
||||||
|
hitFilter: state.hitFilter,
|
||||||
|
});
|
||||||
|
if (state.history.length > 80) state.history.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreSnapshot(entry) {
|
||||||
|
state.map = parseMap(entry.mapText);
|
||||||
|
state.selected = entry.selected;
|
||||||
|
state.hitFilter = entry.hitFilter || "all";
|
||||||
|
if (state.selected && !getSelectedObject()) state.selected = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelectedLayer(direction) {
|
||||||
|
const index = selectedLayerIndex();
|
||||||
|
const nextIndex = index + direction;
|
||||||
|
if (index < 0 || nextIndex < 0 || nextIndex >= state.map.layers.length) return;
|
||||||
|
pushUndo(direction < 0 ? "图层上移" : "图层下移");
|
||||||
|
const [layer] = state.map.layers.splice(index, 1);
|
||||||
|
state.map.layers.splice(nextIndex, 0, layer);
|
||||||
|
state.selected = `layer:${nextIndex}`;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedLayerIndex() {
|
||||||
|
if (!state.selected) return -1;
|
||||||
|
const parts = state.selected.split(":");
|
||||||
|
if (parts[0] === "layer" || parts[0] === "rect" || parts[0] === "tile") return Number(parts[1]);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
function downloadMap() {
|
function downloadMap() {
|
||||||
const blob = new Blob([serializeMap(state.map)], { type: "text/plain" });
|
const blob = new Blob([serializeMap(state.map)], { type: "text/plain" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -13,19 +13,23 @@
|
|||||||
<p>编辑游戏运行时使用的 Frostbite2D .map 文本地图。</p>
|
<p>编辑游戏运行时使用的 Frostbite2D .map 文本地图。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<button id="undoButton" type="button" disabled>撤回</button>
|
||||||
<button id="loadSampleButton" type="button">加载 stage_01.map</button>
|
<button id="loadSampleButton" type="button">加载 stage_01.map</button>
|
||||||
<label class="file-button">
|
<label class="file-button">
|
||||||
打开 .map
|
打开 .map
|
||||||
<input id="fileInput" type="file" accept=".map,text/plain">
|
<input id="fileInput" type="file" accept=".map,text/plain">
|
||||||
</label>
|
</label>
|
||||||
<button id="downloadButton" type="button">下载 .map</button>
|
<button id="downloadButton" type="button" class="primary-button">保存并下载</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="editor-shell">
|
<main class="editor-shell">
|
||||||
<aside class="panel left-panel">
|
<aside class="panel left-panel">
|
||||||
<section>
|
<section class="tool-card">
|
||||||
<h2>地图</h2>
|
<div class="section-title">
|
||||||
|
<h2>地图设置</h2>
|
||||||
|
<span>基础参数</span>
|
||||||
|
</div>
|
||||||
<div class="field-grid">
|
<div class="field-grid">
|
||||||
<label>
|
<label>
|
||||||
地图 ID
|
地图 ID
|
||||||
@@ -57,8 +61,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section class="tool-card">
|
||||||
<h2>新增</h2>
|
<div class="section-title">
|
||||||
|
<h2>新增对象</h2>
|
||||||
|
<span>放到当前视图中心</span>
|
||||||
|
</div>
|
||||||
<div class="button-grid">
|
<div class="button-grid">
|
||||||
<button id="addTilesetButton" type="button">图块集</button>
|
<button id="addTilesetButton" type="button">图块集</button>
|
||||||
<button id="addLayerButton" type="button">图层</button>
|
<button id="addLayerButton" type="button">图层</button>
|
||||||
@@ -73,8 +80,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="object-section">
|
<section class="tool-card object-section">
|
||||||
<h2>对象</h2>
|
<div class="section-title">
|
||||||
|
<h2>对象列表</h2>
|
||||||
|
<span>点击选择后可拖动</span>
|
||||||
|
</div>
|
||||||
<div id="objectList" class="object-list"></div>
|
<div id="objectList" class="object-list"></div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -92,6 +102,10 @@
|
|||||||
<h2>选中对象</h2>
|
<h2>选中对象</h2>
|
||||||
<div id="selectionSummary" class="selection-summary">未选择对象。</div>
|
<div id="selectionSummary" class="selection-summary">未选择对象。</div>
|
||||||
<div id="propertyPanel" class="property-panel"></div>
|
<div id="propertyPanel" class="property-panel"></div>
|
||||||
|
<div class="layer-actions">
|
||||||
|
<button id="moveLayerUpButton" type="button">图层上移</button>
|
||||||
|
<button id="moveLayerDownButton" type="button">图层下移</button>
|
||||||
|
</div>
|
||||||
<button id="deleteButton" type="button" class="danger-button">删除选中对象</button>
|
<button id="deleteButton" type="button" class="danger-button">删除选中对象</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
--bg: #101214;
|
--bg: #101214;
|
||||||
--panel: #181b1f;
|
--panel: #181b1f;
|
||||||
--panel-2: #20242a;
|
--panel-2: #20242a;
|
||||||
|
--panel-3: #14171b;
|
||||||
--line: #343a42;
|
--line: #343a42;
|
||||||
--text: #e8edf2;
|
--text: #e8edf2;
|
||||||
--muted: #9aa4af;
|
--muted: #9aa4af;
|
||||||
@@ -40,11 +41,27 @@ button,
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
color: #68727d;
|
||||||
|
border-color: #272c33;
|
||||||
|
background: #15181d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
button:hover,
|
button:hover,
|
||||||
.file-button:hover {
|
.file-button:hover {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:disabled:hover {
|
||||||
|
border-color: #272c33;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
border-color: #3977c9;
|
||||||
|
background: #17365f;
|
||||||
|
}
|
||||||
|
|
||||||
.app-header {
|
.app-header {
|
||||||
height: 72px;
|
height: 72px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -88,7 +105,7 @@ button:hover,
|
|||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right-panel {
|
.right-panel {
|
||||||
@@ -97,17 +114,37 @@ button:hover,
|
|||||||
}
|
}
|
||||||
|
|
||||||
section + section {
|
section + section {
|
||||||
margin-top: 18px;
|
margin-top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
margin: 0 0 10px;
|
margin: 0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tool-card {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: var(--panel-3);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.field-grid,
|
.field-grid,
|
||||||
.property-panel {
|
.property-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -140,6 +177,11 @@ textarea {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-grid button,
|
||||||
|
.button-grid .file-button {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.object-section {
|
.object-section {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
@@ -157,6 +199,7 @@ textarea {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.object-item:hover {
|
.object-item:hover {
|
||||||
@@ -222,6 +265,13 @@ textarea {
|
|||||||
border-color: var(--danger);
|
border-color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.layer-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
#outputText {
|
#outputText {
|
||||||
height: 220px;
|
height: 220px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
|||||||
Reference in New Issue
Block a user