Compare commits
6 Commits
dddae61bd9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cd665198b | |||
| c6d42ff0e0 | |||
| 21f633658c | |||
| 57caced672 | |||
| bc300f4ff9 | |||
| 7d0f115fcb |
@@ -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
|
||||
|
||||
tileset graybox_ground assets/stage/stage_01/tiles/graybox_ground.png 10 10 0.62 0.68 0.73 1.0
|
||||
tileset graybox_platform assets/stage/stage_01/tiles/graybox_platform.png 10 14 0.62 0.68 0.73 1.0
|
||||
|
||||
layer background_far BackgroundFar 0.25
|
||||
rect 0 0 3200 720 0.08 0.10 0.14 1.0
|
||||
rect 360 120 420 220 0.13 0.16 0.21 1.0
|
||||
rect 1380 90 560 260 0.12 0.15 0.20 1.0
|
||||
rect 2440 140 520 210 0.13 0.16 0.21 1.0
|
||||
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 80 360 280 240 0.18 0.22 0.27 1.0
|
||||
rect 1020 310 340 290 0.18 0.22 0.27 1.0
|
||||
rect 2020 330 360 270 0.18 0.22 0.27 1.0
|
||||
rect 2860 280 260 320 0.18 0.22 0.27 1.0
|
||||
endlayer
|
||||
|
||||
layer level_visual LevelVisual 1.0
|
||||
tile_rect graybox_ground 0 600 320 8 0 0.62 0.68 0.73
|
||||
tile_rect graybox_platform 230 500 22 2 0 0.62 0.68 0.73
|
||||
tile_rect graybox_platform 560 430 26 2 0 0.62 0.68 0.73
|
||||
tile_rect graybox_platform 930 510 26 2 0 0.62 0.68 0.73
|
||||
tile_rect graybox_platform 1250 420 23 2 0 0.62 0.68 0.73
|
||||
tile_rect graybox_platform 1760 500 30 2 0 0.62 0.68 0.73
|
||||
tile_rect graybox_platform 2180 455 26 2 0 0.62 0.68 0.73
|
||||
tile_rect graybox_platform 2650 515 36 2 0 0.62 0.68 0.73
|
||||
endlayer
|
||||
|
||||
layer props_back PropsBack 1.0
|
||||
rect 118 392 88 208 0.26 0.30 0.34 1.0
|
||||
rect 780 250 72 180 0.26 0.30 0.34 1.0
|
||||
rect 1440 300 80 300 0.26 0.30 0.34 1.0
|
||||
rect 1880 360 110 240 0.26 0.30 0.34 1.0
|
||||
rect 2520 390 92 210 0.26 0.30 0.34 1.0
|
||||
rect 3040 315 82 285 0.26 0.30 0.34 1.0
|
||||
endlayer
|
||||
|
||||
layer props_front PropsFront 1.0
|
||||
rect 520 580 220 34 0.14 0.17 0.18 0.90
|
||||
rect 1510 570 280 42 0.14 0.17 0.18 0.90
|
||||
rect 2580 572 260 38 0.14 0.17 0.18 0.90
|
||||
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
|
||||
|
||||
@@ -6,12 +6,15 @@ This directory is reserved for the first playable side-scrolling ACT stage.
|
||||
|
||||
| Slot | Expected file | Notes | Source / License |
|
||||
| --- | --- | --- | --- |
|
||||
| Far background | `background/background_far.png` | Large or horizontally tileable image. | TBD |
|
||||
| Mid background | `background/background_mid.png` | Parallax midground image or prop sheet. | TBD |
|
||||
| Tileset | `tiles/stage_01_tileset.png` | Ground, platform, wall, edge tiles. | TBD |
|
||||
| Far background | `background/mossy_background_decoration.png` | Mossy cave silhouettes used by `BackgroundFar` layer. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD |
|
||||
| Mid background | `background/mossy_hills.png` | Mossy hill silhouettes used by `BackgroundMid` layer. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD |
|
||||
| Tileset | `tiles/mossy_tileset.png` | Full Mossy tileset atlas, reserved for later tile-id based editing. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD |
|
||||
| Floating platforms | `tiles/mossy_floating_platforms.png` | Current `LevelVisual` ground and platform sprites in `stage_01.map`. | Local asset pack: `X:\NS_unknown_game\asset\Mossy Cavern`; license TBD |
|
||||
| Graybox ground tiles | `tiles/graybox_ground.png` | Optional temporary 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/stage_01_props.png` | Crates, signs, lamps, blockers, exits. | 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 |
|
||||
@@ -23,3 +26,4 @@ This directory is reserved for the first playable side-scrolling ACT stage.
|
||||
- Prefer PNG for visual assets.
|
||||
- Record the original download URL, author, license, and any attribution text before using an asset.
|
||||
- Keep collision data separate from visual tiles; visual alignment should stay within 4 pixels of collision.
|
||||
- Current Mossy Cavern integration only covers map scene visuals. Character animations, slimes, plant animations, hazards, and combat use are intentionally deferred.
|
||||
|
||||
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
@@ -72,11 +72,18 @@ endbattle_zone
|
||||
- `tile_index` 当前表示 tileset 第一行的横向 tile 序号。
|
||||
- `r g b` 是该批 tile 的灰盒 fallback 色,alpha 固定为 `1.0`。
|
||||
|
||||
当前 `stage_01.map` 已把 `level_visual` 地面和平台从长色块替换为 `tile_rect`,碰撞仍由 `collision` 独立定义。
|
||||
当前 `stage_01.map` 已按 `asset\Mossy Cavern\参考图\4HibZC.jpg` 重做为一比一参考图复刻版:
|
||||
|
||||
- 原参考图尺寸为 `2432 x 832`,等比映射到当前游戏视口高度后,世界尺寸为 `2105 x 720`。
|
||||
- `BackgroundFar` 使用 `reference/mossy_cavern_reference_4hibzc.png` 作为完整场景底图,保证第一轮视觉尽量贴近参考图。
|
||||
- 碰撞只按参考图中的左侧、中央、右侧可站平台建立,不把背景洞壁和装饰当碰撞。
|
||||
- Battle zone 分为 3 段:左侧平台、中央平台、右侧平台。
|
||||
|
||||
本轮只接入地图场景视觉,碰撞仍由 `collision` 独立定义;角色、怪物、植物动画和 hazard 玩法暂不接入。后续若要把底图拆成可编辑素材层,再逐步用 Mossy 图集替换完整参考底图。
|
||||
|
||||
## 下一步
|
||||
|
||||
- 下一阶段开始正式做地图场景,优先建设简易 `.map` 编辑器,详见 `map_editor.md`。
|
||||
- 继续用地图编辑器调整 Mossy 场景视觉块和碰撞对齐。
|
||||
- 把 `tile_rect` 扩展成可表达 tile row/column 或 tile id。
|
||||
- 增加空 layer、battle zone 无 spawn、tile texture 不存在等更严格错误日志。
|
||||
- 保留 `whitebox_level.cpp` 作为 fallback,不再把主地图写死在 C++。
|
||||
|
||||
@@ -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,13 +119,17 @@ http://127.0.0.1:8787/tools/map_editor/
|
||||
- 支持撤回最近编辑操作,包含新增、删除、拖动、缩放、属性修改、导入图形和图层移动。
|
||||
- 支持 `Ctrl+Z` 快捷撤回。
|
||||
- 支持保存并下载 `.map` 文本。
|
||||
- 使用 `server.js` 启动时,支持直接保存到项目资源目录。
|
||||
- 使用 `server.js` 启动时,支持一键部署到项目资源目录和已存在的 PC/Switch build 资源目录。
|
||||
- 支持选择图层或图层内对象后,对对应图层上移/下移;预览和导出都按当前 `.map` 图层顺序处理。
|
||||
- 通过“导入图形”创建 `rect_sprite`,并自动追加一个同坐标、同尺寸的 `collision`。
|
||||
- 导入图形在当前浏览器会话内支持图片预览;图形移动和缩放时,同步更新绑定碰撞块。
|
||||
- 从仓库根目录启动静态服务时,`.map` 内 `assets/...` 贴图路径会自动映射到 `game/assets/...` 并加载项目内已有图片预览。
|
||||
- `rect_sprite_src` 图形支持在属性面板编辑源图裁切区域。
|
||||
- 重新打开 `.map` 时,如果 `rect_sprite` 与某个 `collision` 坐标尺寸一致,编辑器会自动恢复二者的编辑绑定。
|
||||
- 导出兼容 `StageMapLoader` 的 `.map` 文本。
|
||||
|
||||
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。后续部署到服务器时,可以增加一个保存接口,把编辑器导出的文本写回服务端文件或数据库。
|
||||
浏览器安全限制下,纯静态网页不能直接覆盖本地源文件;当前通过“保存并下载”导出 `.map`。本地开发时可通过 `tools/map_editor/server.js` 启动带写入 API 的服务,直接写回项目文件和已有 build 资源目录。
|
||||
|
||||
## 数据边界
|
||||
|
||||
@@ -129,6 +151,13 @@ assets/stage/stage_01/props/<图片文件名>
|
||||
|
||||
实际运行游戏前,需要把对应图片素材放到该目录。编辑器内的图形-碰撞绑定是工具层能力,导出的 `.map` 仍保持运行时兼容格式,不额外写入私有字段。
|
||||
|
||||
项目内已有素材预览规则:
|
||||
|
||||
- `assets/...` 会映射到仓库内 `game/assets/...`。
|
||||
- `game/assets/...` 会直接映射到仓库同名路径。
|
||||
- 如果文件存在,编辑器重新打开 `.map` 后也会自动预览。
|
||||
- 如果文件不存在,编辑器使用颜色块 fallback。
|
||||
|
||||
## 首版验收
|
||||
|
||||
完成时应达到:
|
||||
|
||||
@@ -34,6 +34,23 @@ UI
|
||||
- 支持 `.map` tile entry 绘制。
|
||||
- 支持 tileset 图片缺失时按 tile fallback 色绘制。
|
||||
- 资源缺失时先检查 `Asset::exists()`,再回退色块,不触发纹理加载错误日志。
|
||||
- `LevelVisual` 里的贴图矩形不再叠加灰盒顶边辅助线;只有纯色占位块会显示该辅助线。
|
||||
|
||||
## 当前素材接入
|
||||
|
||||
第一关已经接入 Mossy Cavern 场景资源:
|
||||
|
||||
- `reference/mossy_cavern_reference_4hibzc.png`
|
||||
- `background/mossy_background_decoration.png`
|
||||
- `background/mossy_hills.png`
|
||||
- `tiles/mossy_floating_platforms.png`
|
||||
- `props/mossy_hanging_plants.png`
|
||||
- `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 后再用于精细铺砖。
|
||||
|
||||
## 约束
|
||||
|
||||
@@ -44,5 +61,5 @@ UI
|
||||
## 下一步
|
||||
|
||||
- 给 tile entry 增加 tile id 或二维 source index。
|
||||
- 把正式 tileset 素材接入 `stage_01.map`。
|
||||
- 用地图编辑器继续调整 Mossy 场景块、前后景装饰和碰撞对齐。
|
||||
- 如果引擎提供统一 Texture cache,再迁移当前局部贴图缓存。
|
||||
|
||||
@@ -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 后只改资源引用。
|
||||
|
||||
### 场景骨架实施顺序
|
||||
@@ -319,3 +318,15 @@ game/assets/stage/stage_01/
|
||||
3. 把灰盒 `StageLayer` 的色块替换成 sprite/tile 条目。
|
||||
4. 对齐视觉块和碰撞块,误差控制在 4 像素以内。
|
||||
5. 更新素材来源和授权记录。
|
||||
|
||||
### Mossy Cavern 场景接入
|
||||
|
||||
当前第一关已经先接入 `X:\NS_unknown_game\asset\Mossy Cavern` 的场景图集,范围只包含地图视觉:
|
||||
|
||||
1. 已完成:把 Mossy 背景、平台、悬挂植物和装饰图集复制到 `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 玩法。下一步围绕地图编辑器继续微调碰撞对齐,并逐步把完整参考底图拆成可编辑图层。
|
||||
|
||||
@@ -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 存档已接入。
|
||||
- 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ void StageRenderer::drawLayer(const LevelDefinition& level,
|
||||
rect.bounds.top() + parallaxOffset.y,
|
||||
rect.bounds.width(), rect.bounds.height());
|
||||
drawStageRect(rect, bounds);
|
||||
if (layer.role == StageLayerRole::LevelVisual) {
|
||||
if (layer.role == StageLayerRole::LevelVisual && !rect.source.hasTexture()) {
|
||||
drawRect(frostbite2D::Rect(bounds.left(), bounds.top(), bounds.width(),
|
||||
4.0f),
|
||||
frostbite2D::Color(0.91f, 0.95f, 0.98f, 1.0f));
|
||||
|
||||
@@ -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`。
|
||||
|
||||
### 左侧菜单
|
||||
@@ -133,6 +150,10 @@ assets/stage/stage_01/props/<图片文件名>
|
||||
|
||||
静态网页不能自动复制图片到项目目录。实际运行游戏前,需要手动把对应图片素材放到该路径。
|
||||
|
||||
如果从仓库根目录启动静态服务,编辑器会把 `.map` 里的 `assets/...` 贴图路径映射到 `game/assets/...` 并自动加载预览。也就是说,项目内已经存在的 Mossy Cavern 等 PNG 会在重新打开地图后直接显示;只有文件没有放到对应目录时才会回退为颜色块。
|
||||
|
||||
`rect_sprite_src` 图形会在属性面板显示 `源 X`、`源 Y`、`源 W`、`源 H`,可以直接调整大图集的裁切区域。普通 `rect_sprite` 仍按整张图绘制,不会因为打开属性面板而自动变成 `rect_sprite_src`。
|
||||
|
||||
### 调整图层顺序
|
||||
|
||||
1. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。
|
||||
@@ -161,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 结构。
|
||||
|
||||
### 保存并下载
|
||||
|
||||
点击顶部“保存并下载”会下载:
|
||||
@@ -171,7 +210,7 @@ Ctrl+Z
|
||||
|
||||
下载内容与右侧“导出文本”一致,并保持 `StageMapLoader` 兼容。
|
||||
|
||||
浏览器安全限制下,静态网页不能直接覆盖项目源文件。当前流程是:
|
||||
普通静态服务模式下,浏览器安全限制不允许直接覆盖项目源文件。当前流程是:
|
||||
|
||||
1. 保存并下载 `.map`。
|
||||
2. 手动替换项目内目标地图文件。
|
||||
@@ -203,9 +242,11 @@ spawn
|
||||
|
||||
## 当前限制
|
||||
|
||||
- 不能直接覆盖本地源文件,只能下载 `.map`。
|
||||
- 普通静态服务模式不能直接覆盖本地源文件,只能下载 `.map`。
|
||||
- `保存` / `一键部署` 需要使用 `node tools/map_editor/server.js` 启动本地可写服务。
|
||||
- 导入图片的真实文件仍需手动放入项目资源目录。
|
||||
- 已导入图片的浏览器预览只在当前会话有效;重新打开 `.map` 后,如果没有重新选择图片,会显示 fallback 色块。
|
||||
- `.map` 中存在于 `game/assets/...` 的 `assets/...` 贴图路径会自动预览。
|
||||
- 如果导入图片后没有把真实文件放到 `game/assets/...` 对应路径,重新打开 `.map` 后会显示 fallback 色块。
|
||||
- 暂未实现重做。
|
||||
- 暂未实现多选和批量移动。
|
||||
- 暂未实现 tile palette 和 tileset 图片预览。
|
||||
@@ -213,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` 文件。
|
||||
|
||||
## 当前能力
|
||||
|
||||
@@ -33,17 +51,26 @@ http://127.0.0.1:8787/tools/map_editor/
|
||||
- 通过“编辑目标”过滤画布选择对象,默认“智能选择”把碰撞块当作上层覆盖;同位置对象第一次点选碰撞块,再点一次切到下层铺砖区或图形。
|
||||
- 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。
|
||||
- 通过“导入图形”选择图片,自动生成一个 `rect_sprite` 图形对象和一个同尺寸 `collision`。
|
||||
- 导入的图形会在浏览器内临时预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。
|
||||
- 从仓库根目录启动静态服务时,`.map` 内 `assets/...` 贴图路径会自动映射到 `game/assets/...` 并显示预览。
|
||||
- 导入的图形会在浏览器内预览,拖动或缩放图形时,同步移动或缩放绑定的碰撞块。
|
||||
- 已有 `rect_sprite_src` 图形可以在属性面板调整源图裁切区域。
|
||||
- 编辑选中对象的数值和文本属性。
|
||||
- 新增/删除图块集、图层、碰撞块、铺砖区、战斗区和出生点。
|
||||
- 支持撤回最近编辑操作,也可以使用 `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` 会清空撤回历史。
|
||||
|
||||
@@ -71,6 +98,7 @@ http://127.0.0.1:8787/tools/map_editor/
|
||||
4. 拖动图形本体可以移动图形和绑定碰撞块。
|
||||
5. 拖动右下角蓝色手柄可以调整图形和绑定碰撞块尺寸。
|
||||
6. 也可以在右侧属性面板修改 `X`、`Y`、`W`、`H`、颜色和贴图路径。
|
||||
7. 如果图形来自 `rect_sprite_src`,属性面板还会显示 `源 X`、`源 Y`、`源 W`、`源 H`,用于调整图集裁切区域。
|
||||
|
||||
静态网页不能自动把图片复制进项目目录。导入后 `.map` 默认写入:
|
||||
|
||||
@@ -89,6 +117,7 @@ assets/stage/stage_01/props/<图片文件名>
|
||||
## 当前限制
|
||||
|
||||
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
|
||||
- 已导入图形的预览只在当前浏览器会话内有效;重新打开 `.map` 后,如果浏览器没有重新选择图片,图形会用颜色块显示。
|
||||
- 如果 `.map` 里的贴图路径指向 `assets/...` 且文件存在于 `game/assets/...`,重新打开后会自动预览图片。
|
||||
- 如果导入图片后没有把真实文件放入 `game/assets/...` 对应路径,重新打开后会回退为颜色块。
|
||||
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
|
||||
- 暂未实现重做和多选。
|
||||
|
||||
@@ -7,6 +7,10 @@ const state = {
|
||||
gridSize: 10,
|
||||
hitFilter: "all",
|
||||
imagePreviews: new Map(),
|
||||
imageLoading: new Set(),
|
||||
imageFailures: new Set(),
|
||||
previewLoadToken: 0,
|
||||
serverAvailable: false,
|
||||
history: [],
|
||||
dragging: null,
|
||||
};
|
||||
@@ -23,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",
|
||||
@@ -42,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));
|
||||
@@ -90,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() {
|
||||
@@ -121,6 +148,7 @@ async function importGraphicAsset(event) {
|
||||
const image = await loadImage(previewUrl);
|
||||
const texturePath = defaultGraphicTexturePath(file.name);
|
||||
state.imagePreviews.set(texturePath, image);
|
||||
state.imageFailures.delete(texturePath);
|
||||
pushUndo("导入图形");
|
||||
addGraphicSprite(texturePath, image.naturalWidth || image.width || 64, image.naturalHeight || image.height || 64);
|
||||
setStatus(`已导入图形 ${file.name},并自动创建同尺寸碰撞块。请把素材放到 ${texturePath}`);
|
||||
@@ -140,14 +168,105 @@ function loadImage(url) {
|
||||
});
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
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 false;
|
||||
}
|
||||
|
||||
state.imageLoading.add(texturePath);
|
||||
try {
|
||||
const image = await loadImage(url);
|
||||
state.imagePreviews.set(texturePath, image);
|
||||
state.imageFailures.delete(texturePath);
|
||||
if (options.refreshAfterLoad !== false) refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
state.imageFailures.add(texturePath);
|
||||
setStatus(`贴图预览加载失败:${texturePath}`);
|
||||
return false;
|
||||
} finally {
|
||||
state.imageLoading.delete(texturePath);
|
||||
}
|
||||
}
|
||||
|
||||
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 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) {
|
||||
try {
|
||||
state.map = parseMap(text);
|
||||
state.selected = null;
|
||||
state.history = [];
|
||||
state.imageFailures.clear();
|
||||
fitWorldToCanvas();
|
||||
setStatus(status);
|
||||
refresh();
|
||||
preloadMapImages(state.map);
|
||||
} catch (error) {
|
||||
setStatus(`解析失败:${error.message}`);
|
||||
}
|
||||
@@ -489,6 +608,7 @@ function drawLayerRect(ctx, rect) {
|
||||
ctx.strokeRect(rect.bounds.x, rect.bounds.y, rect.bounds.width, rect.bounds.height);
|
||||
return;
|
||||
}
|
||||
if (rect.texture) queueImagePreview(rect.texture);
|
||||
drawFilledRect(ctx, rect.bounds, rect.color, 0.55, "#7d8794");
|
||||
}
|
||||
|
||||
@@ -1024,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;
|
||||
@@ -1108,7 +1264,7 @@ function rectFields(rect) {
|
||||
}
|
||||
|
||||
function layerRectFields(item) {
|
||||
return [
|
||||
const fields = [
|
||||
numberField("X", () => item.bounds.x, (v) => {
|
||||
const next = Number.isFinite(v) ? v : item.bounds.x;
|
||||
moveLinkedCollision(item, next - item.bounds.x, 0);
|
||||
@@ -1129,11 +1285,27 @@ function layerRectFields(item) {
|
||||
}),
|
||||
textField("贴图路径", () => item.texture || "", (v) => {
|
||||
const next = String(v || "").trim();
|
||||
if (next) item.texture = next;
|
||||
else delete item.texture;
|
||||
if (next) {
|
||||
item.texture = next;
|
||||
state.imageFailures.delete(next);
|
||||
queueImagePreview(next);
|
||||
} else {
|
||||
delete item.texture;
|
||||
}
|
||||
}),
|
||||
...colorFields(item.color, true),
|
||||
];
|
||||
|
||||
if (item.texture && item.source) {
|
||||
fields.push(
|
||||
numberField("源 X", () => sourceRectFor(item).x, (v) => { sourceRectFor(item).x = Math.max(0, v); }),
|
||||
numberField("源 Y", () => sourceRectFor(item).y, (v) => { sourceRectFor(item).y = Math.max(0, v); }),
|
||||
numberField("源 W", () => sourceRectFor(item).width, (v) => { sourceRectFor(item).width = Math.max(1, v); }),
|
||||
numberField("源 H", () => sourceRectFor(item).height, (v) => { sourceRectFor(item).height = Math.max(1, v); })
|
||||
);
|
||||
}
|
||||
|
||||
fields.push(...colorFields(item.color, true));
|
||||
return fields;
|
||||
}
|
||||
|
||||
function pointFields(point) {
|
||||
@@ -1161,6 +1333,19 @@ function textField(label, get, set, options = null) {
|
||||
return { label, type: "text", options, get, set };
|
||||
}
|
||||
|
||||
function sourceRectFor(item) {
|
||||
if (!item.source) {
|
||||
const image = item.texture ? state.imagePreviews.get(item.texture) : null;
|
||||
item.source = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: image ? (image.naturalWidth || image.width || Math.max(1, item.bounds.width)) : Math.max(1, item.bounds.width),
|
||||
height: image ? (image.naturalHeight || image.height || Math.max(1, item.bounds.height)) : Math.max(1, item.bounds.height),
|
||||
};
|
||||
}
|
||||
return item.source;
|
||||
}
|
||||
|
||||
function ensureLevelVisualLayer() {
|
||||
if (!state.map.layers.some((layer) => layer.role === "LevelVisual")) {
|
||||
state.map.layers.push({ id: "level_visual", role: "LevelVisual", parallax: 1, rects: [], tileRects: [] });
|
||||
|
||||
@@ -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);
|
||||
}
|
||||