Compare commits
4 Commits
bc300f4ff9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cd665198b | |||
| c6d42ff0e0 | |||
| 21f633658c | |||
| 57caced672 |
@@ -1,71 +1,28 @@
|
||||
map stage_01
|
||||
world 3200 720
|
||||
camera 0 0 3200 720
|
||||
player_spawn 160 380
|
||||
world 2105 720
|
||||
camera 0 0 2105 720
|
||||
player_spawn 1030 346
|
||||
|
||||
collision 0 600 3200 80
|
||||
collision 230 500 220 28
|
||||
collision 560 430 260 28
|
||||
collision 930 510 260 28
|
||||
collision 1250 420 230 28
|
||||
collision 1760 500 300 28
|
||||
collision 2180 455 260 28
|
||||
collision 2650 515 360 28
|
||||
collision 0 446 505 42
|
||||
collision 128 560 350 42
|
||||
collision 846 450 470 42
|
||||
collision 1490 506 555 42
|
||||
|
||||
layer background_far BackgroundFar 0.25
|
||||
rect 0 0 3200 720 0.04 0.06 0.05 1.0
|
||||
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
|
||||
layer reference_background BackgroundFar 1
|
||||
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
|
||||
endlayer
|
||||
|
||||
layer background_mid BackgroundMid 0.55
|
||||
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
|
||||
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
|
||||
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
|
||||
battle_zone zone_01_left_platform 0 250 560 310 0 0 1280 720
|
||||
spawn zone_01_enemy_a 210 342
|
||||
spawn zone_01_enemy_b 380 456
|
||||
endbattle_zone
|
||||
|
||||
battle_zone zone_02_platform 1420 250 680 350 660 0 2300 720
|
||||
spawn zone_02_enemy_a 1620 560
|
||||
spawn zone_02_enemy_b 1900 560
|
||||
battle_zone zone_02_center_platform 760 230 650 320 410 0 1280 720
|
||||
spawn zone_02_enemy_a 930 346
|
||||
spawn zone_02_enemy_b 1200 346
|
||||
endbattle_zone
|
||||
|
||||
battle_zone zone_03_exit 2380 250 720 350 1600 0 1600 720
|
||||
spawn zone_03_enemy_a 2580 560
|
||||
spawn zone_03_enemy_b 2860 560
|
||||
battle_zone zone_03_right_platform 1420 270 640 310 825 0 1280 720
|
||||
spawn zone_03_enemy_a 1630 402
|
||||
spawn zone_03_enemy_b 1880 402
|
||||
endbattle_zone
|
||||
|
||||
@@ -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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Dust | `effects/dust.png` | Run, jump, and landing dust. | TBD |
|
||||
| Music / ambience | `../../music/stage_01.ogg` | Optional loopable stage audio cue. | TBD |
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
@@ -72,14 +72,14 @@ endbattle_zone
|
||||
- `tile_index` 当前表示 tileset 第一行的横向 tile 序号。
|
||||
- `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` 做远景洞穴轮廓。
|
||||
- `BackgroundMid` 使用 `mossy_hills.png` 做中景苔藓轮廓。
|
||||
- `LevelVisual` 使用 `mossy_floating_platforms.png` 做地面和悬空平台。
|
||||
- `PropsBack` / `PropsFront` 使用苔藓悬挂植物和平台边缘装饰。
|
||||
- 原参考图尺寸为 `2432 x 832`,等比映射到当前游戏视口高度后,世界尺寸为 `2105 x 720`。
|
||||
- `BackgroundFar` 使用 `reference/mossy_cavern_reference_4hibzc.png` 作为完整场景底图,保证第一轮视觉尽量贴近参考图。
|
||||
- 碰撞只按参考图中的左侧、中央、右侧可站平台建立,不把背景洞壁和装饰当碰撞。
|
||||
- Battle zone 分为 3 段:左侧平台、中央平台、右侧平台。
|
||||
|
||||
本轮只接入地图场景视觉,碰撞仍由 `collision` 独立定义;角色、怪物、植物动画和 hazard 玩法暂不接入。
|
||||
本轮只接入地图场景视觉,碰撞仍由 `collision` 独立定义;角色、怪物、植物动画和 hazard 玩法暂不接入。后续若要把底图拆成可编辑素材层,再逐步用 Mossy 图集替换完整参考底图。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -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 资源目录。
|
||||
|
||||
## 数据边界
|
||||
|
||||
|
||||
@@ -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_hills.png`
|
||||
- `tiles/mossy_floating_platforms.png`
|
||||
@@ -47,6 +48,8 @@ UI
|
||||
- `props/mossy_decorations_hazards.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 后再用于精细铺砖。
|
||||
|
||||
## 约束
|
||||
|
||||
@@ -341,19 +341,20 @@ game/assets
|
||||
|
||||
### 6.3 关卡数据格式
|
||||
|
||||
初期可以用 JSON:
|
||||
当前项目已经改为 `.map` 文本数据,由 `StageMapLoader` 读取:
|
||||
|
||||
```json
|
||||
{
|
||||
"player_spawn": { "x": 100, "y": 300 },
|
||||
"bounds": { "x": 0, "y": 0, "w": 3200, "h": 720 },
|
||||
"solids": [],
|
||||
"enemy_spawns": [],
|
||||
"triggers": []
|
||||
}
|
||||
```text
|
||||
map stage_01
|
||||
world 2105 720
|
||||
camera 0 0 2105 720
|
||||
player_spawn 1030 346
|
||||
collision <x> <y> <w> <h>
|
||||
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. 动画和美术阶段
|
||||
|
||||
|
||||
@@ -204,7 +204,7 @@ mindmap
|
||||
- `StageLayerSource` 已接入:每个 `StageRect` 可选择色块占位或 sprite 资源路径,加载失败时回退色块。
|
||||
- `StageAssetSlots` 已建立:`stage_01` 资源目录和素材来源记录已预留,素材回来后可直接落位。
|
||||
- `StageProps` 已统一:后景/前景 props 都走 `StageLayer`,前景层在 actor 之后绘制,可用于近景遮挡。
|
||||
- 第一关宽度已扩展到 3200 像素,并加入 3 个 battle zone 占位与敌人出生点。
|
||||
- 第一关已按 Mossy Cavern `4HibZC.jpg` 重做为 `2105 x 720` 的参考图复刻版,并加入 3 个 battle zone 占位与敌人出生点。
|
||||
|
||||
下一步:
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
第一轮场景骨架进度:
|
||||
|
||||
- `LevelDefinition` 已扩展世界尺寸、玩家出生点、镜头范围、分层视觉、battle zone 和敌人出生点数据。
|
||||
- 第一关灰盒宽度已扩展到 3200 像素。
|
||||
- 第一关已按 `asset\Mossy Cavern\参考图\4HibZC.jpg` 重做为 `2105 x 720` 的参考图复刻版,当前主数据在 `game/assets/map/stage_01.map`。
|
||||
- `StageRenderer` 已支持远景、中景、地面/平台、后景 props 分层绘制,当前先用色块占位。
|
||||
- `DebugOverlay` 已能显示碰撞、玩家框、battle zone、camera lock 范围和 spawn point。
|
||||
- `WhiteboxScene` 已支持 debug overlay,具体快捷键记录在 `../controls.md`。
|
||||
@@ -35,10 +35,10 @@
|
||||
|
||||
第一关不急着做最终题材,先做通用 ACT 测试关:
|
||||
|
||||
- 横向推进,总宽度先控制在 2400 到 3200 像素。
|
||||
- 横向推进,当前 Mossy Cavern 参考图复刻版宽度为 2105 像素。
|
||||
- 主要视口仍用 1280x720。
|
||||
- 地面以平路为主,加入 2 到 3 段平台高低差。
|
||||
- 至少预留 3 个战斗区域,每个区域宽度 480 到 720 像素。
|
||||
- 主路线先保持连续地面,避免当前平台碰撞不支持台阶/斜坡导致卡关;高度变化主要放在悬空平台上,用于测试跳跃和镜头推进。
|
||||
- 当前按参考图三块主要站立区域预留 3 个战斗区域。
|
||||
- 每个战斗区域有明确的入口、站位空间、敌人刷出点和退出方向。
|
||||
- 保留一个跳跃测试段,用来验证移动手感。
|
||||
- 保留一个窄平台/边缘段,用来验证镜头和碰撞。
|
||||
@@ -164,11 +164,11 @@ game/assets/stage/stage_01/
|
||||
|
||||
### Step 5:加入战斗区域数据
|
||||
|
||||
在关卡中标出 3 个 battle zone:
|
||||
当前 Mossy Cavern 参考图复刻版标出 3 个 battle zone:
|
||||
|
||||
- `zone_01_intro`
|
||||
- `zone_02_platform`
|
||||
- `zone_03_exit`
|
||||
- `zone_01_left_platform`
|
||||
- `zone_02_center_platform`
|
||||
- `zone_03_right_platform`
|
||||
|
||||
每个区域包含:
|
||||
|
||||
@@ -196,11 +196,10 @@ game/assets/stage/stage_01/
|
||||
第一阶段完成时应该达到:
|
||||
|
||||
- 能启动 `Frostbite2DGame`。
|
||||
- 第一关宽度至少 2400 像素。
|
||||
- 玩家可以从起点跑到终点。
|
||||
- 至少 5 个平台/地面碰撞块。
|
||||
- 至少 2 层背景。
|
||||
- 至少 8 个装饰物。
|
||||
- 第一关世界尺寸为 `2105 x 720`,与 `4HibZC.jpg` 等比映射后的宽高一致。
|
||||
- 参考图底图能完整显示,且横向摄像机不露出世界外黑边。
|
||||
- 至少 4 个平台碰撞块,对齐参考图中的左侧、左下、中央、右侧可站平台。
|
||||
- 当前视觉先以 1 张完整参考底图表达,不再沿用上一版拼接地图。
|
||||
- 至少 3 个 battle zone debug 标记。
|
||||
- 场景资源路径集中在 `game/assets/stage/stage_01/`。
|
||||
- 文档记录已使用素材来源和授权信息。
|
||||
@@ -224,8 +223,8 @@ game/assets/stage/stage_01/
|
||||
|
||||
1. 已完成:扩展 `LevelDefinition`,加入世界尺寸、玩家出生点、镜头限制、敌人出生点、battle zone 占位数据。
|
||||
2. 已完成:扩展 `StageRenderer`,支持背景层、地面/平台视觉层、后景 props,第一版继续用色块。
|
||||
3. 已完成:扩展白盒关卡宽度到 3200 像素,并整理成 3 段推进节奏。
|
||||
4. 已完成:加入 3 个 battle zone debug 标记,但暂不做锁镜头和刷敌逻辑。
|
||||
3. 已完成:按 `4HibZC.jpg` 重建关卡为 `2105 x 720` 的参考图复刻版。
|
||||
4. 已完成:加入 3 个 battle zone debug 标记,但暂不做刷敌逻辑。
|
||||
5. 已完成:加入 spawn point debug 标记,用于后续接 `EnemyActor`。
|
||||
6. 已完成:补 debug 开关,可切换碰撞、battle zone、spawn point。
|
||||
|
||||
@@ -252,7 +251,7 @@ game/assets/stage/stage_01/
|
||||
- `LevelDefinition` 能表达世界尺寸、玩家出生点、相机边界、碰撞、视觉层、props、battle zone、spawn point。
|
||||
- `StageRenderer` 能按层绘制远景、中景、地面/平台、后景 props、前景 props,并支持资源图和色块占位两种来源。
|
||||
- `DebugOverlay` 能显示碰撞、battle zone、camera lock、spawn point、玩家包围盒。
|
||||
- 关卡灰盒能从起点跑到终点,至少 3 个推进段和 3 个 battle zone 都能被观察到;具体验收流程见 `../scene_acceptance.md`。
|
||||
- 关卡灰盒能观察到参考图中的左侧、中央、右侧三个主要平台区域,至少 3 个 battle zone 都能被观察到;具体验收流程见 `../scene_acceptance.md`。
|
||||
- 素材目录、命名、来源记录文件已经准备好,后续放入 PNG 后只改资源引用。
|
||||
|
||||
### 场景骨架实施顺序
|
||||
@@ -328,5 +327,6 @@ game/assets/stage/stage_01/
|
||||
2. 已完成:`stage_01.map` 的 `BackgroundFar`、`BackgroundMid`、`LevelVisual`、`PropsBack`、`PropsFront` 已改用 `rect_sprite_src` 裁切 Mossy 图集。
|
||||
3. 已完成:碰撞、玩家出生点、camera bounds、battle zone 和 spawn point 保持原数据,不把视觉块当碰撞使用。
|
||||
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 玩法。下一步围绕地图编辑器继续微调碰撞对齐,并逐步把完整参考底图拆成可编辑图层。
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
- 场景入口:`WhiteboxScene`
|
||||
- 关卡数据:`game/assets/map/stage_01.map`
|
||||
- 关卡 fallback:`CreateWhiteboxLevel()` 内置 C++ 白盒数据
|
||||
- 世界尺寸:`3200 x 720`
|
||||
- 玩家出生点:`(160, 380)`
|
||||
- 关卡边界:`0 -> 3200`
|
||||
- 世界尺寸:`2105 x 720`
|
||||
- 玩家出生点:`(1030, 346)`
|
||||
- 关卡边界:`0 -> 2105`
|
||||
- 视口:`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/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`
|
||||
- UI overlay:`GameUiOverlay`,有字体时使用 `TextSprite`,无字体时使用 fallback 色条。
|
||||
- 音频 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\ASSETS.md
|
||||
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
|
||||
@@ -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\ASSETS.md
|
||||
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 RomFS:已包含 character、shader、`stage_01` 资源槽位和 `assets/map/stage_01.map`。
|
||||
- 地图加载:`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 存档已接入。
|
||||
- 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。
|
||||
|
||||
|
||||
@@ -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` 会清空撤回历史。
|
||||
|
||||
|
||||
+120
-15
@@ -9,6 +9,8 @@ const state = {
|
||||
imagePreviews: new Map(),
|
||||
imageLoading: new Set(),
|
||||
imageFailures: new Set(),
|
||||
previewLoadToken: 0,
|
||||
serverAvailable: false,
|
||||
history: [],
|
||||
dragging: null,
|
||||
};
|
||||
@@ -25,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",
|
||||
@@ -44,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));
|
||||
@@ -92,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() {
|
||||
@@ -143,26 +168,63 @@ function loadImage(url) {
|
||||
});
|
||||
}
|
||||
|
||||
function preloadMapImages(map) {
|
||||
for (const layer of map.layers) {
|
||||
for (const rect of layer.rects) {
|
||||
if (rect.texture) queueImagePreview(rect.texture);
|
||||
async function preloadMapImages(map) {
|
||||
const textures = uniqueMapTextures(map);
|
||||
if (!textures.length) return;
|
||||
|
||||
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 ||
|
||||
state.imagePreviews.has(texturePath) ||
|
||||
state.imageLoading.has(texturePath) ||
|
||||
state.imageFailures.has(texturePath)) {
|
||||
return;
|
||||
state.imagePreviews.has(texturePath)) {
|
||||
return true;
|
||||
}
|
||||
if (state.imageLoading.has(texturePath)) {
|
||||
return false;
|
||||
}
|
||||
if (state.imageFailures.has(texturePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = previewUrlForTexture(texturePath);
|
||||
if (!url) {
|
||||
state.imageFailures.add(texturePath);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
state.imageLoading.add(texturePath);
|
||||
@@ -170,10 +232,12 @@ async function queueImagePreview(texturePath) {
|
||||
const image = await loadImage(url);
|
||||
state.imagePreviews.set(texturePath, image);
|
||||
state.imageFailures.delete(texturePath);
|
||||
refresh();
|
||||
if (options.refreshAfterLoad !== false) refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
state.imageFailures.add(texturePath);
|
||||
setStatus(`贴图预览加载失败:${texturePath}`);
|
||||
return false;
|
||||
} finally {
|
||||
state.imageLoading.delete(texturePath);
|
||||
}
|
||||
@@ -183,9 +247,14 @@ function previewUrlForTexture(texturePath) {
|
||||
const clean = String(texturePath || "").replace(/\\/g, "/").replace(/^\/+/, "");
|
||||
if (!clean) return "";
|
||||
if (/^(blob:|data:|https?:\/\/)/i.test(clean)) return clean;
|
||||
if (clean.startsWith("assets/")) return `../../game/${clean}`;
|
||||
if (clean.startsWith("game/assets/")) return `../../${clean}`;
|
||||
return clean;
|
||||
if (clean.startsWith("assets/")) return cacheBustedUrl(`${location.origin}/game/${clean}`);
|
||||
if (clean.startsWith("game/assets/")) return cacheBustedUrl(`${location.origin}/${clean}`);
|
||||
return cacheBustedUrl(clean);
|
||||
}
|
||||
|
||||
function cacheBustedUrl(url) {
|
||||
const separator = url.includes("?") ? "&" : "?";
|
||||
return `${url}${separator}preview=${Date.now()}`;
|
||||
}
|
||||
|
||||
function loadMapText(text, status) {
|
||||
@@ -194,10 +263,10 @@ function loadMapText(text, status) {
|
||||
state.selected = null;
|
||||
state.history = [];
|
||||
state.imageFailures.clear();
|
||||
preloadMapImages(state.map);
|
||||
fitWorldToCanvas();
|
||||
setStatus(status);
|
||||
refresh();
|
||||
preloadMapImages(state.map);
|
||||
} catch (error) {
|
||||
setStatus(`解析失败:${error.message}`);
|
||||
}
|
||||
@@ -1075,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