Add map editor save and deploy actions
This commit is contained in:
@@ -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。
|
||||
- 增加多选、复制、粘贴、批量移动。
|
||||
- 增加自动检查:视觉铺砖区与碰撞块错位提示。
|
||||
|
||||
@@ -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` 会清空撤回历史。
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user