Files
NS_unknown_game/tools/map_editor/app.js
T
2026-06-10 00:54:05 +08:00

1241 lines
43 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",
imagePreviews: new Map(),
dragging: null,
};
const elements = {};
document.addEventListener("DOMContentLoaded", () => {
bindElements();
bindEvents();
resizeCanvas();
loadSampleMap();
});
function bindElements() {
for (const id of [
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton",
"graphicFileInput",
"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.graphicFileInput.addEventListener("change", importGraphicAsset);
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);
}
async function importGraphicAsset(event) {
const file = event.target.files[0];
if (!file) return;
try {
const previewUrl = URL.createObjectURL(file);
const image = await loadImage(previewUrl);
const texturePath = defaultGraphicTexturePath(file.name);
state.imagePreviews.set(texturePath, image);
addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64);
setStatus(`已导入图形 ${file.name},并自动创建同尺寸碰撞块。请把素材放到 ${texturePath}`);
} catch (error) {
setStatus(`导入图形失败:${error.message}`);
} finally {
event.target.value = "";
}
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("图片读取失败"));
image.src = url;
});
}
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}`);
}
}
rebuildGraphicCollisionLinks(map);
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) drawLayerRect(ctx, rect);
for (const tileRect of layer.tileRects) drawTileRect(ctx, tileRect);
}
}
function drawLayerRect(ctx, rect) {
const image = rect.texture ? state.imagePreviews.get(rect.texture) : null;
if (image) {
ctx.save();
ctx.globalAlpha = rect.color.a === undefined ? 1 : rect.color.a;
if (rect.source) {
ctx.drawImage(
image,
rect.source.x,
rect.source.y,
rect.source.width,
rect.source.height,
rect.bounds.x,
rect.bounds.y,
rect.bounds.width,
rect.bounds.height
);
} else {
ctx.drawImage(image, rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height);
}
ctx.restore();
ctx.strokeStyle = "#7d8794";
ctx.lineWidth = 1 / state.view.scale;
ctx.strokeRect(rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height);
return;
}
drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794");
}
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(objectForLayerRect(`rect:${layerIndex}:${rectIndex}`, `${layer.id} 图形 ${rectIndex + 1}`, rect, true)));
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 objectForLayerRect(id, title, item, deletable) {
return {
key: id,
type: "rect",
title,
meta: item.texture ? item.texture : `${fmt(item.bounds.x)}, ${fmt(item.bounds.y)}, ${fmt(item.bounds.width)}x${fmt(item.bounds.height)}`,
deletable,
bounds: () => item.bounds,
move(dx, dy) {
item.bounds.x += dx;
item.bounds.y += dy;
moveLinkedCollision(item, dx, dy);
},
resize(dw, dh) {
item.bounds.width = Math.max(1, item.bounds.width + dw);
item.bounds.height = Math.max(1, item.bounds.height + dh);
resizeLinkedCollision(item, item.bounds.width, item.bounds.height);
},
fields: () => layerRectFields(item),
};
}
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;
}
if (selected && selected.move && isInsideBounds(selected, world.x, world.y)) {
state.dragging = { last: snapPoint(world) };
setCanvasCursor("move");
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 isInsideBounds(object, x, y) {
const bounds = object.bounds && object.bounds();
if (!bounds) return false;
return x >= bounds.x && x <= bounds.x + bounds.width && y >= bounds.y && y <= bounds.y + bounds.height;
}
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 (selected && selected.move && isInsideBounds(selected, world.x, world.y)) {
setCanvasCursor("move");
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 addGraphicSprite(texturePath, width, height) {
const layerIndex = ensurePropsBackLayer();
const layer = state.map.layers[layerIndex];
const point = snapPoint(viewCenter());
const bounds = {
x: point.x,
y: point.y,
width: Math.max(1, Math.round(width)),
height: Math.max(1, Math.round(height)),
};
const collision = { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height };
state.map.collisions.push(collision);
layer.rects.push({
bounds,
color: { r: 1, g: 1, b: 1, a: 1 },
texture: texturePath,
source: null,
linkedCollisionIndex: state.map.collisions.length - 1,
});
state.selected = `rect:${layerIndex}:${layer.rects.length - 1}`;
state.hitFilter = "rect";
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") 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);
}
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);
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 layerRectFields(item) {
return [
numberField("X", () => item.bounds.x, (v) => {
const next = Number.isFinite(v) ? v : item.bounds.x;
moveLinkedCollision(item, next - item.bounds.x, 0);
item.bounds.x = next;
}),
numberField("Y", () => item.bounds.y, (v) => {
const next = Number.isFinite(v) ? v : item.bounds.y;
moveLinkedCollision(item, 0, next - item.bounds.y);
item.bounds.y = next;
}),
numberField("W", () => item.bounds.width, (v) => {
item.bounds.width = Math.max(1, v);
resizeLinkedCollision(item, item.bounds.width, item.bounds.height);
}),
numberField("H", () => item.bounds.height, (v) => {
item.bounds.height = Math.max(1, v);
resizeLinkedCollision(item, item.bounds.width, item.bounds.height);
}),
textField("贴图路径", () => item.texture || "", (v) => {
const next = String(v || "").trim();
if (next) item.texture = next;
else delete item.texture;
}),
...colorFields(item.color, true),
];
}
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 ensurePropsBackLayer() {
let index = state.map.layers.findIndex((layer) => layer.role === "PropsBack");
if (index >= 0) return index;
state.map.layers.push({ id: "props_back", role: "PropsBack", parallax: 1, rects: [], tileRects: [] });
index = state.map.layers.length - 1;
return index;
}
function defaultGraphicTexturePath(fileName) {
return `assets/stage/stage_01/props/${safeAssetFileName(fileName)}`;
}
function safeAssetFileName(fileName) {
const clean = String(fileName || "graphic.png").replace(/\\/g, "/").split("/").pop();
return clean.replace(/[^A-Za-z0-9._-]/g, "_") || "graphic.png";
}
function linkedCollision(item) {
const index = Number(item.linkedCollisionIndex);
if (!Number.isInteger(index)) return null;
return state.map.collisions[index] || null;
}
function moveLinkedCollision(item, dx, dy) {
const collision = linkedCollision(item);
if (!collision) return;
collision.x += dx;
collision.y += dy;
}
function resizeLinkedCollision(item, width, height) {
const collision = linkedCollision(item);
if (!collision) return;
collision.width = Math.max(1, width);
collision.height = Math.max(1, height);
}
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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 deleteLayer(layerIndex) {
const layer = state.map.layers[layerIndex];
if (!layer) return;
const linkedIndexes = layer.rects
.map((item) => Number(item.linkedCollisionIndex))
.filter((index) => Number.isInteger(index))
.sort((a, b) => b - a);
state.map.layers.splice(layerIndex, 1);
for (const index of linkedIndexes) deleteCollision(index);
}
function deleteLayerRect(layerIndex, rectIndex) {
const layer = state.map.layers[layerIndex];
if (!layer) return;
const item = layer.rects[rectIndex];
if (!item) return;
const linkedIndex = Number(item.linkedCollisionIndex);
layer.rects.splice(rectIndex, 1);
if (Number.isInteger(linkedIndex)) deleteCollision(linkedIndex);
}
function deleteCollision(index) {
if (index < 0 || index >= state.map.collisions.length) return;
state.map.collisions.splice(index, 1);
for (const layer of state.map.layers) {
for (const item of layer.rects) {
const linkedIndex = Number(item.linkedCollisionIndex);
if (!Number.isInteger(linkedIndex)) continue;
if (linkedIndex === index) delete item.linkedCollisionIndex;
else if (linkedIndex > index) item.linkedCollisionIndex = linkedIndex - 1;
}
}
}
function rebuildGraphicCollisionLinks(map) {
const used = new Set();
for (const layer of map.layers) {
for (const item of layer.rects) {
delete item.linkedCollisionIndex;
if (!item.texture) continue;
const index = map.collisions.findIndex((collision, collisionIndex) => {
return !used.has(collisionIndex) && sameRect(collision, item.bounds);
});
if (index >= 0) {
item.linkedCollisionIndex = index;
used.add(index);
}
}
}
}
function sameRect(a, b) {
return nearlyEqual(a.x, b.x) &&
nearlyEqual(a.y, b.y) &&
nearlyEqual(a.width, b.width) &&
nearlyEqual(a.height, b.height);
}
function nearlyEqual(a, b) {
return Math.abs(Number(a) - Number(b)) < 0.001;
}
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`;
}