Improve map editor workflow controls
This commit is contained in:
+98
-9
@@ -7,6 +7,7 @@ const state = {
|
||||
gridSize: 10,
|
||||
hitFilter: "all",
|
||||
imagePreviews: new Map(),
|
||||
history: [],
|
||||
dragging: null,
|
||||
};
|
||||
|
||||
@@ -21,7 +22,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
|
||||
function bindElements() {
|
||||
for (const id of [
|
||||
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
|
||||
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton", "undoButton",
|
||||
"graphicFileInput",
|
||||
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
||||
"hitFilterInput",
|
||||
@@ -29,6 +30,7 @@ function bindElements() {
|
||||
"tilesetList", "outputText", "statusText", "viewText",
|
||||
"addTilesetButton", "addLayerButton", "addCollisionButton",
|
||||
"addTileRectButton", "addBattleZoneButton", "addSpawnButton",
|
||||
"moveLayerUpButton", "moveLayerDownButton",
|
||||
]) {
|
||||
elements[id] = document.getElementById(id);
|
||||
}
|
||||
@@ -39,18 +41,30 @@ function bindEvents() {
|
||||
elements.loadSampleButton.addEventListener("click", loadSampleMap);
|
||||
elements.fileInput.addEventListener("change", openFile);
|
||||
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.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", () => {
|
||||
pushUndo("修改地图 ID");
|
||||
state.map.id = elements.mapIdInput.value.trim() || "stage";
|
||||
refresh();
|
||||
});
|
||||
elements.worldWidthInput.addEventListener("change", () => {
|
||||
pushUndo("修改世界宽度");
|
||||
state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width);
|
||||
refresh();
|
||||
});
|
||||
elements.worldHeightInput.addEventListener("change", () => {
|
||||
pushUndo("修改世界高度");
|
||||
state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height);
|
||||
refresh();
|
||||
});
|
||||
@@ -107,6 +121,7 @@ async function importGraphicAsset(event) {
|
||||
const image = await loadImage(previewUrl);
|
||||
const texturePath = defaultGraphicTexturePath(file.name);
|
||||
state.imagePreviews.set(texturePath, image);
|
||||
pushUndo("导入图形");
|
||||
addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64);
|
||||
setStatus(`已导入图形 ${file.name},并自动创建同尺寸碰撞块。请把素材放到 ${texturePath}`);
|
||||
} catch (error) {
|
||||
@@ -129,6 +144,7 @@ function loadMapText(text, status) {
|
||||
try {
|
||||
state.map = parseMap(text);
|
||||
state.selected = null;
|
||||
state.history = [];
|
||||
fitWorldToCanvas();
|
||||
setStatus(status);
|
||||
refresh();
|
||||
@@ -263,7 +279,7 @@ function serializeMap(map) {
|
||||
}
|
||||
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)}`);
|
||||
for (const rect of layer.rects) {
|
||||
if (rect.texture && rect.source) {
|
||||
@@ -298,6 +314,8 @@ function refresh() {
|
||||
elements.gridSizeInput.value = state.gridSize;
|
||||
elements.hitFilterInput.value = state.hitFilter;
|
||||
elements.outputText.value = serializeMap(state.map);
|
||||
elements.undoButton.disabled = state.history.length === 0;
|
||||
elements.undoButton.textContent = state.history.length ? `撤回 ${state.history.length}` : "撤回";
|
||||
renderTilesets();
|
||||
renderObjectList();
|
||||
renderPropertyPanel();
|
||||
@@ -342,11 +360,14 @@ function renderPropertyPanel() {
|
||||
if (!object) {
|
||||
elements.selectionSummary.textContent = "未选择对象。";
|
||||
elements.deleteButton.disabled = true;
|
||||
elements.moveLayerUpButton.disabled = true;
|
||||
elements.moveLayerDownButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
|
||||
elements.selectionSummary.textContent = `${object.title} | ${object.meta}`;
|
||||
elements.deleteButton.disabled = !object.deletable;
|
||||
updateLayerMoveButtons();
|
||||
const fields = object.fields();
|
||||
for (const field of fields) {
|
||||
const label = document.createElement("label");
|
||||
@@ -367,7 +388,10 @@ function renderPropertyPanel() {
|
||||
}
|
||||
input.value = field.get();
|
||||
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();
|
||||
});
|
||||
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() {
|
||||
const canvas = elements.mapCanvas;
|
||||
const ctx = canvas.getContext("2d");
|
||||
@@ -427,7 +457,7 @@ function drawGrid(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 tileRect of layer.tileRects) drawTileRect(ctx, tileRect);
|
||||
}
|
||||
@@ -741,6 +771,10 @@ function onCanvasMouseMove(event) {
|
||||
const dx = current.x - state.dragging.last.x;
|
||||
const dy = current.y - state.dragging.last.y;
|
||||
if (dx || dy) {
|
||||
if (!state.dragging.undoPushed) {
|
||||
pushUndo(state.dragging.resize ? "调整对象尺寸" : "移动对象");
|
||||
state.dragging.undoPushed = true;
|
||||
}
|
||||
if (state.dragging.resize && object.resize) {
|
||||
object.resize(dx, dy);
|
||||
} else if (object.move) {
|
||||
@@ -833,6 +867,7 @@ function setCanvasCursor(cursor) {
|
||||
}
|
||||
|
||||
function addCollision() {
|
||||
pushUndo("新增碰撞块");
|
||||
const point = snapPoint(viewCenter());
|
||||
state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 });
|
||||
state.selected = `collision:${state.map.collisions.length - 1}`;
|
||||
@@ -865,6 +900,7 @@ function addGraphicSprite(texturePath, width, height) {
|
||||
}
|
||||
|
||||
function addTileset() {
|
||||
pushUndo("新增图块集");
|
||||
const index = state.map.tilesets.length + 1;
|
||||
state.map.tilesets.push({
|
||||
id: `tileset_${index}`,
|
||||
@@ -878,6 +914,7 @@ function addTileset() {
|
||||
}
|
||||
|
||||
function addLayer() {
|
||||
pushUndo("新增图层");
|
||||
const index = state.map.layers.length + 1;
|
||||
state.map.layers.push({
|
||||
id: `layer_${index}`,
|
||||
@@ -891,6 +928,7 @@ function addLayer() {
|
||||
}
|
||||
|
||||
function addTileRect() {
|
||||
pushUndo("新增铺砖区");
|
||||
ensureLevelVisualLayer();
|
||||
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 } });
|
||||
@@ -904,7 +942,8 @@ function addTileRect() {
|
||||
refresh();
|
||||
}
|
||||
|
||||
function addBattleZone() {
|
||||
function addBattleZone(recordHistory = true) {
|
||||
if (recordHistory) pushUndo("新增战斗区");
|
||||
const point = snapPoint(viewCenter());
|
||||
state.map.battleZones.push({
|
||||
id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`,
|
||||
@@ -917,7 +956,8 @@ function addBattleZone() {
|
||||
}
|
||||
|
||||
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 zone = state.map.battleZones[zoneIndex];
|
||||
const point = snapPoint(viewCenter());
|
||||
@@ -929,15 +969,16 @@ function addSpawn() {
|
||||
function deleteSelected() {
|
||||
if (!state.selected) return;
|
||||
const parts = state.selected.split(":");
|
||||
if (parts[0] === "collision") deleteCollision(Number(parts[1]));
|
||||
if (parts[0] === "tileset") {
|
||||
const tileset = state.map.tilesets[Number(parts[1])];
|
||||
if (tileset && isTilesetUsed(tileset.id)) {
|
||||
setStatus(`不能删除图块集 ${tileset.id}:它仍被 tile_rect 使用。`);
|
||||
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] === "rect") deleteLayerRect(Number(parts[1]), Number(parts[2]));
|
||||
if (parts[0] === "tile") state.map.layers[Number(parts[1])].tileRects.splice(Number(parts[2]), 1);
|
||||
@@ -947,6 +988,54 @@ function deleteSelected() {
|
||||
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() {
|
||||
const blob = new Blob([serializeMap(state.map)], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
Reference in New Issue
Block a user