Compare commits

...

4 Commits

Author SHA1 Message Date
wy 0cd665198b Rebuild stage from mossy cavern reference 2026-06-10 11:33:22 +08:00
wy c6d42ff0e0 Expand mossy cavern stage map 2026-06-10 11:08:07 +08:00
wy 21f633658c Add map editor save and deploy actions 2026-06-10 10:45:33 +08:00
wy 57caced672 Make map editor preload texture previews 2026-06-10 10:28:32 +08:00
15 changed files with 435 additions and 129 deletions
+18 -61
View File
@@ -1,71 +1,28 @@
map stage_01 map stage_01
world 3200 720 world 2105 720
camera 0 0 3200 720 camera 0 0 2105 720
player_spawn 160 380 player_spawn 1030 346
collision 0 600 3200 80 collision 0 446 505 42
collision 230 500 220 28 collision 128 560 350 42
collision 560 430 260 28 collision 846 450 470 42
collision 930 510 260 28 collision 1490 506 555 42
collision 1250 420 230 28
collision 1760 500 300 28
collision 2180 455 260 28
collision 2650 515 360 28
layer background_far BackgroundFar 0.25 layer reference_background BackgroundFar 1
rect 0 0 3200 720 0.04 0.06 0.05 1.0 rect_sprite_src 0 0 2105 720 1 1 1 1 assets/stage/stage_01/reference/mossy_cavern_reference_4hibzc.png 0 0 2432 832
rect_sprite_src -140 80 980 620 0.34 0.54 0.36 0.38 assets/stage/stage_01/background/mossy_background_decoration.png 0 0 1030 4096
rect_sprite_src 860 35 940 620 0.30 0.50 0.34 0.34 assets/stage/stage_01/background/mossy_background_decoration.png 1180 0 900 3500
rect_sprite_src 2020 100 980 560 0.31 0.52 0.34 0.34 assets/stage/stage_01/background/mossy_background_decoration.png 2680 170 1060 1520
rect_sprite_src 2660 410 1060 360 0.26 0.44 0.30 0.30 assets/stage/stage_01/background/mossy_background_decoration.png 2460 2920 1180 880
endlayer endlayer
layer background_mid BackgroundMid 0.55 battle_zone zone_01_left_platform 0 250 560 310 0 0 1280 720
rect_sprite_src -180 390 560 260 0.45 0.70 0.45 0.42 assets/stage/stage_01/background/mossy_hills.png 50 580 420 420 spawn zone_01_enemy_a 210 342
rect_sprite_src 520 335 760 300 0.46 0.72 0.46 0.36 assets/stage/stage_01/background/mossy_hills.png 720 40 1160 500 spawn zone_01_enemy_b 380 456
rect_sprite_src 1390 360 720 270 0.42 0.68 0.43 0.36 assets/stage/stage_01/background/mossy_hills.png 760 650 1120 720
rect_sprite_src 2310 330 760 310 0.40 0.66 0.42 0.36 assets/stage/stage_01/background/mossy_hills.png 645 650 1280 840
rect_sprite_src 2860 400 560 240 0.43 0.68 0.44 0.34 assets/stage/stage_01/background/mossy_hills.png 1460 1520 520 420
endlayer
layer level_visual LevelVisual 1.0
rect_sprite_src -70 520 1680 270 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 420 1120 1560 490
rect_sprite_src 1520 520 1680 270 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 420 1120 1560 490
rect_sprite_src 230 460 240 100 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 500 40 1050 420
rect_sprite_src 540 390 300 105 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 500 540 1060 430
rect_sprite_src 910 470 310 105 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 500 40 1050 420
rect_sprite_src 1230 380 270 105 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 500 540 1060 430
rect_sprite_src 1740 460 340 105 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 500 40 1050 420
rect_sprite_src 2160 415 300 105 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 500 540 1060 430
rect_sprite_src 2625 475 400 120 1.0 1.0 1.0 1.0 assets/stage/stage_01/tiles/mossy_floating_platforms.png 420 1120 1560 490
endlayer
layer props_back PropsBack 1.0
rect_sprite_src 110 120 150 500 0.78 0.94 0.72 0.72 assets/stage/stage_01/props/mossy_hanging_plants.png 70 0 360 1240
rect_sprite_src 780 80 130 430 0.78 0.94 0.72 0.62 assets/stage/stage_01/props/mossy_hanging_plants.png 550 0 330 1180
rect_sprite_src 1430 115 150 520 0.78 0.94 0.72 0.68 assets/stage/stage_01/props/mossy_hanging_plants.png 990 0 360 1300
rect_sprite_src 1880 155 190 420 0.78 0.94 0.72 0.62 assets/stage/stage_01/props/mossy_hanging_plants.png 1340 0 470 1040
rect_sprite_src 2510 160 170 450 0.78 0.94 0.72 0.64 assets/stage/stage_01/props/mossy_hanging_plants.png 540 1260 420 1040
rect_sprite_src 3030 110 160 500 0.78 0.94 0.72 0.66 assets/stage/stage_01/props/mossy_hanging_plants.png 1020 1320 420 1120
endlayer
layer props_front PropsFront 1.0
rect_sprite_src 510 560 260 80 0.85 1.0 0.82 0.78 assets/stage/stage_01/tiles/mossy_floating_platforms.png 0 540 430 430
rect_sprite_src 1490 545 330 95 0.85 1.0 0.82 0.78 assets/stage/stage_01/tiles/mossy_floating_platforms.png 0 540 430 430
rect_sprite_src 2560 550 330 90 0.85 1.0 0.82 0.78 assets/stage/stage_01/tiles/mossy_floating_platforms.png 0 540 430 430
endlayer
battle_zone zone_01_intro 520 260 560 340 0 0 1940 720
spawn zone_01_enemy_a 760 560
spawn zone_01_enemy_b 980 560
endbattle_zone endbattle_zone
battle_zone zone_02_platform 1420 250 680 350 660 0 2300 720 battle_zone zone_02_center_platform 760 230 650 320 410 0 1280 720
spawn zone_02_enemy_a 1620 560 spawn zone_02_enemy_a 930 346
spawn zone_02_enemy_b 1900 560 spawn zone_02_enemy_b 1200 346
endbattle_zone endbattle_zone
battle_zone zone_03_exit 2380 250 720 350 1600 0 1600 720 battle_zone zone_03_right_platform 1420 270 640 310 825 0 1280 720
spawn zone_03_enemy_a 2580 560 spawn zone_03_enemy_a 1630 402
spawn zone_03_enemy_b 2860 560 spawn zone_03_enemy_b 1880 402
endbattle_zone endbattle_zone
+1
View File
@@ -14,6 +14,7 @@ This directory is reserved for the first playable side-scrolling ACT stage.
| Graybox platform tiles | `tiles/graybox_platform.png` | Optional temporary platform tileset referenced by `stage_01.map`; fallback color is used if missing. | TBD | | Graybox platform tiles | `tiles/graybox_platform.png` | Optional temporary platform tileset referenced by `stage_01.map`; fallback color is used if missing. | TBD |
| Props sheet | `props/mossy_hanging_plants.png` | Current hanging plant props used by `PropsBack` layer. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD | | Props sheet | `props/mossy_hanging_plants.png` | Current hanging plant props used by `PropsBack` layer. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD |
| Decoration / hazards sheet | `props/mossy_decorations_hazards.png` | Reserved for later foreground props and hazards; not used for gameplay yet. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD | | Decoration / hazards sheet | `props/mossy_decorations_hazards.png` | Reserved for later foreground props and hazards; not used for gameplay yet. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD |
| Reference background | `reference/mossy_cavern_reference_4hibzc.png` | Full-scene reference image used as the first 1:1 rebuild pass for `stage_01.map`. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern\参考图\4HibZC.jpg`; license TBD |
| Hit spark | `effects/hit_spark.png` | Small combat impact effect. | TBD | | Hit spark | `effects/hit_spark.png` | Small combat impact effect. | TBD |
| Dust | `effects/dust.png` | Run, jump, and landing dust. | TBD | | Dust | `effects/dust.png` | Run, jump, and landing dust. | TBD |
| Music / ambience | `../../music/stage_01.ogg` | Optional loopable stage audio cue. | TBD | | Music / ambience | `../../music/stage_01.ogg` | Optional loopable stage audio cue. | TBD |
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+6 -6
View File
@@ -72,14 +72,14 @@ endbattle_zone
- `tile_index` 当前表示 tileset 第一行的横向 tile 序号。 - `tile_index` 当前表示 tileset 第一行的横向 tile 序号。
- `r g b` 是该批 tile 的灰盒 fallback 色,alpha 固定为 `1.0` - `r g b` 是该批 tile 的灰盒 fallback 色,alpha 固定为 `1.0`
当前 `stage_01.map`接入 Mossy Cavern 场景图集 当前 `stage_01.map``asset\Mossy Cavern\参考图\4HibZC.jpg` 重做为一比一参考图复刻版
- `BackgroundFar` 使用 `mossy_background_decoration.png` 做远景洞穴轮廓 - 原参考图尺寸为 `2432 x 832`,等比映射到当前游戏视口高度后,世界尺寸为 `2105 x 720`
- `BackgroundMid` 使用 `mossy_hills.png` 做中景苔藓轮廓 - `BackgroundFar` 使用 `reference/mossy_cavern_reference_4hibzc.png` 作为完整场景底图,保证第一轮视觉尽量贴近参考图
- `LevelVisual` 使用 `mossy_floating_platforms.png` 做地面和悬空平台 - 碰撞只按参考图中的左侧、中央、右侧可站平台建立,不把背景洞壁和装饰当碰撞
- `PropsBack` / `PropsFront` 使用苔藓悬挂植物和平台边缘装饰 - Battle zone 分为 3 段:左侧平台、中央平台、右侧平台
本轮只接入地图场景视觉,碰撞仍由 `collision` 独立定义;角色、怪物、植物动画和 hazard 玩法暂不接入。 本轮只接入地图场景视觉,碰撞仍由 `collision` 独立定义;角色、怪物、植物动画和 hazard 玩法暂不接入。后续若要把底图拆成可编辑素材层,再逐步用 Mossy 图集替换完整参考底图。
## 下一步 ## 下一步
+22 -2
View File
@@ -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 资源目录
## 数据边界 ## 数据边界
+4 -1
View File
@@ -38,8 +38,9 @@ UI
## 当前素材接入 ## 当前素材接入
第一关已经通过 `rect_sprite_src` 接入 Mossy Cavern 场景图集 第一关已经接入 Mossy Cavern 场景资源
- `reference/mossy_cavern_reference_4hibzc.png`
- `background/mossy_background_decoration.png` - `background/mossy_background_decoration.png`
- `background/mossy_hills.png` - `background/mossy_hills.png`
- `tiles/mossy_floating_platforms.png` - `tiles/mossy_floating_platforms.png`
@@ -47,6 +48,8 @@ UI
- `props/mossy_decorations_hazards.png` - `props/mossy_decorations_hazards.png`
- `tiles/mossy_tileset.png` - `tiles/mossy_tileset.png`
当前 `stage_01.map` 已按 `4HibZC.jpg` 重做为 `2105 x 720` 的一比一参考图复刻版。第一轮用完整 PNG 参考图作为底图,优先保证视觉构图贴近参考图;后续再逐步拆成可编辑的背景、平台和前景装饰层。
由于当前 `tile_rect` 只能取 tileset 第一行,正式 Mossy 场景暂时优先使用 `rect_sprite_src` 从大图集裁切。`mossy_tileset.png` 已落位,等后续扩展 tile row/column 或 tile id 后再用于精细铺砖。 由于当前 `tile_rect` 只能取 tileset 第一行,正式 Mossy 场景暂时优先使用 `rect_sprite_src` 从大图集裁切。`mossy_tileset.png` 已落位,等后续扩展 tile row/column 或 tile id 后再用于精细铺砖。
## 约束 ## 约束
+11 -10
View File
@@ -341,19 +341,20 @@ game/assets
### 6.3 关卡数据格式 ### 6.3 关卡数据格式
初期可以用 JSON 当前项目已经改为 `.map` 文本数据,由 `StageMapLoader` 读取
```json ```text
{ map stage_01
"player_spawn": { "x": 100, "y": 300 }, world 2105 720
"bounds": { "x": 0, "y": 0, "w": 3200, "h": 720 }, camera 0 0 2105 720
"solids": [], player_spawn 1030 346
"enemy_spawns": [], collision <x> <y> <w> <h>
"triggers": [] layer <id> <role> <parallax>
} rect_sprite_src <x> <y> <w> <h> <r> <g> <b> <a> <texture> <sx> <sy> <sw> <sh>
battle_zone <id> <trigger_x> <trigger_y> <trigger_w> <trigger_h> <camera_x> <camera_y> <camera_w> <camera_h>
``` ```
后续可以考虑接入 Tiled,或者制作自定义简单编辑器 当前已经有自定义网页地图编辑器,后续优先继续完善 `.map` 编辑和部署流程
## 7. 动画和美术阶段 ## 7. 动画和美术阶段
@@ -204,7 +204,7 @@ mindmap
- `StageLayerSource` 已接入:每个 `StageRect` 可选择色块占位或 sprite 资源路径,加载失败时回退色块。 - `StageLayerSource` 已接入:每个 `StageRect` 可选择色块占位或 sprite 资源路径,加载失败时回退色块。
- `StageAssetSlots` 已建立:`stage_01` 资源目录和素材来源记录已预留,素材回来后可直接落位。 - `StageAssetSlots` 已建立:`stage_01` 资源目录和素材来源记录已预留,素材回来后可直接落位。
- `StageProps` 已统一:后景/前景 props 都走 `StageLayer`,前景层在 actor 之后绘制,可用于近景遮挡。 - `StageProps` 已统一:后景/前景 props 都走 `StageLayer`,前景层在 actor 之后绘制,可用于近景遮挡。
- 第一关宽度已扩展到 3200 像素,并加入 3 个 battle zone 占位与敌人出生点。 - 第一关已按 Mossy Cavern `4HibZC.jpg` 重做为 `2105 x 720` 的参考图复刻版,并加入 3 个 battle zone 占位与敌人出生点。
下一步: 下一步:
+17 -17
View File
@@ -17,7 +17,7 @@
第一轮场景骨架进度: 第一轮场景骨架进度:
- `LevelDefinition` 已扩展世界尺寸、玩家出生点、镜头范围、分层视觉、battle zone 和敌人出生点数据。 - `LevelDefinition` 已扩展世界尺寸、玩家出生点、镜头范围、分层视觉、battle zone 和敌人出生点数据。
- 第一关灰盒宽度已扩展到 3200 像素 - 第一关已按 `asset\Mossy Cavern\参考图\4HibZC.jpg` 重做为 `2105 x 720` 的参考图复刻版,当前主数据在 `game/assets/map/stage_01.map`
- `StageRenderer` 已支持远景、中景、地面/平台、后景 props 分层绘制,当前先用色块占位。 - `StageRenderer` 已支持远景、中景、地面/平台、后景 props 分层绘制,当前先用色块占位。
- `DebugOverlay` 已能显示碰撞、玩家框、battle zone、camera lock 范围和 spawn point。 - `DebugOverlay` 已能显示碰撞、玩家框、battle zone、camera lock 范围和 spawn point。
- `WhiteboxScene` 已支持 debug overlay,具体快捷键记录在 `../controls.md` - `WhiteboxScene` 已支持 debug overlay,具体快捷键记录在 `../controls.md`
@@ -35,10 +35,10 @@
第一关不急着做最终题材,先做通用 ACT 测试关: 第一关不急着做最终题材,先做通用 ACT 测试关:
- 横向推进,总宽度先控制在 2400 到 3200 像素。 - 横向推进,当前 Mossy Cavern 参考图复刻版宽度为 2105 像素。
- 主要视口仍用 1280x720。 - 主要视口仍用 1280x720。
- 地面以平路为主,加入 2 到 3 段平台高低差 - 主路线先保持连续地面,避免当前平台碰撞不支持台阶/斜坡导致卡关;高度变化主要放在悬空平台上,用于测试跳跃和镜头推进
- 至少预留 3 个战斗区域,每个区域宽度 480 到 720 像素 - 当前按参考图三块主要站立区域预留 3 个战斗区域
- 每个战斗区域有明确的入口、站位空间、敌人刷出点和退出方向。 - 每个战斗区域有明确的入口、站位空间、敌人刷出点和退出方向。
- 保留一个跳跃测试段,用来验证移动手感。 - 保留一个跳跃测试段,用来验证移动手感。
- 保留一个窄平台/边缘段,用来验证镜头和碰撞。 - 保留一个窄平台/边缘段,用来验证镜头和碰撞。
@@ -164,11 +164,11 @@ game/assets/stage/stage_01/
### Step 5:加入战斗区域数据 ### Step 5:加入战斗区域数据
在关卡中标出 3 个 battle zone 当前 Mossy Cavern 参考图复刻版标出 3 个 battle zone
- `zone_01_intro` - `zone_01_left_platform`
- `zone_02_platform` - `zone_02_center_platform`
- `zone_03_exit` - `zone_03_right_platform`
每个区域包含: 每个区域包含:
@@ -196,11 +196,10 @@ game/assets/stage/stage_01/
第一阶段完成时应该达到: 第一阶段完成时应该达到:
- 能启动 `Frostbite2DGame` - 能启动 `Frostbite2DGame`
- 第一关宽度至少 2400 像素 - 第一关世界尺寸为 `2105 x 720`,与 `4HibZC.jpg` 等比映射后的宽高一致
- 玩家可以从起点跑到终点 - 参考图底图能完整显示,且横向摄像机不露出世界外黑边
- 至少 5 个平台/地面碰撞块。 - 至少 4 个平台碰撞块,对齐参考图中的左侧、左下、中央、右侧可站平台
- 至少 2 层背景 - 当前视觉先以 1 张完整参考底图表达,不再沿用上一版拼接地图
- 至少 8 个装饰物。
- 至少 3 个 battle zone debug 标记。 - 至少 3 个 battle zone debug 标记。
- 场景资源路径集中在 `game/assets/stage/stage_01/` - 场景资源路径集中在 `game/assets/stage/stage_01/`
- 文档记录已使用素材来源和授权信息。 - 文档记录已使用素材来源和授权信息。
@@ -224,8 +223,8 @@ game/assets/stage/stage_01/
1. 已完成:扩展 `LevelDefinition`,加入世界尺寸、玩家出生点、镜头限制、敌人出生点、battle zone 占位数据。 1. 已完成:扩展 `LevelDefinition`,加入世界尺寸、玩家出生点、镜头限制、敌人出生点、battle zone 占位数据。
2. 已完成:扩展 `StageRenderer`,支持背景层、地面/平台视觉层、后景 props,第一版继续用色块。 2. 已完成:扩展 `StageRenderer`,支持背景层、地面/平台视觉层、后景 props,第一版继续用色块。
3. 已完成:扩展白盒关卡宽度到 3200 像素,并整理成 3 段推进节奏 3. 已完成:`4HibZC.jpg` 重建关卡为 `2105 x 720` 的参考图复刻版
4. 已完成:加入 3 个 battle zone debug 标记,但暂不做锁镜头和刷敌逻辑。 4. 已完成:加入 3 个 battle zone debug 标记,但暂不做刷敌逻辑。
5. 已完成:加入 spawn point debug 标记,用于后续接 `EnemyActor` 5. 已完成:加入 spawn point debug 标记,用于后续接 `EnemyActor`
6. 已完成:补 debug 开关,可切换碰撞、battle zone、spawn point。 6. 已完成:补 debug 开关,可切换碰撞、battle zone、spawn point。
@@ -252,7 +251,7 @@ game/assets/stage/stage_01/
- `LevelDefinition` 能表达世界尺寸、玩家出生点、相机边界、碰撞、视觉层、props、battle zone、spawn point。 - `LevelDefinition` 能表达世界尺寸、玩家出生点、相机边界、碰撞、视觉层、props、battle zone、spawn point。
- `StageRenderer` 能按层绘制远景、中景、地面/平台、后景 props、前景 props,并支持资源图和色块占位两种来源。 - `StageRenderer` 能按层绘制远景、中景、地面/平台、后景 props、前景 props,并支持资源图和色块占位两种来源。
- `DebugOverlay` 能显示碰撞、battle zone、camera lock、spawn point、玩家包围盒。 - `DebugOverlay` 能显示碰撞、battle zone、camera lock、spawn point、玩家包围盒。
- 关卡灰盒能从起点跑到终点,至少 3 个推进段和 3 个 battle zone 都能被观察到;具体验收流程见 `../scene_acceptance.md` - 关卡灰盒能观察到参考图中的左侧、中央、右侧三个主要平台区域,至少 3 个 battle zone 都能被观察到;具体验收流程见 `../scene_acceptance.md`
- 素材目录、命名、来源记录文件已经准备好,后续放入 PNG 后只改资源引用。 - 素材目录、命名、来源记录文件已经准备好,后续放入 PNG 后只改资源引用。
### 场景骨架实施顺序 ### 场景骨架实施顺序
@@ -328,5 +327,6 @@ game/assets/stage/stage_01/
2. 已完成:`stage_01.map``BackgroundFar``BackgroundMid``LevelVisual``PropsBack``PropsFront` 已改用 `rect_sprite_src` 裁切 Mossy 图集。 2. 已完成:`stage_01.map``BackgroundFar``BackgroundMid``LevelVisual``PropsBack``PropsFront` 已改用 `rect_sprite_src` 裁切 Mossy 图集。
3. 已完成:碰撞、玩家出生点、camera bounds、battle zone 和 spawn point 保持原数据,不把视觉块当碰撞使用。 3. 已完成:碰撞、玩家出生点、camera bounds、battle zone 和 spawn point 保持原数据,不把视觉块当碰撞使用。
4. 已完成:贴图 `LevelVisual` 不再叠加灰盒辅助顶线,避免平台素材显示异常。 4. 已完成:贴图 `LevelVisual` 不再叠加灰盒辅助顶线,避免平台素材显示异常。
5. 已完成:参考 `asset\Mossy Cavern\参考图\4HibZC.jpg`,把 `stage_01.map` 重做为 `2105 x 720` 的一比一底图复刻版,完全替换上一版 6400 宽地图。
暂不接入 `BlueWizard Animations``Plant Animations``Slimes` 和 hazard 玩法。下一步围绕地图编辑器继续微调场景块位置和碰撞对齐。 暂不接入 `BlueWizard Animations``Plant Animations``Slimes` 和 hazard 玩法。下一步围绕地图编辑器继续微调碰撞对齐,并逐步把完整参考底图拆成可编辑图层
+8 -6
View File
@@ -7,14 +7,14 @@
- 场景入口:`WhiteboxScene` - 场景入口:`WhiteboxScene`
- 关卡数据:`game/assets/map/stage_01.map` - 关卡数据:`game/assets/map/stage_01.map`
- 关卡 fallback`CreateWhiteboxLevel()` 内置 C++ 白盒数据 - 关卡 fallback`CreateWhiteboxLevel()` 内置 C++ 白盒数据
- 世界尺寸:`3200 x 720` - 世界尺寸:`2105 x 720`
- 玩家出生点:`(160, 380)` - 玩家出生点:`(1030, 346)`
- 关卡边界:`0 -> 3200` - 关卡边界:`0 -> 2105`
- 视口:`1280 x 720` - 视口:`1280 x 720`
- Battle zones`zone_01_intro``zone_02_platform``zone_03_exit` - Battle zones`zone_01_left_platform``zone_02_center_platform``zone_03_right_platform`
- 资源槽位:`game/assets/stage/stage_01/` - 资源槽位:`game/assets/stage/stage_01/`
- 地图资源:`game/assets/map/stage_01.map` - 地图资源:`game/assets/map/stage_01.map`
- Tile 数据:`stage_01.map` 中的 `tileset` / `tile_rect` - 参考底图:`game/assets/stage/stage_01/reference/mossy_cavern_reference_4hibzc.png`
- 全局资源槽位:`game/assets/ASSETS.md` - 全局资源槽位:`game/assets/ASSETS.md`
- UI overlay`GameUiOverlay`,有字体时使用 `TextSprite`,无字体时使用 fallback 色条。 - UI overlay`GameUiOverlay`,有字体时使用 `TextSprite`,无字体时使用 fallback 色条。
- 音频 cue`stage_01``player_jump``player_attack``battle_zone_enter` - 音频 cue`stage_01``player_jump``player_attack``battle_zone_enter`
@@ -43,6 +43,7 @@ if (!$p.HasExited) { Stop-Process -Id $p.Id -Force }
Test-Path build\windows\x64\release\assets\map\stage_01.map Test-Path build\windows\x64\release\assets\map\stage_01.map
Test-Path build\windows\x64\release\assets\ASSETS.md Test-Path build\windows\x64\release\assets\ASSETS.md
Test-Path build\windows\x64\release\assets\fonts Test-Path build\windows\x64\release\assets\fonts
Test-Path build\windows\x64\release\assets\stage\stage_01\reference\mossy_cavern_reference_4hibzc.png
``` ```
### Switch ### Switch
@@ -64,6 +65,7 @@ RomFS 地图检查:
Test-Path build\switch\aarch64\release\switch_game\romfs\assets\map\stage_01.map Test-Path build\switch\aarch64\release\switch_game\romfs\assets\map\stage_01.map
Test-Path build\switch\aarch64\release\switch_game\romfs\assets\ASSETS.md Test-Path build\switch\aarch64\release\switch_game\romfs\assets\ASSETS.md
Test-Path build\switch\aarch64\release\switch_game\romfs\assets\fonts Test-Path build\switch\aarch64\release\switch_game\romfs\assets\fonts
Test-Path build\switch\aarch64\release\switch_game\romfs\assets\stage\stage_01\reference\mossy_cavern_reference_4hibzc.png
``` ```
## 手动跑图检查 ## 手动跑图检查
@@ -92,7 +94,7 @@ Test-Path build\switch\aarch64\release\switch_game\romfs\assets\fonts
- Switch `Frostbite2DGameSwitch` 构建:通过。 - Switch `Frostbite2DGameSwitch` 构建:通过。
- Switch RomFS:已包含 character、shader、`stage_01` 资源槽位和 `assets/map/stage_01.map` - Switch RomFS:已包含 character、shader、`stage_01` 资源槽位和 `assets/map/stage_01.map`
- 地图加载:`stage_01.map` 已作为第一关主数据源;C++ 白盒数据仅作为 fallback。 - 地图加载:`stage_01.map` 已作为第一关主数据源;C++ 白盒数据仅作为 fallback。
- 地图 tile 化:`level_visual` 已由 `tileset` / `tile_rect` 表达;tileset 图片缺失时使用 fallback 色绘制 - 地图视觉:`stage_01.map` 已重做为 `4HibZC.jpg` 的一比一参考图复刻版,当前通过 `reference/mossy_cavern_reference_4hibzc.png` 作为完整底图表达
- 引擎能力接入:`Animation`/NPK/PNG 角色资源优先级、`UIScene`/`TextSprite` UI、`Sound`/`Music` cue、`SaveSystem` debug 存档已接入。 - 引擎能力接入:`Animation`/NPK/PNG 角色资源优先级、`UIScene`/`TextSprite` UI、`Sound`/`Music` cue、`SaveSystem` debug 存档已接入。
- 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。 - 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。
+41 -5
View File
@@ -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。
- 增加多选、复制、粘贴、批量移动。 - 增加多选、复制、粘贴、批量移动。
- 增加自动检查:视觉铺砖区与碰撞块错位提示。 - 增加自动检查:视觉铺砖区与碰撞块错位提示。
+30 -5
View File
@@ -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` 会清空撤回历史。
+120 -15
View File
@@ -9,6 +9,8 @@ const state = {
imagePreviews: new Map(), imagePreviews: new Map(),
imageLoading: new Set(), imageLoading: new Set(),
imageFailures: new Set(), imageFailures: new Set(),
previewLoadToken: 0,
serverAvailable: false,
history: [], history: [],
dragging: null, dragging: null,
}; };
@@ -25,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",
@@ -44,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));
@@ -92,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() {
@@ -143,26 +168,63 @@ function loadImage(url) {
}); });
} }
function preloadMapImages(map) { async function preloadMapImages(map) {
for (const layer of map.layers) { const textures = uniqueMapTextures(map);
for (const rect of layer.rects) { if (!textures.length) return;
if (rect.texture) queueImagePreview(rect.texture);
const token = state.previewLoadToken + 1;
state.previewLoadToken = token;
setStatus(`正在加载贴图预览 0/${textures.length}`);
let loaded = 0;
let failed = 0;
await Promise.all(textures.map(async (texturePath) => {
const ok = await queueImagePreview(texturePath, { refreshAfterLoad: false });
if (ok) loaded += 1;
else failed += 1;
if (state.previewLoadToken === token) {
setStatus(`正在加载贴图预览 ${loaded + failed}/${textures.length}`);
} }
}));
if (state.previewLoadToken !== token) return;
refresh();
if (failed) {
setStatus(`贴图预览完成:成功 ${loaded},失败 ${failed}`);
} else {
setStatus(`贴图预览完成:${loaded}/${textures.length}`);
} }
} }
async function queueImagePreview(texturePath) { function uniqueMapTextures(map) {
const textures = [];
const seen = new Set();
for (const layer of map.layers) {
for (const rect of layer.rects) {
if (!rect.texture || seen.has(rect.texture)) continue;
seen.add(rect.texture);
textures.push(rect.texture);
}
}
return textures;
}
async function queueImagePreview(texturePath, options = {}) {
if (!texturePath || if (!texturePath ||
state.imagePreviews.has(texturePath) || state.imagePreviews.has(texturePath)) {
state.imageLoading.has(texturePath) || return true;
state.imageFailures.has(texturePath)) { }
return; if (state.imageLoading.has(texturePath)) {
return false;
}
if (state.imageFailures.has(texturePath)) {
return false;
} }
const url = previewUrlForTexture(texturePath); const url = previewUrlForTexture(texturePath);
if (!url) { if (!url) {
state.imageFailures.add(texturePath); state.imageFailures.add(texturePath);
return; return false;
} }
state.imageLoading.add(texturePath); state.imageLoading.add(texturePath);
@@ -170,10 +232,12 @@ async function queueImagePreview(texturePath) {
const image = await loadImage(url); const image = await loadImage(url);
state.imagePreviews.set(texturePath, image); state.imagePreviews.set(texturePath, image);
state.imageFailures.delete(texturePath); state.imageFailures.delete(texturePath);
refresh(); if (options.refreshAfterLoad !== false) refresh();
return true;
} catch (error) { } catch (error) {
state.imageFailures.add(texturePath); state.imageFailures.add(texturePath);
setStatus(`贴图预览加载失败:${texturePath}`); setStatus(`贴图预览加载失败:${texturePath}`);
return false;
} finally { } finally {
state.imageLoading.delete(texturePath); state.imageLoading.delete(texturePath);
} }
@@ -183,9 +247,14 @@ function previewUrlForTexture(texturePath) {
const clean = String(texturePath || "").replace(/\\/g, "/").replace(/^\/+/, ""); const clean = String(texturePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
if (!clean) return ""; if (!clean) return "";
if (/^(blob:|data:|https?:\/\/)/i.test(clean)) return clean; if (/^(blob:|data:|https?:\/\/)/i.test(clean)) return clean;
if (clean.startsWith("assets/")) return `../../game/${clean}`; if (clean.startsWith("assets/")) return cacheBustedUrl(`${location.origin}/game/${clean}`);
if (clean.startsWith("game/assets/")) return `../../${clean}`; if (clean.startsWith("game/assets/")) return cacheBustedUrl(`${location.origin}/${clean}`);
return clean; return cacheBustedUrl(clean);
}
function cacheBustedUrl(url) {
const separator = url.includes("?") ? "&" : "?";
return `${url}${separator}preview=${Date.now()}`;
} }
function loadMapText(text, status) { function loadMapText(text, status) {
@@ -194,10 +263,10 @@ function loadMapText(text, status) {
state.selected = null; state.selected = null;
state.history = []; state.history = [];
state.imageFailures.clear(); state.imageFailures.clear();
preloadMapImages(state.map);
fitWorldToCanvas(); fitWorldToCanvas();
setStatus(status); setStatus(status);
refresh(); refresh();
preloadMapImages(state.map);
} catch (error) { } catch (error) {
setStatus(`解析失败:${error.message}`); setStatus(`解析失败:${error.message}`);
} }
@@ -1075,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;
+2
View File
@@ -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>
+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);
}