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);
+}