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` 文本。
|
- 下载兼容 `StageMapLoader` 的 `.map` 文本。
|
||||||
|
|
||||||
|
## 调整碰撞块
|
||||||
|
|
||||||
|
1. 在左侧“编辑目标”里选择“碰撞块”。
|
||||||
|
2. 在画布上点击红色碰撞框,或从对象列表选择对应碰撞块。
|
||||||
|
3. 拖动碰撞块本体可以移动位置。
|
||||||
|
4. 拖动右下角蓝色手柄可以调整宽高。
|
||||||
|
5. 也可以在右侧属性面板直接修改 `X`、`Y`、`W`、`H`。
|
||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
|
|
||||||
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
|
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
|
||||||
|
|||||||
+99
-10
@@ -5,6 +5,7 @@ const state = {
|
|||||||
selected: null,
|
selected: null,
|
||||||
view: { x: -80, y: -40, scale: 0.35 },
|
view: { x: -80, y: -40, scale: 0.35 },
|
||||||
gridSize: 10,
|
gridSize: 10,
|
||||||
|
hitFilter: "collision",
|
||||||
dragging: null,
|
dragging: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ function bindElements() {
|
|||||||
for (const id of [
|
for (const id of [
|
||||||
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
|
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
|
||||||
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
||||||
|
"hitFilterInput",
|
||||||
"objectList", "propertyPanel", "selectionSummary", "deleteButton",
|
"objectList", "propertyPanel", "selectionSummary", "deleteButton",
|
||||||
"tilesetList", "outputText", "statusText", "viewText",
|
"tilesetList", "outputText", "statusText", "viewText",
|
||||||
"addTilesetButton", "addLayerButton", "addCollisionButton",
|
"addTilesetButton", "addLayerButton", "addCollisionButton",
|
||||||
@@ -53,6 +55,10 @@ function bindEvents() {
|
|||||||
state.gridSize = Math.max(1, numberValue(elements.gridSizeInput, state.gridSize));
|
state.gridSize = Math.max(1, numberValue(elements.gridSizeInput, state.gridSize));
|
||||||
refresh();
|
refresh();
|
||||||
});
|
});
|
||||||
|
elements.hitFilterInput.addEventListener("change", () => {
|
||||||
|
state.hitFilter = elements.hitFilterInput.value;
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
|
||||||
elements.addCollisionButton.addEventListener("click", addCollision);
|
elements.addCollisionButton.addEventListener("click", addCollision);
|
||||||
elements.addTilesetButton.addEventListener("click", addTileset);
|
elements.addTilesetButton.addEventListener("click", addTileset);
|
||||||
@@ -260,6 +266,7 @@ function refresh() {
|
|||||||
elements.worldWidthInput.value = state.map.world.width;
|
elements.worldWidthInput.value = state.map.world.width;
|
||||||
elements.worldHeightInput.value = state.map.world.height;
|
elements.worldHeightInput.value = state.map.world.height;
|
||||||
elements.gridSizeInput.value = state.gridSize;
|
elements.gridSizeInput.value = state.gridSize;
|
||||||
|
elements.hitFilterInput.value = state.hitFilter;
|
||||||
elements.outputText.value = serializeMap(state.map);
|
elements.outputText.value = serializeMap(state.map);
|
||||||
renderTilesets();
|
renderTilesets();
|
||||||
renderObjectList();
|
renderObjectList();
|
||||||
@@ -430,7 +437,35 @@ function drawSelection(ctx) {
|
|||||||
const bounds = object.bounds && object.bounds();
|
const bounds = object.bounds && object.bounds();
|
||||||
ctx.strokeStyle = "#54a8ff";
|
ctx.strokeStyle = "#54a8ff";
|
||||||
ctx.lineWidth = 3 / state.view.scale;
|
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) {
|
function drawFilledRect(ctx, rect, color, alpha, stroke) {
|
||||||
@@ -473,15 +508,15 @@ function collectObjects() {
|
|||||||
objectForRect("camera", "摄像机范围", state.map.camera),
|
objectForRect("camera", "摄像机范围", state.map.camera),
|
||||||
];
|
];
|
||||||
state.map.tilesets.forEach((tileset, index) => objects.push(objectForTileset(`tileset:${index}`, `图块集 ${tileset.id}`, tileset, true)));
|
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) => {
|
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)));
|
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)));
|
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) => {
|
||||||
objects.push(objectForRect(`zone:${zoneIndex}`, `战斗区 ${zoneIndex + 1}`, zone.trigger, true, 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));
|
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)));
|
zone.spawns.forEach((spawn, spawnIndex) => objects.push(objectForPoint(`spawn:${zoneIndex}:${spawnIndex}`, `出生点 ${spawn.id}`, spawn, true)));
|
||||||
});
|
});
|
||||||
return objects;
|
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 {
|
return {
|
||||||
key: id,
|
key: id,
|
||||||
|
type,
|
||||||
title,
|
title,
|
||||||
meta: meta || `${fmt(rect.x)}, ${fmt(rect.y)}, ${fmt(rect.width)}x${fmt(rect.height)}`,
|
meta: meta || `${fmt(rect.x)}, ${fmt(rect.y)}, ${fmt(rect.width)}x${fmt(rect.height)}`,
|
||||||
deletable,
|
deletable,
|
||||||
bounds: () => rect,
|
bounds: () => rect,
|
||||||
move(dx, dy) { rect.x += dx; rect.y += dy; },
|
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),
|
fields: () => rectFields(rect),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -532,6 +572,7 @@ function objectForRect(id, title, rect, deletable = false, meta = null) {
|
|||||||
function objectForPoint(id, title, point, deletable = false) {
|
function objectForPoint(id, title, point, deletable = false) {
|
||||||
return {
|
return {
|
||||||
key: id,
|
key: id,
|
||||||
|
type: "spawn",
|
||||||
title,
|
title,
|
||||||
meta: `${fmt(point.x)}, ${fmt(point.y)}`,
|
meta: `${fmt(point.x)}, ${fmt(point.y)}`,
|
||||||
deletable,
|
deletable,
|
||||||
@@ -547,11 +588,18 @@ function objectForTileRect(id, title, tileRect, deletable) {
|
|||||||
const th = tileset ? tileset.tileHeight : 10;
|
const th = tileset ? tileset.tileHeight : 10;
|
||||||
return {
|
return {
|
||||||
key: id,
|
key: id,
|
||||||
|
type: "tile",
|
||||||
title,
|
title,
|
||||||
meta: `${tileRect.tileset} ${tileRect.columns}x${tileRect.rows}`,
|
meta: `${tileRect.tileset} ${tileRect.columns}x${tileRect.rows}`,
|
||||||
deletable,
|
deletable,
|
||||||
bounds: () => ({ x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th }),
|
bounds: () => ({ x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th }),
|
||||||
move(dx, dy) { tileRect.x += dx; tileRect.y += dy; },
|
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: () => [
|
fields: () => [
|
||||||
textField("图块集", () => tileRect.tileset, (v) => { tileRect.tileset = v; }, state.map.tilesets.map((item) => item.id)),
|
textField("图块集", () => tileRect.tileset, (v) => { tileRect.tileset = v; }, state.map.tilesets.map((item) => item.id)),
|
||||||
numberField("X", () => tileRect.x, (v) => { tileRect.x = v; }),
|
numberField("X", () => tileRect.x, (v) => { tileRect.x = v; }),
|
||||||
@@ -571,18 +619,30 @@ function getSelectedObject() {
|
|||||||
|
|
||||||
function onCanvasMouseDown(event) {
|
function onCanvasMouseDown(event) {
|
||||||
const world = screenToWorld(event.offsetX, event.offsetY);
|
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);
|
const object = hitTest(world.x, world.y);
|
||||||
if (object) {
|
if (object) {
|
||||||
state.selected = object.key;
|
state.selected = object.key;
|
||||||
state.dragging = { last: snapPoint(world) };
|
state.dragging = { last: snapPoint(world) };
|
||||||
|
setCanvasCursor("move");
|
||||||
refresh();
|
refresh();
|
||||||
} else {
|
} else {
|
||||||
state.dragging = { pan: true, x: event.clientX, y: event.clientY, viewX: state.view.x, viewY: state.view.y };
|
state.dragging = { pan: true, x: event.clientX, y: event.clientY, viewX: state.view.x, viewY: state.view.y };
|
||||||
|
setCanvasCursor("grabbing");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasMouseMove(event) {
|
function onCanvasMouseMove(event) {
|
||||||
if (!state.dragging) return;
|
if (!state.dragging) {
|
||||||
|
updateCanvasCursor(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (state.dragging.pan) {
|
if (state.dragging.pan) {
|
||||||
state.view.x = state.dragging.viewX - (event.clientX - state.dragging.x) / state.view.scale;
|
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;
|
state.view.y = state.dragging.viewY - (event.clientY - state.dragging.y) / state.view.scale;
|
||||||
@@ -590,12 +650,16 @@ function onCanvasMouseMove(event) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const object = getSelectedObject();
|
const object = getSelectedObject();
|
||||||
if (!object || !object.move) return;
|
if (!object) return;
|
||||||
const current = snapPoint(screenToWorld(event.offsetX, event.offsetY));
|
const current = snapPoint(screenToWorld(event.offsetX, event.offsetY));
|
||||||
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) {
|
||||||
object.move(dx, dy);
|
if (state.dragging.resize && object.resize) {
|
||||||
|
object.resize(dx, dy);
|
||||||
|
} else if (object.move) {
|
||||||
|
object.move(dx, dy);
|
||||||
|
}
|
||||||
state.dragging.last = current;
|
state.dragging.last = current;
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
@@ -603,6 +667,7 @@ function onCanvasMouseMove(event) {
|
|||||||
|
|
||||||
function onCanvasMouseUp() {
|
function onCanvasMouseUp() {
|
||||||
state.dragging = null;
|
state.dragging = null;
|
||||||
|
setCanvasCursor("crosshair");
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCanvasWheel(event) {
|
function onCanvasWheel(event) {
|
||||||
@@ -617,7 +682,7 @@ function onCanvasWheel(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hitTest(x, y) {
|
function hitTest(x, y) {
|
||||||
const objects = collectObjects().reverse();
|
const objects = collectObjects().filter(isHitFilterEnabled).reverse();
|
||||||
for (const object of objects) {
|
for (const object of objects) {
|
||||||
const bounds = object.bounds && object.bounds();
|
const bounds = object.bounds && object.bounds();
|
||||||
if (!bounds) continue;
|
if (!bounds) continue;
|
||||||
@@ -626,6 +691,30 @@ function hitTest(x, y) {
|
|||||||
return null;
|
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() {
|
function addCollision() {
|
||||||
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 });
|
||||||
|
|||||||
@@ -43,6 +43,17 @@
|
|||||||
网格
|
网格
|
||||||
<input id="gridSizeInput" type="number" step="1" min="1" value="10">
|
<input id="gridSizeInput" type="number" step="1" min="1" value="10">
|
||||||
</label>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user