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: "all", imagePreviews: new Map(), imageLoading: new Set(), imageFailures: new Set(), previewLoadToken: 0, serverAvailable: false, history: [], dragging: null, }; const elements = {}; document.addEventListener("DOMContentLoaded", () => { bindElements(); bindEvents(); resizeCanvas(); loadSampleMap(); }); function bindElements() { for (const id of [ "mapCanvas", "fileInput", "loadSampleButton", "downloadButton", "undoButton", "saveProjectButton", "deployButton", "graphicFileInput", "mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput", "hitFilterInput", "objectList", "propertyPanel", "selectionSummary", "deleteButton", "tilesetList", "outputText", "statusText", "viewText", "addTilesetButton", "addLayerButton", "addCollisionButton", "addTileRectButton", "addBattleZoneButton", "addSpawnButton", "moveLayerUpButton", "moveLayerDownButton", ]) { 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", saveAndDownloadMap); elements.saveProjectButton.addEventListener("click", saveProjectMap); elements.deployButton.addEventListener("click", deployProjectMap); elements.undoButton.addEventListener("click", undoLast); elements.deleteButton.addEventListener("click", deleteSelected); elements.moveLayerUpButton.addEventListener("click", () => moveSelectedLayer(-1)); elements.moveLayerDownButton.addEventListener("click", () => moveSelectedLayer(1)); window.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "z") { event.preventDefault(); undoLast(); } }); elements.mapIdInput.addEventListener("change", () => { pushUndo("修改地图 ID"); state.map.id = elements.mapIdInput.value.trim() || "stage"; refresh(); }); elements.worldWidthInput.addEventListener("change", () => { pushUndo("修改世界宽度"); state.map.world.width = numberValue(elements.worldWidthInput, state.map.world.width); refresh(); }); elements.worldHeightInput.addEventListener("change", () => { pushUndo("修改世界高度"); state.map.world.height = numberValue(elements.worldHeightInput, state.map.world.height); refresh(); }); 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 }); detectServerFeatures(); } async function detectServerFeatures() { try { const response = await fetch("/api/map-editor/status", { cache: "no-store" }); const data = response.ok ? await response.json() : null; state.serverAvailable = Boolean(data && data.writable); } catch (error) { state.serverAvailable = false; } updateServerButtons(); } function updateServerButtons() { elements.saveProjectButton.disabled = !state.serverAvailable; elements.deployButton.disabled = !state.serverAvailable; elements.saveProjectButton.title = state.serverAvailable ? "保存到 game/assets/map/stage_01.map" : "需要用 node tools/map_editor/server.js 启动编辑器"; elements.deployButton.title = state.serverAvailable ? "保存并同步到已有 PC/Switch build 资源目录" : "需要用 node tools/map_editor/server.js 启动编辑器"; } 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); state.imageFailures.delete(texturePath); pushUndo("导入图形"); 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; }); } async function preloadMapImages(map) { const textures = uniqueMapTextures(map); if (!textures.length) return; const token = state.previewLoadToken + 1; state.previewLoadToken = token; setStatus(`正在加载贴图预览 0/${textures.length}`); let loaded = 0; let failed = 0; await Promise.all(textures.map(async (texturePath) => { const ok = await queueImagePreview(texturePath, { refreshAfterLoad: false }); if (ok) loaded += 1; else failed += 1; if (state.previewLoadToken === token) { setStatus(`正在加载贴图预览 ${loaded + failed}/${textures.length}`); } })); if (state.previewLoadToken !== token) return; refresh(); if (failed) { setStatus(`贴图预览完成:成功 ${loaded},失败 ${failed}`); } else { setStatus(`贴图预览完成:${loaded}/${textures.length}`); } } function uniqueMapTextures(map) { const textures = []; const seen = new Set(); for (const layer of map.layers) { for (const rect of layer.rects) { if (!rect.texture || seen.has(rect.texture)) continue; seen.add(rect.texture); textures.push(rect.texture); } } return textures; } async function queueImagePreview(texturePath, options = {}) { if (!texturePath || state.imagePreviews.has(texturePath)) { return true; } if (state.imageLoading.has(texturePath)) { return false; } if (state.imageFailures.has(texturePath)) { return false; } const url = previewUrlForTexture(texturePath); if (!url) { state.imageFailures.add(texturePath); return false; } state.imageLoading.add(texturePath); try { const image = await loadImage(url); state.imagePreviews.set(texturePath, image); state.imageFailures.delete(texturePath); if (options.refreshAfterLoad !== false) refresh(); return true; } catch (error) { state.imageFailures.add(texturePath); setStatus(`贴图预览加载失败:${texturePath}`); return false; } finally { state.imageLoading.delete(texturePath); } } function previewUrlForTexture(texturePath) { const clean = String(texturePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); if (!clean) return ""; if (/^(blob:|data:|https?:\/\/)/i.test(clean)) return clean; if (clean.startsWith("assets/")) return cacheBustedUrl(`${location.origin}/game/${clean}`); if (clean.startsWith("game/assets/")) return cacheBustedUrl(`${location.origin}/${clean}`); return cacheBustedUrl(clean); } function cacheBustedUrl(url) { const separator = url.includes("?") ? "&" : "?"; return `${url}${separator}preview=${Date.now()}`; } function loadMapText(text, status) { try { state.map = parseMap(text); state.selected = null; state.history = []; state.imageFailures.clear(); fitWorldToCanvas(); setStatus(status); refresh(); preloadMapImages(state.map); } 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 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); elements.undoButton.disabled = state.history.length === 0; elements.undoButton.textContent = state.history.length ? `撤回 ${state.history.length}` : "撤回"; 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 = `
${escapeHtml(tileset.id)}
${escapeHtml(tileset.texture)} | ${tileset.tileWidth}x${tileset.tileHeight}
`; 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 = `
${escapeHtml(object.title)}
${escapeHtml(object.meta)}
`; 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; elements.moveLayerUpButton.disabled = true; elements.moveLayerDownButton.disabled = true; return; } elements.selectionSummary.textContent = `${object.title} | ${object.meta}`; elements.deleteButton.disabled = !object.deletable; updateLayerMoveButtons(); const fields = object.fields(); for (const field of fields) { const label = document.createElement("label"); 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", () => { const nextValue = input.type === "number" ? Number(input.value) : input.value; if (String(field.get()) === String(nextValue)) return; pushUndo(`修改${field.label}`); field.set(nextValue); refresh(); }); label.appendChild(input); elements.propertyPanel.appendChild(label); } } function updateLayerMoveButtons() { const layerIndex = selectedLayerIndex(); elements.moveLayerUpButton.disabled = layerIndex <= 0; elements.moveLayerDownButton.disabled = layerIndex < 0 || layerIndex >= state.map.layers.length - 1; } function drawCanvas() { const canvas = elements.mapCanvas; const ctx = canvas.getContext("2d"); 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 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; } if (rect.texture) queueImagePreview(rect.texture); 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, false, "player"), objectForRect("camera", "摄像机范围", state.map.camera, false, null, "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, type = "spawn") { return { key: id, type, 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(); const stack = hitStackAt(world.x, world.y); const object = stack[0] || null; 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) && canDragSelectedInPlace(selected, object, stack)) { state.dragging = dragStateFor(selected, stack, world, true); setCanvasCursor("move"); return; } if (object) { state.selected = object.key; state.dragging = dragStateFor(object, stack, world, selected && sameSelection(selected.key, object.key)); 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.undoPushed) { pushUndo(state.dragging.resize ? "调整对象尺寸" : "移动对象"); state.dragging.undoPushed = true; } state.dragging.moved = true; 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() { const dragging = state.dragging; state.dragging = null; if (dragging && dragging.cycleOnClick && !dragging.moved && dragging.cycleKeys && dragging.cycleKeys.length > 1) { cycleSelection(dragging.cycleKeys, dragging.cycleFrom); } 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) { return hitStackAt(x, y)[0] || null; } function hitStackAt(x, y) { const stack = []; for (const object of hitCandidates()) { 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) stack.push(object); } return stack; } function hitCandidates() { const objects = collectObjects().filter(isHitFilterEnabled); if (state.hitFilter !== "all") return objects.reverse(); const priority = { collision: 0, rect: 1, tile: 2, spawn: 3, zone: 4, player: 5, camera: 6 }; return objects .map((object, index) => ({ object, index })) .sort((a, b) => { const pa = priority[a.object.type] ?? 10; const pb = priority[b.object.type] ?? 10; if (pa !== pb) return pa - pb; return b.index - a.index; }) .map((entry) => entry.object); } 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 canDragSelectedInPlace(selected, topHit, stack) { if (state.hitFilter !== "all") return true; return Boolean(topHit) && stack.some((object) => sameSelection(object.key, selected.key)); } function dragStateFor(object, stack, world, cycleOnClick) { return { last: snapPoint(world), cycleKeys: stack.map((item) => item.key), cycleFrom: object.key, cycleOnClick, moved: false, }; } function cycleSelection(cycleKeys, currentKey) { const index = cycleKeys.findIndex((key) => sameSelection(key, currentKey)); if (index < 0) return; const nextKey = cycleKeys[(index + 1) % cycleKeys.length]; if (!sameSelection(nextKey, state.selected)) { state.selected = nextKey; refresh(); } } 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(); const stack = hitStackAt(world.x, world.y); const object = stack[0] || null; if (selected && selected.resize && isInsideResizeHandle(selected, world.x, world.y)) { setCanvasCursor("nwse-resize"); return; } if (selected && selected.move && isInsideBounds(selected, world.x, world.y) && canDragSelectedInPlace(selected, object, stack)) { setCanvasCursor("move"); return; } if (object) { setCanvasCursor("move"); return; } setCanvasCursor("crosshair"); } function setCanvasCursor(cursor) { elements.mapCanvas.style.cursor = cursor; } function addCollision() { pushUndo("新增碰撞块"); const point = snapPoint(viewCenter()); state.map.collisions.push({ x: point.x, y: point.y, width: 160, height: 40 }); state.selected = `collision:${state.map.collisions.length - 1}`; state.hitFilter = "collision"; 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() { pushUndo("新增图块集"); 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() { pushUndo("新增图层"); 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() { pushUndo("新增铺砖区"); ensureLevelVisualLayer(); if (!state.map.tilesets.length) { state.map.tilesets.push({ id: "graybox_tile", texture: "assets/stage/stage_01/tiles/graybox_tile.png", tileWidth: 10, tileHeight: 10, color: { r: 0.62, g: 0.68, b: 0.73, a: 1 } }); } 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}`; state.hitFilter = "tile"; refresh(); } function addBattleZone(recordHistory = true) { if (recordHistory) pushUndo("新增战斗区"); const point = snapPoint(viewCenter()); state.map.battleZones.push({ id: `zone_${String(state.map.battleZones.length + 1).padStart(2, "0")}`, 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() { pushUndo("新增出生点"); if (!state.map.battleZones.length) addBattleZone(false); const zoneIndex = Math.max(0, state.map.battleZones.length - 1); const zone = state.map.battleZones[zoneIndex]; const point = snapPoint(viewCenter()); 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] === "tileset") { const tileset = state.map.tilesets[Number(parts[1])]; if (tileset && isTilesetUsed(tileset.id)) { setStatus(`不能删除图块集 ${tileset.id}:它仍被 tile_rect 使用。`); return; } } pushUndo("删除对象"); if (parts[0] === "collision") deleteCollision(Number(parts[1])); if (parts[0] === "tileset") state.map.tilesets.splice(Number(parts[1]), 1); if (parts[0] === "layer") deleteLayer(Number(parts[1])); if (parts[0] === "rect") deleteLayerRect(Number(parts[1]), Number(parts[2])); if (parts[0] === "tile") state.map.layers[Number(parts[1])].tileRects.splice(Number(parts[2]), 1); 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 saveAndDownloadMap() { downloadMap(); setStatus(`已保存导出文本并下载 ${state.map.id || "stage"}.map`); } async function saveProjectMap() { await writeMapToServer("/api/map-editor/save", "已保存到项目资源目录。"); } async function deployProjectMap() { await writeMapToServer("/api/map-editor/deploy", "已保存并部署到项目和已有 build 资源目录。"); } async function writeMapToServer(url, successPrefix) { if (!state.serverAvailable) { setStatus("当前是静态服务模式,不能直接写入项目文件。请用 node tools/map_editor/server.js 启动。"); return; } const mapText = serializeMap(state.map); elements.saveProjectButton.disabled = true; elements.deployButton.disabled = true; try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mapText }), }); const data = await response.json().catch(() => ({})); if (!response.ok || !data.ok) { throw new Error(data.error || `HTTP ${response.status}`); } const targets = Array.isArray(data.targets) ? data.targets.length : 0; setStatus(targets ? `${successPrefix} 写入 ${targets} 个文件。` : successPrefix); } catch (error) { setStatus(`保存失败:${error.message}`); } finally { updateServerButtons(); } } function undoLast() { const entry = state.history.pop(); if (!entry) return; restoreSnapshot(entry); setStatus(`已撤回:${entry.label}`); refresh(); } function pushUndo(label) { state.history.push({ label, mapText: serializeMap(state.map), selected: state.selected, hitFilter: state.hitFilter, }); if (state.history.length > 80) state.history.shift(); } function restoreSnapshot(entry) { state.map = parseMap(entry.mapText); state.selected = entry.selected; state.hitFilter = entry.hitFilter || "all"; if (state.selected && !getSelectedObject()) state.selected = null; } function moveSelectedLayer(direction) { const index = selectedLayerIndex(); const nextIndex = index + direction; if (index < 0 || nextIndex < 0 || nextIndex >= state.map.layers.length) return; pushUndo(direction < 0 ? "图层上移" : "图层下移"); const [layer] = state.map.layers.splice(index, 1); state.map.layers.splice(nextIndex, 0, layer); state.selected = `layer:${nextIndex}`; refresh(); } function selectedLayerIndex() { if (!state.selected) return -1; const parts = state.selected.split(":"); if (parts[0] === "layer" || parts[0] === "rect" || parts[0] === "tile") return Number(parts[1]); return -1; } function downloadMap() { const blob = new Blob([serializeMap(state.map)], { type: "text/plain" }); const url = URL.createObjectURL(blob); 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) { const fields = [ 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; state.imageFailures.delete(next); queueImagePreview(next); } else { delete item.texture; } }), ]; if (item.texture && item.source) { fields.push( numberField("源 X", () => sourceRectFor(item).x, (v) => { sourceRectFor(item).x = Math.max(0, v); }), numberField("源 Y", () => sourceRectFor(item).y, (v) => { sourceRectFor(item).y = Math.max(0, v); }), numberField("源 W", () => sourceRectFor(item).width, (v) => { sourceRectFor(item).width = Math.max(1, v); }), numberField("源 H", () => sourceRectFor(item).height, (v) => { sourceRectFor(item).height = Math.max(1, v); }) ); } fields.push(...colorFields(item.color, true)); return fields; } 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 sourceRectFor(item) { if (!item.source) { const image = item.texture ? state.imagePreviews.get(item.texture) : null; item.source = { x: 0, y: 0, width: image ? (image.naturalWidth || image.width || Math.max(1, item.bounds.width)) : Math.max(1, item.bounds.width), height: image ? (image.naturalHeight || image.height || Math.max(1, item.bounds.height)) : Math.max(1, item.bounds.height), }; } return item.source; } 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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`; }