Add map editor save and deploy actions
This commit is contained in:
@@ -72,10 +72,28 @@ tools/map_editor/
|
|||||||
├─ index.html
|
├─ index.html
|
||||||
├─ styles.css
|
├─ styles.css
|
||||||
├─ app.js
|
├─ app.js
|
||||||
|
├─ server.js
|
||||||
└─ README.md
|
└─ 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
|
```powershell
|
||||||
python -m http.server 8787
|
python -m http.server 8787
|
||||||
@@ -101,6 +119,8 @@ http://127.0.0.1:8787/tools/map_editor/
|
|||||||
- 支持撤回最近编辑操作,包含新增、删除、拖动、缩放、属性修改、导入图形和图层移动。
|
- 支持撤回最近编辑操作,包含新增、删除、拖动、缩放、属性修改、导入图形和图层移动。
|
||||||
- 支持 `Ctrl+Z` 快捷撤回。
|
- 支持 `Ctrl+Z` 快捷撤回。
|
||||||
- 支持保存并下载 `.map` 文本。
|
- 支持保存并下载 `.map` 文本。
|
||||||
|
- 使用 `server.js` 启动时,支持直接保存到项目资源目录。
|
||||||
|
- 使用 `server.js` 启动时,支持一键部署到项目资源目录和已存在的 PC/Switch build 资源目录。
|
||||||
- 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。
|
- 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。
|
||||||
- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。
|
- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。
|
||||||
- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
|
- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
|
||||||
@@ -109,7 +129,7 @@ http://127.0.0.1:8787/tools/map_editor/
|
|||||||
- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
|
- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
|
||||||
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
|
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
|
||||||
|
|
||||||
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
|
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。本地开发时可通过 `tools/map_editor/server.js` 启动带写入 API 的服务,直接写回项目文件和已有 build 资源目录。
|
||||||
|
|
||||||
## 数据边界
|
## 数据边界
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ tools/map_editor/
|
|||||||
├─ index.html 网页结构
|
├─ index.html 网页结构
|
||||||
├─ styles.css 编辑器样式
|
├─ styles.css 编辑器样式
|
||||||
├─ app.js 解析、绘制、编辑和导出逻辑
|
├─ app.js 解析、绘制、编辑和导出逻辑
|
||||||
|
├─ server.js 本地可写服务,用于保存和部署 .map
|
||||||
├─ README.md 快速说明
|
├─ README.md 快速说明
|
||||||
└─ DOCUMENTATION.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
|
```powershell
|
||||||
python -m http.server 8787
|
python -m http.server 8787
|
||||||
@@ -42,7 +57,7 @@ python -m http.server 8787
|
|||||||
http://127.0.0.1:8787/tools/map_editor/
|
http://127.0.0.1:8787/tools/map_editor/
|
||||||
```
|
```
|
||||||
|
|
||||||
从仓库根目录启动时,工具会尝试自动加载:
|
静态模式可以编辑和下载,但不能直接写回项目文件。无论哪种模式,从仓库根目录启动时,工具都会尝试自动加载:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
game/assets/map/stage_01.map
|
game/assets/map/stage_01.map
|
||||||
@@ -57,6 +72,8 @@ game/assets/map/stage_01.map
|
|||||||
- `撤回`:撤回最近一次编辑操作。
|
- `撤回`:撤回最近一次编辑操作。
|
||||||
- `加载 stage_01.map`:重新加载项目默认地图。
|
- `加载 stage_01.map`:重新加载项目默认地图。
|
||||||
- `打开 .map`:从本地选择一个 `.map` 文本文件。
|
- `打开 .map`:从本地选择一个 `.map` 文本文件。
|
||||||
|
- `保存`:写入项目资源目录的 `game/assets/map/stage_01.map`,需要本地可写服务。
|
||||||
|
- `一键部署`:保存并同步到项目资源目录和已存在的 PC/Switch build 资源目录,需本地可写服务。
|
||||||
- `保存并下载`:下载当前导出的 `<地图ID>.map`。
|
- `保存并下载`:下载当前导出的 `<地图ID>.map`。
|
||||||
|
|
||||||
### 左侧菜单
|
### 左侧菜单
|
||||||
@@ -165,6 +182,24 @@ Ctrl+Z
|
|||||||
|
|
||||||
打开或重新加载 `.map` 会清空撤回历史。当前未实现重做。
|
打开或重新加载 `.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` 兼容。
|
下载内容与右侧“导出文本”一致,并保持 `StageMapLoader` 兼容。
|
||||||
|
|
||||||
浏览器安全限制下,静态网页不能直接覆盖项目源文件。当前流程是:
|
普通静态服务模式下,浏览器安全限制不允许直接覆盖项目源文件。当前流程是:
|
||||||
|
|
||||||
1. 保存并下载 `.map`。
|
1. 保存并下载 `.map`。
|
||||||
2. 手动替换项目内目标地图文件。
|
2. 手动替换项目内目标地图文件。
|
||||||
@@ -207,7 +242,8 @@ spawn
|
|||||||
|
|
||||||
## 当前限制
|
## 当前限制
|
||||||
|
|
||||||
- 不能直接覆盖本地源文件,只能下载 `.map`。
|
- 普通静态服务模式不能直接覆盖本地源文件,只能下载 `.map`。
|
||||||
|
- `保存` / `一键部署` 需要使用 `node tools/map_editor/server.js` 启动本地可写服务。
|
||||||
- 导入图片的真实文件仍需手动放入项目资源目录。
|
- 导入图片的真实文件仍需手动放入项目资源目录。
|
||||||
- `.map` 中存在于 `game/assets/...` 的 `assets/...` 贴图路径会自动预览。
|
- `.map` 中存在于 `game/assets/...` 的 `assets/...` 贴图路径会自动预览。
|
||||||
- 如果导入图片后没有把真实文件放到 `game/assets/...` 对应路径,重新打开 `.map` 后会显示 fallback 色块。
|
- 如果导入图片后没有把真实文件放到 `game/assets/...` 对应路径,重新打开 `.map` 后会显示 fallback 色块。
|
||||||
@@ -218,7 +254,7 @@ spawn
|
|||||||
|
|
||||||
## 后续扩展建议
|
## 后续扩展建议
|
||||||
|
|
||||||
- 增加服务端保存接口,部署后直接保存 `.map` 到服务器文件或数据库。
|
- 增加正式服务器保存接口,部署后直接保存 `.map` 到服务器文件或数据库。
|
||||||
- 增加 tileset 图片预览和 tile palette。
|
- 增加 tileset 图片预览和 tile palette。
|
||||||
- 增加多选、复制、粘贴、批量移动。
|
- 增加多选、复制、粘贴、批量移动。
|
||||||
- 增加自动检查:视觉铺砖区与碰撞块错位提示。
|
- 增加自动检查:视觉铺砖区与碰撞块错位提示。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 地图编辑器
|
# 地图编辑器
|
||||||
|
|
||||||
这是一个静态网页地图编辑器,用来编辑游戏使用的 `.map` 文本文件。
|
这是一个网页地图编辑器,用来编辑游戏使用的 `.map` 文本文件。
|
||||||
|
|
||||||
完整工具说明见:
|
完整工具说明见:
|
||||||
|
|
||||||
@@ -10,10 +10,10 @@ tools/map_editor/DOCUMENTATION.md
|
|||||||
|
|
||||||
## 本地运行
|
## 本地运行
|
||||||
|
|
||||||
在仓库根目录执行:
|
推荐使用本地可写服务启动:
|
||||||
|
|
||||||
```powershell
|
```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/
|
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`。
|
- 支持撤回最近编辑操作,也可以使用 `Ctrl+Z`。
|
||||||
- 选中图层或图层内对象后,可以把对应图层上移或下移。
|
- 选中图层或图层内对象后,可以把对应图层上移或下移。
|
||||||
|
- 本地 Node 服务模式下支持保存到项目资源目录。
|
||||||
|
- 本地 Node 服务模式下支持一键部署到项目资源目录和已存在的 PC/Switch build 资源目录。
|
||||||
- 保存并下载兼容 `StageMapLoader` 的 `.map` 文本。
|
- 保存并下载兼容 `StageMapLoader` 的 `.map` 文本。
|
||||||
|
|
||||||
## 保存和撤回
|
## 保存和撤回
|
||||||
|
|
||||||
- “保存并下载”会把右侧导出文本下载成 `<地图ID>.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` 会清空撤回历史。
|
- 打开或重新加载 `.map` 会清空撤回历史。
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const state = {
|
|||||||
imageLoading: new Set(),
|
imageLoading: new Set(),
|
||||||
imageFailures: new Set(),
|
imageFailures: new Set(),
|
||||||
previewLoadToken: 0,
|
previewLoadToken: 0,
|
||||||
|
serverAvailable: false,
|
||||||
history: [],
|
history: [],
|
||||||
dragging: null,
|
dragging: null,
|
||||||
};
|
};
|
||||||
@@ -26,6 +27,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
function bindElements() {
|
function bindElements() {
|
||||||
for (const id of [
|
for (const id of [
|
||||||
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton", "undoButton",
|
"mapCanvas", "fileInput", "loadSampleButton", "downloadButton", "undoButton",
|
||||||
|
"saveProjectButton", "deployButton",
|
||||||
"graphicFileInput",
|
"graphicFileInput",
|
||||||
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
"mapIdInput", "worldWidthInput", "worldHeightInput", "gridSizeInput",
|
||||||
"hitFilterInput",
|
"hitFilterInput",
|
||||||
@@ -45,6 +47,8 @@ function bindEvents() {
|
|||||||
elements.fileInput.addEventListener("change", openFile);
|
elements.fileInput.addEventListener("change", openFile);
|
||||||
elements.graphicFileInput.addEventListener("change", importGraphicAsset);
|
elements.graphicFileInput.addEventListener("change", importGraphicAsset);
|
||||||
elements.downloadButton.addEventListener("click", saveAndDownloadMap);
|
elements.downloadButton.addEventListener("click", saveAndDownloadMap);
|
||||||
|
elements.saveProjectButton.addEventListener("click", saveProjectMap);
|
||||||
|
elements.deployButton.addEventListener("click", deployProjectMap);
|
||||||
elements.undoButton.addEventListener("click", undoLast);
|
elements.undoButton.addEventListener("click", undoLast);
|
||||||
elements.deleteButton.addEventListener("click", deleteSelected);
|
elements.deleteButton.addEventListener("click", deleteSelected);
|
||||||
elements.moveLayerUpButton.addEventListener("click", () => moveSelectedLayer(-1));
|
elements.moveLayerUpButton.addEventListener("click", () => moveSelectedLayer(-1));
|
||||||
@@ -93,6 +97,26 @@ function bindEvents() {
|
|||||||
canvas.addEventListener("mouseup", onCanvasMouseUp);
|
canvas.addEventListener("mouseup", onCanvasMouseUp);
|
||||||
canvas.addEventListener("mouseleave", onCanvasMouseUp);
|
canvas.addEventListener("mouseleave", onCanvasMouseUp);
|
||||||
canvas.addEventListener("wheel", onCanvasWheel, { passive: false });
|
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() {
|
async function loadSampleMap() {
|
||||||
@@ -1120,6 +1144,42 @@ function saveAndDownloadMap() {
|
|||||||
setStatus(`已保存导出文本并下载 ${state.map.id || "stage"}.map`);
|
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() {
|
function undoLast() {
|
||||||
const entry = state.history.pop();
|
const entry = state.history.pop();
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
打开 .map
|
打开 .map
|
||||||
<input id="fileInput" type="file" accept=".map,text/plain">
|
<input id="fileInput" type="file" accept=".map,text/plain">
|
||||||
</label>
|
</label>
|
||||||
|
<button id="saveProjectButton" type="button" disabled>保存</button>
|
||||||
|
<button id="deployButton" type="button" disabled>一键部署</button>
|
||||||
<button id="downloadButton" type="button" class="primary-button">保存并下载</button>
|
<button id="downloadButton" type="button" class="primary-button">保存并下载</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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