Fix collision editing in map editor
This commit is contained in:
@@ -24,10 +24,20 @@ http://127.0.0.1:8787/tools/map_editor/
|
||||
- 可视化世界范围、摄像机范围、玩家出生点、碰撞块、场景矩形、铺砖区、战斗区和敌人出生点。
|
||||
- 从画布或对象列表选择对象。
|
||||
- 在画布上拖动对象,支持网格吸附。
|
||||
- 通过“编辑目标”过滤画布选择对象,默认优先编辑碰撞块,避免点到重叠的铺砖区。
|
||||
- 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。
|
||||
- 编辑选中对象的数值和文本属性。
|
||||
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
|
||||
- 下载兼容 `StageMapLoader` 的 `.map` 文本。
|
||||
|
||||
## 调整碰撞块
|
||||
|
||||
1. 在左侧“编辑目标”里选择“碰撞块”。
|
||||
2. 在画布上点击红色碰撞框,或从对象列表选择对应碰撞块。
|
||||
3. 拖动碰撞块本体可以移动位置。
|
||||
4. 拖动右下角蓝色手柄可以调整宽高。
|
||||
5. 也可以在右侧属性面板直接修改 `X`、`Y`、`W`、`H`。
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
|
||||
|
||||
+98
-9
@@ -5,6 +5,7 @@ const state = {
|
||||
selected: null,
|
||||
view: { x: -80, y: -40, scale: 0.35 },
|
||||
gridSize: 10,
|
||||
hitFilter: "collision",
|
||||
dragging: null,
|
||||
};
|
||||
|
||||
@@ -21,6 +22,7 @@ function bindElements() {
|
||||
for (const id of [
|
||||
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
|
||||
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
||||
"hitFilterInput",
|
||||
"objectList", "propertyPanel", "selectionSummary", "deleteButton",
|
||||
"tilesetList", "outputText", "statusText", "viewText",
|
||||
"addTilesetButton", "addLayerButton", "addCollisionButton",
|
||||
@@ -53,6 +55,10 @@ function bindEvents() {
|
||||
state.gridSize = Math.max(1, numberValue(elements.gridSizeInput, state.gridSize));
|
||||
refresh();
|
||||
});
|
||||
elements.hitFilterInput.addEventListener("change", () => {
|
||||
state.hitFilter = elements.hitFilterInput.value;
|
||||
refresh();
|
||||
});
|
||||
|
||||
elements.addCollisionButton.addEventListener("click", addCollision);
|
||||
elements.addTilesetButton.addEventListener("click", addTileset);
|
||||
@@ -260,6 +266,7 @@ function refresh() {
|
||||
elements.worldWidthInput.value = state.map.world.width;
|
||||
elements.worldHeightInput.value = state.map.world.height;
|
||||
elements.gridSizeInput.value = state.gridSize;
|
||||
elements.hitFilterInput.value = state.hitFilter;
|
||||
elements.outputText.value = serializeMap(state.map);
|
||||
renderTilesets();
|
||||
renderObjectList();
|
||||
@@ -430,7 +437,35 @@ function drawSelection(ctx) {
|
||||
const bounds = object.bounds && object.bounds();
|
||||
ctx.strokeStyle = "#54a8ff";
|
||||
ctx.lineWidth = 3 / state.view.scale;
|
||||
if (bounds) ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
if (bounds) {
|
||||
ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
||||
if (object.resize) {
|
||||
const handle = resizeHandleFor(bounds);
|
||||
ctx.fillStyle = "#54a8ff";
|
||||
ctx.fillRect(handle.x, handle.y, handle.width, handle.height);
|
||||
ctx.strokeStyle = "#0b0d10";
|
||||
ctx.lineWidth = 1 / state.view.scale;
|
||||
ctx.strokeRect(handle.x, handle.y, handle.width, handle.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resizeHandleFor(bounds) {
|
||||
const size = 12 / state.view.scale;
|
||||
return {
|
||||
x: bounds.x + bounds.width - size,
|
||||
y: bounds.y + bounds.height - size,
|
||||
width: size,
|
||||
height: size,
|
||||
};
|
||||
}
|
||||
|
||||
function isInsideResizeHandle(object, x, y) {
|
||||
const bounds = object.bounds && object.bounds();
|
||||
if (!bounds) return false;
|
||||
const handle = resizeHandleFor(bounds);
|
||||
return x >= handle.x && x <= handle.x + handle.width &&
|
||||
y >= handle.y && y <= handle.y + handle.height;
|
||||
}
|
||||
|
||||
function drawFilledRect(ctx, rect, color, alpha, stroke) {
|
||||
@@ -473,15 +508,15 @@ function collectObjects() {
|
||||
objectForRect("camera", "摄像机范围", state.map.camera),
|
||||
];
|
||||
state.map.tilesets.forEach((tileset, index) => objects.push(objectForTileset(`tileset:${index}`, `图块集 ${tileset.id}`, tileset, true)));
|
||||
state.map.collisions.forEach((rect, index) => objects.push(objectForRect(`collision:${index}`, `碰撞块 ${index + 1}`, rect, true)));
|
||||
state.map.collisions.forEach((rect, index) => objects.push(objectForRect(`collision:${index}`, `碰撞块 ${index + 1}`, rect, true, null, "collision")));
|
||||
state.map.layers.forEach((layer, layerIndex) => {
|
||||
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)));
|
||||
layer.rects.forEach((rect, rectIndex) => objects.push(objectForRect(`rect:${layerIndex}:${rectIndex}`, `${layer.id} 矩形 ${rectIndex + 1}`, rect.bounds, true, null, "rect")));
|
||||
layer.tileRects.forEach((tileRect, tileIndex) => objects.push(objectForTileRect(`tile:${layerIndex}:${tileIndex}`, `${layer.id} 铺砖区 ${tileIndex + 1}`, tileRect, true)));
|
||||
});
|
||||
state.map.battleZones.forEach((zone, zoneIndex) => {
|
||||
objects.push(objectForRect(`zone:${zoneIndex}`, `战斗区 ${zoneIndex + 1}`, zone.trigger, true, zone.id));
|
||||
objects.push(objectForRect(`zone_camera:${zoneIndex}`, `战斗区镜头 ${zoneIndex + 1}`, zone.camera, false, zone.id));
|
||||
objects.push(objectForRect(`zone:${zoneIndex}`, `战斗区 ${zoneIndex + 1}`, zone.trigger, true, zone.id, "zone"));
|
||||
objects.push(objectForRect(`zone_camera:${zoneIndex}`, `战斗区镜头 ${zoneIndex + 1}`, zone.camera, false, zone.id, "zone"));
|
||||
zone.spawns.forEach((spawn, spawnIndex) => objects.push(objectForPoint(`spawn:${zoneIndex}:${spawnIndex}`, `出生点 ${spawn.id}`, spawn, true)));
|
||||
});
|
||||
return objects;
|
||||
@@ -517,14 +552,19 @@ function objectForLayer(id, title, layer, deletable) {
|
||||
};
|
||||
}
|
||||
|
||||
function objectForRect(id, title, rect, deletable = false, meta = null) {
|
||||
function objectForRect(id, title, rect, deletable = false, meta = null, type = "rect") {
|
||||
return {
|
||||
key: id,
|
||||
type,
|
||||
title,
|
||||
meta: meta || `${fmt(rect.x)}, ${fmt(rect.y)}, ${fmt(rect.width)}x${fmt(rect.height)}`,
|
||||
deletable,
|
||||
bounds: () => rect,
|
||||
move(dx, dy) { rect.x += dx; rect.y += dy; },
|
||||
resize(dw, dh) {
|
||||
rect.width = Math.max(1, rect.width + dw);
|
||||
rect.height = Math.max(1, rect.height + dh);
|
||||
},
|
||||
fields: () => rectFields(rect),
|
||||
};
|
||||
}
|
||||
@@ -532,6 +572,7 @@ function objectForRect(id, title, rect, deletable = false, meta = null) {
|
||||
function objectForPoint(id, title, point, deletable = false) {
|
||||
return {
|
||||
key: id,
|
||||
type: "spawn",
|
||||
title,
|
||||
meta: `${fmt(point.x)}, ${fmt(point.y)}`,
|
||||
deletable,
|
||||
@@ -547,11 +588,18 @@ function objectForTileRect(id, title, tileRect, deletable) {
|
||||
const th = tileset ? tileset.tileHeight : 10;
|
||||
return {
|
||||
key: id,
|
||||
type: "tile",
|
||||
title,
|
||||
meta: `${tileRect.tileset} ${tileRect.columns}x${tileRect.rows}`,
|
||||
deletable,
|
||||
bounds: () => ({ x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th }),
|
||||
move(dx, dy) { tileRect.x += dx; tileRect.y += dy; },
|
||||
resize(dw, dh) {
|
||||
const nextWidth = Math.max(tw, tileRect.columns * tw + dw);
|
||||
const nextHeight = Math.max(th, tileRect.rows * th + dh);
|
||||
tileRect.columns = Math.max(1, Math.round(nextWidth / tw));
|
||||
tileRect.rows = Math.max(1, Math.round(nextHeight / th));
|
||||
},
|
||||
fields: () => [
|
||||
textField("图块集", () => tileRect.tileset, (v) => { tileRect.tileset = v; }, state.map.tilesets.map((item) => item.id)),
|
||||
numberField("X", () => tileRect.x, (v) => { tileRect.x = v; }),
|
||||
@@ -571,18 +619,30 @@ function getSelectedObject() {
|
||||
|
||||
function onCanvasMouseDown(event) {
|
||||
const world = screenToWorld(event.offsetX, event.offsetY);
|
||||
const selected = getSelectedObject();
|
||||
if (selected && selected.resize && isInsideResizeHandle(selected, world.x, world.y)) {
|
||||
state.dragging = { resize: true, last: snapPoint(world) };
|
||||
setCanvasCursor("nwse-resize");
|
||||
return;
|
||||
}
|
||||
|
||||
const object = hitTest(world.x, world.y);
|
||||
if (object) {
|
||||
state.selected = object.key;
|
||||
state.dragging = { last: snapPoint(world) };
|
||||
setCanvasCursor("move");
|
||||
refresh();
|
||||
} else {
|
||||
state.dragging = { pan: true, x: event.clientX, y: event.clientY, viewX: state.view.x, viewY: state.view.y };
|
||||
setCanvasCursor("grabbing");
|
||||
}
|
||||
}
|
||||
|
||||
function onCanvasMouseMove(event) {
|
||||
if (!state.dragging) return;
|
||||
if (!state.dragging) {
|
||||
updateCanvasCursor(event);
|
||||
return;
|
||||
}
|
||||
if (state.dragging.pan) {
|
||||
state.view.x = state.dragging.viewX - (event.clientX - state.dragging.x) / state.view.scale;
|
||||
state.view.y = state.dragging.viewY - (event.clientY - state.dragging.y) / state.view.scale;
|
||||
@@ -590,12 +650,16 @@ function onCanvasMouseMove(event) {
|
||||
return;
|
||||
}
|
||||
const object = getSelectedObject();
|
||||
if (!object || !object.move) return;
|
||||
if (!object) return;
|
||||
const current = snapPoint(screenToWorld(event.offsetX, event.offsetY));
|
||||
const dx = current.x - state.dragging.last.x;
|
||||
const dy = current.y - state.dragging.last.y;
|
||||
if (dx || dy) {
|
||||
if (state.dragging.resize && object.resize) {
|
||||
object.resize(dx, dy);
|
||||
} else if (object.move) {
|
||||
object.move(dx, dy);
|
||||
}
|
||||
state.dragging.last = current;
|
||||
refresh();
|
||||
}
|
||||
@@ -603,6 +667,7 @@ function onCanvasMouseMove(event) {
|
||||
|
||||
function onCanvasMouseUp() {
|
||||
state.dragging = null;
|
||||
setCanvasCursor("crosshair");
|
||||
}
|
||||
|
||||
function onCanvasWheel(event) {
|
||||
@@ -617,7 +682,7 @@ function onCanvasWheel(event) {
|
||||
}
|
||||
|
||||
function hitTest(x, y) {
|
||||
const objects = collectObjects().reverse();
|
||||
const objects = collectObjects().filter(isHitFilterEnabled).reverse();
|
||||
for (const object of objects) {
|
||||
const bounds = object.bounds && object.bounds();
|
||||
if (!bounds) continue;
|
||||
@@ -626,6 +691,30 @@ function hitTest(x, y) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isHitFilterEnabled(object) {
|
||||
if (state.hitFilter === "all") return true;
|
||||
if (state.hitFilter === "zone") return object.type === "zone";
|
||||
return object.type === state.hitFilter;
|
||||
}
|
||||
|
||||
function updateCanvasCursor(event) {
|
||||
const world = screenToWorld(event.offsetX, event.offsetY);
|
||||
const selected = getSelectedObject();
|
||||
if (selected && selected.resize && isInsideResizeHandle(selected, world.x, world.y)) {
|
||||
setCanvasCursor("nwse-resize");
|
||||
return;
|
||||
}
|
||||
if (hitTest(world.x, world.y)) {
|
||||
setCanvasCursor("move");
|
||||
return;
|
||||
}
|
||||
setCanvasCursor("crosshair");
|
||||
}
|
||||
|
||||
function setCanvasCursor(cursor) {
|
||||
elements.mapCanvas.style.cursor = cursor;
|
||||
}
|
||||
|
||||
function addCollision() {
|
||||
const point = snapPoint(viewCenter());
|
||||
state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 });
|
||||
|
||||
@@ -43,6 +43,17 @@
|
||||
网格
|
||||
<input id="gridSizeInput" type="number" step="1" min="1" value="10">
|
||||
</label>
|
||||
<label>
|
||||
编辑目标
|
||||
<select id="hitFilterInput">
|
||||
<option value="collision">碰撞块</option>
|
||||
<option value="all">全部对象</option>
|
||||
<option value="tile">铺砖区</option>
|
||||
<option value="zone">战斗区</option>
|
||||
<option value="spawn">出生点</option>
|
||||
<option value="rect">场景矩形</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user