Add map editor save and deploy actions

This commit is contained in:
2026-06-10 10:45:33 +08:00
parent 57caced672
commit 21f633658c
6 changed files with 309 additions and 12 deletions
+22 -2
View File
@@ -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 资源目录
## 数据边界
+41 -5
View File
@@ -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。
- 增加多选、复制、粘贴、批量移动。
- 增加自动检查:视觉铺砖区与碰撞块错位提示。
+30 -5
View File
@@ -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` 会清空撤回历史。
+60
View File
@@ -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;
+2
View File
@@ -19,6 +19,8 @@
打开 .map
<input id="fileInput" type="file" accept=".map,text/plain">
</label>
<button id="saveProjectButton" type="button" disabled>保存</button>
<button id="deployButton" type="button" disabled>一键部署</button>
<button id="downloadButton" type="button" class="primary-button">保存并下载</button>
</div>
</header>
+154
View File
@@ -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);
}