993 lines
35 KiB
JavaScript
993 lines
35 KiB
JavaScript
const ROLE_ORDER = ["BackgroundFar", "BackgroundMid", "LevelVisual", "PropsBack", "PropsFront"];
|
|
|
|
const state = {
|
|
map: createEmptyMap(),
|
|
selected: null,
|
|
view: { x: -80, y: -40, scale: 0.35 },
|
|
gridSize: 10,
|
|
hitFilter: "collision",
|
|
dragging: null,
|
|
};
|
|
|
|
const elements = {};
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
bindElements();
|
|
bindEvents();
|
|
resizeCanvas();
|
|
loadSampleMap();
|
|
});
|
|
|
|
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",
|
|
"addTileRectButton", "addBattleZoneButton", "addSpawnButton",
|
|
]) {
|
|
elements[id] = document.getElementById(id);
|
|
}
|
|
}
|
|
|
|
function bindEvents() {
|
|
window.addEventListener("resize", resizeCanvas);
|
|
elements.loadSampleButton.addEventListener("click", loadSampleMap);
|
|
elements.fileInput.addEventListener("change", openFile);
|
|
elements.downloadButton.addEventListener("click", downloadMap);
|
|
elements.deleteButton.addEventListener("click", deleteSelected);
|
|
|
|
elements.mapIdInput.addEventListener("change", () => {
|
|
state.map.id = elements.mapIdInput.value.trim() || "stage";
|
|
refresh();
|
|
});
|
|
elements.worldWidthInput.addEventListener("change", () => {
|
|
state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width);
|
|
refresh();
|
|
});
|
|
elements.worldHeightInput.addEventListener("change", () => {
|
|
state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height);
|
|
refresh();
|
|
});
|
|
elements.gridSizeInput.addEventListener("change", () => {
|
|
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);
|
|
elements.addLayerButton.addEventListener("click", addLayer);
|
|
elements.addTileRectButton.addEventListener("click", addTileRect);
|
|
elements.addBattleZoneButton.addEventListener("click", addBattleZone);
|
|
elements.addSpawnButton.addEventListener("click", addSpawn);
|
|
|
|
const canvas = elements.mapCanvas;
|
|
canvas.addEventListener("mousedown", onCanvasMouseDown);
|
|
canvas.addEventListener("mousemove", onCanvasMouseMove);
|
|
canvas.addEventListener("mouseup", onCanvasMouseUp);
|
|
canvas.addEventListener("mouseleave", onCanvasMouseUp);
|
|
canvas.addEventListener("wheel", onCanvasWheel, { passive: false });
|
|
}
|
|
|
|
async function loadSampleMap() {
|
|
try {
|
|
const response = await fetch("../../game/assets/map/stage_01.map");
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
loadMapText(await response.text(), "已加载 stage_01.map");
|
|
} catch (error) {
|
|
setStatus(`无法读取 stage_01.map,请使用“打开 .map”。${error.message}`);
|
|
loadMapText(defaultMapText(), "已加载内置起始地图");
|
|
}
|
|
}
|
|
|
|
function openFile(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
const reader = new FileReader();
|
|
reader.onload = () => loadMapText(String(reader.result || ""), `已打开 ${file.name}`);
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
function loadMapText(text, status) {
|
|
try {
|
|
state.map = parseMap(text);
|
|
state.selected = null;
|
|
fitWorldToCanvas();
|
|
setStatus(status);
|
|
refresh();
|
|
} catch (error) {
|
|
setStatus(`解析失败:${error.message}`);
|
|
}
|
|
}
|
|
|
|
function createEmptyMap() {
|
|
return {
|
|
id: "stage",
|
|
world: { width: 1280, height: 720 },
|
|
camera: { x: 0, y: 0, width: 1280, height: 720 },
|
|
playerSpawn: { x: 120, y: 520 },
|
|
collisions: [],
|
|
tilesets: [],
|
|
layers: [],
|
|
battleZones: [],
|
|
};
|
|
}
|
|
|
|
function parseMap(text) {
|
|
const map = createEmptyMap();
|
|
map.collisions = [];
|
|
map.tilesets = [];
|
|
map.layers = [];
|
|
map.battleZones = [];
|
|
|
|
let currentLayer = null;
|
|
let currentBattleZone = null;
|
|
const lines = text.split(/\r?\n/);
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const tokens = tokenize(lines[index]);
|
|
if (!tokens.length) continue;
|
|
const command = tokens[0];
|
|
const lineNumber = index + 1;
|
|
|
|
if (command === "map") {
|
|
map.id = requireToken(tokens, 1, lineNumber);
|
|
} else if (command === "world") {
|
|
map.world = { width: num(tokens, 1, lineNumber), height: num(tokens, 2, lineNumber) };
|
|
} else if (command === "camera") {
|
|
map.camera = rectFrom(tokens, 1, lineNumber);
|
|
} else if (command === "player_spawn") {
|
|
map.playerSpawn = { x: num(tokens, 1, lineNumber), y: num(tokens, 2, lineNumber) };
|
|
} else if (command === "collision") {
|
|
map.collisions.push(rectFrom(tokens, 1, lineNumber));
|
|
} else if (command === "tileset") {
|
|
map.tilesets.push({
|
|
id: requireToken(tokens, 1, lineNumber),
|
|
texture: requireToken(tokens, 2, lineNumber),
|
|
tileWidth: num(tokens, 3, lineNumber),
|
|
tileHeight: num(tokens, 4, lineNumber),
|
|
color: colorFrom(tokens, 5, lineNumber, true),
|
|
});
|
|
} else if (command === "layer") {
|
|
currentLayer = {
|
|
id: requireToken(tokens, 1, lineNumber),
|
|
role: requireToken(tokens, 2, lineNumber),
|
|
parallax: num(tokens, 3, lineNumber),
|
|
rects: [],
|
|
tileRects: [],
|
|
};
|
|
map.layers.push(currentLayer);
|
|
} else if (command === "endlayer") {
|
|
currentLayer = null;
|
|
} else if (command === "rect") {
|
|
requireLayer(currentLayer, lineNumber);
|
|
currentLayer.rects.push({ bounds: rectFrom(tokens, 1, lineNumber), color: colorFrom(tokens, 5, lineNumber, true) });
|
|
} else if (command === "rect_sprite" || command === "rect_sprite_src") {
|
|
requireLayer(currentLayer, lineNumber);
|
|
const item = {
|
|
bounds: rectFrom(tokens, 1, lineNumber),
|
|
color: colorFrom(tokens, 5, lineNumber, true),
|
|
texture: requireToken(tokens, 9, lineNumber),
|
|
source: null,
|
|
};
|
|
if (command === "rect_sprite_src") {
|
|
item.source = rectFrom(tokens, 10, lineNumber);
|
|
}
|
|
currentLayer.rects.push(item);
|
|
} else if (command === "tile_rect") {
|
|
requireLayer(currentLayer, lineNumber);
|
|
currentLayer.tileRects.push({
|
|
tileset: requireToken(tokens, 1, lineNumber),
|
|
x: num(tokens, 2, lineNumber),
|
|
y: num(tokens, 3, lineNumber),
|
|
columns: intNum(tokens, 4, lineNumber),
|
|
rows: intNum(tokens, 5, lineNumber),
|
|
tileIndex: intNum(tokens, 6, lineNumber),
|
|
color: colorFrom(tokens, 7, lineNumber, false),
|
|
});
|
|
} else if (command === "battle_zone") {
|
|
currentBattleZone = {
|
|
id: requireToken(tokens, 1, lineNumber),
|
|
trigger: rectFrom(tokens, 2, lineNumber),
|
|
camera: rectFrom(tokens, 6, lineNumber),
|
|
spawns: [],
|
|
};
|
|
map.battleZones.push(currentBattleZone);
|
|
} else if (command === "spawn") {
|
|
if (!currentBattleZone) throw new Error(`第 ${lineNumber} 行:spawn 必须写在 battle_zone 内`);
|
|
currentBattleZone.spawns.push({
|
|
id: requireToken(tokens, 1, lineNumber),
|
|
x: num(tokens, 2, lineNumber),
|
|
y: num(tokens, 3, lineNumber),
|
|
});
|
|
} else if (command === "endbattle_zone") {
|
|
currentBattleZone = null;
|
|
} else {
|
|
throw new Error(`第 ${lineNumber} 行:未知命令 ${command}`);
|
|
}
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
function serializeMap(map) {
|
|
const lines = [];
|
|
lines.push(`map ${map.id}`);
|
|
lines.push(`world ${fmt(map.world.width)} ${fmt(map.world.height)}`);
|
|
lines.push(`camera ${rectText(map.camera)}`);
|
|
lines.push(`player_spawn ${fmt(map.playerSpawn.x)} ${fmt(map.playerSpawn.y)}`);
|
|
lines.push("");
|
|
|
|
for (const item of map.collisions) lines.push(`collision ${rectText(item)}`);
|
|
lines.push("");
|
|
|
|
for (const tileset of map.tilesets) {
|
|
lines.push(`tileset ${tileset.id} ${tileset.texture} ${fmt(tileset.tileWidth)} ${fmt(tileset.tileHeight)} ${colorText(tileset.color, true)}`);
|
|
}
|
|
lines.push("");
|
|
|
|
for (const layer of sortLayers(map.layers)) {
|
|
lines.push(`layer ${layer.id} ${layer.role} ${fmt(layer.parallax)}`);
|
|
for (const rect of layer.rects) {
|
|
if (rect.texture && rect.source) {
|
|
lines.push(`rect_sprite_src ${rectText(rect.bounds)} ${colorText(rect.color, true)} ${rect.texture} ${rectText(rect.source)}`);
|
|
} else if (rect.texture) {
|
|
lines.push(`rect_sprite ${rectText(rect.bounds)} ${colorText(rect.color, true)} ${rect.texture}`);
|
|
} else {
|
|
lines.push(`rect ${rectText(rect.bounds)} ${colorText(rect.color, true)}`);
|
|
}
|
|
}
|
|
for (const tileRect of layer.tileRects) {
|
|
lines.push(`tile_rect ${tileRect.tileset} ${fmt(tileRect.x)} ${fmt(tileRect.y)} ${fmt(tileRect.columns)} ${fmt(tileRect.rows)} ${fmt(tileRect.tileIndex)} ${colorText(tileRect.color, false)}`);
|
|
}
|
|
lines.push("endlayer");
|
|
lines.push("");
|
|
}
|
|
|
|
for (const zone of map.battleZones) {
|
|
lines.push(`battle_zone ${zone.id} ${rectText(zone.trigger)} ${rectText(zone.camera)}`);
|
|
for (const spawn of zone.spawns) lines.push(`spawn ${spawn.id} ${fmt(spawn.x)} ${fmt(spawn.y)}`);
|
|
lines.push("endbattle_zone");
|
|
lines.push("");
|
|
}
|
|
|
|
return `${lines.join("\n").trim()}\n`;
|
|
}
|
|
|
|
function refresh() {
|
|
elements.mapIdInput.value = state.map.id;
|
|
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();
|
|
renderPropertyPanel();
|
|
drawCanvas();
|
|
}
|
|
|
|
function renderTilesets() {
|
|
elements.tilesetList.innerHTML = "";
|
|
for (const tileset of state.map.tilesets) {
|
|
const node = document.createElement("button");
|
|
node.type = "button";
|
|
node.className = "tileset-item";
|
|
node.innerHTML = `<div class="object-title">${escapeHtml(tileset.id)}</div><div class="object-meta">${escapeHtml(tileset.texture)} | ${tileset.tileWidth}x${tileset.tileHeight}</div>`;
|
|
node.addEventListener("click", () => {
|
|
const index = state.map.tilesets.indexOf(tileset);
|
|
state.selected = `tileset:${index}`;
|
|
refresh();
|
|
});
|
|
elements.tilesetList.appendChild(node);
|
|
}
|
|
}
|
|
|
|
function renderObjectList() {
|
|
const objects = collectObjects();
|
|
elements.objectList.innerHTML = "";
|
|
for (const object of objects) {
|
|
const node = document.createElement("button");
|
|
node.type = "button";
|
|
node.className = `object-item ${sameSelection(object.key, state.selected) ? "active" : ""}`;
|
|
node.innerHTML = `<div class="object-title">${escapeHtml(object.title)}</div><div class="object-meta">${escapeHtml(object.meta)}</div>`;
|
|
node.addEventListener("click", () => {
|
|
state.selected = object.key;
|
|
refresh();
|
|
});
|
|
elements.objectList.appendChild(node);
|
|
}
|
|
}
|
|
|
|
function renderPropertyPanel() {
|
|
const object = getSelectedObject();
|
|
elements.propertyPanel.innerHTML = "";
|
|
if (!object) {
|
|
elements.selectionSummary.textContent = "未选择对象。";
|
|
elements.deleteButton.disabled = true;
|
|
return;
|
|
}
|
|
|
|
elements.selectionSummary.textContent = `${object.title} | ${object.meta}`;
|
|
elements.deleteButton.disabled = !object.deletable;
|
|
const fields = object.fields();
|
|
for (const field of fields) {
|
|
const label = document.createElement("label");
|
|
label.textContent = field.label;
|
|
let input;
|
|
if (field.options) {
|
|
input = document.createElement("select");
|
|
for (const option of field.options) {
|
|
const node = document.createElement("option");
|
|
node.value = option;
|
|
node.textContent = option;
|
|
input.appendChild(node);
|
|
}
|
|
} else {
|
|
input = document.createElement("input");
|
|
input.type = field.type || "number";
|
|
if (input.type === "number") input.step = field.step || "1";
|
|
}
|
|
input.value = field.get();
|
|
input.addEventListener("change", () => {
|
|
field.set(input.type === "number" ? Number(input.value) : input.value);
|
|
refresh();
|
|
});
|
|
label.appendChild(input);
|
|
elements.propertyPanel.appendChild(label);
|
|
}
|
|
}
|
|
|
|
function drawCanvas() {
|
|
const canvas = elements.mapCanvas;
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
ctx.save();
|
|
ctx.translate(-state.view.x * state.view.scale, -state.view.y * state.view.scale);
|
|
ctx.scale(state.view.scale, state.view.scale);
|
|
|
|
drawWorld(ctx);
|
|
drawGrid(ctx);
|
|
drawLayers(ctx);
|
|
drawCollisions(ctx);
|
|
drawBattleZones(ctx);
|
|
drawPlayerSpawn(ctx);
|
|
drawSelection(ctx);
|
|
|
|
ctx.restore();
|
|
elements.viewText.textContent = `缩放 ${state.view.scale.toFixed(2)} | 网格 ${state.gridSize}`;
|
|
}
|
|
|
|
function drawWorld(ctx) {
|
|
ctx.fillStyle = "#151a20";
|
|
ctx.fillRect(0, 0, state.map.world.width, state.map.world.height);
|
|
ctx.strokeStyle = "#53606d";
|
|
ctx.lineWidth = 2 / state.view.scale;
|
|
ctx.strokeRect(0, 0, state.map.world.width, state.map.world.height);
|
|
|
|
ctx.strokeStyle = "#4f7cff";
|
|
ctx.setLineDash([12 / state.view.scale, 8 / state.view.scale]);
|
|
ctx.strokeRect(state.map.camera.x, state.map.camera.y, state.map.camera.width, state.map.camera.height);
|
|
ctx.setLineDash([]);
|
|
}
|
|
|
|
function drawGrid(ctx) {
|
|
const step = Math.max(1, state.gridSize);
|
|
ctx.strokeStyle = "rgba(255,255,255,0.05)";
|
|
ctx.lineWidth = 1 / state.view.scale;
|
|
for (let x = 0; x <= state.map.world.width; x += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x, 0);
|
|
ctx.lineTo(x, state.map.world.height);
|
|
ctx.stroke();
|
|
}
|
|
for (let y = 0; y <= state.map.world.height; y += step) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, y);
|
|
ctx.lineTo(state.map.world.width, y);
|
|
ctx.stroke();
|
|
}
|
|
}
|
|
|
|
function drawLayers(ctx) {
|
|
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 tileRect of layer.tileRects) drawTileRect(ctx, tileRect);
|
|
}
|
|
}
|
|
|
|
function drawTileRect(ctx, tileRect) {
|
|
const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset);
|
|
const tw = tileset ? tileset.tileWidth : 10;
|
|
const th = tileset ? tileset.tileHeight : 10;
|
|
const bounds = { x: tileRect.x, y: tileRect.y, width: tileRect.columns * tw, height: tileRect.rows * th };
|
|
drawFilledRect(ctx, bounds, tileRect.color, 0.75, "#d7e1ea");
|
|
ctx.strokeStyle = "rgba(255,255,255,0.22)";
|
|
ctx.lineWidth = 1 / state.view.scale;
|
|
for (let x = bounds.x; x <= bounds.x + bounds.width; x += tw) line(ctx, x, bounds.y, x, bounds.y + bounds.height);
|
|
for (let y = bounds.y; y <= bounds.y + bounds.height; y += th) line(ctx, bounds.x, y, bounds.x + bounds.width, y);
|
|
}
|
|
|
|
function drawCollisions(ctx) {
|
|
for (const rect of state.map.collisions) drawStrokeRect(ctx, rect, "rgba(255,104,104,0.95)", "rgba(255,104,104,0.10)");
|
|
}
|
|
|
|
function drawBattleZones(ctx) {
|
|
for (const zone of state.map.battleZones) {
|
|
drawStrokeRect(ctx, zone.trigger, "rgba(255,214,102,0.95)", "rgba(255,214,102,0.08)");
|
|
drawStrokeRect(ctx, zone.camera, "rgba(84,168,255,0.75)", "rgba(84,168,255,0.05)");
|
|
for (const spawn of zone.spawns) drawPoint(ctx, spawn.x, spawn.y, "#9ed06a");
|
|
}
|
|
}
|
|
|
|
function drawPlayerSpawn(ctx) {
|
|
drawPoint(ctx, state.map.playerSpawn.x, state.map.playerSpawn.y, "#ffffff");
|
|
}
|
|
|
|
function drawSelection(ctx) {
|
|
const object = getSelectedObject();
|
|
if (!object) return;
|
|
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 (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) {
|
|
ctx.fillStyle = rgba(color, alpha);
|
|
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
ctx.strokeStyle = stroke;
|
|
ctx.lineWidth = 1 / state.view.scale;
|
|
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
}
|
|
|
|
function drawStrokeRect(ctx, rect, stroke, fill) {
|
|
ctx.fillStyle = fill;
|
|
ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
|
|
ctx.strokeStyle = stroke;
|
|
ctx.lineWidth = 2 / state.view.scale;
|
|
ctx.strokeRect(rect.x, rect.y, rect.width, rect.height);
|
|
}
|
|
|
|
function drawPoint(ctx, x, y, color) {
|
|
const r = 8 / state.view.scale;
|
|
ctx.fillStyle = color;
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, r, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.strokeStyle = "#0b0d10";
|
|
ctx.lineWidth = 2 / state.view.scale;
|
|
ctx.stroke();
|
|
}
|
|
|
|
function line(ctx, x1, y1, x2, y2) {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.lineTo(x2, y2);
|
|
ctx.stroke();
|
|
}
|
|
|
|
function collectObjects() {
|
|
const objects = [
|
|
objectForPoint("player", "玩家出生点", state.map.playerSpawn),
|
|
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, 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, 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, "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;
|
|
}
|
|
|
|
function objectForTileset(id, title, tileset, deletable) {
|
|
return {
|
|
key: id,
|
|
title,
|
|
meta: `${tileset.texture} | ${tileset.tileWidth}x${tileset.tileHeight}`,
|
|
deletable,
|
|
fields: () => [
|
|
textField("ID", () => tileset.id, (v) => { renameTileset(tileset.id, v || tileset.id); }),
|
|
textField("贴图路径", () => tileset.texture, (v) => { tileset.texture = v || tileset.texture; }),
|
|
numberField("图块宽", () => tileset.tileWidth, (v) => { tileset.tileWidth = Math.max(1, v); }),
|
|
numberField("图块高", () => tileset.tileHeight, (v) => { tileset.tileHeight = Math.max(1, v); }),
|
|
...colorFields(tileset.color, true),
|
|
],
|
|
};
|
|
}
|
|
|
|
function objectForLayer(id, title, layer, deletable) {
|
|
return {
|
|
key: id,
|
|
title,
|
|
meta: `${layer.role} | parallax ${layer.parallax}`,
|
|
deletable,
|
|
fields: () => [
|
|
textField("ID", () => layer.id, (v) => { layer.id = v || layer.id; }),
|
|
textField("类型", () => layer.role, (v) => { layer.role = v; }, ROLE_ORDER),
|
|
numberField("视差", () => layer.parallax, (v) => { layer.parallax = v; }, "0.05"),
|
|
],
|
|
};
|
|
}
|
|
|
|
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),
|
|
};
|
|
}
|
|
|
|
function objectForPoint(id, title, point, deletable = false) {
|
|
return {
|
|
key: id,
|
|
type: "spawn",
|
|
title,
|
|
meta: `${fmt(point.x)}, ${fmt(point.y)}`,
|
|
deletable,
|
|
bounds: () => ({ x: point.x - 8, y: point.y - 8, width: 16, height: 16 }),
|
|
move(dx, dy) { point.x += dx; point.y += dy; },
|
|
fields: () => pointFields(point),
|
|
};
|
|
}
|
|
|
|
function objectForTileRect(id, title, tileRect, deletable) {
|
|
const tileset = state.map.tilesets.find((item) => item.id === tileRect.tileset);
|
|
const tw = tileset ? tileset.tileWidth : 10;
|
|
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; }),
|
|
numberField("Y", () => tileRect.y, (v) => { tileRect.y = v; }),
|
|
numberField("列数", () => tileRect.columns, (v) => { tileRect.columns = Math.max(1, Math.round(v)); }),
|
|
numberField("行数", () => tileRect.rows, (v) => { tileRect.rows = Math.max(1, Math.round(v)); }),
|
|
numberField("图块索引", () => tileRect.tileIndex, (v) => { tileRect.tileIndex = Math.max(0, Math.round(v)); }),
|
|
...colorFields(tileRect.color, false),
|
|
],
|
|
};
|
|
}
|
|
|
|
function getSelectedObject() {
|
|
if (!state.selected) return null;
|
|
return collectObjects().find((object) => sameSelection(object.key, state.selected)) || null;
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
drawCanvas();
|
|
return;
|
|
}
|
|
const object = getSelectedObject();
|
|
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();
|
|
}
|
|
}
|
|
|
|
function onCanvasMouseUp() {
|
|
state.dragging = null;
|
|
setCanvasCursor("crosshair");
|
|
}
|
|
|
|
function onCanvasWheel(event) {
|
|
event.preventDefault();
|
|
const before = screenToWorld(event.offsetX, event.offsetY);
|
|
const factor = event.deltaY < 0 ? 1.12 : 0.88;
|
|
state.view.scale = clamp(state.view.scale * factor, 0.08, 4);
|
|
const after = screenToWorld(event.offsetX, event.offsetY);
|
|
state.view.x += before.x - after.x;
|
|
state.view.y += before.y - after.y;
|
|
drawCanvas();
|
|
}
|
|
|
|
function hitTest(x, y) {
|
|
const objects = collectObjects().filter(isHitFilterEnabled).reverse();
|
|
for (const object of objects) {
|
|
const bounds = object.bounds && object.bounds();
|
|
if (!bounds) continue;
|
|
if (x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height) return object;
|
|
}
|
|
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 });
|
|
state.selected = `collision:${state.map.collisions.length - 1}`;
|
|
refresh();
|
|
}
|
|
|
|
function addTileset() {
|
|
const index = state.map.tilesets.length + 1;
|
|
state.map.tilesets.push({
|
|
id: `tileset_${index}`,
|
|
texture: `assets/stage/stage_01/tiles/tileset_${index}.png`,
|
|
tileWidth: 10,
|
|
tileHeight: 10,
|
|
color: { r: 0.62, g: 0.68, b: 0.73, a: 1 },
|
|
});
|
|
state.selected = `tileset:${state.map.tilesets.length - 1}`;
|
|
refresh();
|
|
}
|
|
|
|
function addLayer() {
|
|
const index = state.map.layers.length + 1;
|
|
state.map.layers.push({
|
|
id: `layer_${index}`,
|
|
role: "PropsBack",
|
|
parallax: 1,
|
|
rects: [],
|
|
tileRects: [],
|
|
});
|
|
state.selected = `layer:${state.map.layers.length - 1}`;
|
|
refresh();
|
|
}
|
|
|
|
function addTileRect() {
|
|
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 } });
|
|
}
|
|
const layerIndex = state.map.layers.findIndex((layer) => layer.role === "LevelVisual");
|
|
const layer = state.map.layers[layerIndex];
|
|
const point = snapPoint(viewCenter());
|
|
layer.tileRects.push({ tileset: state.map.tilesets[0].id, x: point.x, y: point.y, columns: 8, rows: 4, tileIndex: 0, color: { r: 0.62, g: 0.68, b: 0.73 } });
|
|
state.selected = `tile:${layerIndex}:${layer.tileRects.length - 1}`;
|
|
refresh();
|
|
}
|
|
|
|
function addBattleZone() {
|
|
const point = snapPoint(viewCenter());
|
|
state.map.battleZones.push({
|
|
id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`,
|
|
trigger: { x: point.x, y: point.y, width: 520, height: 320 },
|
|
camera: { x: Math.max(0, point.x - 520), y: 0, width: 1280, height: state.map.world.height },
|
|
spawns: [],
|
|
});
|
|
state.selected = `zone:${state.map.battleZones.length - 1}`;
|
|
refresh();
|
|
}
|
|
|
|
function addSpawn() {
|
|
if (!state.map.battleZones.length) addBattleZone();
|
|
const zoneIndex = Math.max(0, state.map.battleZones.length - 1);
|
|
const zone = state.map.battleZones[zoneIndex];
|
|
const point = snapPoint(viewCenter());
|
|
zone.spawns.push({ id: `${zone.id}_enemy_${zone.spawns.length + 1}`, x: point.x, y: point.y });
|
|
state.selected = `spawn:${zoneIndex}:${zone.spawns.length - 1}`;
|
|
refresh();
|
|
}
|
|
|
|
function deleteSelected() {
|
|
if (!state.selected) return;
|
|
const parts = state.selected.split(":");
|
|
if (parts[0] === "collision") state.map.collisions.splice(Number(parts[1]), 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);
|
|
}
|
|
if (parts[0] === "layer") state.map.layers.splice(Number(parts[1]), 1);
|
|
if (parts[0] === "rect") state.map.layers[Number(parts[1])].rects.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] === "spawn") state.map.battleZones[Number(parts[1])].spawns.splice(Number(parts[2]), 1);
|
|
state.selected = null;
|
|
refresh();
|
|
}
|
|
|
|
function downloadMap() {
|
|
const blob = new Blob([serializeMap(state.map)], { type: "text/plain" });
|
|
const url = URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = `${state.map.id || "stage"}.map`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function fitWorldToCanvas() {
|
|
const canvas = elements.mapCanvas;
|
|
const scaleX = canvas.width / Math.max(1, state.map.world.width);
|
|
const scaleY = canvas.height / Math.max(1, state.map.world.height);
|
|
state.view.scale = Math.max(0.08, Math.min(scaleX, scaleY) * 0.92);
|
|
state.view.x = -((canvas.width / state.view.scale - state.map.world.width) / 2);
|
|
state.view.y = -((canvas.height / state.view.scale - state.map.world.height) / 2);
|
|
}
|
|
|
|
function resizeCanvas() {
|
|
const canvas = elements.mapCanvas;
|
|
const rect = canvas.getBoundingClientRect();
|
|
canvas.width = Math.max(1, Math.floor(rect.width * window.devicePixelRatio));
|
|
canvas.height = Math.max(1, Math.floor(rect.height * window.devicePixelRatio));
|
|
const ctx = canvas.getContext("2d");
|
|
ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
|
|
canvas.width = Math.max(1, Math.floor(rect.width));
|
|
canvas.height = Math.max(1, Math.floor(rect.height));
|
|
drawCanvas();
|
|
}
|
|
|
|
function rectFields(rect) {
|
|
return [
|
|
numberField("X", () => rect.x, (v) => { rect.x = v; }),
|
|
numberField("Y", () => rect.y, (v) => { rect.y = v; }),
|
|
numberField("W", () => rect.width, (v) => { rect.width = Math.max(1, v); }),
|
|
numberField("H", () => rect.height, (v) => { rect.height = Math.max(1, v); }),
|
|
];
|
|
}
|
|
|
|
function pointFields(point) {
|
|
return [
|
|
numberField("X", () => point.x, (v) => { point.x = v; }),
|
|
numberField("Y", () => point.y, (v) => { point.y = v; }),
|
|
];
|
|
}
|
|
|
|
function colorFields(color, includeAlpha) {
|
|
const fields = [
|
|
numberField("R", () => color.r, (v) => { color.r = clamp(v, 0, 1); }, "0.01"),
|
|
numberField("G", () => color.g, (v) => { color.g = clamp(v, 0, 1); }, "0.01"),
|
|
numberField("B", () => color.b, (v) => { color.b = clamp(v, 0, 1); }, "0.01"),
|
|
];
|
|
if (includeAlpha) fields.push(numberField("A", () => color.a, (v) => { color.a = clamp(v, 0, 1); }, "0.01"));
|
|
return fields;
|
|
}
|
|
|
|
function numberField(label, get, set, step = "1") {
|
|
return { label, type: "number", step, get, set };
|
|
}
|
|
|
|
function textField(label, get, set, options = null) {
|
|
return { label, type: "text", options, get, set };
|
|
}
|
|
|
|
function ensureLevelVisualLayer() {
|
|
if (!state.map.layers.some((layer) => layer.role === "LevelVisual")) {
|
|
state.map.layers.push({ id: "level_visual", role: "LevelVisual", parallax: 1, rects: [], tileRects: [] });
|
|
}
|
|
}
|
|
|
|
function sortLayers(layers) {
|
|
return [...layers].sort((a, b) => ROLE_ORDER.indexOf(a.role) - ROLE_ORDER.indexOf(b.role));
|
|
}
|
|
|
|
function tokenize(line) {
|
|
const tokens = [];
|
|
for (const token of line.trim().split(/\s+/).filter(Boolean)) {
|
|
if (token.startsWith("#")) break;
|
|
tokens.push(token);
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function requireLayer(layer, lineNumber) {
|
|
if (!layer) throw new Error(`第 ${lineNumber} 行:该命令必须写在 layer 内`);
|
|
}
|
|
|
|
function requireToken(tokens, index, lineNumber) {
|
|
if (tokens.length <= index) throw new Error(`第 ${lineNumber} 行:缺少第 ${index} 个参数`);
|
|
return tokens[index];
|
|
}
|
|
|
|
function num(tokens, index, lineNumber) {
|
|
const value = Number(requireToken(tokens, index, lineNumber));
|
|
if (!Number.isFinite(value)) throw new Error(`第 ${lineNumber} 行:无效数字 ${tokens[index]}`);
|
|
return value;
|
|
}
|
|
|
|
function intNum(tokens, index, lineNumber) {
|
|
return Math.round(num(tokens, index, lineNumber));
|
|
}
|
|
|
|
function rectFrom(tokens, start, lineNumber) {
|
|
return { x: num(tokens, start, lineNumber), y: num(tokens, start + 1, lineNumber), width: num(tokens, start + 2, lineNumber), height: num(tokens, start + 3, lineNumber) };
|
|
}
|
|
|
|
function colorFrom(tokens, start, lineNumber, includeAlpha) {
|
|
return { r: num(tokens, start, lineNumber), g: num(tokens, start + 1, lineNumber), b: num(tokens, start + 2, lineNumber), a: includeAlpha ? num(tokens, start + 3, lineNumber) : 1 };
|
|
}
|
|
|
|
function rectText(rect) {
|
|
return `${fmt(rect.x)} ${fmt(rect.y)} ${fmt(rect.width)} ${fmt(rect.height)}`;
|
|
}
|
|
|
|
function colorText(color, includeAlpha) {
|
|
return includeAlpha ? `${fmt(color.r)} ${fmt(color.g)} ${fmt(color.b)} ${fmt(color.a)}` : `${fmt(color.r)} ${fmt(color.g)} ${fmt(color.b)}`;
|
|
}
|
|
|
|
function fmt(value) {
|
|
return Number(value).toFixed(3).replace(/\.?0+$/, "");
|
|
}
|
|
|
|
function rgba(color, alphaMul) {
|
|
const a = color.a === undefined ? 1 : color.a;
|
|
return `rgba(${Math.round(color.r * 255)},${Math.round(color.g * 255)},${Math.round(color.b * 255)},${a * alphaMul})`;
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[char]));
|
|
}
|
|
|
|
function sameSelection(a, b) {
|
|
return a === b;
|
|
}
|
|
|
|
function numberValue(input, fallback) {
|
|
const value = Number(input.value);
|
|
return Number.isFinite(value) ? value : fallback;
|
|
}
|
|
|
|
function screenToWorld(x, y) {
|
|
return { x: state.view.x + x / state.view.scale, y: state.view.y + y / state.view.scale };
|
|
}
|
|
|
|
function viewCenter() {
|
|
const canvas = elements.mapCanvas;
|
|
return screenToWorld(canvas.width / 2, canvas.height / 2);
|
|
}
|
|
|
|
function snapPoint(point) {
|
|
return { x: Math.round(point.x / state.gridSize) * state.gridSize, y: Math.round(point.y / state.gridSize) * state.gridSize };
|
|
}
|
|
|
|
function clamp(value, min, max) {
|
|
return Math.max(min, Math.min(max, value));
|
|
}
|
|
|
|
function setStatus(text) {
|
|
elements.statusText.textContent = text;
|
|
}
|
|
|
|
function renameTileset(oldId, newId) {
|
|
const cleanId = String(newId).trim();
|
|
if (!cleanId) return;
|
|
if (cleanId !== oldId && state.map.tilesets.some((item) => item.id === cleanId)) {
|
|
setStatus(`不能重命名图块集:${cleanId} 已存在。`);
|
|
return;
|
|
}
|
|
const tileset = state.map.tilesets.find((item) => item.id === oldId);
|
|
if (!tileset) return;
|
|
tileset.id = cleanId;
|
|
for (const layer of state.map.layers) {
|
|
for (const tileRect of layer.tileRects) {
|
|
if (tileRect.tileset === oldId) tileRect.tileset = cleanId;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isTilesetUsed(id) {
|
|
return state.map.layers.some((layer) => layer.tileRects.some((tileRect) => tileRect.tileset === id));
|
|
}
|
|
|
|
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`;
|
|
}
|