From 21f633658c7713bead83ed374b89256ae005ac0a Mon Sep 17 00:00:00 2001 From: 1165565692-lang <1165565692@qq.com> Date: Wed, 10 Jun 2026 10:45:33 +0800 Subject: [PATCH] Add map editor save and deploy actions --- game/docs/modules/map_editor.md | 24 ++++- tools/map_editor/DOCUMENTATION.md | 46 ++++++++- tools/map_editor/README.md | 35 ++++++- tools/map_editor/app.js | 60 ++++++++++++ tools/map_editor/index.html | 2 + tools/map_editor/server.js | 154 ++++++++++++++++++++++++++++++ 6 files changed, 309 insertions(+), 12 deletions(-) create mode 100644 tools/map_editor/server.js diff --git a/game/docs/modules/map_editor.md b/game/docs/modules/map_editor.md index 6a6965b..f77af11 100644 --- a/game/docs/modules/map_editor.md +++ b/game/docs/modules/map_editor.md @@ -72,10 +72,28 @@ tools/map_editor/ ├─ index.html ├─ styles.css ├─ app.js +├─ server.js └─ README.md ``` -本地运行: +推荐本地运行: + +```powershell +node tools/map_editor/server.js +``` + +打开: + +```text +http://127.0.0.1:8787/tools/map_editor/ +``` + +这种模式下支持顶部 `保存` 和 `一键部署`: + +- `保存`:写入 `game/assets/map/stage_01.map`。 +- `一键部署`:写入项目资源目录,并同步到已存在的 PC/Switch build 资源目录。 + +只需要下载 `.map` 时,也可以使用普通静态服务: ```powershell python -m http.server 8787 @@ -101,6 +119,8 @@ http://127.0.0.1:8787/tools/map_editor/ - 支持撤回最近编辑操作,包含新增、删除、拖动、缩放、属性修改、导入图形和图层移动。 - 支持 `Ctrl+Z` 快捷撤回。 - 支持保存并下载 `.map` 文本。 +- 使用 `server.js` 启动时,支持直接保存到项目资源目录。 +- 使用 `server.js` 启动时,支持一键部署到项目资源目录和已存在的 PC/Switch build 资源目录。 - 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。 - 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。 - 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。 @@ -109,7 +129,7 @@ http://127.0.0.1:8787/tools/map_editor/ - 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。 - 导出兼容 `StageMapLoader` 的 `.map` 文本。 -浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。 +浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。本地开发时可通过 `tools/map_editor/server.js` 启动带写入 API 的服务,直接写回项目文件和已有 build 资源目录。 ## 数据边界 diff --git a/tools/map_editor/DOCUMENTATION.md b/tools/map_editor/DOCUMENTATION.md index 3376446..947c6bb 100644 --- a/tools/map_editor/DOCUMENTATION.md +++ b/tools/map_editor/DOCUMENTATION.md @@ -18,6 +18,7 @@ tools/map_editor/ ├─ index.html 网页结构 ├─ styles.css 编辑器样式 ├─ app.js 解析、绘制、编辑和导出逻辑 +├─ server.js 本地可写服务,用于保存和部署 .map ├─ README.md 快速说明 └─ DOCUMENTATION.md 本文档 ``` @@ -30,7 +31,21 @@ game/assets/map/stage_01.map ## 本地运行 -在仓库根目录启动静态服务: +推荐在仓库根目录启动本地可写服务: + +```powershell +node tools/map_editor/server.js +``` + +打开: + +```text +http://127.0.0.1:8787/tools/map_editor/ +``` + +这种模式下,顶部工具栏的 `保存` 和 `一键部署` 会启用。 + +也可以启动普通静态服务: ```powershell python -m http.server 8787 @@ -42,7 +57,7 @@ python -m http.server 8787 http://127.0.0.1:8787/tools/map_editor/ ``` -从仓库根目录启动时,工具会尝试自动加载: +静态模式可以编辑和下载,但不能直接写回项目文件。无论哪种模式,从仓库根目录启动时,工具都会尝试自动加载: ```text game/assets/map/stage_01.map @@ -57,6 +72,8 @@ game/assets/map/stage_01.map - `撤回`:撤回最近一次编辑操作。 - `加载 stage_01.map`:重新加载项目默认地图。 - `打开 .map`:从本地选择一个 `.map` 文本文件。 +- `保存`:写入项目资源目录的 `game/assets/map/stage_01.map`,需要本地可写服务。 +- `一键部署`:保存并同步到项目资源目录和已存在的 PC/Switch build 资源目录,需本地可写服务。 - `保存并下载`:下载当前导出的 `<地图ID>.map`。 ### 左侧菜单 @@ -165,6 +182,24 @@ Ctrl+Z 打开或重新加载 `.map` 会清空撤回历史。当前未实现重做。 +### 保存和部署 + +使用本地可写服务启动时: + +```powershell +node tools/map_editor/server.js +``` + +顶部按钮会启用: + +- `保存`:写入 `game/assets/map/stage_01.map`。 +- `一键部署`:写入 `game/assets/map/stage_01.map`,并同步到已存在的构建资源目录: + - `build/windows/x64/release/assets/map/stage_01.map` + - `build/switch/aarch64/release/switch_game/assets/map/stage_01.map` + - `build/switch/aarch64/release/switch_game/romfs/assets/map/stage_01.map` + +如果某个 build 目录不存在,一键部署会跳过该目标,不会创建完整 build 结构。 + ### 保存并下载 点击顶部“保存并下载”会下载: @@ -175,7 +210,7 @@ Ctrl+Z 下载内容与右侧“导出文本”一致,并保持 `StageMapLoader` 兼容。 -浏览器安全限制下,静态网页不能直接覆盖项目源文件。当前流程是: +普通静态服务模式下,浏览器安全限制不允许直接覆盖项目源文件。当前流程是: 1. 保存并下载 `.map`。 2. 手动替换项目内目标地图文件。 @@ -207,7 +242,8 @@ spawn ## 当前限制 -- 不能直接覆盖本地源文件,只能下载 `.map`。 +- 普通静态服务模式不能直接覆盖本地源文件,只能下载 `.map`。 +- `保存` / `一键部署` 需要使用 `node tools/map_editor/server.js` 启动本地可写服务。 - 导入图片的真实文件仍需手动放入项目资源目录。 - `.map` 中存在于 `game/assets/...` 的 `assets/...` 贴图路径会自动预览。 - 如果导入图片后没有把真实文件放到 `game/assets/...` 对应路径,重新打开 `.map` 后会显示 fallback 色块。 @@ -218,7 +254,7 @@ spawn ## 后续扩展建议 -- 增加服务端保存接口,部署后直接保存 `.map` 到服务器文件或数据库。 +- 增加正式服务器保存接口,部署后直接保存 `.map` 到服务器文件或数据库。 - 增加 tileset 图片预览和 tile palette。 - 增加多选、复制、粘贴、批量移动。 - 增加自动检查:视觉铺砖区与碰撞块错位提示。 diff --git a/tools/map_editor/README.md b/tools/map_editor/README.md index 8f0fde6..357feeb 100644 --- a/tools/map_editor/README.md +++ b/tools/map_editor/README.md @@ -1,6 +1,6 @@ # 地图编辑器 -这是一个静态网页地图编辑器,用来编辑游戏使用的 `.map` 文本文件。 +这是一个网页地图编辑器,用来编辑游戏使用的 `.map` 文本文件。 完整工具说明见: @@ -10,10 +10,10 @@ tools/map_editor/DOCUMENTATION.md ## 本地运行 -在仓库根目录执行: +推荐使用本地可写服务启动: ```powershell -python -m http.server 8787 +node tools/map_editor/server.js ``` 然后打开: @@ -22,7 +22,25 @@ python -m http.server 8787 http://127.0.0.1:8787/tools/map_editor/ ``` -从仓库根目录启动静态服务时,编辑器可以自动读取 `game/assets/map/stage_01.map`。也可以通过“打开 .map”选择任意 `.map` 文件。 +这种模式下可以使用: + +- `保存`:写入 `game/assets/map/stage_01.map`。 +- `一键部署`:写入项目资源目录,并同步到已存在的 PC/Switch build 资源目录。 +- `保存并下载`:仍然下载 `<地图ID>.map`。 + +也可以只用静态服务预览和下载: + +```powershell +python -m http.server 8787 +``` + +静态模式下打开地址相同: + +```text +http://127.0.0.1:8787/tools/map_editor/ +``` + +从仓库根目录启动服务时,编辑器可以自动读取 `game/assets/map/stage_01.map`。也可以通过“打开 .map”选择任意 `.map` 文件。 ## 当前能力 @@ -40,12 +58,19 @@ http://127.0.0.1:8787/tools/map_editor/ - 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。 - 支持撤回最近编辑操作,也可以使用 `Ctrl+Z`。 - 选中图层或图层内对象后,可以把对应图层上移或下移。 +- 本地 Node 服务模式下支持保存到项目资源目录。 +- 本地 Node 服务模式下支持一键部署到项目资源目录和已存在的 PC/Switch build 资源目录。 - 保存并下载兼容 `StageMapLoader` 的 `.map` 文本。 ## 保存和撤回 - “保存并下载”会把右侧导出文本下载成 `<地图ID>.map`。 -- 浏览器安全限制下,静态网页不能直接覆盖本地项目文件;下载后仍需要替换项目里的地图文件。 +- “保存”会把右侧导出文本写入 `game/assets/map/stage_01.map`,需要使用 `node tools/map_editor/server.js` 启动。 +- “一键部署”会先保存到项目资源目录,再同步到已存在的 build 资源目录,包含: + - `build/windows/x64/release/assets/map/stage_01.map` + - `build/switch/aarch64/release/switch_game/assets/map/stage_01.map` + - `build/switch/aarch64/release/switch_game/romfs/assets/map/stage_01.map` +- 浏览器安全限制下,普通静态服务不能直接覆盖本地项目文件;这种模式只能下载后手动替换。 - “撤回”会恢复最近一次地图编辑操作,包括新增、删除、拖动、缩放、属性修改、导入图形和图层移动。 - 打开或重新加载 `.map` 会清空撤回历史。 diff --git a/tools/map_editor/app.js b/tools/map_editor/app.js index 9cd4069..3458309 100644 --- a/tools/map_editor/app.js +++ b/tools/map_editor/app.js @@ -10,6 +10,7 @@ const state = { imageLoading: new Set(), imageFailures: new Set(), previewLoadToken: 0, + serverAvailable: false, history: [], dragging: null, }; @@ -26,6 +27,7 @@ document.addEventListener("DOMContentLoaded", () => { function bindElements() { for (const id of [ "mapCanvas", "fileInput", "loadSampleButton", "downloadButton", "undoButton", + "saveProjectButton", "deployButton", "graphicFileInput", "mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput", "hitFilterInput", @@ -45,6 +47,8 @@ function bindEvents() { 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)); @@ -93,6 +97,26 @@ function bindEvents() { 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() { @@ -1120,6 +1144,42 @@ function saveAndDownloadMap() { 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; diff --git a/tools/map_editor/index.html b/tools/map_editor/index.html index 2e9512e..4914a54 100644 --- a/tools/map_editor/index.html +++ b/tools/map_editor/index.html @@ -19,6 +19,8 @@ 打开 .map + + diff --git a/tools/map_editor/server.js b/tools/map_editor/server.js new file mode 100644 index 0000000..f7611dd --- /dev/null +++ b/tools/map_editor/server.js @@ -0,0 +1,154 @@ +const http = require("http"); +const fs = require("fs"); +const path = require("path"); +const { URL } = require("url"); + +const repoRoot = path.resolve(__dirname, "..", ".."); +const port = Number(process.env.MAP_EDITOR_PORT || process.env.PORT || 8787); + +const contentTypes = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".map": "text/plain; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".json": "application/json; charset=utf-8", +}; + +const sourceMapPath = path.join(repoRoot, "game", "assets", "map", "stage_01.map"); +const deployTargets = [ + sourceMapPath, + path.join(repoRoot, "build", "windows", "x64", "release", "assets", "map", "stage_01.map"), + path.join(repoRoot, "build", "switch", "aarch64", "release", "switch_game", "assets", "map", "stage_01.map"), + path.join(repoRoot, "build", "switch", "aarch64", "release", "switch_game", "romfs", "assets", "map", "stage_01.map"), +]; + +const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host || "localhost"}`); + if (url.pathname === "/api/map-editor/status" && request.method === "GET") { + sendJson(response, 200, { ok: true, writable: true }); + return; + } + if (url.pathname === "/api/map-editor/save" && request.method === "POST") { + const { mapText } = await readJsonBody(request); + const targets = writeMapToTargets(mapText, [sourceMapPath]); + sendJson(response, 200, { ok: true, targets }); + return; + } + if (url.pathname === "/api/map-editor/deploy" && request.method === "POST") { + const { mapText } = await readJsonBody(request); + const targets = writeMapToTargets(mapText, deployTargets, { skipMissingParents: true }); + sendJson(response, 200, { ok: true, targets }); + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { + sendJson(response, 405, { ok: false, error: "Method not allowed" }); + return; + } + serveStatic(url.pathname, request, response); + } catch (error) { + sendJson(response, 500, { ok: false, error: error.message }); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`Map editor: http://127.0.0.1:${port}/tools/map_editor/`); +}); + +function writeMapToTargets(mapText, targets, options = {}) { + validateMapText(mapText); + const written = []; + for (const target of targets) { + const parent = path.dirname(target); + if (options.skipMissingParents && !fs.existsSync(parent)) continue; + fs.mkdirSync(parent, { recursive: true }); + fs.writeFileSync(target, normalizeText(mapText), "utf8"); + written.push(path.relative(repoRoot, target).replace(/\\/g, "/")); + } + return written; +} + +function validateMapText(mapText) { + if (typeof mapText !== "string" || !mapText.trim()) { + throw new Error("mapText is empty"); + } + if (!/^map\s+\S+/m.test(mapText) || !/^world\s+[-.\d]+\s+[-.\d]+/m.test(mapText)) { + throw new Error("mapText does not look like a .map file"); + } +} + +function normalizeText(text) { + return `${text.replace(/\r\n/g, "\n").replace(/\s+$/g, "")}\n`; +} + +function serveStatic(urlPath, request, response) { + const pathname = decodeURIComponent(urlPath === "/" ? "/tools/map_editor/" : urlPath); + const relative = pathname.replace(/^\/+/, ""); + const target = path.resolve(repoRoot, relative); + if (!target.startsWith(repoRoot + path.sep) && target !== repoRoot) { + sendText(response, 403, "Forbidden"); + return; + } + + let filePath = target; + if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) { + filePath = path.join(filePath, "index.html"); + } + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + sendText(response, 404, "Not found"); + return; + } + + response.writeHead(200, { + "Content-Type": contentTypes[path.extname(filePath).toLowerCase()] || "application/octet-stream", + "Cache-Control": "no-store", + }); + if (request.method === "HEAD") { + response.end(); + return; + } + fs.createReadStream(filePath).pipe(response); +} + +function readJsonBody(request) { + return new Promise((resolve, reject) => { + let body = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + body += chunk; + if (body.length > 1024 * 1024) { + reject(new Error("Request body too large")); + request.destroy(); + } + }); + request.on("end", () => { + try { + resolve(JSON.parse(body || "{}")); + } catch (error) { + reject(new Error("Invalid JSON body")); + } + }); + request.on("error", reject); + }); +} + +function sendJson(response, status, payload) { + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(JSON.stringify(payload)); +} + +function sendText(response, status, text) { + response.writeHead(status, { + "Content-Type": "text/plain; charset=utf-8", + "Cache-Control": "no-store", + }); + response.end(text); +}