Compare commits

..

37 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
wy bc300f4ff9 Improve map editor image previews 2026-06-10 10:17:25 +08:00
wy 7d0f115fcb Integrate mossy cavern stage scene 2026-06-10 02:21:44 +08:00
wy dddae61bd9 Document map editor tool 2026-06-10 01:38:12 +08:00
wy 8daf5d27aa Fix layered collision selection 2026-06-10 01:30:06 +08:00
wy 8050ab2c5e Improve map editor workflow controls 2026-06-10 01:20:39 +08:00
wy 078e131381 Fix map editor tile selection 2026-06-10 01:02:03 +08:00
wy d46bb4dae0 Improve map editor graphic imports 2026-06-10 00:54:05 +08:00
wy d652d576e3 Fix collision editing in map editor 2026-06-10 00:31:53 +08:00
wy d7bc9e6a24 Localize map editor to Chinese 2026-06-10 00:22:36 +08:00
wy 759abfd527 Add web map editor prototype 2026-06-10 00:12:17 +08:00
wy fb54c9f207 Document map editor plan 2026-06-09 23:48:27 +08:00
wy e1429f3046 Silence missing placeholder tile textures 2026-06-09 23:41:47 +08:00
wy 51a3776494 Add stage map tile rendering path 2026-06-09 23:32:47 +08:00
wy 0257c8058e Split game module documentation 2026-06-09 23:11:08 +08:00
wy d41f9d1096 Wire engine feature services into game skeleton 2026-06-09 22:58:42 +08:00
wy d353e91f5e Organize project docs and clean local artifacts 2026-06-09 22:25:54 +08:00
wy 3fc9caa80b Load stage map into level definition 2026-06-09 22:16:19 +08:00
wy 45a8712b00 Wire engine services into game skeleton 2026-06-09 21:47:07 +08:00
wy 562f9618c4 Audit engine capabilities for map workflow 2026-06-09 21:31:38 +08:00
wy f5cbb5731c Add scene acceptance workflow 2026-06-09 16:11:37 +08:00
wy 61249e2a29 Document controls and input mapping 2026-06-09 15:30:33 +08:00
wy 3a19005bbf Add scene debug layer toggles 2026-06-09 15:25:56 +08:00
wy 0a59d20ffa Unify stage prop layers 2026-06-09 15:08:17 +08:00
wy cf7b7dfb5a Add stage layer asset sources 2026-06-09 15:02:04 +08:00
wy 3e2e25eb93 Smooth battle zone camera transitions 2026-06-09 14:50:50 +08:00
wy 493b2bd3b8 Fix camera zone lock bounds 2026-06-09 14:35:06 +08:00
wy 3a56db3dc7 Add camera zone lock 2026-06-09 14:28:36 +08:00
wy c1096f0ed5 Expand architecture plan for online ACT prototype 2026-06-09 14:17:58 +08:00
wy 47936abed0 Add player camera controller 2026-06-09 14:11:12 +08:00
wy 8a89346978 Plan complete scene skeleton 2026-06-09 14:01:25 +08:00
wy 51f1bdd166 Build first scene skeleton 2026-06-09 13:56:30 +08:00
wy 3c5a4968ba Prioritize scene architecture work 2026-06-09 13:42:57 +08:00
wy d5e876e542 Adapt game input for Switch controllers 2026-06-09 13:28:31 +08:00
85 changed files with 7458 additions and 142 deletions
+13
View File
@@ -121,6 +121,8 @@ Switch 版依赖不走 XMake 的桌面包,而是使用 devkitPro 的 portlibs
- SDL2_ttf - SDL2_ttf
- GLM - GLM
- zlib - zlib
- HarfBuzz
- bzip2
- libnx - libnx
### 2.2 编译 Switch 引擎静态库 ### 2.2 编译 Switch 引擎静态库
@@ -302,6 +304,7 @@ romfs:/assets
- `Frostbite2DGameSwitch` 可成功编译为 ELF/NRO - `Frostbite2DGameSwitch` 可成功编译为 ELF/NRO
- PC 和 Switch 共用同一套 GLES 300 es shader - PC 和 Switch 共用同一套 GLES 300 es shader
- Windows 游戏目标仍可正常编译和启动 - Windows 游戏目标仍可正常编译和启动
- devkitPro 当前版本的 `SDL2_ttf` / `freetype` 需要静态链接 `harfbuzz``bz2`,该链接项已写入 `engine.lua`
## 3. 常见问题 ## 3. 常见问题
@@ -355,3 +358,13 @@ romfs:/assets/character/male/*.png
旧版本曾只把资源复制到 NRO 旁边,运行时引擎找不到 shader 和纹理, 旧版本曾只把资源复制到 NRO 旁边,运行时引擎找不到 shader 和纹理,
会表现为黑屏或没有场景内容。 会表现为黑屏或没有场景内容。
### 3.5 Switch 链接时报 `hb_*` 或 `BZ2_*` 未定义
这是 devkitPro portlibs 静态库依赖未完整链接。当前 `engine.lua` 已把
`harfbuzz``bz2` 加入 Switch 链接列表;如果换机器后再次出现,先确认:
```powershell
Test-Path C:\devkitPro\portlibs\switch\lib\libharfbuzz.a
Test-Path C:\devkitPro\portlibs\switch\lib\libbz2.a
```
@@ -204,6 +204,7 @@ bool Application::initCoreModules() {
ScopedStartupTrace stageTrace("SDL_GameControllerOpen(0)"); ScopedStartupTrace stageTrace("SDL_GameControllerOpen(0)");
if (SDL_GameController *controller = SDL_GameControllerOpen(0)) { if (SDL_GameController *controller = SDL_GameControllerOpen(0)) {
SDL_Log("GameController opened: %s", SDL_GameControllerName(controller)); SDL_Log("GameController opened: %s", SDL_GameControllerName(controller));
SDL_GameControllerClose(controller);
} else { } else {
SDL_Log("No GameController found"); SDL_Log("No GameController found");
} }
+1 -1
View File
@@ -46,7 +46,7 @@ function add_frostbite2d_target()
add_includedirs(path.join(portlibs, "include/freetype2"), {public = true}) add_includedirs(path.join(portlibs, "include/freetype2"), {public = true})
add_includedirs(path.join(libnx, "include"), {public = true}) add_includedirs(path.join(libnx, "include"), {public = true})
add_linkdirs(path.join(portlibs, "lib"), path.join(libnx, "lib"), {public = true}) add_linkdirs(path.join(portlibs, "lib"), path.join(libnx, "lib"), {public = true})
add_links("SDL2_ttf", "freetype", "SDL2_mixer", "vorbisidec", "opusfile", "opus", "modplug", "mpg123", "mikmod", "FLAC", "ogg", {public = true}) add_links("SDL2_ttf", "freetype", "harfbuzz", "bz2", "SDL2_mixer", "vorbisidec", "opusfile", "opus", "modplug", "mpg123", "mikmod", "FLAC", "ogg", {public = true})
add_links("SDL2_image", "png", "jpeg", "webp", "z", "SDL2", "EGL", "GLESv2", "glapi", "drm_nouveau", "nx", "m", "pthread", "stdc++", {public = true}) add_links("SDL2_image", "png", "jpeg", "webp", "z", "SDL2", "EGL", "GLESv2", "glapi", "drm_nouveau", "nx", "m", "pthread", "stdc++", {public = true})
else else
add_packages("libsdl2", {public = true}) add_packages("libsdl2", {public = true})
+31
View File
@@ -0,0 +1,31 @@
# Game Asset Slots
This file records project-wide asset slots that are consumed by engine-backed
systems. Keep final assets in these folders so Windows and Switch builds can
copy them into `assets/` and RomFS without code changes.
## Global Slots
| Slot | Path | Engine path | Notes |
| --- | --- | --- | --- |
| Debug UI font | `fonts/debug.ttf` | `FontManager` + `TextSprite` | Optional. UI falls back to colored bars if missing. |
| UI font | `fonts/ui.ttf` | `FontManager` + `TextSprite` | Optional fallback font name for UI. |
| Stage music | `music/stage_01.ogg` | `Music::loadFromFile` | Optional BGM cue id: `stage_01`. |
| Player jump SFX | `sounds/player_jump.wav` | `Sound::loadFromFile` | Optional cue id: `player_jump`. |
| Player attack SFX | `sounds/player_attack.wav` | `Sound::loadFromFile` | Optional cue id: `player_attack`. |
| Battle zone enter SFX | `sounds/battle_zone_enter.wav` | `Sound::loadFromFile` | Optional cue id: `battle_zone_enter`. |
| Audio database | `audio/audio.xml` | `AudioDatabase` | Optional cue database; can override direct file lookup. |
| PVF scripts | `Script.pvf` | `PvfArchive` / `ScriptParser` | Optional `.map` and `.ani` source. |
| Image packs | `ImagePacks2/*.npk` | `NpkArchive` / `Sprite::createFromNpk` | Optional packed sprite source. |
| Sound packs | `SoundPacks/*.npk` | `SoundPackArchive` | Optional packed sound source. |
## Character Animation Slots
Current temporary PNG spritesheets stay in `character/male/`. If PVF/NPK assets
are provided later, the game will try these engine paths first:
| State | ANI path | NPK IMG path | PNG fallback |
| --- | --- | --- | --- |
| Idle | `character/male/idle.ani` | `sprite/character/male/idle.img` | `character/male/Male_spritesheet_idle.png` |
| Run | `character/male/run.ani` | `sprite/character/male/run.img` | `character/male/Male_spritesheet_run.png` |
| Punch | `character/male/punch_1.ani` | `sprite/character/male/punch_1.img` | `character/male/Male_spritesheet_punch_1.png` |
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+28
View File
@@ -0,0 +1,28 @@
map stage_01
world 2105 720
camera 0 0 2105 720
player_spawn 1030 346
collision 0 446 505 42
collision 128 560 350 42
collision 846 450 470 42
collision 1490 506 555 42
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
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_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_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
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+29
View File
@@ -0,0 +1,29 @@
# Stage 01 Assets
This directory is reserved for the first playable side-scrolling ACT stage.
## Slots
| Slot | Expected file | Notes | Source / License |
| --- | --- | --- | --- |
| 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/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 |
| Stage SFX | `../../sounds/` | Optional one-shot SFX cues. | TBD |
## Import Rules
- Keep file names lowercase with underscores.
- 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.
@@ -0,0 +1 @@
@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

@@ -0,0 +1 @@
@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

+26
View File
@@ -4,9 +4,35 @@
## Planning ## Planning
- `planning/development_plan.md`:横板 2D ACT 清版游戏开发总计划,包含里程碑、玩法阶段、技术阶段和风险。
- `planning/game_architecture_board.md`:游戏整体架构白板,包含思维导图、模块划分、数据流和阶段路线。 - `planning/game_architecture_board.md`:游戏整体架构白板,包含思维导图、模块划分、数据流和阶段路线。
- `planning/scene_build_plan.md`:第一关场景搭建计划,包含素材清单、场景分层和验收标准。 - `planning/scene_build_plan.md`:第一关场景搭建计划,包含素材清单、场景分层和验收标准。
## References
- `../assets/ASSETS.md`:全局资源槽位,包含字体、音频、PVF、NPK、SoundPack 和角色动画 fallback 路径。
- `modules/README.md`:游戏模块文档索引,按源码模块拆分说明。
- `controls.md`:玩家操作、平台输入映射和开发调试快捷键。
- `engine_capability_audit.md`:引擎已有能力复盘,以及游戏层应优先复用的系统。
- `game_skeleton.md`:当前游戏骨架如何接入引擎基础能力。
- `scene_acceptance.md`:第一关场景骨架验收流程和最近一次验收结果。
## Modules
- `modules/core_services.md`:启动、服务、配置、资源路径。
- `modules/assets.md`:资源槽位和素材落位。
- `modules/data_map.md`:地图数据和 `LevelDefinition`
- `modules/map_editor.md`:简易 `.map` 地图编辑器实现状态;完整工具说明见 `../../tools/map_editor/DOCUMENTATION.md`
- `modules/stage_rendering.md`:场景分层和渲染。
- `modules/scene_camera.md`:场景流程、摄像机、battle zone。
- `modules/movement_collision.md`:横版移动和平台碰撞。
- `modules/actor_player.md`:玩家 Actor、状态和动画。
- `modules/input.md`:输入语义。
- `modules/ui_overlay.md`:局内 UI overlay。
- `modules/audio_save.md`:音频 cue 和存档。
- `modules/debug_tools.md`:调试显示。
- `modules/combat.md`:战斗模块预留和后续接入。
## Naming ## Naming
- 文件名使用小写 `snake_case` - 文件名使用小写 `snake_case`
+44
View File
@@ -0,0 +1,44 @@
# Controls and Input
本文档记录当前游戏层操作输入和开发调试快捷键。后续如果增加键位配置、手柄配置或 Switch 专用映射,以本文档作为唯一同步位置。
## 玩家操作
| 行为 | PC 键盘 | SDL 手柄 | Switch |
| --- | --- | --- | --- |
| 左右移动 | `A` / `D`,方向键左/右 | 左摇杆,方向键左/右 | 左摇杆,方向键左/右 |
| 跳跃 | `Space``W`,方向键上 | `A` / `B` | `A` / `B` |
| 攻击 | `J` / `K` | `X` / `Y` | `X` / `Y` |
当前 `InputState` 只暴露:
- `moveX`:水平输入,范围 `-1.0``1.0`
- `jumpPressed`:跳跃按下瞬间,只在从未按到按下的那一帧为 true。
- `attackPressed`:攻击按下瞬间,只在从未按到按下的那一帧为 true。
## 调试快捷键
这些快捷键用于白盒/灰盒开发阶段,后续可以迁移到调试面板。
| 快捷键 | 功能 |
| --- | --- |
| `F1` | Debug overlay 总开关。 |
| `F2` | Debug 清当前 battle zone,占位模拟清场解锁。 |
| `F3` | 切换碰撞矩形和场景网格。 |
| `F4` | 切换玩家包围盒。 |
| `F5` | 切换 battle zone 触发框和 camera bounds。 |
| `F6` | 切换敌人出生点。 |
## 输入实现约束
- 角色逻辑只消费 `InputState`,不直接读取 SDL、Switch 或平台 API。
- `InputStateProvider` 负责聚合 PC 键盘、SDL GameController 和 Switch `PadState`
- PC 和 Switch 的游戏逻辑必须共用同一套 `InputState` 语义。
- 动作输入当前按“按下瞬间”触发,避免按住跳跃/攻击每帧重复触发。
- 后续新增冲刺、防御、交互、菜单操作时,先扩展 `InputState`,再补平台映射。
## 后续计划
1. 增加 `InputConfig`,把默认键位/手柄映射迁移到可配置数据。
2. 区分 gameplay 输入和 debug 输入,避免正式版本保留开发快捷键。
3. 增加输入状态 debug 显示,用于排查手柄、Switch Joy-Con 和模拟器输入问题。
+344
View File
@@ -0,0 +1,344 @@
# Engine Capability Audit
目标:后续游戏层尽量复用 Frostbite2D 已有能力,避免在游戏项目里重复造资源、动画、地图、渲染、输入和存档轮子。本文基于当前引擎代码和游戏层现状整理。
## 结论
当前游戏层已经用了引擎核心框架,并且已把地图、动画、UI、音频、存档和 NPK/SoundPack 资源入口接到真实调用路径。
最重要的调整已经完成:场景地图不再以 C++ 硬编码为主,`stage_01.map` 通过 `Asset` / `PvfArchive` / `ScriptParser` 读取入口进入 `LevelDefinition`
当前骨架接入状态:游戏启动时通过 `GameServices` 探测/初始化 `AudioSystem``FontManager``PvfArchive``NpkArchive``SoundPackArchive``AudioDatabase``SaveSystem` 状态;`PlayerActor` 已接入 `Animation`/NPK/PNG 优先级;`WhiteboxScene` 已接入 `UIScene`、音频 cue 和 debug 存档。详细状态见 `game_skeleton.md`
推荐方向:
```text
.map 文件
-> Asset 普通资源读取,或 PvfArchive 从 Script.pvf 内读取
-> ScriptParser / map parser 解析
-> 转成 LevelDefinition
-> StageRenderer / DebugOverlay / WhiteboxScene 继续复用
```
这样地图可以外部维护,素材、图层、碰撞、出生点、camera bounds 和 battle zone 都能从数据文件进入游戏,而不是继续写死在 `whitebox_level.cpp`
## 已经用上的引擎能力
### Core / Scene / Actor
已使用:
- `Application`
- `SceneManager`
- `Scene`
- `Actor`
- `RefPtr` / `MakePtr`
当前状态:
- `WhiteboxScene` 继承 `frostbite2D::Scene`
- `PlayerActor` 继承 `frostbite2D::Actor`
- 场景生命周期、Actor 子节点、Render/Update 流程已接入引擎。
保留原则:
- 后续敌人、道具、特效仍应优先做成 `Actor` 子类或 Actor 子节点。
- 不在游戏层另写对象树、生命周期系统或场景管理器。
### Graphics / Camera / Render
已使用:
- `Renderer::drawQuad`
- `Renderer::drawSprite`
- `Texture::loadFromFile`
- `Camera`
- `RenderStyleProfileId::PixelArt2D`
- `ResolutionScaleMode::Fit`
当前状态:
- `StageRenderer` 已复用引擎 Renderer 和 Texture。
- `CameraController` 使用引擎 `Camera` 做位置控制。
- `GameUiOverlay` 通过 `UIScene` 进入 UI scene stack。
- 游戏入口已设置像素风渲染风格。
需要调整:
- `StageRenderer` 当前自己维护贴图缓存。后续如果引擎已有统一资源缓存或纹理缓存,应迁移过去。
- 地图层和 tile 层不应该额外造渲染管线,继续输出到引擎 Renderer 即可。
### Resource / Asset
已部分使用:
- `Asset` 被引擎纹理加载间接使用。
- Windows / Switch 的资源根目录、RomFS 路径已经由引擎 `Asset` 解析。
当前状态:
- `StageMapResource` 已直接使用 `Asset::readFileToString` 读取 `map/stage_01.map``assets/map/stage_01.map`
- Windows 和 Switch 构建会把 `game/assets/*` 复制到产物 `assets/`,保证 `assets/map/stage_01.map` 路径可用。
- `stage_01` 主地图已迁移到外部 `.map` 文件。
- `GameAudio``GameServices``PlayerAnimationAssets` 都通过 `Asset` 检查可选资源。
下一步:
- 继续把输入配置、角色动画配置等文本/脚本配置接入 `Asset` 或 PVF。
### PVF / ScriptParser
引擎已有:
- `PvfArchive::open`
- `PvfArchive::getFileContent`
- `PvfArchive::getFileBytes`
- `PvfArchive::getFileRawData`
- `PvfArchive::resolvePath`
- `ScriptParser`
- `ScriptValue`
当前状态:
- `StageMapResource` 已能在 `PvfArchive` 打开后尝试读取 `map/stage_01.map`
- 若资源像 PVF 二进制脚本,会先用 `ScriptParser` 解析出 script values。
- 若资源是 PVF 文本,会作为普通 `.map` 文本交给 `StageMapLoader`
- `ScriptParser` 支持 PVF 脚本二进制格式,不是普通文本 parser。
地图结论:
- 如果我们的 `.map` 是 PVF 内的二进制脚本格式,就应使用 `PvfArchive + ScriptParser`
- 如果我们的 `.map` 是普通文本/JSON/自定义文本格式,就应先用 `Asset` 读取,再由游戏层做最小 parser。
- 无论哪种格式,最终都应该落到 `LevelDefinition`,不要让 `WhiteboxScene` 直接理解文件格式。
### NPK / Sprite / Animation
引擎已有:
- `NpkArchive`
- `Sprite::createFromNpk`
- `Sprite::createFromFile`
- `Animation`
- `parseAniFromPvf`
- `parseAlsFromPvf`
当前状态:
- 玩家动画加载顺序为 PVF `.ani` -> NPK IMG -> PNG fallback。
- 如果 PVF 中存在 `character/male/*.ani``PlayerActor` 会使用引擎 `Animation`
- 如果 NPK 中存在 `sprite/character/male/*.img`,会使用 `Sprite::createFromNpk`
- 当前仓库没有正式 PVF/NPK 动画资源,因此运行时仍会回退到 PNG spritesheet。
风险:
- 只要正式动画资源未进入 PVF/NPK,临时 PNG fallback 仍需要少量手写帧切换。
下一步建议:
- 为玩家和敌人补齐 PVF `.ani` 或 NPK IMG 后,逐步移除 PNG fallback 的手写帧切换。
- 将 attack/damage box 从 `Animation::GetCurrentFrameInfo()` 接入战斗 hitbox。
### Audio / Save
引擎已有:
- `AudioSystem`
- `Sound`
- `Music`
- `SoundPackArchive`
- `AudioDatabase`
- `SaveSystem`
当前状态:
- `GameAudio` 已接入 `Sound``Music``AudioDatabase` 查询。
- `WhiteboxScene` 会尝试播放 `stage_01` BGM 和 battle zone 进入音效。
- `PlayerActor` 会尝试播放跳跃、攻击音效。
- `GameSave` 已用 `SaveSystem` 保存 debug overlay 开关。
后续原则:
- 正式音效、BGM、环境音直接放入资源槽位或 `audio.xml`
- 设置、进度继续用 `SaveSystem`,不要在游戏层重新写文件存档服务。
### UI / Text
引擎已有:
- `UIScene`
- `TextSprite`
- `FontManager`
当前状态:
- `GameUiOverlay` 已作为 `UIScene` 接入。
-`assets/fonts/debug.ttf``assets/fonts/ui.ttf` 时,状态 UI 使用 `TextSprite`
- 缺字体时使用 `Renderer::drawQuad` 色条 fallback,避免 Switch/PC 因字体缺失黑屏。
### Event / Input
已使用:
- `KeyEvent`
- `EventType::KeyDown`
游戏层自定义:
- `InputState`
- `InputStateProvider`
判断:
- `InputState` 作为游戏语义输入是合理的,能隔离 PC/Switch 差异。
- 但底层输入事件和生命周期应继续使用引擎事件系统。
下一步:
- `InputConfig` 可以作为游戏层配置,但读取配置应走 `Asset` 或 PVF。
- Debug 输入和 gameplay 输入要分离,避免正式版本保留开发快捷键。
## 当前游戏层自造/硬编码点
### 1. 地图硬编码
文件:
- `game/src/data/whitebox_level.cpp`
当前状态:
- 已新增 `game/assets/map/stage_01.map`
- 已新增 `StageMapLoader`,把 `.map` 文本解析成 `LevelDefinition`
- `CreateWhiteboxLevel()` 已改为优先加载 `.map`,失败时才回退 C++ 白盒数据。
- C++ 中保留的世界尺寸、平台、视觉层、props、battle zone、spawn point 现在只作为 fallback。
后续建议:
- 继续扩展 `.map` 的 tileset/tile entry 表达。
- 逐步把 `level_visual` 的长色块替换成 tile 数据。
### 2. PNG fallback 手写动画帧切换
文件:
- `game/src/actor/player_actor.cpp`
当前状态:
- 已接入 `Animation` 和 NPK 优先路径。
- 临时 PNG fallback 仍在 C++ 里硬编码帧数、帧时间和 source rect。
建议:
- 等正式 `.ani` / `.img` 资源补齐后,删除 PNG fallback 的手写帧切换。
### 3. 游戏层贴图缓存
文件:
- `game/src/stage/stage_renderer.*`
问题:
- `StageRenderer` 自己维护 `textureCache_`
建议:
- 短期可接受,避免每帧重复加载。
- 如果引擎后续提供统一 Texture cache,迁移到引擎资源缓存。
### 4. 调试绘制工具
文件:
- `game/src/debug/debug_overlay.*`
判断:
- 这是游戏特定调试可视化,保留合理。
- 但底层绘制继续用 `Renderer::drawQuad`,不要另造 debug render backend。
## `.map` 框架建议
### 加载优先级
第一阶段已支持普通资源文件:
```text
Asset::readTextFile("map/stage_01.map")
```
第二阶段已预留 PVF
```text
PvfArchive::getFileContent("map/stage_01.map")
```
如果 `.map` 是 PVF 二进制脚本,则改用:
```text
PvfArchive::getFileRawData("map/stage_01.map")
ScriptParser parser(rawData, "map/stage_01.map")
```
### 数据流
```text
StageMapLoader
-> 读取 .map
-> 解析 world / camera / spawn / layers / collision / zones
-> 输出 LevelDefinition
-> WhiteboxScene 使用 LevelDefinition
```
### `.map` 应表达的字段
- `world`
- width
- height
- `camera`
- bounds
- `player`
- spawn
- `layers`
- id
- role
- parallax
- rect / tile / sprite source
- `collision`
- platforms
- walls
- bounds
- `battle_zones`
- id
- trigger
- camera_bounds
- enemy_spawns
- `assets`
- background
- tileset
- props
- effects
### 不建议做的事
- 不要在 `WhiteboxScene` 里解析 `.map`
- 不要绕开 `Asset` 直接用 `std::ifstream` 读资源。
- 不要继续把地图扩展写死在 `whitebox_level.cpp`
- 不要为了地图单独造一套渲染系统。
## 推荐执行顺序
1. 已完成:`StageMapResource` 定义地图文件路径、普通资源读取和 PVF 读取优先级。
2. 已完成:`StageMapLoader``.map` 转成 `LevelDefinition`
3. 已完成:`stage_01.map` 把当前 `whitebox_level.cpp` 数据迁移成外部地图文件。
4. 已完成:`CreateWhiteboxLevel()` 改为加载 `stage_01.map`,失败时回退旧白盒数据。
5. `StageRenderer`:继续消费 `LevelDefinition`,不直接关心 `.map`
6. 文档:把地图编辑流程写入 `scene_acceptance.md` 和场景计划。
## 总体原则
- 引擎已有的资源、归档、动画、音频、事件、Actor 和 Scene 能力优先使用。
- 游戏层只做“游戏语义转换”:地图语义、战斗语义、角色状态、关卡流程。
- 外部资源文件优先通过 `Asset` / `PvfArchive` 进入。
- C++ 硬编码只作为 fallback,不作为长期地图生产方式。
+59
View File
@@ -0,0 +1,59 @@
# Game Skeleton
本文档只保留游戏骨架总览。具体模块细节已经拆分到 `modules/`,后续查实现优先看对应模块文档。
## 总体目标
当前游戏骨架的目标是:游戏层只负责 ACT 清版游戏语义,资源、归档、动画、音频、存档、场景、UI 和渲染基础设施尽量复用 Frostbite2D 引擎能力。
## 当前主流程
```text
main.cpp
-> Application::init()
-> GameServices::Initialize()
-> WhiteboxScene
-> LoadStageMap("map/stage_01.map")
-> LevelDefinition
-> StageRenderer / PlayerActor / DebugOverlay / GameUiOverlay
```
## 模块文档入口
- `modules/core_services.md`:启动、服务、配置、资源路径。
- `modules/assets.md`:全局资源槽位、PVF/NPK/PNG fallback。
- `modules/data_map.md``.map``StageMapLoader``LevelDefinition`
- `modules/stage_rendering.md`:场景分层、parallax、sprite/色块 fallback。
- `modules/scene_camera.md``WhiteboxScene`、摄像机、battle zone。
- `modules/movement_collision.md`:平台碰撞、横版移动。
- `modules/actor_player.md`:玩家 Actor、动画、输入消费。
- `modules/input.md`PC/Switch 输入统一语义。
- `modules/ui_overlay.md``UIScene``TextSprite`、fallback UI。
- `modules/audio_save.md`:音频 cue、debug 存档。
- `modules/debug_tools.md`:调试可视化。
- `modules/combat.md`:战斗模块预留。
## 当前已接入的引擎能力
- `Asset`:地图、贴图、字体、音频 fallback 路径。
- `PvfArchive` / `ScriptParser``.map``.ani` 预留读取入口。
- `NpkArchive` / `Sprite::createFromNpk`:角色资源 fallback。
- `Animation`:玩家动画优先路径。
- `AudioSystem` / `Sound` / `Music` / `AudioDatabase`:音频 cue。
- `SaveSystem`debug 开关存档。
- `UIScene` / `TextSprite` / `FontManager`:局内 UI overlay。
- `Scene` / `Actor` / `Renderer` / `Camera`:场景主循环、对象树、渲染和镜头。
## 当前 fallback 策略
- 缺 PVF/NPK 时,玩家回退 PNG spritesheet。
- 缺字体时,UI overlay 回退色条。
- 缺音频资源时,音频 cue 静默跳过。
-`.map` 或解析失败时,`whitebox_level.cpp` 提供 C++ 白盒 fallback。
## 原则
- 引擎已有能力优先使用。
- 游戏层只做数据转换和玩法语义。
- 外部资源统一走 `Asset` / `PvfArchive`
- C++ 白盒数据只保留为 fallback,不作为长期生产方式。
+26
View File
@@ -0,0 +1,26 @@
# Game Modules
本目录按游戏源码模块拆分说明文档。查具体代码时优先从这里进入;跨模块路线、阶段计划和验收流程仍放在 `../planning/``../game_skeleton.md``../scene_acceptance.md`
## 模块索引
- `core_services.md`:游戏启动、`GameServices`、资源路径、基础配置。
- `assets.md`:全局资源槽位、角色资源优先级、地图和 stage 资源落位。
- `data_map.md``LevelDefinition``.map` 加载、地图数据格式。
- `map_editor.md`:简易 `.map` 地图编辑器目标、范围和验收标准;完整工具说明见 `../../../tools/map_editor/DOCUMENTATION.md`
- `stage_rendering.md``StageRenderer`、场景分层、贴图/色块 fallback。
- `scene_camera.md``WhiteboxScene`、摄像机跟随、battle zone 锁镜头。
- `movement_collision.md``PlatformWorld``PlatformMover`、横版基础碰撞。
- `actor_player.md``PlayerActor`、玩家状态、动画和音效触发。
- `input.md``InputState`、PC/Switch 输入语义和后续配置计划。
- `ui_overlay.md``GameUiOverlay``UIScene``TextSprite` 和 fallback UI。
- `audio_save.md``GameAudio``GameSave`、音频 cue 和 debug 存档。
- `debug_tools.md``DebugOverlay`、调试开关、可视化范围。
- `combat.md`:当前 hitbox 预留和后续战斗模块拆分方向。
## 维护规则
- 新增源码模块时,同步新增或更新本目录对应文档。
- 单个模块文档只写模块职责、入口文件、数据流、当前状态和下一步。
- 跨模块验收结果写到 `../scene_acceptance.md`
- 长期计划写到 `../planning/`,不要塞进模块文档。
+48
View File
@@ -0,0 +1,48 @@
# Actor and Player
## 职责
Actor 模块负责游戏对象。当前已实现玩家,敌人、道具、投射物和特效后续按同样方式扩展。
主要源码:
- `game/src/actor/player_actor.*`
- `game/src/actor/player_animation_assets.*`
- `game/src/combat/hitbox.h`
## `PlayerActor`
当前能力:
- 左右移动。
- 跳跃。
- 普通攻击触发。
- idle/run/punch 三个临时状态。
- 朝向翻转。
- 基础平台碰撞。
- 跳跃/攻击音效 cue。
## 动画资源
玩家动画加载顺序:
```text
PVF .ani
-> NPK IMG
-> PNG spritesheet fallback
```
如果使用 PVF `.ani`,由引擎 `Animation` 播放;如果回退 PNG,当前仍保留少量手写帧切换。
## 当前限制
- 还没有完整 ACT 状态机。
- 攻击还没有命中判定。
- 受击、击退、血量、硬直未接入。
- PNG fallback 的帧数和帧时长仍在 C++ 中。
## 下一步
- 把 idle/move/jump/attack/hit/dead 做成互斥状态机。
-`Animation::GetCurrentFrameInfo()` 接入 attack box / damage box。
- 新增 `EnemyActor` 时复用 Actor、PlatformWorld 和动画资源加载链路。
+58
View File
@@ -0,0 +1,58 @@
# Assets
## 职责
资源模块定义游戏素材落位规则,让代码只引用稳定路径,不关心素材最终来自普通文件、PVF、NPK 还是 SoundPack。
主要文件:
- `game/assets/ASSETS.md`
- `game/assets/stage/stage_01/ASSETS.md`
- `game/src/core/asset_paths.h`
- `game/src/data/stage_asset_slots.h`
## 全局资源槽位
当前已预留:
- `assets/fonts/`UI/debug 字体。
- `assets/sounds/`:直接读取的 wav 音效 fallback。
- `assets/music/`:直接读取的 ogg 音乐 fallback。
- `assets/audio/audio.xml`:音频 cue 表。
- `assets/ImagePacks2/`:角色/场景 NPK 图片包。
- `assets/SoundPacks/`SoundPack 音频包。
- `assets/Script.pvf`PVF 脚本和动画资源。
## 角色资源优先级
玩家动画加载顺序:
```text
PVF .ani
-> NPK IMG
-> PNG spritesheet fallback
```
当前 PNG fallback 已能运行;正式素材到位后,优先把 `.ani``.img` 放到 `asset_paths.h` 约定的路径。
## Stage 资源
第一关资源放在:
```text
game/assets/stage/stage_01/
```
目录职责:
- `background/`:远景、中景背景。
- `tiles/`:地面、平台、墙体 tileset。
- `props/`:后景/前景装饰。
- `effects/`:场景特效。
- `audio/`:关卡局部音频素材。
## 下一步
- 素材到位后先更新 `ASSETS.md` 的来源和授权。
- 地图 tile 化后,把 tileset 路径写入 `.map` 数据。
- 如果素材进入 NPK,补 stage layer 的 NPK sprite path 支持。
+53
View File
@@ -0,0 +1,53 @@
# Audio and Save
## 职责
音频和存档模块负责调用引擎已有 `Sound``Music``AudioDatabase``SaveSystem`
主要源码:
- `game/src/core/game_audio.*`
- `game/src/core/game_save.*`
- `game/src/core/game_services.*`
## 音频
`GameAudio` 支持:
- `PlaySoundCue(cueId)`
- `PlayMusicCue(cueId)`
路径解析优先级:
```text
AudioDatabase cue
-> assets/sounds/<cue>.wav
-> assets/music/<cue>.ogg
```
当前已接入 cue
- `stage_01`
- `player_jump`
- `player_attack`
- `battle_zone_enter`
缺音频资源时直接跳过,不影响场景运行。
## 存档
`GameSave` 当前用 `SaveSystem` slot `0` 保存 debug overlay 开关。
保存内容:
- debug 总开关。
- 碰撞/网格显示。
- 玩家包围盒。
- battle zone/camera bounds。
- spawn point。
## 下一步
- 正式设置、进度、角色成长继续走 `SaveSystem`
- 正式音频资源到位后补 `assets/audio/audio.xml`
- 根据 Switch 实机行为确认 save root 和权限。
+47
View File
@@ -0,0 +1,47 @@
# Combat
## 职责
战斗模块负责攻击、命中、受击、伤害、击退和状态互斥。当前只做了最小预留,还未进入正式玩法实现。
主要源码:
- `game/src/combat/hitbox.h`
- `game/src/actor/player_actor.*`
## 当前状态
已有:
- 玩家攻击输入。
- punch 动画状态。
- 攻击音效 cue。
- `hitbox.h` 预留。
未完成:
- 攻击 hitbox。
- hurtbox。
- 命中检测。
- 伤害数值。
- 受击硬直。
- 击退。
- 血量。
- 怪物受击和死亡。
## 后续拆分建议
优先保持轻量,不急着做复杂战斗框架:
- `HitboxComponent`:攻击框、受击框。
- `CombatStats`:血量、伤害、防御等固定数值。
- `CombatResolver`:命中判定和伤害结算。
- `HitReaction`:硬直、击退、受伤状态。
## 接入顺序
1. 从玩家 punch 状态产生一帧或多帧 hitbox。
2. DebugOverlay 显示 hitbox/hurtbox。
3. 新增一个最小 `EnemyActor`
4. 完成玩家攻击命中敌人的闭环。
5. 再接 battle zone 清场。
+45
View File
@@ -0,0 +1,45 @@
# Core Services
## 职责
核心模块负责游戏启动后的基础服务接入,不承载具体玩法逻辑。
主要源码:
- `game/src/main.cpp`
- `game/src/core/game_config.h`
- `game/src/core/game_services.*`
- `game/src/core/asset_paths.h`
- `game/src/core/debug_flags.h`
## 当前接入
`Application::init()` 由引擎负责初始化:
- `Asset` 工作目录和资源路径。
- `SaveSystem`
- SDL video/events/controller。
- Window、Renderer、Camera。
- PixelArt2D 渲染风格和虚拟分辨率。
- `TaskSystem`
`GameServices` 只做项目级可选服务:
- 初始化 `AudioSystem`
- 初始化 `FontManager`,尝试注册 `debug` 字体。
- 探测并打开 `Script.pvf`
- 初始化 `NpkArchive`
- 初始化 `SoundPackArchive`
- 探测并加载 `AudioDatabase`
- 记录 `SaveSystem` 状态。
## 运行原则
- 缺少 PVF、NPK、SoundPack、字体或音频表时,游戏必须继续启动。
- 游戏层只记录服务状态,不重新实现引擎服务。
- Windows 和 Switch 通过同一个 `main.cpp` 入口进入游戏,平台差异放在编译分支和引擎平台层。
## 下一步
- 增加项目级日志分类,区分资源、地图、输入、场景和战斗。
- 将 debug/service 状态接入更完整的调试面板。
+89
View File
@@ -0,0 +1,89 @@
# Data and Map
## 职责
数据模块负责把外部关卡数据转换成游戏运行时使用的 `LevelDefinition`
主要源码:
- `game/src/data/level_definition.h`
- `game/src/data/stage_map_resource.*`
- `game/src/data/stage_map_loader.*`
- `game/src/data/whitebox_level.cpp`
- `game/assets/map/stage_01.map`
## 数据流
```text
stage_01.map
-> StageMapResource
-> StageMapLoader
-> LevelDefinition
-> WhiteboxScene / StageRenderer / DebugOverlay
```
读取优先级:
1. `Asset` 普通文本资源:`map/stage_01.map`
2. `Asset` fallback`assets/map/stage_01.map`
3. `PvfArchive` 二进制脚本:`map/stage_01.map`
4. `PvfArchive` 文本内容:`map/stage_01.map`
## `LevelDefinition`
当前包含:
- 世界宽高。
- 玩家出生点。
- 摄像机边界。
- 平台碰撞世界。
- tileset 定义和 tile entry。
- 场景视觉层。
- battle zone。
- enemy spawn point。
## `.map` 当前命令
```text
map <id>
world <width> <height>
camera <x> <y> <width> <height>
player_spawn <x> <y>
collision <x> <y> <width> <height>
tileset <id> <texture> <tile_w> <tile_h> <r> <g> <b> <a>
layer <id> <BackgroundFar|BackgroundMid|LevelVisual|PropsBack|PropsFront> <parallax>
rect <x> <y> <width> <height> <r> <g> <b> <a>
rect_sprite <x> <y> <width> <height> <r> <g> <b> <a> <texture>
rect_sprite_src <x> <y> <width> <height> <r> <g> <b> <a> <texture> <sx> <sy> <sw> <sh>
tile_rect <tileset> <x> <y> <columns> <rows> <tile_index> <r> <g> <b>
endlayer
battle_zone <id> <trigger_x> <trigger_y> <trigger_w> <trigger_h> <camera_x> <camera_y> <camera_w> <camera_h>
spawn <id> <x> <y>
endbattle_zone
```
## Tile 约定
`tileset` 定义 tile 图片来源、单格尺寸和 fallback 色。图片不存在时,`StageRenderer` 会按 tile fallback 色绘制,不阻断场景启动。
`tile_rect` 只能写在 `layer` 内,用于批量铺同一种 tile
- `columns` / `rows` 是重复格数。
- `tile_index` 当前表示 tileset 第一行的横向 tile 序号。
- `r g b` 是该批 tile 的灰盒 fallback 色,alpha 固定为 `1.0`
当前 `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 图集替换完整参考底图。
## 下一步
- 继续用地图编辑器调整 Mossy 场景视觉块和碰撞对齐。
-`tile_rect` 扩展成可表达 tile row/column 或 tile id。
- 增加空 layer、battle zone 无 spawn、tile texture 不存在等更严格错误日志。
- 保留 `whitebox_level.cpp` 作为 fallback,不再把主地图写死在 C++。
+38
View File
@@ -0,0 +1,38 @@
# Debug Tools
## 职责
调试模块负责白盒/灰盒阶段的可视化和开发开关。
主要源码:
- `game/src/debug/debug_overlay.*`
- `game/src/core/debug_flags.h`
- `game/docs/controls.md`
## 当前显示项
- 场景碰撞。
- 场景网格。
- 玩家包围盒。
- battle zone 触发框。
- battle zone camera bounds。
- enemy spawn point。
## 快捷键
快捷键统一维护在 `../controls.md`
当前 debug 开关会通过 `GameSave` 保存,下次启动后恢复。
## 约束
- 调试绘制继续使用引擎 `Renderer::drawQuad`
- 不单独实现 debug render backend。
- 正式版本需要能关闭或剥离开发快捷键。
## 下一步
- 增加输入状态显示。
- 增加 hitbox/hurtbox 显示。
- 增加地图加载错误和资源缺失提示面板。
+34
View File
@@ -0,0 +1,34 @@
# Input
## 职责
输入模块负责把 PC、SDL 手柄和 Switch 输入统一成游戏语义。
主要源码:
- `game/src/core/input_state.*`
- `game/docs/controls.md`
## 当前语义
`InputState` 只暴露游戏需要的动作:
- `moveX`:水平输入,范围 `-1.0``1.0`
- `jumpPressed`:跳跃按下瞬间。
- `attackPressed`:攻击按下瞬间。
## 当前映射
详细按键见 `../controls.md`
原则:
- 角色逻辑只消费 `InputState`
- PC/Switch 差异只存在于 `InputStateProvider`
- 跳跃和攻击使用“按下瞬间”,避免长按每帧触发。
## 下一步
- 增加 `InputConfig`,并通过 `Asset` 或 PVF 读取。
- 分离 gameplay 输入和 debug 输入。
- 增加输入状态 debug 显示。
+187
View File
@@ -0,0 +1,187 @@
# Map Editor
## 目标
下一阶段开始正式做地图场景。为了避免手写 `.map` 文件效率太低,需要先做一个简易地图编辑器,用来编辑和保存当前游戏使用的 `.map` 数据。
首版编辑器目标不是完整关卡编辑器,而是服务第一关灰盒/素材替换阶段:
- 能打开 `game/assets/map/stage_01.map`
- 能可视化编辑地图基础对象。
- 能保存回当前 `.map` 文本格式。
- 保存后的 `.map` 必须能被 `StageMapLoader` 直接读取。
- 不改变游戏运行时主流程。
## 首版范围
### 必须支持
- 打开/保存 `.map`
- 显示世界尺寸、摄像机范围和玩家出生点。
- 显示并编辑 `collision` 矩形。
- 显示并编辑 `layer`
- 显示并编辑 `tileset`
- 显示并编辑 `tile_rect`
- 显示并编辑 `battle_zone`
- 显示并编辑 `spawn`
- 网格吸附,默认以 tile size 或 10px 为单位。
- 基础缩放和平移视图。
### 可以暂缓
- 重做。
- 多选批量操作。
- 自动铺边缘 tile。
- 地图对象属性面板的高级校验。
- 直接预览游戏运行效果。
- PVF 二进制脚本导入/导出。
## 推荐形态
优先做项目内工具,而不是改游戏本体:
```text
tools/map_editor/
```
推荐首版技术路线:
- 桌面本地工具或轻量网页工具均可。
- 数据层必须读写当前 `.map` 文本格式。
- 编辑器内部可以有自己的 UI 状态,但导出文件必须保持 `StageMapLoader` 兼容。
如果做网页工具,首版可以使用:
- 单页 HTML/JS。
- Canvas 绘制地图。
- 文件打开/保存走浏览器 File API。
如果做原生工具,首版可以使用:
- C++/SDL + ImGui。
- 或 C#/.NET 简易桌面工具。
当前优先推荐轻量网页工具,因为它能更快完成 `.map` 可视化编辑,且不影响引擎编译链路。
## 当前实现
首版网页工具已建立:
```text
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
```
打开:
```text
http://127.0.0.1:8787/tools/map_editor/
```
当前能力:
- 从仓库静态服务加载 `game/assets/map/stage_01.map`
- 通过文件选择打开任意 `.map`
- Canvas 可视化 world、camera、player spawn、collision、rect、tile_rect、battle zone、spawn。
- 对象列表选择和画布点击选择。
- 网格吸附拖动对象。
- 通过“编辑目标”过滤画布命中目标,默认“智能选择”把 `collision` 当作上层覆盖;重叠位置第一次点选碰撞块,再点一次切到下层 `tile_rect` 或图形。
- 矩形对象支持右下角手柄缩放。
- 属性面板编辑选中对象的数值或文本。
- 新增/删除 tileset、layer、collision、tile_rect、battle zone、spawn。
- 支持撤回最近编辑操作,包含新增、删除、拖动、缩放、属性修改、导入图形和图层移动。
- 支持 `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`。本地开发时可通过 `tools/map_editor/server.js` 启动带写入 API 的服务,直接写回项目文件和已有 build 资源目录。
## 数据边界
编辑器只负责编辑 `.map` 数据,不负责:
- 修改 `WhiteboxScene`
- 修改 `StageRenderer`
- 修改碰撞运行逻辑。
- 修改角色、战斗或 AI。
- 管理素材授权。
素材路径仍由 `.map``game/assets/stage/stage_01/ASSETS.md` 记录。
导入图形时,静态网页不能直接复制图片文件到仓库。编辑器默认把贴图路径写为:
```text
assets/stage/stage_01/props/<图片文件名>
```
实际运行游戏前,需要把对应图片素材放到该目录。编辑器内的图形-碰撞绑定是工具层能力,导出的 `.map` 仍保持运行时兼容格式,不额外写入私有字段。
项目内已有素材预览规则:
- `assets/...` 会映射到仓库内 `game/assets/...`
- `game/assets/...` 会直接映射到仓库同名路径。
- 如果文件存在,编辑器重新打开 `.map` 后也会自动预览。
- 如果文件不存在,编辑器使用颜色块 fallback。
## 首版验收
完成时应达到:
1. 已完成:能载入当前 `stage_01.map`
2. 已完成:能看到地面、平台、battle zone、spawn point。
3. 已完成:能新增/移动/删除一个 `collision`
4. 已完成:能新增/移动/删除一个 `tile_rect`
5. 已完成:`tile_rect``collision` 重叠时,默认智能选择先选中并移动碰撞块,再次点击同一区域可切到铺砖区。
6. 已完成:能导入图形,生成 `rect_sprite` 和同尺寸 `collision`
7. 已完成:导入图形支持移动、缩放和同步调整绑定碰撞块。
8. 已完成:能撤回最近编辑操作。
9. 已完成:能保存并下载 `.map`
10. 已完成:能上移/下移图层,并按当前图层顺序预览和导出。
11. 已完成:编辑器 parser/serializer 烟测通过,保存格式保持 `StageMapLoader` 兼容。
12. 待后续地图修改后复测:Windows 构建通过,短启动通过。
13. 待后续地图修改后复测:Switch 构建能把保存后的 `.map` 打进 RomFS。
## 后续增强
- tile palette。
- tileset 图片预览。
- 保存接口:部署到服务器后,把 `.map` 文本写回服务端文件或数据库。
- battle zone 清场出口配置。
- props 图层编辑。
- 自动检查视觉 tile 与碰撞误差。
- 一键运行 Windows 版做快速预览。
+42
View File
@@ -0,0 +1,42 @@
# Movement and Collision
## 职责
移动碰撞模块负责横版基础移动和平台碰撞,是玩家、敌人和可移动物体共用的底层玩法能力。
主要源码:
- `game/src/movement/platform_world.*`
- `game/src/data/level_definition.h`
- `game/src/actor/player_actor.*`
## 当前能力
- 地面和平台矩形碰撞。
- 重力。
- 水平移动。
- 落地检测。
- 防止玩家穿过平台。
- 平台数据来自 `LevelDefinition::collision`
## 当前使用方式
`WhiteboxScene` 把关卡碰撞世界传给 `PlayerActor`
```text
LevelDefinition.collision -> PlayerActor::SetMovementWorld()
```
`PlayerActor` 只提交移动意图,最终位置由 `PlatformMover` 约束。
## 约束
- 碰撞数据和视觉数据分离。
- 不让 `StageRenderer` 反推碰撞。
- 后续敌人也应复用 `PlatformWorld`,不要另写一套地形碰撞。
## 下一步
- 增加墙体/左右边界碰撞语义。
- 增加防卡地形和异常状态重置。
- 扩展成可服务敌人 AI 的移动查询接口。
+51
View File
@@ -0,0 +1,51 @@
# Scene and Camera
## 职责
场景模块负责局内生命周期、关卡装配、摄像机和 battle zone 流程。
主要源码:
- `game/src/scene/whitebox_scene.*`
- `game/src/scene/camera_controller.*`
## `WhiteboxScene`
当前负责:
- 加载 `LevelDefinition`
- 创建玩家。
- 配置摄像机。
- 驱动 battle zone 状态。
- 调用 `StageRenderer`
- 调用 `DebugOverlay`
- 推入 `GameUiOverlay`
- 播放关卡 BGM 和 battle zone 进入音效。
- 保存 debug 开关。
## 摄像机
`CameraController` 当前支持:
- 玩家横向跟随。
- dead zone。
- 平滑移动。
- 世界边界 clamp。
- battle zone 临时 camera bounds。
进入/离开 battle zone 时,camera bounds 通过短过渡平滑切换,避免硬切瞬移。
## Battle Zone
当前 battle zone 还是场景骨架阶段:
- 玩家进入触发区后激活。
- 摄像机锁定到 zone camera bounds。
- `F2` debug 清场后解除锁定。
- enemy spawn point 只做 debug 显示,暂不刷敌。
## 下一步
- 用敌人清空结果替代 `F2` debug 清场。
- 给 battle zone 增加入口/出口锁定数据。
- 把场景切换、重置、加载/卸载流程独立成 SceneFlow 模块。
+65
View File
@@ -0,0 +1,65 @@
# Stage Rendering
## 职责
Stage 渲染模块负责把 `LevelDefinition` 的视觉层画出来,不处理玩法规则。
主要源码:
- `game/src/stage/stage_renderer.*`
- `game/src/data/level_definition.h`
## 当前分层
```text
BackgroundFar
BackgroundMid
LevelVisual
PropsBack
Actors
PropsFront
UI
```
`StageRenderer::Render()` 绘制 actor 之前的层;`RenderForeground()` 绘制 actor 之后的前景遮挡层。
## 当前能力
- 支持背景色。
- 支持远景/中景/地面/平台/props 分层。
- 支持 parallax。
- 支持色块占位。
- 支持普通 PNG texture path。
- 支持 source rect。
- 支持 `.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 后再用于精细铺砖。
## 约束
- 渲染继续使用引擎 `Renderer`
- `StageRenderer` 不直接解析 `.map`
- 碰撞和视觉分离,视觉 tile 不直接决定碰撞。
## 下一步
- 给 tile entry 增加 tile id 或二维 source index。
- 用地图编辑器继续调整 Mossy 场景块、前后景装饰和碰撞对齐。
- 如果引擎提供统一 Texture cache,再迁移当前局部贴图缓存。
+38
View File
@@ -0,0 +1,38 @@
# UI Overlay
## 职责
UI 模块负责局内 HUD、状态显示和调试面板。当前只有最小 overlay。
主要源码:
- `game/src/ui/game_ui_overlay.*`
## 当前能力
`GameUiOverlay` 继承引擎 `UIScene`,通过 `SceneManager` 进入 UI scene stack。
有字体时:
- 使用 `TextSprite` 显示游戏状态。
- 显示 debug 状态、battle zone、音频、存档、PVF、NPK、AudioDB 状态。
缺字体时:
- 使用 `Renderer::drawQuad` 画 fallback 色条。
- 不因字体缺失阻断 PC/Switch 启动。
## 资源
字体候选:
```text
assets/fonts/debug.ttf
assets/fonts/ui.ttf
```
## 下一步
- 增加血量、连击、联机状态等 gameplay HUD。
- 增加 debug 面板,替代部分 F 键调试。
- 确认 Switch 字体资源进入 RomFS 后再启用正式文字 UI。
+841
View File
@@ -0,0 +1,841 @@
# 横板 2D ACT 清版游戏开发计划
本文档用于规划基于 Frostbite2D-Engine 制作一款横板 2D ACT 清版游戏的开发路线。当前题材未确定,因此计划先围绕核心玩法、技术验证和内容管线推进,题材、美术风格和世界观在玩法原型稳定后再定。
## 1. 项目目标
### 1.1 游戏定位
- 类型:横板 2D ACT / 清版动作
- 视角:侧视角
- 核心体验:移动、跳跃、闪避、近战连段、技能释放、清理成组敌人、阶段推进
- 目标平台:Windows PC 优先,Nintendo Switch 可运行验证
- 引擎基础:项目内 Frostbite2D-Engine
### 1.2 第一阶段目标
先做一个可玩的垂直切片,而不是直接扩展完整游戏。
垂直切片应包含:
- 一个可控制角色
- 基础移动、跳跃、冲刺、攻击
- 至少 2 种敌人
- 一个小关卡
- 基础血量、受击、死亡、胜利条件
- 简单 UI
- PC 可运行 exe
- Switch 可运行 nro
### 1.3 长期目标
在垂直切片验证后,逐步扩展为完整清版 ACT:
- 多关卡流程
- 多种敌人和精英敌人
- Boss 战
- 角色成长或技能解锁
- 存档
- 音效、音乐、打击反馈
- PC / Switch 双平台发布构建
## 2. 开发原则
### 2.1 先玩法,后题材
题材未定时,不提前锁死美术和剧情。先用占位图和简单音效验证:
- 操作是否顺手
- 攻击距离是否舒服
- 敌人压力是否合理
- 受击、硬直、闪避是否清晰
- 清版节奏是否有爽感
### 2.2 小步快跑
每个阶段都要产出可运行版本。避免长期只写底层系统但没有可玩内容。
### 2.3 PC 优先,Switch 持续验证
PC 端开发效率高,主循环先在 PC 上跑通。Switch 每完成一个重要阶段就编译并运行一次,避免最后集中处理平台问题。
### 2.4 数据驱动
角色、敌人、攻击、关卡尽量用配置数据驱动,减少硬编码。
优先数据化:
- 角色基础参数
- 攻击判定框
- 敌人参数
- 掉落物
- 关卡对象
- 音效绑定
## 3. 技术基础阶段
### 3.1 编译链路确认
当前已经验证:
- Windows 静态库:`build/windows/x64/release/Frostbite2D.lib`
- Windows 测试 exe`build/windows/x64/release/Frostbite2DWindowsSmoke.exe`
- Switch 静态库:`build/switch/aarch64/release/libFrostbite2D.a`
- Switch 测试 nro`build/switch/aarch64/release/switch_smoke/Frostbite2D_smoke_meta.nro`
后续需要维护:
- `BUILD.md` 中的 PC / Switch 编译命令
- Windows DLL 复制流程
- Switch NACP / icon 打包流程
### 3.2 项目结构整理
建议后续把游戏项目和引擎分层:
```text
Frostbite2D-Engine/
├── Frostbite2D/ # 引擎源码
├── examples/ # smoke test
├── game/ # 游戏源码,后续新增
│ ├── src/
│ ├── include/
│ ├── assets/
│ └── docs/
├── BUILD.md
├── engine.lua
└── xmake.lua
```
第一版游戏可以先放在:
```text
game/src
game/assets
```
等项目变大后再拆分模块。
### 3.3 引擎能力检查
需要确认和补齐这些能力:
- 窗口和渲染初始化
- Sprite 渲染
- 动画播放
- 批量渲染
- 输入系统
- 场景管理
- 音频播放
- 资源读取
- 存档系统
- 碰撞检测
- 简单 UI
- PC / Switch 条件编译
其中碰撞、ACT 战斗、关卡对象系统可能需要在游戏层实现,必要时再下沉到引擎。
## 4. 玩法原型阶段
### 4.1 角色控制
目标:做出一个手感可调的主角。
功能:
- 左右移动
- 地面加速和减速
- 空中移动
- 跳跃
- 可变跳高
- 下落重力
- 落地检测
- 冲刺或闪避
- 面向方向切换
需要可调参数:
- 最大移动速度
- 地面加速度
- 地面摩擦
- 空中加速度
- 跳跃初速度
- 重力
- 最大下落速度
- 冲刺速度
- 冲刺时间
- 冲刺冷却
验收标准:
- 角色能在测试地形上稳定移动
- 跳跃不会穿地
- 手柄和键盘都能控制
- 参数可以快速修改
### 4.2 基础物理和碰撞
横板 ACT 不需要复杂真实物理,优先做稳定的盒体碰撞。
功能:
- AABB 碰撞盒
- 地面碰撞
- 墙体碰撞
- 单向平台,视需求后置
- 角色 hurtbox
- 攻击 hitbox
- 敌人 hurtbox
- 触发器区域
验收标准:
- 角色不会穿墙穿地
- 攻击命中能稳定检测
- 敌人和角色的受击框可以调试显示
### 4.3 基础攻击
目标:实现 ACT 清版的最小战斗闭环。
功能:
- 普通攻击 3 连段
- 攻击前摇、 active、后摇
- 攻击命中敌人
- 敌人扣血
- 命中停顿
- 简单击退
- 攻击期间限制移动
- 攻击取消规则,先简单实现
可调参数:
- 每段攻击伤害
- 每段攻击持续时间
- 攻击判定框位置
- 攻击判定框大小
- 击退距离
- 命中停顿帧数
- 连段输入缓冲时间
验收标准:
- 玩家能完成三段普通攻击
- 攻击判定可视化
- 敌人被击中有明显反馈
### 4.4 受击和生命
功能:
- 角色生命值
- 敌人生命值
- 受击硬直
- 无敌时间
- 死亡状态
- 击飞或击退
- 受击闪白
验收标准:
- 敌人能被打死
- 玩家能被敌人打死
- 受击反馈清晰
- 不会重复多次扣血,除非设计允许
## 5. 敌人系统阶段
### 5.1 敌人基础框架
敌人需要状态机:
- Idle
- Patrol
- Chase
- Attack
- Hitstun
- Knockback
- Dead
基础字段:
- 血量
- 移动速度
- 攻击力
- 视野范围
- 攻击范围
- 碰撞盒
- 受击盒
- 掉落表
### 5.2 第一种敌人:近战杂兵
行为:
- 玩家进入范围后接近
- 到达攻击范围后攻击
- 被攻击时硬直
- 死亡后消失
验收标准:
- 能追踪玩家
- 能攻击玩家
- 能被连段压制
### 5.3 第二种敌人:远程或盾兵
建议二选一:
- 远程兵:保持距离,发射投射物
- 盾兵:正面减伤,需要绕后或破防
目的:
- 让清版战斗不只是站桩连打
- 验证玩家移动、闪避、攻击选择
### 5.4 敌人生成器
功能:
- 关卡中放置敌人生成点
- 进入触发区域后生成一波敌人
- 当前波次清完后打开出口
- 支持多波次
验收标准:
- 一个房间能刷出 2 到 3 波敌人
- 清完敌人后进入下一段
## 6. 关卡系统阶段
### 6.1 测试关卡
先做一个灰盒关卡:
- 起点
- 平台
- 墙体
- 2 到 3 个战斗房间
- 终点
使用简单矩形和占位贴图即可。
### 6.2 摄像机
功能:
- 跟随玩家
- 平滑移动
- 限制在关卡边界内
- 战斗房间锁定,视需求实现
验收标准:
- 玩家移动时镜头稳定
- 不露出关卡外区域
- 战斗中镜头不乱晃
### 6.3 关卡数据格式
当前项目已经改为 `.map` 文本数据,由 `StageMapLoader` 读取:
```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>
```
当前已经有自定义网页地图编辑器,后续优先继续完善 `.map` 编辑和部署流程。
## 7. 动画和美术阶段
### 7.1 占位动画
先用简单色块或临时素材完成:
- Idle
- Run
- Jump
- Fall
- Dash
- Attack1
- Attack2
- Attack3
- Hit
- Death
敌人至少需要:
- Idle
- Move
- Attack
- Hit
- Death
### 7.2 动画事件
ACT 游戏需要动画事件:
- 第几帧开启攻击判定
- 第几帧关闭攻击判定
- 第几帧播放音效
- 第几帧允许连段输入
- 第几帧允许取消
建议设计一个简单事件表:
```json
{
"attack_1": {
"events": [
{ "frame": 3, "type": "hitbox_on", "id": "slash_1" },
{ "frame": 6, "type": "hitbox_off", "id": "slash_1" },
{ "frame": 8, "type": "combo_window_on" }
]
}
}
```
## 8. 战斗体验阶段
### 8.1 打击反馈
优先级很高。
需要实现:
- 命中停顿
- 角色/敌人闪白
- 屏幕轻微震动
- 击退
- 命中特效
- 命中音效
- 飘字,视风格决定
### 8.2 连段和技能
第一版:
- 轻攻击 3 连
- 重攻击 1 个
- 闪避
- 跳跃攻击
第二版:
- 上挑
- 空中追击
- 破防
- 特殊技能
- 能量条
### 8.3 战斗规则
需要明确:
- 玩家是否有精力条
- 闪避是否有无敌帧
- 敌人攻击是否可打断
- 是否有浮空
- 是否有倒地
- 是否有处决或终结技
- 是否有防御或格挡
建议垂直切片先不做太复杂:
- 闪避有短暂无敌
- 普通敌人可被打断
- 精英敌人部分攻击霸体
- Boss 攻击不可轻易打断
## 9. UI 和流程阶段
### 9.1 游戏内 UI
第一版:
- 玩家血条
- 敌人血条,精英和 Boss 显示
- 当前关卡目标
- 暂停菜单
第二版:
- 技能冷却
- 能量条
- 道具栏
- 伤害数字
### 9.2 菜单流程
最小流程:
- 启动
- 主菜单
- 开始游戏
- 暂停
- 继续
- 退出
- 游戏结束
## 10. 音频阶段
### 10.1 音效
优先实现:
- 普通攻击挥砍
- 命中
- 受击
- 跳跃
- 落地
- 闪避
- 敌人死亡
- UI 确认/取消
### 10.2 音乐
垂直切片:
- 一个关卡 BGM
- 一个战斗 BGM,视需求
- Boss BGM,后置
## 11. 存档和配置阶段
### 11.1 配置保存
需要保存:
- 分辨率
- 全屏
- 音量
- 键位
- 语言
### 11.2 游戏存档
垂直切片可先只保存:
- 当前关卡
- 玩家生命上限
- 已解锁技能
完整版本再扩展:
- 收集品
- 关卡进度
- 难度
- 游玩时间
## 12. 平台适配阶段
### 12.1 Windows
需要持续验证:
- Debug / Release 构建
- EXE 启动
- DLL 打包
- 窗口和全屏切换
- Xbox 手柄输入
- 中文路径兼容
### 12.2 Switch
需要持续验证:
- `.a` 静态库构建
- `.nro` 生成
- NACP / icon 元数据
- Ryujinx 启动
- 真机启动,后续条件允许再测
- 手柄输入
- 性能和内存
## 13. 工具和调试
### 13.1 调试显示
必须尽早做:
- 碰撞盒显示
- 攻击判定显示
- 敌人状态显示
- FPS 显示
- 当前场景名
- 玩家坐标
### 13.2 参数热重载
中期目标:
- 角色参数 JSON 热重载
- 敌人参数 JSON 热重载
- 攻击判定 JSON 热重载
这样能大幅提高调手感效率。
### 13.3 简单关卡编辑流程
初期可手写 JSON。
中期可选择:
- 接入 Tiled
- 写一个简单内部编辑器
- 使用 CSV / JSON + 调试可视化
## 14. 题材方向备选
题材暂不确定,但可以先保留几个方向:
### 14.1 现代都市异能
优点:
- 动作设计自由
- 敌人类型丰富
- 场景容易做成街道、地铁、工厂
风险:
- 美术需要统一风格
### 14.2 东方奇幻
优点:
- 武器、技能、怪物设计空间大
- 适合夸张打击效果
风险:
- 素材工作量较大
### 14.3 科幻废土
优点:
- 机械敌人、枪械、近战都能兼容
- 关卡可做工厂、飞船、遗迹
风险:
- 需要较强美术设定
### 14.4 暗黑童话
优点:
- 视觉记忆点强
- Boss 和敌人设计有特色
风险:
- 氛围和动作爽感需要平衡
题材决策建议放在第一版战斗原型完成之后。
## 15. 阶段里程碑
### Milestone 0:构建基线
状态:已基本完成。
内容:
- Windows 引擎库可编译
- Windows smoke exe 可运行
- Switch 引擎库可编译
- Switch smoke nro 可运行
- 编译文档完成
### Milestone 1:角色可控
目标:
- 游戏项目入口
- 主角可移动、跳跃、冲刺
- 测试地图
- 摄像机跟随
- PC 可运行
完成标准:
- 能在测试关卡里稳定跑跳
- 操作参数可调
### Milestone 2:战斗闭环
目标:
- 普通攻击 3 连
- 攻击判定
- 敌人受击
- 敌人死亡
- 玩家受击
完成标准:
- 可以打死敌人
- 敌人可以打死玩家
- 打击反馈初步成立
### Milestone 3:清版房间
目标:
- 关卡触发器
- 敌人波次
- 房间封锁和解锁
- 简单胜利条件
完成标准:
- 玩家进入房间后刷敌
- 清完敌人后进入下一段
### Milestone 4:垂直切片
目标:
- 一个完整小关卡
- 两种敌人
- 一个小 Boss 或精英敌人
- UI
- 音效
- PC exe
- Switch nro
完成标准:
- 从开始到结束可完整游玩
- 没有阻断流程的崩溃
- PC 和 Ryujinx 都能启动
### Milestone 5:题材确定
目标:
- 选定世界观
- 确定主角设定
- 确定敌人阵营
- 确定美术风格
- 确定第一章关卡主题
完成标准:
- 有一页核心设定文档
- 有主角和敌人参考图或风格板
- 不再使用完全抽象占位方向
### Milestone 6:内容扩展
目标:
- 3 到 5 个普通敌人
- 1 到 2 个 Boss
- 3 个以上关卡
- 技能成长
- 存档
完成标准:
- 游戏可玩 20 到 30 分钟
- 形成基本内容节奏
## 16. 近期任务清单
### 第 1 周
- 新增 `game` 目录和游戏 target
- 做 Windows 游戏入口
- 做 Switch 游戏入口
- 显示一个空场景
- 加载一张占位贴图
- 显示 FPS / 调试信息
### 第 2 周
- 实现玩家移动
- 实现跳跃
- 实现地面碰撞
- 实现摄像机跟随
- 搭建灰盒测试关卡
### 第 3 周
- 实现普通攻击
- 实现 hitbox / hurtbox
- 实现敌人基础类
- 实现第一种近战敌人
- 加入调试碰撞框显示
### 第 4 周
- 实现受击硬直
- 实现击退
- 实现生命和死亡
- 加入命中停顿
- 加入基础音效
- 做第一版战斗房间
## 17. 风险和注意事项
### 17.1 引擎仍在早期
当前引擎已经能编译 PC / Switch,但完整游戏开发中可能会继续暴露:
- 渲染接口平台差异
- 资源路径问题
- 音频格式兼容问题
- 输入差异
- 存档路径差异
需要每个里程碑都做 PC 和 Switch smoke 验证。
### 17.2 清版 ACT 对手感要求高
如果移动和攻击手感不好,后续内容越多越难改。前期要优先调:
- 移动速度
- 攻击前后摇
- 命中停顿
- 击退
- 敌人硬直
- 闪避无敌
### 17.3 美术工作量不可低估
横板 ACT 需要大量动画。题材确定前不要过早制作大量正式动画,先用占位素材验证。
### 17.4 Switch 性能要早测
清版游戏可能有大量敌人、特效和贴图。Switch 上要关注:
- 批量渲染是否稳定
- 贴图大小
- 同屏敌人数
- 粒子数量
- 音频内存
## 18. 下一步
最合理的下一步是创建真正的游戏 target:
- `Frostbite2DGame` Windows exe
- `Frostbite2DGame` Switch elf/nro
- `game/src/main.cpp`
- `game/assets/`
然后进入 Milestone 1:主角移动和测试地图。
+205 -19
View File
@@ -14,12 +14,22 @@ mindmap
近战连段 近战连段
关卡节奏 关卡节奏
游戏循环 游戏循环
登录入口
房间匹配
进入关卡 进入关卡
探索与移动 探索与移动
触发战斗区域 触发战斗区域
击败敌人 击败敌人
解锁推进 解锁推进
局内重置
结算与升级 结算与升级
底层框架
客户端服务端分离
基础热更
日志输出
异常捕获
资源异步加载
容错兜底
角色系统 角色系统
玩家 玩家
输入 输入
@@ -28,12 +38,14 @@ mindmap
攻击 攻击
受击 受击
动画状态机 动画状态机
血量
敌人 敌人
巡逻 巡逻
追击 追击
攻击 攻击
受击硬直 受击硬直
死亡 死亡
固定数值
NPC与可交互物 NPC与可交互物
对话 对话
机关 机关
@@ -70,11 +82,21 @@ mindmap
关卡配置 关卡配置
攻击配置 攻击配置
掉落配置 掉落配置
联网原型
房间匹配
2到4人联机
玩家状态同步
战斗同步
怪物同步
断线检测
简易重连
工具与调试 工具与调试
白盒编辑 白盒编辑
碰撞可视化 碰撞可视化
战斗区域可视化 战斗区域可视化
Hitbox可视化 Hitbox可视化
联机状态提示
简单调试面板
帧率与日志 帧率与日志
构建文档 构建文档
NS平台构建 NS平台构建
@@ -104,6 +126,12 @@ mindmap
+----------------------+ +----------------------+ +----------------------+ +----------------------+ +----------------------+ +----------------------+
| Data | ---> | Debug | ---> | Build | | Data | ---> | Debug | ---> | Build |
| 配置表/关卡/参数 | | 可视化/日志/测试 | | PC/Switch/资源复制 | | 配置表/关卡/参数 | | 可视化/日志/测试 | | PC/Switch/资源复制 |
+----------------------+ +----------------------+ +----------------------+
|
v
+----------------------+ +----------------------+ +----------------------+
| Foundation | ---> | UI | ---> | Network |
| 日志/异常/热更/加载 | | 登录/匹配/状态/调试 | | 房间/同步/断线重连 |
+----------------------+ +----------------------+ +----------------------+ +----------------------+ +----------------------+ +----------------------+
``` ```
@@ -123,11 +151,18 @@ mindmap
- `core/asset_paths.h` - `core/asset_paths.h`
- `core/input_state.*` - `core/input_state.*`
- `core/debug_flags.h` - `core/debug_flags.h`
- `InputStateProvider` 已聚合键盘、SDL GameController 和 Switch 原生 `PadState`
当前输入映射:
- 详见 `../controls.md`
- 当前已覆盖 PC 键盘、SDL GameController 和 Switch `PadState`
- 角色逻辑只消费 `InputState`,不直接读取平台输入 API。
下一步: 下一步:
- 增加 `GameContext`,统一传递全局服务。 - 增加 `GameContext`,统一传递全局服务。
- 输入层支持键盘和手柄统一映射。 - 增加可配置键位表,让 PC 和 NS 可以分别覆盖默认映射。
### 2. Scene ### 2. Scene
@@ -137,16 +172,20 @@ mindmap
- 装载关卡数据。 - 装载关卡数据。
- 创建玩家、敌人、道具和场景对象。 - 创建玩家、敌人、道具和场景对象。
- 控制镜头、战斗区域、关卡推进。 - 控制镜头、战斗区域、关卡推进。
- 支持单局场景重置:重置玩家、敌人、battle zone、掉落和临时状态。
已有: 已有:
- `scene/whitebox_scene.*` - `scene/whitebox_scene.*`
- `scene/camera_controller.*`:玩家跟随、dead zone、平滑和世界边界 clamp。
- `CameraZoneLock`:玩家进入 battle zone 后摄像机平滑切换到 zone camera bounds,可移动锁定范围会防御性保证不小于视口,当前通过 debug 清场或离开 zone 解锁。
下一步: 下一步:
- 抽象 `GameplayScene` 作为通用玩法场景基类。 - 抽象 `GameplayScene` 作为通用玩法场景基类。
- `WhiteboxScene` 只保留第一关测试内容。 - `WhiteboxScene` 只保留第一关测试内容。
- 引入 battle zone 触发逻辑 - battle zone debug 清场替换为敌人清空自动解锁
- 增加局内 reset 入口,用于卡状态、死亡重开和联机重同步。
### 3. Stage ### 3. Stage
@@ -161,12 +200,18 @@ mindmap
- `data/whitebox_level.cpp` - `data/whitebox_level.cpp`
- `movement/platform_world.*` - `movement/platform_world.*`
- `stage/stage_renderer.*` - `stage/stage_renderer.*`
- `StageLayer` 灰盒数据已接入:远景、中景、地面/平台、后景 props 先用色块占位。
- `StageLayerSource` 已接入:每个 `StageRect` 可选择色块占位或 sprite 资源路径,加载失败时回退色块。
- `StageAssetSlots` 已建立:`stage_01` 资源目录和素材来源记录已预留,素材回来后可直接落位。
- `StageProps` 已统一:后景/前景 props 都走 `StageLayer`,前景层在 actor 之后绘制,可用于近景遮挡。
- 第一关已按 Mossy Cavern `4HibZC.jpg` 重做为 `2105 x 720` 的参考图复刻版,并加入 3 个 battle zone 占位与敌人出生点。
下一步: 下一步:
- 增加 `StageLayer` 数据结构 - `StageLayer` 色块逐步替换为 tileset、背景图和 props 素材
- 关卡宽度扩展到 2400 到 3200 像素 - 补齐分项 debug 开关和场景验收跑图标准
- 引入远景/中景/地面三层绘制 - 将 battle zone 占位升级为触发、锁镜头、清场解锁逻辑
- 将敌人出生点接入 `EnemyActor`
### 4. Actor ### 4. Actor
@@ -174,6 +219,7 @@ mindmap
- 表达游戏对象:玩家、敌人、道具、投射物、可破坏物。 - 表达游戏对象:玩家、敌人、道具、投射物、可破坏物。
- 每个 Actor 只处理自身状态,不直接管理全局流程。 - 每个 Actor 只处理自身状态,不直接管理全局流程。
- 玩家和怪物都要支持血量、受击、击退、死亡/销毁基础生命周期。
已有: 已有:
@@ -184,6 +230,7 @@ mindmap
- 增加 `EnemyActor` - 增加 `EnemyActor`
- 增加 `BreakablePropActor` - 增加 `BreakablePropActor`
- 抽出 `CharacterActor` 作为玩家和敌人的共同基类。 - 抽出 `CharacterActor` 作为玩家和敌人的共同基类。
- 增加 `HealthComponent` 或等价数据结构,统一玩家/怪物血量与死亡判定。
- 将玩家动画切换改为数据驱动。 - 将玩家动画切换改为数据驱动。
### 5. Movement ### 5. Movement
@@ -191,7 +238,9 @@ mindmap
职责: 职责:
- 处理速度、重力、平台落地、边界限制。 - 处理速度、重力、平台落地、边界限制。
- 支持横版手感:左右平移、加速、移动减速、单次跳跃、重力落地、空中姿态锁定。
- 后续支持斜坡、墙体、击退、空中状态。 - 后续支持斜坡、墙体、击退、空中状态。
- 兜底防卡:防卡地形、防穿模、异常位置重置。
已有: 已有:
@@ -202,9 +251,11 @@ mindmap
下一步: 下一步:
- 支持水平墙体阻挡。 - 支持水平墙体阻挡。
- 支持移动加速/减速参数。
- 支持击退速度覆盖。 - 支持击退速度覆盖。
- 支持落地事件。 - 支持落地事件。
- 支持移动调试可视化。 - 支持移动调试可视化。
- 增加卡地形检测和安全出生点/检查点回退。
### 6. Combat ### 6. Combat
@@ -212,6 +263,8 @@ mindmap
- 管理攻击盒、受击盒、伤害结算、击退、硬直、命中特效。 - 管理攻击盒、受击盒、伤害结算、击退、硬直、命中特效。
- 保证战斗逻辑不散落在 Actor 内部。 - 保证战斗逻辑不散落在 Actor 内部。
- 表达普通攻击前摇/后摇、命中窗口、固定伤害数值、受击反馈。
- 支持跨玩家攻击命中和怪物状态同步的网络事件接口。
已有: 已有:
@@ -222,6 +275,8 @@ mindmap
- 增加 `CombatSystem` - 增加 `CombatSystem`
- 每帧收集 active hitbox / hurtbox。 - 每帧收集 active hitbox / hurtbox。
- 支持 team 过滤、重复命中过滤、伤害事件。 - 支持 team 过滤、重复命中过滤、伤害事件。
- 增加攻击前摇/后摇时间线。
- 增加伤害、击退、僵直和血量变化事件。
- 增加 debug hitbox 绘制。 - 增加 debug hitbox 绘制。
### 7. Animation ### 7. Animation
@@ -230,6 +285,7 @@ mindmap
- 管理 spritesheet 帧、动画状态、播放速度、循环和过渡。 - 管理 spritesheet 帧、动画状态、播放速度、循环和过渡。
- 避免每个 Actor 手写帧切换。 - 避免每个 Actor 手写帧切换。
- 与动作状态机配合,保证 idle/移动/跳跃/攻击/受击 状态互斥,杜绝动作冲突。
当前状态: 当前状态:
@@ -239,6 +295,7 @@ mindmap
- 增加 `AnimationClip` - 增加 `AnimationClip`
- 增加 `SpriteAnimator` - 增加 `SpriteAnimator`
- 增加 `CharacterStateMachine`,管理 idle/move/jump/attack/hitstun/dead。
- 增加动画配置表。 - 增加动画配置表。
- 攻击动画和 hitbox active frame 绑定。 - 攻击动画和 hitbox active frame 绑定。
@@ -248,6 +305,8 @@ mindmap
- 将玩法参数从 C++ 硬编码逐步迁移到数据。 - 将玩法参数从 C++ 硬编码逐步迁移到数据。
- 支持关卡、敌人、动画、攻击、掉落配置。 - 支持关卡、敌人、动画、攻击、掉落配置。
- 支持角色/怪物血量、伤害、速度、追击范围等固定数值配置。
- 支持后续热更清单:资源版本、配置版本、加载失败兜底资源。
当前状态: 当前状态:
@@ -258,6 +317,7 @@ mindmap
- 先用 C++ struct 稳定字段。 - 先用 C++ struct 稳定字段。
- 再迁移到 JSON 或编辑器导出格式。 - 再迁移到 JSON 或编辑器导出格式。
- 资源路径统一记录,避免散落字符串。 - 资源路径统一记录,避免散落字符串。
- 增加配置默认值和缺失字段兜底。
### 9. Render 与资源 ### 9. Render 与资源
@@ -265,6 +325,8 @@ mindmap
- 绘制角色、关卡、背景、道具、特效和调试图形。 - 绘制角色、关卡、背景、道具、特效和调试图形。
- 管理资源目录和运行时拷贝。 - 管理资源目录和运行时拷贝。
- 支持场景、角色、贴图的极简异步加载。
- 加载失败时使用占位图/色块兜底,避免场景直接崩溃。
已有: 已有:
@@ -278,6 +340,8 @@ mindmap
- 引入 tileset、背景、props。 - 引入 tileset、背景、props。
- 增加视差背景绘制。 - 增加视差背景绘制。
- 建立素材来源和授权记录。 - 建立素材来源和授权记录。
- 增加 `AssetManifest`,记录 stage/character/effect 资源槽位。
- 增加异步加载队列和加载中/加载失败调试提示。
### 10. Debug 与工具 ### 10. Debug 与工具
@@ -285,16 +349,17 @@ mindmap
- 提供开发时必要的可视化和状态观察。 - 提供开发时必要的可视化和状态观察。
- 降低调试碰撞、战斗和关卡触发的成本。 - 降低调试碰撞、战斗和关卡触发的成本。
- 输出基础日志、异常捕获信息、资源加载失败原因、联机状态。
- 提供简单调试面板:FPS、场景、玩家状态、网络状态、重置按钮。
下一步: 下一步:
- `debug/debug_overlay.*` 已经提供碰撞玩家包围盒绘制。 - `debug/debug_overlay.*` 已经提供碰撞玩家包围盒、battle zone、camera lock 和 spawn point 绘制。
- `F1` 切换 debug overlay。 - Debug overlay 已支持总开关和分项开关,具体快捷键记录在 `../controls.md`
- 绘制平台碰撞。
- 绘制 battle zone。
- 绘制 spawn point。
- 绘制 hitbox / hurtbox。 - 绘制 hitbox / hurtbox。
- 输出当前场景、角色状态、FPS。 - 输出当前场景、角色状态、FPS。
- 增加日志分级和异常状态重置入口。
- 增加网络延迟、房间号、连接状态显示。
### 11. Platform ### 11. Platform
@@ -302,6 +367,7 @@ mindmap
- 保持 PC 和 NS 共用游戏逻辑。 - 保持 PC 和 NS 共用游戏逻辑。
- 只在入口、构建、资源部署、平台输入处做差异化。 - 只在入口、构建、资源部署、平台输入处做差异化。
- 为后续客户端/服务端分离保留平台抽象:本地单机、局域网、公网房间都走统一会话接口。
已有: 已有:
@@ -312,15 +378,110 @@ mindmap
下一步: 下一步:
- 将 Switch Joy-Con 输入接入 `InputStateProvider`
- 在 Ryujinx 或真机上验证主循环、shader 和角色纹理。 - 在 Ryujinx 或真机上验证主循环、shader 和角色纹理。
- 如果 RomFS 不可用,再补 SD 卡外部资源部署脚本。 - 如果 RomFS 不可用,再补 SD 卡外部资源部署脚本。
### 12. Foundation
职责:
- 建立底层框架基础:客户端/服务端分离思路、日志、异常捕获、热更清单、资源加载兜底。
- 单机开发阶段先以本地会话模拟服务端,后续再替换为真实网络服务端。
- 所有系统要能在异常状态下恢复到可继续测试的状态。
当前状态:
- 已有 PC/Switch 构建入口。
- 已有资源工作目录和 RomFS/SDMC 基础路径。
- 还没有独立日志系统、异常捕获边界、热更 manifest。
下一步:
- 增加 `GameSession`:本地单机/联机都通过同一会话驱动场景。
- 增加 `AssetManifest`:记录资源版本、资源槽位和 fallback。
- 增加统一日志输出入口,至少支持 info/warn/error。
- 增加异常状态重置入口:位置异常、状态卡死、资源加载失败。
### 13. UI
职责:
- 提供基础 UI:登录入口、房间匹配、联机状态提示、简单调试面板。
- 原型期 UI 不追求美术,只要求流程可达、状态清楚、可调试。
当前状态:
- 还没有游戏层 UI。
下一步:
- 增加 `LoginScene` 或入口面板:本地游玩、创建房间、加入房间。
- 增加 `RoomPanel`:显示房间号、玩家列表、准备状态。
- 增加 `NetworkStatusPanel`:显示连接状态、延迟、断线/重连状态。
- 增加 `DebugPanel`:显示 FPS、场景名、玩家状态、怪物数量、重置按钮。
### 14. Enemy AI
职责:
- 实现极简怪物 AI:固定巡逻、玩家进入范围追击、近战攻击、受击掉血、死亡销毁。
- 怪物行为要能被 battle zone 控制生成和清场。
当前状态:
- 还没有 `EnemyActor`
下一步:
- 增加 `EnemyActor`idle/patrol/chase/attack/hitstun/dead。
- 增加巡逻范围、追击范围、攻击范围、血量、伤害配置。
- 接入 `BattleZone` 的 spawn point。
- 预留网络同步字段:位置、状态、血量、目标玩家。
### 15. Networking
职责:
- 多人网游原型核心:局域网/公网极简房间匹配,支持 2 到 4 人联机。
- 服务端同步所有玩家位置、动作状态、血量状态。
- 同步跨玩家攻击命中、怪物状态、清场进度。
- 支持断线检测、简易重连、网络延迟适配。
当前状态:
- 还没有网络模块。
- 先不阻塞场景和 ACT 手感开发,但架构上要预留服务端权威状态。
下一步:
- 增加 `NetworkSession` 接口:offline/local/online 三种模式。
- 增加 `PlayerStateSnapshot`:位置、速度、动作状态、血量、朝向。
- 增加 `CombatEvent` / `EnemyStateSnapshot`:攻击命中、怪物状态、死亡事件。
- 增加断线检测和重连状态机。
### 16. Game Loop Closure
职责:
- 做单局闭环:进入场景 -> 战斗交互 -> 怪物清空 -> 局内重置。
- 做核心 bug 兜底:防卡地形、防穿模、防状态卡死、异常状态重置。
当前状态:
- 当前只有进入白盒场景和玩家移动。
下一步:
- 增加 `RunState`loading/playing/cleared/failed/resetting。
- 增加局内重置:重置玩家、怪物、battle zone、摄像机和临时效果。
- 增加安全点:玩家卡地形或掉出世界时回到最近检查点。
- 增加状态超时兜底:攻击/受击/重连状态超过上限自动恢复。
## 数据流白板 ## 数据流白板
```text ```text
输入 输入
SDL Keyboard / Controller SDL Keyboard / Controller / Switch PadState
-> InputStateProvider -> InputStateProvider
-> InputState -> InputState
-> PlayerActor -> PlayerActor
@@ -361,21 +522,27 @@ mindmap
目标:验证 ACT 基础手感。 目标:验证 ACT 基础手感。
- 基础日志、资源加载兜底和调试面板可用。
- 玩家移动、跳跃、出拳稳定。 - 玩家移动、跳跃、出拳稳定。
- 第一关横向推进可从头跑到尾。 - 第一关横向推进可从头跑到尾。
- battle zone 可触发、锁镜头、解锁。 - battle zone 可触发、锁镜头、解锁。
- 有 1 种敌人占位。 - 有 1 种敌人占位。
- 有 hitbox/hurtbox debug。 - 有 hitbox/hurtbox debug。
- 有局内重置入口,能重置玩家、怪物、battle zone 和摄像机。
### Milestone 2:灰盒战斗验证 ### Milestone 2:灰盒战斗验证
目标:验证清版节奏。 目标:验证清版节奏。
- `CharacterStateMachine` 管理 idle/move/jump/attack/hitstun/dead。
- 玩家 2 到 3 段基础连击。 - 玩家 2 到 3 段基础连击。
- 攻击前摇/后摇和命中窗口可配置。
- 受击僵直、击退、血量变化可见。
- 敌人追击、攻击、受击、死亡。 - 敌人追击、攻击、受击、死亡。
- 每个 battle zone 有敌人波次。 - 每个 battle zone 有敌人波次。
- 有简单掉落或奖励。 - 有简单掉落或奖励。
- 关卡能完整结算。 - 关卡能完整结算。
- 防卡地形、防穿模、防状态卡死有基础兜底。
### Milestone 3:第一关美术测试 ### Milestone 3:第一关美术测试
@@ -387,7 +554,18 @@ mindmap
- 保留 debug overlay。 - 保留 debug overlay。
- 记录素材来源和授权。 - 记录素材来源和授权。
### Milestone 4数据化 ### Milestone 4联机原型
目标:跑通 2 到 4 人多人原型。
- 有登录/入口 UI。
- 有创建房间、加入房间、玩家列表和准备状态。
- 支持局域网或公网极简房间匹配。
- 服务端同步玩家位置、动作状态、血量。
- 服务端同步怪物状态、攻击命中、清场进度。
- 有断线检测、简易重连和联机状态提示。
### Milestone 5:数据化与热更
目标:降低后续扩展成本。 目标:降低后续扩展成本。
@@ -395,17 +573,25 @@ mindmap
- 攻击参数数据化。 - 攻击参数数据化。
- 敌人参数数据化。 - 敌人参数数据化。
- 关卡数据逐步外部化。 - 关卡数据逐步外部化。
- 资源 manifest、配置版本和 fallback 规则可用。
## 当前优先级 ## 当前优先级
当前调整为先做场景。理由:先把第一关的世界宽度、分层数据、镜头范围、视觉层和 debug 标记搭起来,后续 `BattleZone`、敌人刷出和战斗系统才有稳定承载位置。
新增的底层框架、UI、联网和闭环兜底会作为架构约束提前预留接口,但短期实现仍以“场景骨架 -> ACT 手感 -> 单局闭环”为主。多人联网在 Milestone 4 集中实现,当前阶段只避免把玩法逻辑写死成无法同步的本地状态。
下一步建议按这个顺序做: 下一步建议按这个顺序做:
1. `BattleZone`:做清版推进的核心框架 1. 已完成:`StageLayerSource``StageLayer` 同时支持色块占位和 sprite 资源路径
2. `SwitchInput`:把 Joy-Con/手柄输入接入 `InputStateProvider` 2. 已完成:`StageAssetSlots` 建立 `stage_01` 资源槽位和素材来源记录,做到素材可直接落位
3. `EnemyActor`:做第一个敌人占位 3. 已完成:`StageProps` 把后景/前景 props 统一到 layer 数据
4. `CombatSystem`:把 hitbox/hurtbox 真正跑起来 4. 已完成:`SceneAcceptanceRun` 建立场景骨架验收流程,记录 PC/Switch 构建和跑图检查项
5. `SpriteAnimator`:把玩家动画从硬编码改成组件 5. `BattleZone`:把 debug 清场替换为敌人清空自动解锁
6. `StageLayer`:把白盒视觉层扩展为背景、tiles 和 props 6. `EnemyActor`:做第一个敌人占位,接入出生点
7. `CombatSystem`:把 hitbox/hurtbox 真正跑起来。
8. `SpriteAnimator`:把玩家动画从硬编码改成组件。
9. `InputConfig`:把默认键位/手柄映射迁移到可配置数据。
## 素材协作提示 ## 素材协作提示
+142 -20
View File
@@ -14,14 +14,31 @@
当前白盒只解决“能跑、能跳、能看到关卡结构”。下一步要把场景拆成数据层、视觉层和玩法层。 当前白盒只解决“能跑、能跳、能看到关卡结构”。下一步要把场景拆成数据层、视觉层和玩法层。
第一轮场景骨架进度:
- `LevelDefinition` 已扩展世界尺寸、玩家出生点、镜头范围、分层视觉、battle zone 和敌人出生点数据。
- 第一关已按 `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`
- `CameraController` 已接入:修复场景 `OnUpdate()` 不被调用导致摄像机不动的问题,并提供跟随、dead zone、平滑和世界边界 clamp。
- `CameraZoneLock` 已接入:玩家进入 battle zone 后摄像机临时锁定到 zone camera boundsdebug 清场后恢复全关卡跟随。
- 已修复 zone camera bounds 小于 1280 视口导致摄像机锁死的问题;所有锁镜头范围必须不小于当前视口,debug 阶段玩家离开 zone 会自动清场解锁。
- 已修复进入 battle zone 时摄像机硬切的问题;zone camera bounds 表示摄像机可移动范围,进入/离开 zone 都通过短过渡平滑切换约束。
- `StageLayerSource` 已接入:`StageRect` 支持色块占位或 sprite 资源路径,资源不存在时回退色块。
- `StageAssetSlots` 已建立:`game/assets/stage/stage_01/` 已预留 background、tiles、props、effects、audio 目录和素材来源记录。
- `StageProps` 已统一:旧 `WhiteboxProp` 已移除,后景/前景 props 都走 `StageLayer`,其中 `props_front` 在角色之后绘制用于遮挡。
- `SceneDebug` 已扩展:支持 debug 总开关和碰撞/网格、玩家框、battle zone/camera、spawn point 分项开关,具体快捷键记录在 `../controls.md`
- `SceneAcceptanceRun` 已建立:场景骨架验收流程和本轮构建结果记录在 `../scene_acceptance.md`
## 2. 第一关场景目标 ## 2. 第一关场景目标
第一关不急着做最终题材,先做通用 ACT 测试关: 第一关不急着做最终题材,先做通用 ACT 测试关:
- 横向推进,总宽度先控制在 2400 到 3200 像素。 - 横向推进,当前 Mossy Cavern 参考图复刻版宽度为 2105 像素。
- 主要视口仍用 1280x720。 - 主要视口仍用 1280x720。
- 地面以平路为主,加入 2 到 3 段平台高低差 - 主路线先保持连续地面,避免当前平台碰撞不支持台阶/斜坡导致卡关;高度变化主要放在悬空平台上,用于测试跳跃和镜头推进
- 至少预留 3 个战斗区域,每个区域宽度 480 到 720 像素 - 当前按参考图三块主要站立区域预留 3 个战斗区域
- 每个战斗区域有明确的入口、站位空间、敌人刷出点和退出方向。 - 每个战斗区域有明确的入口、站位空间、敌人刷出点和退出方向。
- 保留一个跳跃测试段,用来验证移动手感。 - 保留一个跳跃测试段,用来验证移动手感。
- 保留一个窄平台/边缘段,用来验证镜头和碰撞。 - 保留一个窄平台/边缘段,用来验证镜头和碰撞。
@@ -147,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`
每个区域包含: 每个区域包含:
@@ -172,18 +189,17 @@ game/assets/stage/stage_01/
- 敌人出生点。 - 敌人出生点。
- battle zone。 - battle zone。
建议先用键盘 `F1` 切换 具体 debug 快捷键统一记录在 `../controls.md`
## 7. 第一版验收标准 ## 7. 第一版验收标准
第一阶段完成时应该达到: 第一阶段完成时应该达到:
- 能启动 `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/`
- 文档记录已使用素材来源和授权信息。 - 文档记录已使用素材来源和授权信息。
@@ -199,12 +215,118 @@ game/assets/stage/stage_01/
5. 再接 props,丰富场景轮廓。 5. 再接 props,丰富场景轮廓。
6. 最后接 effects 和音频。 6. 最后接 effects 和音频。
## 9. 下一轮建议任务 ## 9. 当前执行顺序
你找到素材后,下一轮优先做: 本轮开始先做场景,不等待最终素材。第一阶段目标是把第一关变成可替换素材的灰盒场景骨架,让后续素材只需要填到既定层级和数据结构里。
- 创建 `stage_01` 资源目录。 ### 先做工程骨架
- 新增 `StageRenderer`
- 扩展 `LevelDefinition` 1. 已完成:扩展 `LevelDefinition`,加入世界尺寸、玩家出生点、镜头限制、敌人出生点、battle zone 占位数据
- 把当前白盒平台替换成第一版 tile 视觉 2. 已完成:扩展 `StageRenderer`,支持背景层、地面/平台视觉层、后景 props,第一版继续用色块
- 保留 debug 碰撞显示 3. 已完成:按 `4HibZC.jpg` 重建关卡为 `2105 x 720` 的参考图复刻版
4. 已完成:加入 3 个 battle zone debug 标记,但暂不做刷敌逻辑。
5. 已完成:加入 spawn point debug 标记,用于后续接 `EnemyActor`
6. 已完成:补 debug 开关,可切换碰撞、battle zone、spawn point。
### 素材回来后再替换
1. 整理素材到 `game/assets/stage/stage_01/`
2. 记录素材来源和授权。
3. 先接背景图,验证相机移动。
4. 再接地面/平台 tiles,验证碰撞对齐。
5. 再接 props,丰富场景轮廓。
6. 最后接 effects 和音频。
## 10. 下一轮建议任务
当前目标调整为:先完成“可直接放素材”的场景骨架,而不是立刻进入素材替换。骨架完成后,素材只需要整理到约定目录并填入 layer/prop/tile 数据,不再改场景主流程。
### 完整场景骨架完成定义
完成后应达到:
- 摄像机能跟随玩家横向推进,不再固定在起点视口。
- 摄像机遵守关卡边界,不露出世界外黑边。
- 摄像机能被 battle zone 临时锁定到指定范围,清场后恢复跟随。
- `LevelDefinition` 能表达世界尺寸、玩家出生点、相机边界、碰撞、视觉层、props、battle zone、spawn point。
- `StageRenderer` 能按层绘制远景、中景、地面/平台、后景 props、前景 props,并支持资源图和色块占位两种来源。
- `DebugOverlay` 能显示碰撞、battle zone、camera lock、spawn point、玩家包围盒。
- 关卡灰盒能观察到参考图中的左侧、中央、右侧三个主要平台区域,至少 3 个 battle zone 都能被观察到;具体验收流程见 `../scene_acceptance.md`
- 素材目录、命名、来源记录文件已经准备好,后续放入 PNG 后只改资源引用。
### 场景骨架实施顺序
1. 已完成:`CameraController` 修复当前摄像机不会动的问题,实现玩家跟随、边界 clamp、dead zone 和平滑参数。
2. 已完成:`CameraZoneLock` 让 battle zone 可以锁定摄像机范围,先用 debug 清场快捷键做解锁占位。
3. 已完成:`StageLayerSource` 扩展 `StageLayer`,让每个 rect 支持色块占位或 sprite 资源路径。
4. 已完成:`StageAssetSlots` 创建并记录 `stage_01` 资源槽位,不要求已有最终素材。
5. 已完成:`StageProps` 把 props 从旧 `WhiteboxProp` 迁移到统一 layer 数据,支持 back/front 两层。
6. 已完成:`SceneDebug` 补齐 debug 总开关,并预留/接入分项开关。
7. 已完成:`SceneAcceptanceRun` 建立场景骨架验收文档;PC 和 Switch 构建通过,PC 短启动通过。
### 场景骨架阶段结论
当前场景骨架已经达到“可直接放素材”的工程状态。后续素材接入主要修改 `game/assets/stage/stage_01/` 和关卡 layer 数据,不需要再重写 `WhiteboxScene` 主流程。
地图框架调整已完成第一步:优先复用引擎 `Asset``PvfArchive``ScriptParser` 能力,`stage_01.map` 已从占位文件升级为第一关主数据源。`StageMapLoader` 会把 `.map` 解析成 `LevelDefinition`,C++ 白盒地图仅作为读取或解析失败时的 fallback。详细复盘见 `../engine_capability_audit.md`
第一轮引擎能力接入已完成:`GameServices` 统一接入音频、PVF、NPK、SoundPack、AudioDB、SaveSystem 状态;`StageMapResource` 负责从 Asset/PVF 读取 `map/stage_01.map``StageMapLoader` 负责转成游戏层 `LevelDefinition`。完整骨架说明见 `../game_skeleton.md`
第二轮引擎能力接入已完成:
- `Animation` 已接入玩家资源加载链路,优先读取 PVF 内 `.ani`,不可用时回退到 NPK IMG,再回退到 PNG 精灵表。
- `NpkArchive` / `Sprite::createFromNpk` 已接入角色动画资源兜底链路,后续把角色 IMG 放入 `ImagePacks2` 后无需改玩家主流程。
- `TextSprite` / `UIScene` 已接入局内 UI overlay;有 debug 字体时显示文本状态,没有字体时回退成低依赖状态条。
- `Sound` / `Music` 已通过 `GameAudio` 接入 cue 播放;优先读取 `AudioDatabase`,缺少音频表时从 `assets/sounds``assets/music` 直读占位资源。
- `SaveSystem` 已用于保存/读取场景 debug 开关,避免每次启动后重复配置调试显示。
- 全局素材槽位已建立在 `game/assets/ASSETS.md`,字体、音乐、音效、PVF、NPK、SoundPack、角色 ANI/IMG/PNG 的落位规则已经记录。
### 下一轮建议任务
继续沿着“优先使用引擎能力”的方向,把地图从色块灰盒推进到可放素材的完整骨架。当前不进入敌人和战斗玩法,下一轮只处理地图视觉表达:
1. 已完成:给 `.map` 增加 `tileset` / `tile_rect` 约定,先不做编辑器,保证手写数据能表达重复地砖。
2. 已完成:在 `StageRenderer` 中增加 tile 绘制路径,复用当前 `Texture` 加载和 source rect 支持。
3. 已完成:把 `level_visual` 的长色块替换成 tile entries,碰撞仍独立保留在 `collision`
4. 已完成:给 map loader 增加重复 tileset、未知 tileset、非法 tile rect 等错误日志。
5. 已完成:补轻量 map 验收流程:启动时日志确认加载 `.map`Windows/Switch 产物确认存在 `assets/map/stage_01.map` 和全局素材槽位。
### 当前阶段:地图场景制作
从现在开始,重点从“场景骨架”切到“地图场景制作”。因为 `.map` 已经成为第一关主数据源,继续手写地图会拖慢制作效率,所以下一步先做一个简易 `.map` 地图编辑器。编辑器计划见 `../modules/map_editor.md`
### 下一轮建议任务
继续围绕地图制作工具和地图视觉骨架,不进入战斗玩法:
1. 建立 `tools/map_editor/`,实现能打开/保存 `stage_01.map` 的最小编辑器。
2. 编辑器首版先支持 world、camera、player spawn、collision、tileset、tile_rect、battle zone、spawn point。
3. 在编辑器中提供网格吸附、视图平移、缩放和对象选择。
4. 保存后的 `.map` 必须能被 `StageMapLoader` 直接读取。
5. 编辑器稳定后,再给 `tile_rect` 增加二维 source index 或 tile id,接正式 tileset。
### Debug 快捷键
场景 debug 快捷键统一记录在 `../controls.md`
### 素材落位标准
素材准备好后只做这些操作:
1. 放入 `game/assets/stage/stage_01/background/``tiles/``props/``effects/`
2.`stage_01_assets` 或后续关卡数据中填写资源路径。
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 玩法。下一步围绕地图编辑器继续微调碰撞对齐,并逐步把完整参考底图拆成可编辑图层。
+105
View File
@@ -0,0 +1,105 @@
# Scene Acceptance
本文档记录第一关场景骨架的验收流程。每次改动场景、摄像机、关卡数据、输入或渲染层后,至少执行构建检查;涉及跑图手感时再执行手动跑图检查。
## 验收范围
- 场景入口:`WhiteboxScene`
- 关卡数据:`game/assets/map/stage_01.map`
- 关卡 fallback`CreateWhiteboxLevel()` 内置 C++ 白盒数据
- 世界尺寸:`2105 x 720`
- 玩家出生点:`(1030, 346)`
- 关卡边界:`0 -> 2105`
- 视口:`1280 x 720`
- 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`
- 参考底图:`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`
## 构建检查
### Windows
```powershell
& 'C:\Program Files\xmake\xmake.exe' f -c -p windows -a x64 -m release -y
& 'C:\Program Files\xmake\xmake.exe' -b Frostbite2DGame
```
短启动检查:
```powershell
$exe = Join-Path (Get-Location) 'build\windows\x64\release\Frostbite2DGame.exe'
$p = Start-Process -FilePath $exe -WorkingDirectory (Get-Location) -PassThru -WindowStyle Hidden
Start-Sleep -Seconds 3
if (!$p.HasExited) { Stop-Process -Id $p.Id -Force }
```
资源路径检查:
```powershell
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
```powershell
& 'C:\Program Files\xmake\xmake.exe' f -c -p switch -a aarch64 -m release --toolchain=cross --sdk='C:\devkitPro\devkitA64' --bin='C:\devkitPro\devkitA64\bin' --cross='aarch64-none-elf-' -y
& 'C:\Program Files\xmake\xmake.exe' -b Frostbite2DGameSwitch
```
产物:
```text
build\switch\aarch64\release\switch_game\NSUnknownGame.nro
```
RomFS 地图检查:
```powershell
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
```
## 手动跑图检查
操作映射见 `controls.md`
1. 启动 Windows 版 `Frostbite2DGame.exe`
2. 从出生点向右移动,确认摄像机横向跟随且不露出世界外黑边。
3. 依次进入 3 个 battle zone,确认 camera bounds 平滑切换,没有硬切瞬移。
4. 通过 debug 清场或离开区域解除锁定,确认摄像机恢复全关卡跟随。
5. 使用 debug 分项开关检查:
- 碰撞矩形和场景网格。
- 玩家包围盒。
- battle zone 触发框和 camera bounds。
- 敌人出生点。
6. 从起点移动到终点附近,确认所有平台、props、前景遮挡和背景层没有闪烁或黑屏。
7. 确认 UI overlay 可见;若没有字体资源,应显示左上角色条 fallback。
8. 切换 F1/F3/F4/F5/F6 后重启,确认 debug 开关能通过 `SaveSystem` 记住。
## 当前验收结果
日期:2026-06-09
- Windows release 构建:通过。
- Windows 3 秒短启动:通过。
- Switch `Frostbite2DGameSwitch` 构建:通过。
- Switch RomFS:已包含 character、shader、`stage_01` 资源槽位和 `assets/map/stage_01.map`
- 地图加载:`stage_01.map` 已作为第一关主数据源;C++ 白盒数据仅作为 fallback。
- 地图视觉:`stage_01.map` 已重做为 `4HibZC.jpg` 的一比一参考图复刻版,当前通过 `reference/mossy_cavern_reference_4hibzc.png` 作为完整底图表达。
- 引擎能力接入:`Animation`/NPK/PNG 角色资源优先级、`UIScene`/`TextSprite` UI、`Sound`/`Music` cue、`SaveSystem` debug 存档已接入。
- 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。
## 后续补充
- 增加输入状态 debug 显示后,把输入检查纳入本流程。
- 增加 `EnemyActor` 后,把 battle zone 自动清场纳入本流程。
- 增加 hitbox/hurtbox 后,把战斗 debug 绘制纳入本流程。
+51 -41
View File
@@ -1,6 +1,7 @@
#include "player_actor.h" #include "player_actor.h"
#include "../core/asset_paths.h" #include "../core/asset_paths.h"
#include "../core/game_audio.h"
#include "../core/game_config.h" #include "../core/game_config.h"
#include <algorithm> #include <algorithm>
@@ -8,13 +9,6 @@
namespace ns_game { namespace ns_game {
namespace {
constexpr float kSpriteOffsetX = -32.0f;
constexpr float kSpriteOffsetY = -24.0f;
} // namespace
PlayerActor::PlayerActor() { PlayerActor::PlayerActor() {
SetSize(64.0f, 104.0f); SetSize(64.0f, 104.0f);
SetTopLeftPosition(160.0f, 380.0f); SetTopLeftPosition(160.0f, 380.0f);
@@ -26,29 +20,29 @@ void PlayerActor::SetMovementWorld(const PlatformWorld* world) {
} }
void PlayerActor::loadSprites() { void PlayerActor::loadSprites() {
idleSprite_ = frostbite2D::Sprite::createFromFile(assets::kMaleIdle); idleAsset_ = LoadPlayerAnimationAsset(assets::kMaleIdleAni,
runSprite_ = frostbite2D::Sprite::createFromFile(assets::kMaleRun); assets::kMaleIdleImg,
punchSprite_ = frostbite2D::Sprite::createFromFile(assets::kMalePunch); assets::kMaleIdle, 0);
runAsset_ = LoadPlayerAnimationAsset(assets::kMaleRunAni, assets::kMaleRunImg,
assets::kMaleRun, 0);
punchAsset_ = LoadPlayerAnimationAsset(assets::kMalePunchAni,
assets::kMalePunchImg,
assets::kMalePunch, 0);
auto configure = [this](frostbite2D::Ptr<frostbite2D::Sprite> sprite) { addAnimationAsset(idleAsset_);
if (!sprite) { addAnimationAsset(runAsset_);
return; addAnimationAsset(punchAsset_);
}
sprite->SetSize(config::kPlayerFrameSize, config::kPlayerFrameSize);
sprite->SetSourceRect(frostbite2D::Rect(0.0f, 0.0f,
config::kPlayerFrameSize,
config::kPlayerFrameSize));
sprite->SetTopLeftPosition(kSpriteOffsetX, kSpriteOffsetY);
sprite->SetVisible(false);
AddChild(sprite);
};
configure(idleSprite_);
configure(runSprite_);
configure(punchSprite_);
setAnimState(AnimState::Idle); setAnimState(AnimState::Idle);
} }
void PlayerActor::addAnimationAsset(PlayerAnimationAsset& asset) {
if (asset.animation) {
AddChild(asset.animation);
} else if (asset.sprite) {
AddChild(asset.sprite);
}
}
void PlayerActor::OnUpdate(float deltaTime) { void PlayerActor::OnUpdate(float deltaTime) {
deltaTime = std::min(deltaTime, 1.0f / 30.0f); deltaTime = std::min(deltaTime, 1.0f / 30.0f);
updateInput(); updateInput();
@@ -68,11 +62,13 @@ void PlayerActor::updateInput() {
if (input.jumpPressed && grounded_) { if (input.jumpPressed && grounded_) {
velocity_.y = config::kPlayerJumpSpeed; velocity_.y = config::kPlayerJumpSpeed;
grounded_ = false; grounded_ = false;
GameAudio::Get().PlaySoundCue("player_jump");
} }
if (input.attackPressed) { if (input.attackPressed) {
attackTimer_ = config::kAttackDuration; attackTimer_ = config::kAttackDuration;
setAnimState(AnimState::Punch); setAnimState(AnimState::Punch);
GameAudio::Get().PlaySoundCue("player_attack");
} }
} }
@@ -117,43 +113,57 @@ void PlayerActor::updateAnimation(float deltaTime) {
frameIndex_ = (frameIndex_ + 1) % frameCount; frameIndex_ = (frameIndex_ + 1) % frameCount;
} }
auto sprite = activeSprite(); PlayerAnimationAsset* asset = activeAsset();
if (sprite) { if (!asset) {
sprite->SetSourceRect( return;
}
if (asset->animation) {
asset->animation->SetDirection(facingLeft_ ? -1 : 1);
}
if (asset->sprite) {
asset->sprite->SetSourceRect(
frostbite2D::Rect(frameIndex_ * config::kPlayerFrameSize, 0.0f, frostbite2D::Rect(frameIndex_ * config::kPlayerFrameSize, 0.0f,
config::kPlayerFrameSize, config::kPlayerFrameSize,
config::kPlayerFrameSize)); config::kPlayerFrameSize));
sprite->SetFlippedX(facingLeft_); asset->sprite->SetFlippedX(facingLeft_);
} }
} }
void PlayerActor::setAnimState(AnimState state) { void PlayerActor::setAnimState(AnimState state) {
if (animState_ != state) { const bool stateChanged = animState_ != state;
if (stateChanged) {
animState_ = state; animState_ = state;
animTimer_ = 0.0f; animTimer_ = 0.0f;
frameIndex_ = 0; frameIndex_ = 0;
} }
if (idleSprite_) { auto setVisible = [stateChanged](PlayerAnimationAsset& asset, bool visible) {
idleSprite_->SetVisible(state == AnimState::Idle); if (asset.animation) {
asset.animation->SetVisible(visible);
if (visible && stateChanged) {
asset.animation->Reset();
} }
if (runSprite_) {
runSprite_->SetVisible(state == AnimState::Run);
} }
if (punchSprite_) { if (asset.sprite) {
punchSprite_->SetVisible(state == AnimState::Punch); asset.sprite->SetVisible(visible);
} }
};
setVisible(idleAsset_, state == AnimState::Idle);
setVisible(runAsset_, state == AnimState::Run);
setVisible(punchAsset_, state == AnimState::Punch);
} }
frostbite2D::Ptr<frostbite2D::Sprite> PlayerActor::activeSprite() const { PlayerAnimationAsset* PlayerActor::activeAsset() {
switch (animState_) { switch (animState_) {
case AnimState::Run: case AnimState::Run:
return runSprite_; return &runAsset_;
case AnimState::Punch: case AnimState::Punch:
return punchSprite_; return &punchAsset_;
case AnimState::Idle: case AnimState::Idle:
default: default:
return idleSprite_; return &idleAsset_;
} }
} }
+6 -5
View File
@@ -2,9 +2,9 @@
#include "../core/input_state.h" #include "../core/input_state.h"
#include "../movement/platform_world.h" #include "../movement/platform_world.h"
#include "player_animation_assets.h"
#include <frostbite2D/2d/actor.h> #include <frostbite2D/2d/actor.h>
#include <frostbite2D/2d/sprite.h>
#include <frostbite2D/types/type_math.h> #include <frostbite2D/types/type_math.h>
namespace ns_game { namespace ns_game {
@@ -24,18 +24,19 @@ private:
}; };
void loadSprites(); void loadSprites();
void addAnimationAsset(PlayerAnimationAsset& asset);
void updateInput(); void updateInput();
void updatePhysics(float deltaTime); void updatePhysics(float deltaTime);
void updateAnimation(float deltaTime); void updateAnimation(float deltaTime);
void setAnimState(AnimState state); void setAnimState(AnimState state);
frostbite2D::Ptr<frostbite2D::Sprite> activeSprite() const; PlayerAnimationAsset* activeAsset();
const PlatformWorld* movementWorld_ = nullptr; const PlatformWorld* movementWorld_ = nullptr;
InputStateProvider inputProvider_; InputStateProvider inputProvider_;
PlatformMover platformMover_; PlatformMover platformMover_;
frostbite2D::Ptr<frostbite2D::Sprite> idleSprite_; PlayerAnimationAsset idleAsset_;
frostbite2D::Ptr<frostbite2D::Sprite> runSprite_; PlayerAnimationAsset runAsset_;
frostbite2D::Ptr<frostbite2D::Sprite> punchSprite_; PlayerAnimationAsset punchAsset_;
frostbite2D::Vec2 velocity_; frostbite2D::Vec2 velocity_;
AnimState animState_ = AnimState::Idle; AnimState animState_ = AnimState::Idle;
@@ -0,0 +1,64 @@
#include "player_animation_assets.h"
#include <frostbite2D/resource/asset.h>
#include <frostbite2D/resource/npk_archive.h>
#include <frostbite2D/resource/pvf_archive.h>
#include <SDL2/SDL.h>
namespace ns_game {
namespace {
constexpr float kSpriteOffsetX = -32.0f;
constexpr float kSpriteOffsetY = -24.0f;
} // namespace
PlayerAnimationAsset LoadPlayerAnimationAsset(const std::string& aniPath,
const std::string& npkImgPath,
const std::string& pngPath,
int zOrder) {
PlayerAnimationAsset asset;
frostbite2D::PvfArchive& pvf = frostbite2D::PvfArchive::get();
if (pvf.isOpen() && pvf.hasFile(aniPath)) {
asset.animation = frostbite2D::MakePtr<frostbite2D::Animation>(aniPath);
if (asset.animation && asset.animation->IsUsable()) {
asset.animation->SetTopLeftPosition(kSpriteOffsetX, kSpriteOffsetY);
asset.animation->SetVisible(false);
asset.animation->SetZOrder(zOrder);
return asset;
}
asset.animation = nullptr;
}
frostbite2D::NpkArchive& npk = frostbite2D::NpkArchive::get();
if (npk.isOpen() && npk.hasImg(npkImgPath)) {
asset.sprite = frostbite2D::Sprite::createFromNpk(npkImgPath, 0);
}
if (!asset.sprite && frostbite2D::Asset::get().exists(pngPath)) {
asset.sprite = frostbite2D::Sprite::createFromFile(pngPath);
}
ConfigurePlayerSpriteFallback(asset.sprite);
if (asset.sprite) {
asset.sprite->SetVisible(false);
asset.sprite->SetZOrder(zOrder);
}
return asset;
}
void ConfigurePlayerSpriteFallback(frostbite2D::Ptr<frostbite2D::Sprite> sprite) {
if (!sprite) {
return;
}
sprite->SetSize(config::kPlayerFrameSize, config::kPlayerFrameSize);
sprite->SetSourceRect(frostbite2D::Rect(0.0f, 0.0f,
config::kPlayerFrameSize,
config::kPlayerFrameSize));
sprite->SetTopLeftPosition(kSpriteOffsetX, kSpriteOffsetY);
}
} // namespace ns_game
+24
View File
@@ -0,0 +1,24 @@
#pragma once
#include "../core/game_config.h"
#include <frostbite2D/2d/sprite.h>
#include <frostbite2D/animation/animation.h>
#include <string>
namespace ns_game {
struct PlayerAnimationAsset {
frostbite2D::Ptr<frostbite2D::Animation> animation;
frostbite2D::Ptr<frostbite2D::Sprite> sprite;
bool usesEngineAnimation() const { return animation != nullptr; }
};
PlayerAnimationAsset LoadPlayerAnimationAsset(const std::string& aniPath,
const std::string& npkImgPath,
const std::string& pngPath,
int zOrder);
void ConfigurePlayerSpriteFallback(frostbite2D::Ptr<frostbite2D::Sprite> sprite);
} // namespace ns_game
+8
View File
@@ -9,4 +9,12 @@ constexpr const char* kMaleRun =
constexpr const char* kMalePunch = constexpr const char* kMalePunch =
"assets/character/male/Male_spritesheet_punch_1.png"; "assets/character/male/Male_spritesheet_punch_1.png";
constexpr const char* kMaleIdleAni = "character/male/idle.ani";
constexpr const char* kMaleRunAni = "character/male/run.ani";
constexpr const char* kMalePunchAni = "character/male/punch_1.ani";
constexpr const char* kMaleIdleImg = "sprite/character/male/idle.img";
constexpr const char* kMaleRunImg = "sprite/character/male/run.img";
constexpr const char* kMalePunchImg = "sprite/character/male/punch_1.img";
} // namespace ns_game::assets } // namespace ns_game::assets
+5
View File
@@ -3,9 +3,14 @@
namespace ns_game { namespace ns_game {
struct DebugFlags { struct DebugFlags {
bool enabled = true;
bool showStageGrid = true; bool showStageGrid = true;
bool showCollision = true; bool showCollision = true;
bool showActorBounds = true; bool showActorBounds = true;
bool showBattleZones = true;
bool showSpawnPoints = true;
bool showHitboxes = false;
bool showEnemies = true;
}; };
} // namespace ns_game } // namespace ns_game
+99
View File
@@ -0,0 +1,99 @@
#include "game_audio.h"
#include "game_services.h"
#include <frostbite2D/audio/audio_system.h>
#include <frostbite2D/resource/asset.h>
#include <frostbite2D/resource/audio_database.h>
#include <SDL2/SDL.h>
namespace ns_game {
GameAudio& GameAudio::Get() {
static GameAudio audio;
return audio;
}
void GameAudio::PlaySoundCue(const std::string& cueId) {
if (!frostbite2D::AudioSystem::get().isInitialized()) {
return;
}
const std::string path = ResolveAudioPath(cueId);
if (path.empty()) {
return;
}
auto cached = soundCache_.find(path);
if (cached == soundCache_.end()) {
frostbite2D::Ptr<frostbite2D::Sound> sound = LoadSound(path);
if (!sound) {
return;
}
cached = soundCache_.emplace(path, sound).first;
}
cached->second->play();
}
void GameAudio::PlayMusicCue(const std::string& cueId) {
if (!frostbite2D::AudioSystem::get().isInitialized()) {
return;
}
const std::string path = ResolveAudioPath(cueId);
if (path.empty() || frostbite2D::Music::isPathPlaying(path)) {
return;
}
frostbite2D::Ptr<frostbite2D::Music> music = LoadMusic(path);
if (!music) {
return;
}
currentMusic_ = music;
currentMusic_->fadeIn(250);
}
frostbite2D::Ptr<frostbite2D::Sound>
GameAudio::LoadSound(const std::string& path) const {
frostbite2D::Asset& asset = frostbite2D::Asset::get();
if (path.rfind("assets/", 0) == 0 && asset.exists(path)) {
return frostbite2D::Sound::loadFromFile(path);
}
return frostbite2D::Sound::loadFromPath(path);
}
frostbite2D::Ptr<frostbite2D::Music>
GameAudio::LoadMusic(const std::string& path) const {
frostbite2D::Asset& asset = frostbite2D::Asset::get();
if (path.rfind("assets/", 0) == 0 && asset.exists(path)) {
return frostbite2D::Music::loadFromFile(path);
}
return frostbite2D::Music::loadFromPath(path);
}
std::string GameAudio::ResolveAudioPath(const std::string& cueId) const {
frostbite2D::AudioDatabase& database = frostbite2D::AudioDatabase::get();
if (database.isLoaded()) {
if (auto path = database.getFilePath(cueId); path && !path->empty()) {
return *path;
}
}
frostbite2D::Asset& asset = frostbite2D::Asset::get();
const std::string soundPath = "assets/sounds/" + cueId + ".wav";
if (asset.exists(soundPath)) {
return soundPath;
}
const std::string musicPath = "assets/music/" + cueId + ".ogg";
if (asset.exists(musicPath)) {
return musicPath;
}
return {};
}
} // namespace ns_game
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include <frostbite2D/audio/music.h>
#include <frostbite2D/audio/sound.h>
#include <string>
#include <unordered_map>
namespace ns_game {
class GameAudio {
public:
static GameAudio& Get();
void PlaySoundCue(const std::string& cueId);
void PlayMusicCue(const std::string& cueId);
private:
GameAudio() = default;
frostbite2D::Ptr<frostbite2D::Sound> LoadSound(const std::string& path) const;
frostbite2D::Ptr<frostbite2D::Music> LoadMusic(const std::string& path) const;
std::string ResolveAudioPath(const std::string& cueId) const;
std::unordered_map<std::string, frostbite2D::Ptr<frostbite2D::Sound>>
soundCache_;
frostbite2D::Ptr<frostbite2D::Music> currentMusic_;
};
} // namespace ns_game
+70
View File
@@ -0,0 +1,70 @@
#include "game_save.h"
#include <frostbite2D/resource/save_system.h>
#include <SDL2/SDL.h>
#include <json/json.hpp>
namespace ns_game {
namespace {
constexpr int kDebugSlot = 0;
constexpr int kSchemaVersion = 1;
nlohmann::json toJson(const DebugFlags& flags) {
return {
{"schema", kSchemaVersion},
{"debug", {
{"enabled", flags.enabled},
{"showCollision", flags.showCollision},
{"showStageGrid", flags.showStageGrid},
{"showActorBounds", flags.showActorBounds},
{"showBattleZones", flags.showBattleZones},
{"showSpawnPoints", flags.showSpawnPoints},
}},
};
}
DebugFlags fromJson(const nlohmann::json& json) {
DebugFlags flags;
const nlohmann::json debug = json.value("debug", nlohmann::json::object());
flags.enabled = debug.value("enabled", flags.enabled);
flags.showCollision = debug.value("showCollision", flags.showCollision);
flags.showStageGrid = debug.value("showStageGrid", flags.showStageGrid);
flags.showActorBounds = debug.value("showActorBounds", flags.showActorBounds);
flags.showBattleZones =
debug.value("showBattleZones", flags.showBattleZones);
flags.showSpawnPoints =
debug.value("showSpawnPoints", flags.showSpawnPoints);
return flags;
}
} // namespace
GameSave& GameSave::Get() {
static GameSave save;
return save;
}
void GameSave::SaveDebugFlags(const DebugFlags& flags) {
frostbite2D::SaveMeta meta;
meta.displayName = "Debug Flags";
meta.userTag = "debug";
if (!frostbite2D::SaveSystem::get().saveSlot(kDebugSlot, toJson(flags),
meta)) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"GameSave: failed to save debug flags");
}
}
std::optional<DebugFlags> GameSave::LoadDebugFlags() const {
const std::optional<nlohmann::json> payload =
frostbite2D::SaveSystem::get().loadSlotPayload(kDebugSlot);
if (!payload || !payload->is_object()) {
return std::nullopt;
}
return fromJson(*payload);
}
} // namespace ns_game
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "../core/debug_flags.h"
#include <optional>
namespace ns_game {
class GameSave {
public:
static GameSave& Get();
void SaveDebugFlags(const DebugFlags& flags);
std::optional<DebugFlags> LoadDebugFlags() const;
private:
GameSave() = default;
};
} // namespace ns_game
+134
View File
@@ -0,0 +1,134 @@
#include "game_services.h"
#include <frostbite2D/audio/audio_system.h>
#include <frostbite2D/graphics/font_manager.h>
#include <frostbite2D/resource/asset.h>
#include <frostbite2D/resource/audio_database.h>
#include <frostbite2D/resource/npk_archive.h>
#include <frostbite2D/resource/pvf_archive.h>
#include <frostbite2D/resource/save_system.h>
#include <frostbite2D/resource/sound_pack_archive.h>
#include <SDL2/SDL.h>
namespace ns_game {
namespace {
bool assetExists(const char* path) {
return frostbite2D::Asset::get().exists(path);
}
const char* firstExistingPath(const char* primary, const char* fallback) {
if (assetExists(primary)) {
return primary;
}
if (fallback && assetExists(fallback)) {
return fallback;
}
return nullptr;
}
} // namespace
GameServices& GameServices::Get() {
static GameServices services;
return services;
}
void GameServices::Initialize() {
if (initialized_) {
return;
}
status_.saveInitialized = frostbite2D::SaveSystem::get().listSlots().size() > 0;
initializeAudio();
initializeFonts();
initializePvfArchive();
initializeNpkArchive();
initializeSoundPackArchive();
initializeAudioDatabase();
SDL_Log("GameServices: audio=%d save=%d font=%d debugfont=%d pvf=%d npk=%d soundpack=%d audiodb=%d",
status_.audioInitialized ? 1 : 0,
status_.saveInitialized ? 1 : 0,
status_.fontInitialized ? 1 : 0,
status_.debugFontLoaded ? 1 : 0,
status_.pvfArchiveOpen ? 1 : 0,
status_.npkArchiveOpen ? 1 : 0,
status_.soundPackOpen ? 1 : 0,
status_.audioDatabaseLoaded ? 1 : 0);
initialized_ = true;
}
void GameServices::initializeAudio() {
frostbite2D::AudioConfig config;
status_.audioInitialized = frostbite2D::AudioSystem::get().init(config);
}
void GameServices::initializeFonts() {
frostbite2D::FontManager& fontManager = frostbite2D::FontManager::get();
status_.fontInitialized = fontManager.init();
if (!status_.fontInitialized) {
return;
}
const char* debugFontPath =
firstExistingPath("assets/fonts/debug.ttf", "assets/fonts/ui.ttf");
if (!debugFontPath) {
SDL_Log("GameServices: optional debug font not found");
return;
}
status_.debugFontLoaded =
fontManager.registerFont("debug", debugFontPath, 18);
}
void GameServices::initializePvfArchive() {
const char* pvfPath = firstExistingPath("Script.pvf", "assets/Script.pvf");
if (!pvfPath) {
SDL_Log("GameServices: optional Script.pvf not found");
return;
}
auto& pvf = frostbite2D::PvfArchive::get();
if (pvf.open(pvfPath)) {
pvf.init();
status_.pvfArchiveOpen = pvf.isOpen();
}
}
void GameServices::initializeNpkArchive() {
auto& asset = frostbite2D::Asset::get();
auto& npk = frostbite2D::NpkArchive::get();
if (asset.isDirectory("assets/ImagePacks2")) {
npk.setImagePackDirectory("assets/ImagePacks2");
}
npk.init();
status_.npkArchiveOpen = npk.isOpen();
}
void GameServices::initializeSoundPackArchive() {
auto& asset = frostbite2D::Asset::get();
auto& soundPack = frostbite2D::SoundPackArchive::get();
if (asset.isDirectory("assets/SoundPacks")) {
soundPack.setSoundPackDirectory("assets/SoundPacks");
}
soundPack.init();
status_.soundPackOpen = soundPack.isOpen();
}
void GameServices::initializeAudioDatabase() {
const char* audioDbPath =
firstExistingPath("assets/audio/audio.xml", "assets/audio.xml");
if (!audioDbPath) {
SDL_Log("GameServices: optional audio database not found");
return;
}
status_.audioDatabaseLoaded =
frostbite2D::AudioDatabase::get().loadFromFile(audioDbPath);
}
} // namespace ns_game
+40
View File
@@ -0,0 +1,40 @@
#pragma once
namespace ns_game {
struct GameServiceStatus {
bool audioInitialized = false;
bool saveInitialized = false;
bool fontInitialized = false;
bool debugFontLoaded = false;
bool pvfArchiveOpen = false;
bool npkArchiveOpen = false;
bool soundPackOpen = false;
bool audioDatabaseLoaded = false;
};
class GameServices {
public:
static GameServices& Get();
GameServices(const GameServices&) = delete;
GameServices& operator=(const GameServices&) = delete;
void Initialize();
const GameServiceStatus& Status() const { return status_; }
private:
GameServices() = default;
void initializeAudio();
void initializeFonts();
void initializePvfArchive();
void initializeNpkArchive();
void initializeSoundPackArchive();
void initializeAudioDatabase();
GameServiceStatus status_;
bool initialized_ = false;
};
} // namespace ns_game
+111 -4
View File
@@ -1,34 +1,141 @@
#include "input_state.h" #include "input_state.h"
#include <SDL2/SDL.h> #include <SDL2/SDL.h>
#include <algorithm>
#ifdef __SWITCH__
#include <switch.h>
#endif
namespace ns_game { namespace ns_game {
namespace { namespace {
constexpr Sint16 kControllerAxisDeadZone = 10000;
bool isDown(const Uint8* keys, SDL_Scancode key) { bool isDown(const Uint8* keys, SDL_Scancode key) {
return keys && keys[key] != 0; return keys && keys[key] != 0;
} }
bool controllerButtonDown(SDL_GameController* controller,
SDL_GameControllerButton button) {
return controller &&
SDL_GameControllerGetButton(controller, button) == SDL_PRESSED;
}
Sint16 controllerAxis(SDL_GameController* controller,
SDL_GameControllerAxis axis) {
if (!controller) {
return 0;
}
return SDL_GameControllerGetAxis(controller, axis);
}
#ifdef __SWITCH__
PadState& switchPad() {
static PadState pad;
static bool initialized = false;
if (!initialized) {
padInitializeDefault(&pad);
initialized = true;
}
return pad;
}
#endif
} // namespace } // namespace
InputStateProvider::InputStateProvider() {
ensureController();
}
InputStateProvider::~InputStateProvider() {
if (controller_) {
SDL_GameControllerClose(controller_);
controller_ = nullptr;
}
}
void InputStateProvider::ensureController() {
if (controller_) {
return;
}
const int joystickCount = SDL_NumJoysticks();
for (int i = 0; i < joystickCount; ++i) {
if (!SDL_IsGameController(i)) {
continue;
}
controller_ = SDL_GameControllerOpen(i);
if (controller_) {
SDL_Log("InputStateProvider opened controller: %s",
SDL_GameControllerName(controller_));
return;
}
}
}
InputState InputStateProvider::Sample() { InputState InputStateProvider::Sample() {
ensureController();
const Uint8* keys = SDL_GetKeyboardState(nullptr); const Uint8* keys = SDL_GetKeyboardState(nullptr);
InputState state; InputState state;
bool moveLeftHeld = false;
bool moveRightHeld = false;
if (isDown(keys, SDL_SCANCODE_A) || isDown(keys, SDL_SCANCODE_LEFT)) { if (isDown(keys, SDL_SCANCODE_A) || isDown(keys, SDL_SCANCODE_LEFT)) {
state.moveX -= 1.0f; moveLeftHeld = true;
} }
if (isDown(keys, SDL_SCANCODE_D) || isDown(keys, SDL_SCANCODE_RIGHT)) { if (isDown(keys, SDL_SCANCODE_D) || isDown(keys, SDL_SCANCODE_RIGHT)) {
state.moveX += 1.0f; moveRightHeld = true;
} }
const bool jumpHeld = bool jumpHeld =
isDown(keys, SDL_SCANCODE_SPACE) || isDown(keys, SDL_SCANCODE_W) || isDown(keys, SDL_SCANCODE_SPACE) || isDown(keys, SDL_SCANCODE_W) ||
isDown(keys, SDL_SCANCODE_UP); isDown(keys, SDL_SCANCODE_UP);
const bool attackHeld = bool attackHeld =
isDown(keys, SDL_SCANCODE_J) || isDown(keys, SDL_SCANCODE_K); isDown(keys, SDL_SCANCODE_J) || isDown(keys, SDL_SCANCODE_K);
if (controller_) {
const Sint16 leftX =
controllerAxis(controller_, SDL_CONTROLLER_AXIS_LEFTX);
moveLeftHeld = moveLeftHeld ||
leftX < -kControllerAxisDeadZone ||
controllerButtonDown(controller_,
SDL_CONTROLLER_BUTTON_DPAD_LEFT);
moveRightHeld = moveRightHeld ||
leftX > kControllerAxisDeadZone ||
controllerButtonDown(controller_,
SDL_CONTROLLER_BUTTON_DPAD_RIGHT);
jumpHeld = jumpHeld ||
controllerButtonDown(controller_, SDL_CONTROLLER_BUTTON_A) ||
controllerButtonDown(controller_, SDL_CONTROLLER_BUTTON_B);
attackHeld = attackHeld ||
controllerButtonDown(controller_, SDL_CONTROLLER_BUTTON_X) ||
controllerButtonDown(controller_, SDL_CONTROLLER_BUTTON_Y);
}
#ifdef __SWITCH__
PadState& pad = switchPad();
padUpdate(&pad);
const u64 buttons = padGetButtons(&pad);
moveLeftHeld = moveLeftHeld || (buttons & HidNpadButton_AnyLeft) != 0;
moveRightHeld = moveRightHeld || (buttons & HidNpadButton_AnyRight) != 0;
jumpHeld = jumpHeld ||
(buttons & (HidNpadButton_A | HidNpadButton_B)) != 0;
attackHeld = attackHeld ||
(buttons & (HidNpadButton_X | HidNpadButton_Y)) != 0;
#endif
if (moveLeftHeld) {
state.moveX -= 1.0f;
}
if (moveRightHeld) {
state.moveX += 1.0f;
}
state.moveX = std::clamp(state.moveX, -1.0f, 1.0f);
state.jumpPressed = jumpHeld && !jumpHeldLastFrame_; state.jumpPressed = jumpHeld && !jumpHeldLastFrame_;
state.attackPressed = attackHeld && !attackHeldLastFrame_; state.attackPressed = attackHeld && !attackHeldLastFrame_;
+11
View File
@@ -1,5 +1,7 @@
#pragma once #pragma once
#include <SDL2/SDL_gamecontroller.h>
namespace ns_game { namespace ns_game {
struct InputState { struct InputState {
@@ -10,9 +12,18 @@ struct InputState {
class InputStateProvider { class InputStateProvider {
public: public:
InputStateProvider();
~InputStateProvider();
InputStateProvider(const InputStateProvider&) = delete;
InputStateProvider& operator=(const InputStateProvider&) = delete;
InputState Sample(); InputState Sample();
private: private:
void ensureController();
SDL_GameController* controller_ = nullptr;
bool jumpHeldLastFrame_ = false; bool jumpHeldLastFrame_ = false;
bool attackHeldLastFrame_ = false; bool attackHeldLastFrame_ = false;
}; };
+74 -2
View File
@@ -3,18 +3,90 @@
#include "../movement/platform_world.h" #include "../movement/platform_world.h"
#include <frostbite2D/types/type_color.h> #include <frostbite2D/types/type_color.h>
#include <frostbite2D/types/type_math.h> #include <frostbite2D/types/type_math.h>
#include <string>
#include <vector> #include <vector>
namespace ns_game { namespace ns_game {
struct WhiteboxProp { enum class StageLayerRole {
BackgroundFar,
BackgroundMid,
LevelVisual,
PropsBack,
PropsFront
};
struct StageLayerSource {
std::string texturePath;
frostbite2D::Rect sourceRect;
bool useSourceRect = false;
frostbite2D::Color tint = frostbite2D::Color(1.0f, 1.0f, 1.0f, 1.0f);
bool hasTexture() const { return !texturePath.empty(); }
};
struct StageRect {
frostbite2D::Rect bounds; frostbite2D::Rect bounds;
frostbite2D::Color color; frostbite2D::Color color;
StageLayerRole layer = StageLayerRole::PropsBack;
StageLayerSource source;
};
struct TileSetDefinition {
std::string id;
std::string texturePath;
float tileWidth = 0.0f;
float tileHeight = 0.0f;
frostbite2D::Color fallbackColor;
};
struct StageTile {
std::string tileSetId;
frostbite2D::Rect bounds;
frostbite2D::Rect sourceRect;
frostbite2D::Color fallbackColor;
};
struct SpawnPoint {
std::string id;
frostbite2D::Vec2 position;
};
struct BattleZoneDefinition {
std::string id;
frostbite2D::Rect trigger;
frostbite2D::Rect cameraBounds;
std::vector<SpawnPoint> enemySpawns;
};
enum class BattleZoneDebugState {
Idle,
Active,
Cleared
};
struct BattleZoneDebugInfo {
size_t zoneIndex = 0;
BattleZoneDebugState state = BattleZoneDebugState::Idle;
};
struct StageLayer {
std::string id;
StageLayerRole role = StageLayerRole::LevelVisual;
float parallax = 1.0f;
std::vector<StageRect> rects;
std::vector<StageTile> tiles;
}; };
struct LevelDefinition { struct LevelDefinition {
float worldWidth = 0.0f;
float worldHeight = 0.0f;
frostbite2D::Vec2 playerSpawn;
frostbite2D::Rect cameraBounds;
PlatformWorld collision; PlatformWorld collision;
std::vector<WhiteboxProp> props; std::vector<TileSetDefinition> tileSets;
std::vector<StageLayer> layers;
std::vector<BattleZoneDefinition> battleZones;
}; };
LevelDefinition CreateWhiteboxLevel(); LevelDefinition CreateWhiteboxLevel();
+18
View File
@@ -0,0 +1,18 @@
#pragma once
namespace ns_game::stage_assets::stage_01 {
constexpr const char* kBackgroundFar =
"assets/stage/stage_01/background/background_far.png";
constexpr const char* kBackgroundMid =
"assets/stage/stage_01/background/background_mid.png";
constexpr const char* kTileset =
"assets/stage/stage_01/tiles/stage_01_tileset.png";
constexpr const char* kProps =
"assets/stage/stage_01/props/stage_01_props.png";
constexpr const char* kHitSpark =
"assets/stage/stage_01/effects/hit_spark.png";
constexpr const char* kDust =
"assets/stage/stage_01/effects/dust.png";
} // namespace ns_game::stage_assets::stage_01
+487
View File
@@ -0,0 +1,487 @@
#include "stage_map_loader.h"
#include "stage_map_resource.h"
#include <SDL2/SDL.h>
#include <charconv>
#include <cmath>
#include <sstream>
namespace ns_game {
namespace {
struct ParseContext {
LevelDefinition level;
StageLayer* currentLayer = nullptr;
BattleZoneDefinition* currentBattleZone = nullptr;
bool hasMap = false;
bool hasWorld = false;
bool hasCamera = false;
bool hasPlayerSpawn = false;
};
std::optional<float> parseFloat(const std::string& value) {
float result = 0.0f;
const char* begin = value.data();
const char* end = value.data() + value.size();
const auto parsed = std::from_chars(begin, end, result);
if (parsed.ec != std::errc() || parsed.ptr != end) {
return std::nullopt;
}
return result;
}
std::optional<frostbite2D::Rect>
parseRect(const std::vector<std::string>& tokens, size_t start) {
if (tokens.size() < start + 4) {
return std::nullopt;
}
const auto x = parseFloat(tokens[start]);
const auto y = parseFloat(tokens[start + 1]);
const auto w = parseFloat(tokens[start + 2]);
const auto h = parseFloat(tokens[start + 3]);
if (!x || !y || !w || !h) {
return std::nullopt;
}
return frostbite2D::Rect(*x, *y, *w, *h);
}
std::optional<frostbite2D::Color>
parseColor(const std::vector<std::string>& tokens, size_t start) {
if (tokens.size() < start + 4) {
return std::nullopt;
}
const auto r = parseFloat(tokens[start]);
const auto g = parseFloat(tokens[start + 1]);
const auto b = parseFloat(tokens[start + 2]);
const auto a = parseFloat(tokens[start + 3]);
if (!r || !g || !b || !a) {
return std::nullopt;
}
return frostbite2D::Color(*r, *g, *b, *a);
}
std::optional<TileSetDefinition*>
findTileSet(ParseContext& context, const std::string& id) {
for (TileSetDefinition& tileSet : context.level.tileSets) {
if (tileSet.id == id) {
return &tileSet;
}
}
return std::nullopt;
}
std::optional<StageLayerRole> parseLayerRole(const std::string& value) {
if (value == "BackgroundFar") {
return StageLayerRole::BackgroundFar;
}
if (value == "BackgroundMid") {
return StageLayerRole::BackgroundMid;
}
if (value == "LevelVisual") {
return StageLayerRole::LevelVisual;
}
if (value == "PropsBack") {
return StageLayerRole::PropsBack;
}
if (value == "PropsFront") {
return StageLayerRole::PropsFront;
}
return std::nullopt;
}
std::vector<std::string> tokenize(const std::string& line) {
std::istringstream stream(line);
std::vector<std::string> tokens;
std::string token;
while (stream >> token) {
if (token.rfind("#", 0) == 0) {
break;
}
tokens.push_back(token);
}
return tokens;
}
bool warnLine(const std::string& path, size_t lineNumber,
const std::string& message) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION, "StageMapLoader: %s:%zu: %s",
path.c_str(), lineNumber, message.c_str());
return false;
}
bool ensureNoOpenBlock(const ParseContext& context, const std::string& path,
size_t lineNumber) {
if (context.currentLayer) {
return warnLine(path, lineNumber,
"expected endlayer before starting this block");
}
if (context.currentBattleZone) {
return warnLine(path, lineNumber,
"expected endbattle_zone before starting this block");
}
return true;
}
bool parseLine(ParseContext& context, const std::string& path,
size_t lineNumber, const std::vector<std::string>& tokens) {
const std::string& command = tokens[0];
if (command == "map") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 2) {
return warnLine(path, lineNumber, "expected: map <id>");
}
context.hasMap = true;
return true;
}
if (command == "world") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 3) {
return warnLine(path, lineNumber, "expected: world <width> <height>");
}
const auto width = parseFloat(tokens[1]);
const auto height = parseFloat(tokens[2]);
if (!width || !height || *width <= 0.0f || *height <= 0.0f) {
return warnLine(path, lineNumber, "invalid world size");
}
context.level.worldWidth = *width;
context.level.worldHeight = *height;
context.level.collision.levelLeft = 0.0f;
context.level.collision.levelRight = *width;
context.hasWorld = true;
return true;
}
if (command == "camera") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 5) {
return warnLine(path, lineNumber,
"expected: camera <x> <y> <width> <height>");
}
const auto rect = parseRect(tokens, 1);
if (!rect) {
return warnLine(path, lineNumber, "invalid camera rect");
}
context.level.cameraBounds = *rect;
context.hasCamera = true;
return true;
}
if (command == "player_spawn") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 3) {
return warnLine(path, lineNumber, "expected: player_spawn <x> <y>");
}
const auto x = parseFloat(tokens[1]);
const auto y = parseFloat(tokens[2]);
if (!x || !y) {
return warnLine(path, lineNumber, "invalid player spawn");
}
context.level.playerSpawn = frostbite2D::Vec2(*x, *y);
context.hasPlayerSpawn = true;
return true;
}
if (command == "collision") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 5) {
return warnLine(path, lineNumber,
"expected: collision <x> <y> <width> <height>");
}
const auto rect = parseRect(tokens, 1);
if (!rect) {
return warnLine(path, lineNumber, "invalid collision rect");
}
context.level.collision.platforms.push_back(*rect);
return true;
}
if (command == "tileset") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 9) {
return warnLine(path, lineNumber,
"expected: tileset <id> <texture> <tile_w> <tile_h> <r> <g> <b> <a>");
}
if (findTileSet(context, tokens[1])) {
return warnLine(path, lineNumber, "duplicate tileset id: " + tokens[1]);
}
const auto tileWidth = parseFloat(tokens[3]);
const auto tileHeight = parseFloat(tokens[4]);
const auto fallbackColor = parseColor(tokens, 5);
if (!tileWidth || !tileHeight || *tileWidth <= 0.0f ||
*tileHeight <= 0.0f || !fallbackColor) {
return warnLine(path, lineNumber, "invalid tileset definition");
}
context.level.tileSets.push_back(
{tokens[1], tokens[2], *tileWidth, *tileHeight, *fallbackColor});
return true;
}
if (command == "layer") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 4) {
return warnLine(path, lineNumber,
"expected: layer <id> <role> <parallax>");
}
const auto role = parseLayerRole(tokens[2]);
const auto parallax = parseFloat(tokens[3]);
if (!role || !parallax) {
return warnLine(path, lineNumber, "invalid layer role or parallax");
}
context.level.layers.push_back({tokens[1], *role, *parallax, {}});
context.currentLayer = &context.level.layers.back();
return true;
}
if (command == "endlayer") {
if (!context.currentLayer || tokens.size() != 1) {
return warnLine(path, lineNumber, "unexpected endlayer");
}
context.currentLayer = nullptr;
return true;
}
if (command == "rect") {
if (!context.currentLayer || tokens.size() != 9) {
return warnLine(path, lineNumber,
"expected in layer: rect <x> <y> <w> <h> <r> <g> <b> <a>");
}
const auto rect = parseRect(tokens, 1);
const auto color = parseColor(tokens, 5);
if (!rect || !color) {
return warnLine(path, lineNumber, "invalid layer rect");
}
context.currentLayer->rects.push_back(
{*rect, *color, context.currentLayer->role, {}});
return true;
}
if (command == "tile_rect") {
if (!context.currentLayer || tokens.size() != 10) {
return warnLine(path, lineNumber,
"expected in layer: tile_rect <tileset> <x> <y> <columns> <rows> <tile_index> <r> <g> <b>");
}
const auto tileSet = findTileSet(context, tokens[1]);
if (!tileSet) {
return warnLine(path, lineNumber, "unknown tileset id: " + tokens[1]);
}
const auto x = parseFloat(tokens[2]);
const auto y = parseFloat(tokens[3]);
const auto columnsFloat = parseFloat(tokens[4]);
const auto rowsFloat = parseFloat(tokens[5]);
const auto tileIndexFloat = parseFloat(tokens[6]);
const auto r = parseFloat(tokens[7]);
const auto g = parseFloat(tokens[8]);
const auto b = parseFloat(tokens[9]);
if (!x || !y || !columnsFloat || !rowsFloat || !tileIndexFloat || !r ||
!g || !b) {
return warnLine(path, lineNumber, "invalid tile_rect values");
}
const int columns = static_cast<int>(*columnsFloat);
const int rows = static_cast<int>(*rowsFloat);
const int tileIndex = static_cast<int>(*tileIndexFloat);
if (columns <= 0 || rows <= 0 || tileIndex < 0 ||
std::floor(*columnsFloat) != *columnsFloat ||
std::floor(*rowsFloat) != *rowsFloat ||
std::floor(*tileIndexFloat) != *tileIndexFloat) {
return warnLine(path, lineNumber,
"tile_rect columns, rows and tile_index must be non-negative integers");
}
const TileSetDefinition& definition = **tileSet;
const frostbite2D::Color fallbackColor(*r, *g, *b, 1.0f);
for (int row = 0; row < rows; ++row) {
for (int column = 0; column < columns; ++column) {
const frostbite2D::Rect bounds(
*x + static_cast<float>(column) * definition.tileWidth,
*y + static_cast<float>(row) * definition.tileHeight,
definition.tileWidth, definition.tileHeight);
const frostbite2D::Rect sourceRect(
static_cast<float>(tileIndex) * definition.tileWidth, 0.0f,
definition.tileWidth, definition.tileHeight);
context.currentLayer->tiles.push_back(
{definition.id, bounds, sourceRect, fallbackColor});
}
}
return true;
}
if (command == "rect_sprite" || command == "rect_sprite_src") {
if (!context.currentLayer) {
return warnLine(path, lineNumber,
"sprite rect must be declared inside a layer");
}
const bool hasSourceRect = command == "rect_sprite_src";
if ((!hasSourceRect && tokens.size() != 10) ||
(hasSourceRect && tokens.size() != 14)) {
return warnLine(path, lineNumber,
hasSourceRect
? "expected: rect_sprite_src <x> <y> <w> <h> <r> <g> <b> <a> <texture> <sx> <sy> <sw> <sh>"
: "expected: rect_sprite <x> <y> <w> <h> <r> <g> <b> <a> <texture>");
}
const auto rect = parseRect(tokens, 1);
const auto color = parseColor(tokens, 5);
if (!rect || !color) {
return warnLine(path, lineNumber, "invalid sprite rect");
}
StageLayerSource source;
source.texturePath = tokens[9];
source.tint = *color;
if (hasSourceRect) {
const auto sourceRect = parseRect(tokens, 10);
if (!sourceRect) {
return warnLine(path, lineNumber, "invalid sprite source rect");
}
source.sourceRect = *sourceRect;
source.useSourceRect = true;
}
context.currentLayer->rects.push_back(
{*rect, *color, context.currentLayer->role, source});
return true;
}
if (command == "battle_zone") {
if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 10) {
return warnLine(path, lineNumber,
"expected: battle_zone <id> <tx> <ty> <tw> <th> <cx> <cy> <cw> <ch>");
}
const auto trigger = parseRect(tokens, 2);
const auto camera = parseRect(tokens, 6);
if (!trigger || !camera) {
return warnLine(path, lineNumber, "invalid battle zone rect");
}
context.level.battleZones.push_back({tokens[1], *trigger, *camera, {}});
context.currentBattleZone = &context.level.battleZones.back();
return true;
}
if (command == "spawn") {
if (!context.currentBattleZone || tokens.size() != 4) {
return warnLine(path, lineNumber,
"expected in battle_zone: spawn <id> <x> <y>");
}
const auto x = parseFloat(tokens[2]);
const auto y = parseFloat(tokens[3]);
if (!x || !y) {
return warnLine(path, lineNumber, "invalid spawn point");
}
context.currentBattleZone->enemySpawns.push_back(
{tokens[1], frostbite2D::Vec2(*x, *y)});
return true;
}
if (command == "endbattle_zone") {
if (!context.currentBattleZone || tokens.size() != 1) {
return warnLine(path, lineNumber, "unexpected endbattle_zone");
}
context.currentBattleZone = nullptr;
return true;
}
return warnLine(path, lineNumber, "unknown command: " + command);
}
std::optional<LevelDefinition> parseMapText(const StageMapResource& resource) {
ParseContext context;
std::istringstream stream(resource.text);
std::string line;
size_t lineNumber = 0;
while (std::getline(stream, line)) {
++lineNumber;
const std::vector<std::string> tokens = tokenize(line);
if (tokens.empty()) {
continue;
}
if (!parseLine(context, resource.path, lineNumber, tokens)) {
return std::nullopt;
}
}
if (context.currentLayer) {
warnLine(resource.path, lineNumber, "missing endlayer at end of file");
return std::nullopt;
}
if (context.currentBattleZone) {
warnLine(resource.path, lineNumber,
"missing endbattle_zone at end of file");
return std::nullopt;
}
if (!context.hasMap || !context.hasWorld || !context.hasPlayerSpawn) {
warnLine(resource.path, lineNumber,
"map requires map, world and player_spawn commands");
return std::nullopt;
}
if (!context.hasCamera) {
context.level.cameraBounds = frostbite2D::Rect(
0.0f, 0.0f, context.level.worldWidth, context.level.worldHeight);
}
if (context.level.collision.platforms.empty()) {
warnLine(resource.path, lineNumber, "map has no collision platforms");
return std::nullopt;
}
if (context.level.layers.empty()) {
warnLine(resource.path, lineNumber, "map has no visual layers");
return std::nullopt;
}
return context.level;
}
const char* sourceName(StageMapResourceSource source) {
switch (source) {
case StageMapResourceSource::AssetText:
return "asset";
case StageMapResourceSource::PvfText:
return "pvf-text";
case StageMapResourceSource::PvfBinaryScript:
return "pvf-script";
case StageMapResourceSource::None:
return "none";
}
return "unknown";
}
} // namespace
std::optional<LevelDefinition> LoadStageMap(const std::string& path) {
const std::optional<StageMapResource> resource = LoadStageMapResource(path);
if (!resource) {
return std::nullopt;
}
if (resource->text.empty()) {
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"StageMapLoader: %s loaded from %s but has no text map body",
resource->path.c_str(), sourceName(resource->source));
return std::nullopt;
}
std::optional<LevelDefinition> level = parseMapText(*resource);
if (!level) {
return std::nullopt;
}
size_t tileCount = 0;
for (const StageLayer& layer : level->layers) {
tileCount += layer.tiles.size();
}
SDL_Log("StageMapLoader: loaded %s from %s (%zu platforms, %zu layers, %zu tiles, %zu zones)",
resource->path.c_str(), sourceName(resource->source),
level->collision.platforms.size(), level->layers.size(), tileCount,
level->battleZones.size());
return level;
}
} // namespace ns_game
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "level_definition.h"
#include <optional>
#include <string>
namespace ns_game {
std::optional<LevelDefinition> LoadStageMap(const std::string& path);
} // namespace ns_game
+93
View File
@@ -0,0 +1,93 @@
#include "stage_map_resource.h"
#include <frostbite2D/resource/asset.h>
#include <frostbite2D/resource/pvf_archive.h>
#include <frostbite2D/resource/script_parser.h>
#include <SDL2/SDL.h>
namespace ns_game {
namespace {
std::string assetFallbackPath(const std::string& path) {
if (path.rfind("assets/", 0) == 0) {
return path;
}
return "assets/" + path;
}
bool isLikelyPvfScript(const frostbite2D::RawData& rawData) {
if (!rawData.data || rawData.size < 7) {
return false;
}
const auto opcode = static_cast<unsigned char>(rawData.data[2]);
switch (static_cast<frostbite2D::ScriptOpcode>(opcode)) {
case frostbite2D::ScriptOpcode::Integer:
case frostbite2D::ScriptOpcode::Float:
case frostbite2D::ScriptOpcode::StringRef5:
case frostbite2D::ScriptOpcode::StringRef6:
case frostbite2D::ScriptOpcode::StringRef7:
case frostbite2D::ScriptOpcode::StringRef8:
case frostbite2D::ScriptOpcode::ExtendedString9:
case frostbite2D::ScriptOpcode::ExtendedString10:
return true;
default:
return false;
}
}
} // namespace
std::optional<StageMapResource> LoadStageMapResource(const std::string& path) {
frostbite2D::Asset& asset = frostbite2D::Asset::get();
if (auto content = asset.readFileToString(path)) {
StageMapResource resource;
resource.source = StageMapResourceSource::AssetText;
resource.path = path;
resource.text = *content;
return resource;
}
const std::string fallbackPath = assetFallbackPath(path);
if (fallbackPath != path) {
if (auto content = asset.readFileToString(fallbackPath)) {
StageMapResource resource;
resource.source = StageMapResourceSource::AssetText;
resource.path = fallbackPath;
resource.text = *content;
return resource;
}
}
frostbite2D::PvfArchive& pvf = frostbite2D::PvfArchive::get();
if (pvf.isOpen()) {
if (auto rawData = pvf.getFileRawData(path);
rawData && isLikelyPvfScript(*rawData)) {
frostbite2D::ScriptParser parser(*rawData, path);
if (parser.isValid()) {
StageMapResource resource;
resource.source = StageMapResourceSource::PvfBinaryScript;
resource.path = path;
for (const frostbite2D::ScriptValue& value : parser.parseAll()) {
resource.scriptValues.push_back(value.toString());
}
return resource;
}
}
if (auto content = pvf.getFileContent(path)) {
StageMapResource resource;
resource.source = StageMapResourceSource::PvfText;
resource.path = path;
resource.text = *content;
return resource;
}
}
SDL_Log("StageMapResource: map not found: %s", path.c_str());
return std::nullopt;
}
} // namespace ns_game
+29
View File
@@ -0,0 +1,29 @@
#pragma once
#include <optional>
#include <string>
#include <vector>
namespace ns_game {
enum class StageMapResourceSource {
None,
AssetText,
PvfText,
PvfBinaryScript
};
struct StageMapResource {
StageMapResourceSource source = StageMapResourceSource::None;
std::string path;
std::string text;
std::vector<std::string> scriptValues;
bool hasContent() const {
return !text.empty() || !scriptValues.empty();
}
};
std::optional<StageMapResource> LoadStageMapResource(const std::string& path);
} // namespace ns_game
+134 -8
View File
@@ -1,27 +1,153 @@
#include "level_definition.h" #include "level_definition.h"
#include "stage_map_loader.h"
#include <SDL2/SDL.h>
namespace ns_game { namespace ns_game {
LevelDefinition CreateWhiteboxLevel() { namespace {
LevelDefinition CreateFallbackWhiteboxLevel() {
LevelDefinition level; LevelDefinition level;
level.worldWidth = 3200.0f;
level.worldHeight = 720.0f;
level.playerSpawn = frostbite2D::Vec2(160.0f, 380.0f);
level.cameraBounds = frostbite2D::Rect(0.0f, 0.0f, level.worldWidth,
level.worldHeight);
level.collision.levelLeft = 0.0f; level.collision.levelLeft = 0.0f;
level.collision.levelRight = 1600.0f; level.collision.levelRight = level.worldWidth;
level.collision.platforms = { level.collision.platforms = {
frostbite2D::Rect(0.0f, 600.0f, 1600.0f, 80.0f), frostbite2D::Rect(0.0f, 600.0f, 3200.0f, 80.0f),
frostbite2D::Rect(230.0f, 500.0f, 220.0f, 28.0f), frostbite2D::Rect(230.0f, 500.0f, 220.0f, 28.0f),
frostbite2D::Rect(560.0f, 430.0f, 260.0f, 28.0f), frostbite2D::Rect(560.0f, 430.0f, 260.0f, 28.0f),
frostbite2D::Rect(930.0f, 510.0f, 260.0f, 28.0f), frostbite2D::Rect(930.0f, 510.0f, 260.0f, 28.0f),
frostbite2D::Rect(1250.0f, 420.0f, 230.0f, 28.0f), frostbite2D::Rect(1250.0f, 420.0f, 230.0f, 28.0f),
frostbite2D::Rect(1760.0f, 500.0f, 300.0f, 28.0f),
frostbite2D::Rect(2180.0f, 455.0f, 260.0f, 28.0f),
frostbite2D::Rect(2650.0f, 515.0f, 360.0f, 28.0f),
}; };
level.props = { level.layers = {
{frostbite2D::Rect(118.0f, 392.0f, 88.0f, 208.0f), {"background_far",
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f)}, StageLayerRole::BackgroundFar,
0.25f,
{{frostbite2D::Rect(0.0f, 0.0f, 3200.0f, 720.0f),
frostbite2D::Color(0.08f, 0.10f, 0.14f, 1.0f),
StageLayerRole::BackgroundFar},
{frostbite2D::Rect(360.0f, 120.0f, 420.0f, 220.0f),
frostbite2D::Color(0.13f, 0.16f, 0.21f, 1.0f),
StageLayerRole::BackgroundFar},
{frostbite2D::Rect(1380.0f, 90.0f, 560.0f, 260.0f),
frostbite2D::Color(0.12f, 0.15f, 0.20f, 1.0f),
StageLayerRole::BackgroundFar},
{frostbite2D::Rect(2440.0f, 140.0f, 520.0f, 210.0f),
frostbite2D::Color(0.13f, 0.16f, 0.21f, 1.0f),
StageLayerRole::BackgroundFar}}},
{"background_mid",
StageLayerRole::BackgroundMid,
0.55f,
{{frostbite2D::Rect(80.0f, 360.0f, 280.0f, 240.0f),
frostbite2D::Color(0.18f, 0.22f, 0.27f, 1.0f),
StageLayerRole::BackgroundMid},
{frostbite2D::Rect(1020.0f, 310.0f, 340.0f, 290.0f),
frostbite2D::Color(0.18f, 0.22f, 0.27f, 1.0f),
StageLayerRole::BackgroundMid},
{frostbite2D::Rect(2020.0f, 330.0f, 360.0f, 270.0f),
frostbite2D::Color(0.18f, 0.22f, 0.27f, 1.0f),
StageLayerRole::BackgroundMid},
{frostbite2D::Rect(2860.0f, 280.0f, 260.0f, 320.0f),
frostbite2D::Color(0.18f, 0.22f, 0.27f, 1.0f),
StageLayerRole::BackgroundMid}}},
{"level_visual",
StageLayerRole::LevelVisual,
1.0f,
{{frostbite2D::Rect(0.0f, 600.0f, 3200.0f, 80.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual},
{frostbite2D::Rect(230.0f, 500.0f, 220.0f, 28.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual},
{frostbite2D::Rect(560.0f, 430.0f, 260.0f, 28.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual},
{frostbite2D::Rect(930.0f, 510.0f, 260.0f, 28.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual},
{frostbite2D::Rect(1250.0f, 420.0f, 230.0f, 28.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual},
{frostbite2D::Rect(1760.0f, 500.0f, 300.0f, 28.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual},
{frostbite2D::Rect(2180.0f, 455.0f, 260.0f, 28.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual},
{frostbite2D::Rect(2650.0f, 515.0f, 360.0f, 28.0f),
frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f),
StageLayerRole::LevelVisual}}},
{"props_back",
StageLayerRole::PropsBack,
1.0f,
{{frostbite2D::Rect(118.0f, 392.0f, 88.0f, 208.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f),
StageLayerRole::PropsBack},
{frostbite2D::Rect(780.0f, 250.0f, 72.0f, 180.0f), {frostbite2D::Rect(780.0f, 250.0f, 72.0f, 180.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f)}, frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f),
StageLayerRole::PropsBack},
{frostbite2D::Rect(1440.0f, 300.0f, 80.0f, 300.0f), {frostbite2D::Rect(1440.0f, 300.0f, 80.0f, 300.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f)}, frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f),
StageLayerRole::PropsBack},
{frostbite2D::Rect(1880.0f, 360.0f, 110.0f, 240.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f),
StageLayerRole::PropsBack},
{frostbite2D::Rect(2520.0f, 390.0f, 92.0f, 210.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f),
StageLayerRole::PropsBack},
{frostbite2D::Rect(3040.0f, 315.0f, 82.0f, 285.0f),
frostbite2D::Color(0.26f, 0.30f, 0.34f, 1.0f),
StageLayerRole::PropsBack}}},
{"props_front",
StageLayerRole::PropsFront,
1.0f,
{{frostbite2D::Rect(520.0f, 580.0f, 220.0f, 34.0f),
frostbite2D::Color(0.14f, 0.17f, 0.18f, 0.90f),
StageLayerRole::PropsFront},
{frostbite2D::Rect(1510.0f, 570.0f, 280.0f, 42.0f),
frostbite2D::Color(0.14f, 0.17f, 0.18f, 0.90f),
StageLayerRole::PropsFront},
{frostbite2D::Rect(2580.0f, 572.0f, 260.0f, 38.0f),
frostbite2D::Color(0.14f, 0.17f, 0.18f, 0.90f),
StageLayerRole::PropsFront}}},
};
level.battleZones = {
{"zone_01_intro",
frostbite2D::Rect(520.0f, 260.0f, 560.0f, 340.0f),
frostbite2D::Rect(0.0f, 0.0f, 1940.0f, 720.0f),
{{"zone_01_enemy_a", frostbite2D::Vec2(760.0f, 560.0f)},
{"zone_01_enemy_b", frostbite2D::Vec2(980.0f, 560.0f)}}},
{"zone_02_platform",
frostbite2D::Rect(1420.0f, 250.0f, 680.0f, 350.0f),
frostbite2D::Rect(660.0f, 0.0f, 2300.0f, 720.0f),
{{"zone_02_enemy_a", frostbite2D::Vec2(1620.0f, 560.0f)},
{"zone_02_enemy_b", frostbite2D::Vec2(1900.0f, 560.0f)}}},
{"zone_03_exit",
frostbite2D::Rect(2380.0f, 250.0f, 720.0f, 350.0f),
frostbite2D::Rect(1600.0f, 0.0f, 1600.0f, 720.0f),
{{"zone_03_enemy_a", frostbite2D::Vec2(2580.0f, 560.0f)},
{"zone_03_enemy_b", frostbite2D::Vec2(2860.0f, 560.0f)}}},
}; };
return level; return level;
} }
} // namespace
LevelDefinition CreateWhiteboxLevel() {
if (auto loadedLevel = LoadStageMap("map/stage_01.map")) {
return *loadedLevel;
}
SDL_LogWarn(SDL_LOG_CATEGORY_APPLICATION,
"CreateWhiteboxLevel: using C++ fallback level");
return CreateFallbackWhiteboxLevel();
}
} // namespace ns_game } // namespace ns_game
+72 -1
View File
@@ -24,13 +24,64 @@ void drawOutline(const frostbite2D::Rect& rect, const frostbite2D::Color& color,
color); color);
} }
void drawCross(const frostbite2D::Vec2& position,
const frostbite2D::Color& color) {
constexpr float kSize = 18.0f;
constexpr float kThickness = 3.0f;
drawRect(frostbite2D::Rect(position.x - kSize * 0.5f,
position.y - kThickness * 0.5f, kSize,
kThickness),
color);
drawRect(frostbite2D::Rect(position.x - kThickness * 0.5f,
position.y - kSize * 0.5f, kThickness, kSize),
color);
}
BattleZoneDebugState resolveZoneState(
size_t zoneIndex, const std::vector<BattleZoneDebugInfo>* debugInfo) {
if (!debugInfo) {
return BattleZoneDebugState::Idle;
}
for (const BattleZoneDebugInfo& info : *debugInfo) {
if (info.zoneIndex == zoneIndex) {
return info.state;
}
}
return BattleZoneDebugState::Idle;
}
frostbite2D::Color zoneColor(BattleZoneDebugState state) {
switch (state) {
case BattleZoneDebugState::Active:
return frostbite2D::Color(1.0f, 0.82f, 0.15f, 0.95f);
case BattleZoneDebugState::Cleared:
return frostbite2D::Color(0.45f, 0.48f, 0.52f, 0.65f);
case BattleZoneDebugState::Idle:
default:
return frostbite2D::Color(0.95f, 0.25f, 0.25f, 0.9f);
}
}
} // namespace } // namespace
void DebugOverlay::Render(const LevelDefinition& level, const DebugFlags& flags, void DebugOverlay::Render(const LevelDefinition& level, const DebugFlags& flags,
const frostbite2D::Rect* playerBounds) const { const frostbite2D::Rect* playerBounds,
const std::vector<BattleZoneDebugInfo>*
battleZoneDebug) const {
if (!flags.enabled) {
return;
}
if (flags.showCollision) { if (flags.showCollision) {
drawCollision(level); drawCollision(level);
} }
if (flags.showBattleZones) {
drawBattleZones(level, battleZoneDebug);
}
if (flags.showSpawnPoints) {
drawSpawnPoints(level);
}
if (flags.showActorBounds && playerBounds) { if (flags.showActorBounds && playerBounds) {
drawActorBounds(*playerBounds); drawActorBounds(*playerBounds);
} }
@@ -46,4 +97,24 @@ void DebugOverlay::drawActorBounds(const frostbite2D::Rect& bounds) const {
drawOutline(bounds, frostbite2D::Color(1.0f, 0.78f, 0.1f, 0.9f)); drawOutline(bounds, frostbite2D::Color(1.0f, 0.78f, 0.1f, 0.9f));
} }
void DebugOverlay::drawBattleZones(
const LevelDefinition& level,
const std::vector<BattleZoneDebugInfo>* battleZoneDebug) const {
for (size_t i = 0; i < level.battleZones.size(); ++i) {
const BattleZoneDefinition& zone = level.battleZones[i];
drawOutline(zone.trigger, zoneColor(resolveZoneState(i, battleZoneDebug)),
3.0f);
drawOutline(zone.cameraBounds,
frostbite2D::Color(0.25f, 0.55f, 1.0f, 0.75f), 2.0f);
}
}
void DebugOverlay::drawSpawnPoints(const LevelDefinition& level) const {
for (const BattleZoneDefinition& zone : level.battleZones) {
for (const SpawnPoint& spawn : zone.enemySpawns) {
drawCross(spawn.position, frostbite2D::Color(1.0f, 0.2f, 0.9f, 0.95f));
}
}
}
} // namespace ns_game } // namespace ns_game
+7 -1
View File
@@ -4,17 +4,23 @@
#include "../data/level_definition.h" #include "../data/level_definition.h"
#include <frostbite2D/types/type_math.h> #include <frostbite2D/types/type_math.h>
#include <vector>
namespace ns_game { namespace ns_game {
class DebugOverlay { class DebugOverlay {
public: public:
void Render(const LevelDefinition& level, const DebugFlags& flags, void Render(const LevelDefinition& level, const DebugFlags& flags,
const frostbite2D::Rect* playerBounds) const; const frostbite2D::Rect* playerBounds,
const std::vector<BattleZoneDebugInfo>* battleZoneDebug) const;
private: private:
void drawCollision(const LevelDefinition& level) const; void drawCollision(const LevelDefinition& level) const;
void drawActorBounds(const frostbite2D::Rect& bounds) const; void drawActorBounds(const frostbite2D::Rect& bounds) const;
void drawBattleZones(
const LevelDefinition& level,
const std::vector<BattleZoneDebugInfo>* battleZoneDebug) const;
void drawSpawnPoints(const LevelDefinition& level) const;
}; };
} // namespace ns_game } // namespace ns_game
+3
View File
@@ -1,5 +1,6 @@
#include "scene/whitebox_scene.h" #include "scene/whitebox_scene.h"
#include "core/game_config.h" #include "core/game_config.h"
#include "core/game_services.h"
#include <cstdio> #include <cstdio>
@@ -80,6 +81,8 @@ int main(int argc, char** argv) {
return 1; return 1;
} }
ns_game::GameServices::Get().Initialize();
app.run([]() { app.run([]() {
frostbite2D::SceneManager::get().ReplaceScene( frostbite2D::SceneManager::get().ReplaceScene(
frostbite2D::MakePtr<ns_game::WhiteboxScene>()); frostbite2D::MakePtr<ns_game::WhiteboxScene>());
+79
View File
@@ -0,0 +1,79 @@
#include "camera_controller.h"
#include <algorithm>
#include <cmath>
namespace ns_game {
namespace {
float clampValue(float value, float minValue, float maxValue) {
if (maxValue < minValue) {
return minValue;
}
return std::clamp(value, minValue, maxValue);
}
float smoothFactor(float smoothing, float deltaTime) {
if (smoothing <= 0.0f) {
return 1.0f;
}
return 1.0f - std::exp(-smoothing * std::max(deltaTime, 0.0f));
}
} // namespace
void CameraController::Configure(const CameraControllerConfig& config) {
config_ = config;
}
void CameraController::SetWorldBounds(const frostbite2D::Rect& worldBounds) {
const float width = std::max(worldBounds.width(), config_.viewportWidth);
const float height = std::max(worldBounds.height(), config_.viewportHeight);
config_.worldBounds =
frostbite2D::Rect(worldBounds.left(), worldBounds.top(), width, height);
}
void CameraController::SnapToTarget(frostbite2D::Camera& camera,
const frostbite2D::Rect& targetBounds) {
camera.setPosition(clampToWorld(
resolveDesiredPosition(camera.getPosition(), targetBounds)));
}
void CameraController::Update(frostbite2D::Camera& camera,
const frostbite2D::Rect& targetBounds,
float deltaTime) {
const frostbite2D::Vec2 current = camera.getPosition();
const frostbite2D::Vec2 desired =
clampToWorld(resolveDesiredPosition(current, targetBounds));
const float t = smoothFactor(config_.smoothing, deltaTime);
camera.setPosition(frostbite2D::Vec2(
current.x + (desired.x - current.x) * t,
current.y + (desired.y - current.y) * t));
}
frostbite2D::Vec2 CameraController::resolveDesiredPosition(
const frostbite2D::Vec2& currentPosition,
const frostbite2D::Rect& targetBounds) const {
frostbite2D::Vec2 desired = currentPosition;
const float targetCenterX = targetBounds.left() + targetBounds.width() * 0.5f;
const float screenX = targetCenterX - currentPosition.x;
if (screenX < config_.deadZone.left()) {
desired.x = targetCenterX - config_.deadZone.left();
} else if (screenX > config_.deadZone.right()) {
desired.x = targetCenterX - config_.deadZone.right();
}
desired.y = config_.worldBounds.top();
return desired;
}
frostbite2D::Vec2 CameraController::clampToWorld(
const frostbite2D::Vec2& position) const {
return frostbite2D::Vec2(
clampValue(position.x, config_.worldBounds.left(),
config_.worldBounds.right() - config_.viewportWidth),
clampValue(position.y, config_.worldBounds.top(),
config_.worldBounds.bottom() - config_.viewportHeight));
}
} // namespace ns_game
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <frostbite2D/graphics/camera.h>
#include <frostbite2D/types/type_math.h>
namespace ns_game {
struct CameraControllerConfig {
frostbite2D::Rect worldBounds;
float viewportWidth = 1280.0f;
float viewportHeight = 720.0f;
frostbite2D::Rect deadZone = frostbite2D::Rect(420.0f, 0.0f, 340.0f, 720.0f);
float smoothing = 14.0f;
};
class CameraController {
public:
void Configure(const CameraControllerConfig& config);
void SetWorldBounds(const frostbite2D::Rect& worldBounds);
void SnapToTarget(frostbite2D::Camera& camera,
const frostbite2D::Rect& targetBounds);
void Update(frostbite2D::Camera& camera, const frostbite2D::Rect& targetBounds,
float deltaTime);
private:
frostbite2D::Vec2 resolveDesiredPosition(
const frostbite2D::Vec2& currentPosition,
const frostbite2D::Rect& targetBounds) const;
frostbite2D::Vec2 clampToWorld(const frostbite2D::Vec2& position) const;
CameraControllerConfig config_;
};
} // namespace ns_game
+213 -13
View File
@@ -1,10 +1,15 @@
#include "whitebox_scene.h" #include "whitebox_scene.h"
#include "../core/game_audio.h"
#include "../core/game_config.h" #include "../core/game_config.h"
#include "../core/game_save.h"
#include "../core/game_services.h"
#include <frostbite2D/core/application.h> #include <frostbite2D/core/application.h>
#include <frostbite2D/event/key_event.h>
#include <frostbite2D/graphics/camera.h> #include <frostbite2D/graphics/camera.h>
#include <frostbite2D/graphics/renderer.h> #include <frostbite2D/graphics/renderer.h>
#include <frostbite2D/scene/scene_manager.h>
#include <algorithm> #include <algorithm>
namespace ns_game { namespace ns_game {
@@ -12,41 +17,236 @@ namespace ns_game {
void WhiteboxScene::onEnter() { void WhiteboxScene::onEnter() {
frostbite2D::Scene::onEnter(); frostbite2D::Scene::onEnter();
level_ = CreateWhiteboxLevel(); level_ = CreateWhiteboxLevel();
if (auto savedDebugFlags = GameSave::Get().LoadDebugFlags()) {
debugFlags_ = *savedDebugFlags;
}
battleZoneStates_.clear();
battleZoneStates_.resize(level_.battleZones.size());
activeBattleZoneIndex_ = -1;
player_ = frostbite2D::MakePtr<PlayerActor>(); player_ = frostbite2D::MakePtr<PlayerActor>();
player_->SetTopLeftPosition(level_.playerSpawn);
player_->SetMovementWorld(&level_.collision); player_->SetMovementWorld(&level_.collision);
AddChild(player_); AddChild(player_);
configureCamera();
uiOverlay_ = frostbite2D::MakePtr<GameUiOverlay>();
frostbite2D::SceneManager::get().ReplaceUIScene(uiOverlay_);
updateUiOverlay();
GameAudio::Get().PlayMusicCue("stage_01");
} }
void WhiteboxScene::OnUpdate(float deltaTime) { void WhiteboxScene::Update(float deltaTime) {
(void)deltaTime; frostbite2D::Scene::Update(deltaTime);
updateCamera(); updateBattleZones();
updateCameraBoundsBlend(deltaTime);
updateCamera(deltaTime);
updateUiOverlay();
}
bool WhiteboxScene::OnEvent(const frostbite2D::Event& event) {
if (event.getType() == frostbite2D::EventType::KeyDown) {
const auto& keyEvent = static_cast<const frostbite2D::KeyEvent&>(event);
if (!keyEvent.isRepeat() && handleDebugKey(keyEvent.getKeyCode())) {
return true;
}
}
return frostbite2D::Scene::OnEvent(event);
} }
void WhiteboxScene::Render() { void WhiteboxScene::Render() {
stageRenderer_.Render(level_, debugFlags_); stageRenderer_.Render(level_, debugFlags_);
frostbite2D::Scene::Render(); frostbite2D::Scene::Render();
stageRenderer_.RenderForeground(level_);
const frostbite2D::Rect playerBounds = const frostbite2D::Rect playerBounds =
player_ ? player_->GetWorldBounds() : frostbite2D::Rect::Zero(); player_ ? player_->GetWorldBounds() : frostbite2D::Rect::Zero();
debugOverlay_.Render(level_, debugFlags_, player_ ? &playerBounds : nullptr); const std::vector<BattleZoneDebugInfo> battleZoneDebug =
buildBattleZoneDebugInfo();
debugOverlay_.Render(level_, debugFlags_, player_ ? &playerBounds : nullptr,
&battleZoneDebug);
} }
void WhiteboxScene::updateCamera() { frostbite2D::Camera* WhiteboxScene::activeCamera() const {
auto* renderer = frostbite2D::Application::get().getRenderer();
if (!renderer) {
return nullptr;
}
return renderer->getCamera();
}
std::vector<BattleZoneDebugInfo> WhiteboxScene::buildBattleZoneDebugInfo()
const {
std::vector<BattleZoneDebugInfo> debugInfo;
debugInfo.reserve(battleZoneStates_.size());
for (size_t i = 0; i < battleZoneStates_.size(); ++i) {
BattleZoneDebugState state = BattleZoneDebugState::Idle;
if (battleZoneStates_[i].cleared) {
state = BattleZoneDebugState::Cleared;
} else if (activeBattleZoneIndex_ == static_cast<int>(i)) {
state = BattleZoneDebugState::Active;
}
debugInfo.push_back(BattleZoneDebugInfo{i, state});
}
return debugInfo;
}
void WhiteboxScene::configureCamera() {
auto* camera = activeCamera();
if (!camera) {
return;
}
CameraControllerConfig config;
config.worldBounds = level_.cameraBounds;
config.viewportWidth = static_cast<float>(config::kVirtualWidth);
config.viewportHeight = static_cast<float>(config::kVirtualHeight);
cameraController_.Configure(config);
currentCameraBounds_ = level_.cameraBounds;
if (player_) {
cameraController_.SnapToTarget(*camera, player_->GetWorldBounds());
}
}
bool WhiteboxScene::handleDebugKey(frostbite2D::KeyCode keyCode) {
switch (keyCode) {
case frostbite2D::KeyCode::F1:
toggleDebugOverlay();
return true;
case frostbite2D::KeyCode::F2:
clearActiveBattleZone();
return true;
case frostbite2D::KeyCode::F3:
debugFlags_.showCollision = !debugFlags_.showCollision;
debugFlags_.showStageGrid = debugFlags_.showCollision;
persistDebugFlags();
return true;
case frostbite2D::KeyCode::F4:
debugFlags_.showActorBounds = !debugFlags_.showActorBounds;
persistDebugFlags();
return true;
case frostbite2D::KeyCode::F5:
debugFlags_.showBattleZones = !debugFlags_.showBattleZones;
persistDebugFlags();
return true;
case frostbite2D::KeyCode::F6:
debugFlags_.showSpawnPoints = !debugFlags_.showSpawnPoints;
persistDebugFlags();
return true;
default:
return false;
}
}
void WhiteboxScene::toggleDebugOverlay() {
debugFlags_.enabled = !debugFlags_.enabled;
persistDebugFlags();
}
void WhiteboxScene::beginCameraBoundsBlend(
const frostbite2D::Rect& targetBounds) {
cameraBoundsBlendStart_ = currentCameraBounds_;
cameraBoundsBlendTarget_ = targetBounds;
cameraBoundsBlendTimer_ = 0.0f;
cameraBoundsBlendActive_ = true;
}
void WhiteboxScene::clearActiveBattleZone() {
if (activeBattleZoneIndex_ < 0 ||
activeBattleZoneIndex_ >= static_cast<int>(battleZoneStates_.size())) {
return;
}
battleZoneStates_[static_cast<size_t>(activeBattleZoneIndex_)].cleared = true;
activeBattleZoneIndex_ = -1;
beginCameraBoundsBlend(level_.cameraBounds);
}
void WhiteboxScene::updateCameraBoundsBlend(float deltaTime) {
if (!cameraBoundsBlendActive_) {
return;
}
cameraBoundsBlendTimer_ += deltaTime;
const float t = cameraBoundsBlendDuration_ > 0.0f
? std::min(cameraBoundsBlendTimer_ /
cameraBoundsBlendDuration_,
1.0f)
: 1.0f;
const auto lerp = [t](float from, float to) {
return from + (to - from) * t;
};
currentCameraBounds_ = frostbite2D::Rect(
lerp(cameraBoundsBlendStart_.left(), cameraBoundsBlendTarget_.left()),
lerp(cameraBoundsBlendStart_.top(), cameraBoundsBlendTarget_.top()),
lerp(cameraBoundsBlendStart_.width(), cameraBoundsBlendTarget_.width()),
lerp(cameraBoundsBlendStart_.height(), cameraBoundsBlendTarget_.height()));
cameraController_.SetWorldBounds(currentCameraBounds_);
if (t >= 1.0f) {
cameraBoundsBlendActive_ = false;
}
}
void WhiteboxScene::updateBattleZones() {
if (!player_) { if (!player_) {
return; return;
} }
auto* renderer = frostbite2D::Application::get().getRenderer(); const frostbite2D::Rect playerBounds = player_->GetWorldBounds();
if (!renderer || !renderer->getCamera()) { if (activeBattleZoneIndex_ >= 0) {
const size_t activeIndex = static_cast<size_t>(activeBattleZoneIndex_);
if (activeIndex < level_.battleZones.size() &&
!level_.battleZones[activeIndex].trigger.intersects(playerBounds)) {
clearActiveBattleZone();
}
return; return;
} }
const auto playerPos = player_->GetTopLeftPosition(); for (size_t i = 0; i < level_.battleZones.size(); ++i) {
const float targetX = std::clamp(playerPos.x - 420.0f, BattleZoneRuntime& runtime = battleZoneStates_[i];
level_.collision.levelLeft, if (runtime.cleared ||
level_.collision.levelRight - !level_.battleZones[i].trigger.intersects(playerBounds)) {
static_cast<float>(config::kVirtualWidth)); continue;
renderer->getCamera()->setPosition(frostbite2D::Vec2(targetX, 0.0f)); }
runtime.triggered = true;
activeBattleZoneIndex_ = static_cast<int>(i);
beginCameraBoundsBlend(level_.battleZones[i].cameraBounds);
GameAudio::Get().PlaySoundCue("battle_zone_enter");
break;
}
}
void WhiteboxScene::updateCamera(float deltaTime) {
if (!player_) {
return;
}
auto* camera = activeCamera();
if (!camera) {
return;
}
cameraController_.Update(*camera, player_->GetWorldBounds(), deltaTime);
}
void WhiteboxScene::updateUiOverlay() {
if (!uiOverlay_) {
return;
}
GameUiOverlayState state;
state.debugFlags = debugFlags_;
state.services = GameServices::Get().Status();
state.activeBattleZoneIndex = activeBattleZoneIndex_;
uiOverlay_->SetState(state);
}
void WhiteboxScene::persistDebugFlags() {
GameSave::Get().SaveDebugFlags(debugFlags_);
} }
} // namespace ns_game } // namespace ns_game
+33 -2
View File
@@ -4,26 +4,57 @@
#include "../core/debug_flags.h" #include "../core/debug_flags.h"
#include "../data/level_definition.h" #include "../data/level_definition.h"
#include "../debug/debug_overlay.h" #include "../debug/debug_overlay.h"
#include "../ui/game_ui_overlay.h"
#include "camera_controller.h"
#include "../stage/stage_renderer.h" #include "../stage/stage_renderer.h"
#include <frostbite2D/scene/scene.h> #include <frostbite2D/scene/scene.h>
#include <frostbite2D/types/type_math.h> #include <frostbite2D/types/type_math.h>
#include <string>
#include <vector>
namespace ns_game { namespace ns_game {
class WhiteboxScene : public frostbite2D::Scene { class WhiteboxScene : public frostbite2D::Scene {
public: public:
void onEnter() override; void onEnter() override;
void OnUpdate(float deltaTime) override; void Update(float deltaTime) override;
bool OnEvent(const frostbite2D::Event& event) override;
void Render() override; void Render() override;
private: private:
void updateCamera(); struct BattleZoneRuntime {
bool triggered = false;
bool cleared = false;
};
frostbite2D::Camera* activeCamera() const;
std::vector<BattleZoneDebugInfo> buildBattleZoneDebugInfo() const;
void configureCamera();
bool handleDebugKey(frostbite2D::KeyCode keyCode);
void toggleDebugOverlay();
void beginCameraBoundsBlend(const frostbite2D::Rect& targetBounds);
void clearActiveBattleZone();
void updateCameraBoundsBlend(float deltaTime);
void updateBattleZones();
void updateCamera(float deltaTime);
void updateUiOverlay();
void persistDebugFlags();
LevelDefinition level_; LevelDefinition level_;
DebugFlags debugFlags_; DebugFlags debugFlags_;
std::vector<BattleZoneRuntime> battleZoneStates_;
int activeBattleZoneIndex_ = -1;
bool cameraBoundsBlendActive_ = false;
float cameraBoundsBlendTimer_ = 0.0f;
float cameraBoundsBlendDuration_ = 0.35f;
frostbite2D::Rect cameraBoundsBlendStart_;
frostbite2D::Rect cameraBoundsBlendTarget_;
frostbite2D::Rect currentCameraBounds_;
CameraController cameraController_;
StageRenderer stageRenderer_; StageRenderer stageRenderer_;
DebugOverlay debugOverlay_; DebugOverlay debugOverlay_;
frostbite2D::Ptr<GameUiOverlay> uiOverlay_;
frostbite2D::Ptr<PlayerActor> player_; frostbite2D::Ptr<PlayerActor> player_;
}; };
+139 -12
View File
@@ -2,7 +2,11 @@
#include "../core/game_config.h" #include "../core/game_config.h"
#include <frostbite2D/graphics/camera.h>
#include <frostbite2D/graphics/renderer.h> #include <frostbite2D/graphics/renderer.h>
#include <frostbite2D/graphics/types.h>
#include <frostbite2D/graphics/texture.h>
#include <frostbite2D/resource/asset.h>
namespace ns_game { namespace ns_game {
@@ -12,20 +16,61 @@ void drawRect(const frostbite2D::Rect& rect, const frostbite2D::Color& color) {
frostbite2D::Renderer::get().drawQuad(rect, color); frostbite2D::Renderer::get().drawQuad(rect, color);
} }
void drawTexture(const frostbite2D::Rect& bounds,
const frostbite2D::Rect& sourceRect,
frostbite2D::Ptr<frostbite2D::Texture> texture,
const frostbite2D::Color& tint) {
frostbite2D::Renderer& renderer = frostbite2D::Renderer::get();
frostbite2D::Quad quad = frostbite2D::Quad::createTextured(
bounds, sourceRect,
frostbite2D::Vec2(static_cast<float>(texture->getWidth()),
static_cast<float>(texture->getHeight())),
tint.r, tint.g, tint.b, tint.a, renderer.shouldShrinkSubTextureUVs());
auto* shader = renderer.getShaderManager().getShader("sprite");
if (shader) {
shader->use();
}
renderer.getBatch().submitQuad(quad, frostbite2D::Transform2D::identity(),
texture, shader,
frostbite2D::BlendMode::Normal);
}
float worldWidthOf(const LevelDefinition& level) { float worldWidthOf(const LevelDefinition& level) {
return level.collision.levelRight - level.collision.levelLeft; return level.collision.levelRight - level.collision.levelLeft;
} }
const TileSetDefinition* findTileSet(const LevelDefinition& level,
const std::string& id) {
for (const TileSetDefinition& tileSet : level.tileSets) {
if (tileSet.id == id) {
return &tileSet;
}
}
return nullptr;
}
} // namespace } // namespace
void StageRenderer::Render(const LevelDefinition& level, void StageRenderer::Render(const LevelDefinition& level,
const DebugFlags& debugFlags) const { const DebugFlags& debugFlags) const {
drawBackground(level); drawBackground(level);
if (debugFlags.showStageGrid) { for (const StageLayer& layer : level.layers) {
if (layer.role == StageLayerRole::PropsFront) {
continue;
}
drawLayer(level, layer);
}
if (debugFlags.enabled && debugFlags.showStageGrid) {
drawGrid(level); drawGrid(level);
} }
drawProps(level); }
drawPlatforms(level);
void StageRenderer::RenderForeground(const LevelDefinition& level) const {
for (const StageLayer& layer : level.layers) {
if (layer.role == StageLayerRole::PropsFront) {
drawLayer(level, layer);
}
}
} }
void StageRenderer::drawBackground(const LevelDefinition& level) const { void StageRenderer::drawBackground(const LevelDefinition& level) const {
@@ -47,19 +92,101 @@ void StageRenderer::drawGrid(const LevelDefinition& level) const {
} }
} }
void StageRenderer::drawProps(const LevelDefinition& level) const { void StageRenderer::drawLayer(const LevelDefinition& level,
for (const auto& prop : level.props) { const StageLayer& layer) const {
drawRect(prop.bounds, prop.color); (void)level;
} frostbite2D::Vec2 parallaxOffset;
if (auto* camera = frostbite2D::Renderer::get().getCamera()) {
const frostbite2D::Vec2 cameraPos = camera->getPosition();
parallaxOffset.x = cameraPos.x * (1.0f - layer.parallax);
parallaxOffset.y = cameraPos.y * (1.0f - layer.parallax);
} }
void StageRenderer::drawPlatforms(const LevelDefinition& level) const { for (const StageRect& rect : layer.rects) {
for (const auto& platform : level.collision.platforms) { frostbite2D::Rect bounds(rect.bounds.left() + parallaxOffset.x,
drawRect(platform, frostbite2D::Color(0.62f, 0.68f, 0.73f, 1.0f)); rect.bounds.top() + parallaxOffset.y,
drawRect(frostbite2D::Rect(platform.left(), platform.top(), rect.bounds.width(), rect.bounds.height());
platform.width(), 4.0f), drawStageRect(rect, bounds);
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)); frostbite2D::Color(0.91f, 0.95f, 0.98f, 1.0f));
} }
} }
for (const StageTile& tile : layer.tiles) {
frostbite2D::Rect bounds(tile.bounds.left() + parallaxOffset.x,
tile.bounds.top() + parallaxOffset.y,
tile.bounds.width(), tile.bounds.height());
drawStageTile(level, tile, bounds);
}
}
void StageRenderer::drawStageRect(const StageRect& rect,
const frostbite2D::Rect& bounds) const {
if (!rect.source.hasTexture()) {
drawRect(bounds, rect.color);
return;
}
frostbite2D::Ptr<frostbite2D::Texture> texture =
getTexture(rect.source.texturePath);
if (!texture) {
drawRect(bounds, rect.color);
return;
}
if (rect.source.useSourceRect) {
drawTexture(bounds, rect.source.sourceRect, texture, rect.source.tint);
return;
}
drawTexture(bounds,
frostbite2D::Rect(0.0f, 0.0f,
static_cast<float>(texture->getWidth()),
static_cast<float>(texture->getHeight())),
texture, rect.source.tint);
}
void StageRenderer::drawStageTile(const LevelDefinition& level,
const StageTile& tile,
const frostbite2D::Rect& bounds) const {
const TileSetDefinition* tileSet = findTileSet(level, tile.tileSetId);
if (!tileSet || tileSet->texturePath.empty()) {
drawRect(bounds, tile.fallbackColor);
return;
}
frostbite2D::Ptr<frostbite2D::Texture> texture =
getTexture(tileSet->texturePath);
if (!texture) {
drawRect(bounds, tile.fallbackColor);
return;
}
drawTexture(bounds, tile.sourceRect, texture,
frostbite2D::Color(1.0f, 1.0f, 1.0f, 1.0f));
}
frostbite2D::Ptr<frostbite2D::Texture>
StageRenderer::getTexture(const std::string& texturePath) const {
const auto existing = textureCache_.find(texturePath);
if (existing != textureCache_.end()) {
return existing->second;
}
if (!frostbite2D::Asset::get().exists(texturePath)) {
textureCache_[texturePath] = nullptr;
return nullptr;
}
frostbite2D::Ptr<frostbite2D::Texture> texture =
frostbite2D::Texture::loadFromFile(texturePath);
if (texture) {
texture->setSampling(frostbite2D::TextureSampling::PixelArt);
}
textureCache_[texturePath] = texture;
return texture;
}
} // namespace ns_game } // namespace ns_game
+15 -2
View File
@@ -3,17 +3,30 @@
#include "../data/level_definition.h" #include "../data/level_definition.h"
#include "../core/debug_flags.h" #include "../core/debug_flags.h"
#include <frostbite2D/graphics/texture.h>
#include <string>
#include <unordered_map>
namespace ns_game { namespace ns_game {
class StageRenderer { class StageRenderer {
public: public:
void Render(const LevelDefinition& level, const DebugFlags& debugFlags) const; void Render(const LevelDefinition& level, const DebugFlags& debugFlags) const;
void RenderForeground(const LevelDefinition& level) const;
private: private:
void drawBackground(const LevelDefinition& level) const; void drawBackground(const LevelDefinition& level) const;
void drawGrid(const LevelDefinition& level) const; void drawGrid(const LevelDefinition& level) const;
void drawProps(const LevelDefinition& level) const; void drawLayer(const LevelDefinition& level, const StageLayer& layer) const;
void drawPlatforms(const LevelDefinition& level) const; void drawStageRect(const StageRect& rect,
const frostbite2D::Rect& bounds) const;
void drawStageTile(const LevelDefinition& level, const StageTile& tile,
const frostbite2D::Rect& bounds) const;
frostbite2D::Ptr<frostbite2D::Texture>
getTexture(const std::string& texturePath) const;
mutable std::unordered_map<std::string, frostbite2D::Ptr<frostbite2D::Texture>>
textureCache_;
}; };
} // namespace ns_game } // namespace ns_game
+77
View File
@@ -0,0 +1,77 @@
#include "game_ui_overlay.h"
#include "../core/game_config.h"
#include <frostbite2D/graphics/font_manager.h>
#include <frostbite2D/graphics/renderer.h>
#include <sstream>
namespace ns_game {
void GameUiOverlay::onEnter() {
frostbite2D::UIScene::onEnter();
if (frostbite2D::FontManager::get().hasFont("debug")) {
statusText_ = frostbite2D::TextSprite::create();
statusText_->SetFont("debug");
statusText_->SetTextColor(0.88f, 0.94f, 1.0f, 1.0f);
statusText_->SetTopLeftPosition(16.0f, 14.0f);
statusText_->SetZOrder(10);
AddChild(statusText_);
textReady_ = true;
}
}
void GameUiOverlay::SetState(const GameUiOverlayState& state) {
state_ = state;
if (textReady_ && statusText_) {
statusText_->SetText(BuildStatusText());
}
}
void GameUiOverlay::Render() {
if (!textReady_) {
RenderFallbackPanel();
}
frostbite2D::UIScene::Render();
}
std::string GameUiOverlay::BuildStatusText() const {
std::ostringstream text;
text << "NS Unknown Game\n";
text << "Debug: " << (state_.debugFlags.enabled ? "on" : "off")
<< " Zone: " << state_.activeBattleZoneIndex << "\n";
text << "Audio " << (state_.services.audioInitialized ? "on" : "off")
<< " Save " << (state_.services.saveInitialized ? "on" : "off")
<< " PVF " << (state_.services.pvfArchiveOpen ? "on" : "off")
<< " NPK " << (state_.services.npkArchiveOpen ? "on" : "off")
<< " AudioDB "
<< (state_.services.audioDatabaseLoaded ? "on" : "off");
return text.str();
}
void GameUiOverlay::RenderFallbackPanel() const {
frostbite2D::Renderer& renderer = frostbite2D::Renderer::get();
renderer.drawQuad(frostbite2D::Rect(12.0f, 12.0f, 250.0f, 54.0f),
frostbite2D::Color(0.04f, 0.05f, 0.06f, 0.75f));
const float serviceCount =
(state_.services.audioInitialized ? 1.0f : 0.0f) +
(state_.services.saveInitialized ? 1.0f : 0.0f) +
(state_.services.pvfArchiveOpen ? 1.0f : 0.0f) +
(state_.services.npkArchiveOpen ? 1.0f : 0.0f) +
(state_.services.audioDatabaseLoaded ? 1.0f : 0.0f);
renderer.drawQuad(frostbite2D::Rect(20.0f, 24.0f, serviceCount * 38.0f,
10.0f),
frostbite2D::Color(0.25f, 0.72f, 0.94f, 0.95f));
renderer.drawQuad(frostbite2D::Rect(20.0f, 44.0f,
state_.debugFlags.enabled ? 180.0f
: 52.0f,
10.0f),
state_.debugFlags.enabled
? frostbite2D::Color(0.95f, 0.78f, 0.25f, 0.95f)
: frostbite2D::Color(0.45f, 0.48f, 0.52f, 0.8f));
}
} // namespace ns_game
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include "../core/debug_flags.h"
#include "../core/game_services.h"
#include <frostbite2D/2d/text_sprite.h>
#include <frostbite2D/scene/ui_scene.h>
#include <string>
namespace ns_game {
struct GameUiOverlayState {
DebugFlags debugFlags;
GameServiceStatus services;
int activeBattleZoneIndex = -1;
};
class GameUiOverlay : public frostbite2D::UIScene {
public:
void onEnter() override;
void SetState(const GameUiOverlayState& state);
void Render() override;
private:
std::string BuildStatusText() const;
void RenderFallbackPanel() const;
GameUiOverlayState state_;
frostbite2D::Ptr<frostbite2D::TextSprite> statusText_;
bool textReady_ = false;
};
} // namespace ns_game
+262
View File
@@ -0,0 +1,262 @@
# 地图编辑器工具文档
本文档说明 `tools/map_editor/` 网页地图编辑器的用途、运行方式、操作流程和当前边界。该工具用于编辑游戏运行时读取的 `.map` 文本地图,不修改引擎源码,也不改变 `StageMapLoader` 的数据格式。
## 工具定位
地图编辑器服务第一关场景制作阶段,目标是让 `.map` 文件从手写配置变成可视化编辑:
- 可视化编辑世界范围、摄像机、玩家出生点、碰撞块、铺砖区、图形矩形、战斗区和敌人出生点。
- 生成兼容 `StageMapLoader``.map` 文本。
- 保持静态网页形态,方便后续部署到服务器。
- 当前只负责地图数据,不负责直接运行游戏预览、不负责复制图片素材、不负责管理素材授权。
## 文件位置
```text
tools/map_editor/
├─ index.html 网页结构
├─ styles.css 编辑器样式
├─ app.js 解析、绘制、编辑和导出逻辑
├─ server.js 本地可写服务,用于保存和部署 .map
├─ README.md 快速说明
└─ DOCUMENTATION.md 本文档
```
默认编辑目标地图:
```text
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
```
打开:
```text
http://127.0.0.1:8787/tools/map_editor/
```
静态模式可以编辑和下载,但不能直接写回项目文件。无论哪种模式,从仓库根目录启动时,工具都会尝试自动加载:
```text
game/assets/map/stage_01.map
```
也可以通过顶部“打开 .map”选择其他 `.map` 文件。
## 界面区域
### 顶部工具栏
- `撤回`:撤回最近一次编辑操作。
- `加载 stage_01.map`:重新加载项目默认地图。
- `打开 .map`:从本地选择一个 `.map` 文本文件。
- `保存`:写入项目资源目录的 `game/assets/map/stage_01.map`,需要本地可写服务。
- `一键部署`:保存并同步到项目资源目录和已存在的 PC/Switch build 资源目录,需本地可写服务。
- `保存并下载`:下载当前导出的 `<地图ID>.map`
### 左侧菜单
- `地图设置`:地图 ID、世界宽高、网格大小、编辑目标。
- `新增对象`:新增图块集、图层、碰撞块、铺砖区、导入图形、战斗区、出生点。
- `对象列表`:列出当前地图中的可编辑对象,点击后选中对象。
### 中间画布
- 显示世界范围、网格、摄像机范围、图层内容、碰撞块、战斗区和出生点。
- 鼠标滚轮缩放。
- 空白区域拖拽可以平移视图。
- 选中对象后拖动本体可以移动。
- 选中矩形类对象后拖动右下角蓝色手柄可以调整尺寸。
### 右侧面板
- `选中对象`:显示当前对象属性。
- `图层上移 / 图层下移`:调整选中图层或选中对象所在图层的顺序。
- `删除选中对象`:删除当前对象。
- `图块集`:显示已有 tileset。
- `导出文本`:实时显示将要保存的 `.map` 文本。
## 编辑目标和命中规则
左侧“编辑目标”用于控制画布点击优先命中的对象类型:
- `智能选择`:默认模式。
- `碰撞块`:只选择 `collision`
- `铺砖区`:只选择 `tile_rect`
- `战斗区`:选择 battle zone 触发范围和镜头范围。
- `出生点`:选择玩家或敌人出生点。
- `场景矩形`:选择 `rect` / `rect_sprite` 图形对象。
智能选择模式下,碰撞块被视为上层覆盖:
1. 如果碰撞块和铺砖区/图形重叠,第一次点击会选中碰撞块。
2. 不拖动,在同一区域再点一次,会切换到下层铺砖区或图形。
3. 选中碰撞块后拖动,会移动碰撞块本身,不会误移动铺砖区。
4. 如果想跳过分层点击,可以直接把“编辑目标”切到目标类型。
## 常用流程
### 编辑碰撞块
1. 选择“智能选择”或“碰撞块”。
2. 点击红色碰撞块。
3. 拖动本体移动。
4. 拖动右下角蓝色手柄调整宽高。
5. 也可以在右侧属性面板直接修改 `X / Y / W / H`
### 编辑铺砖区
1. 选择“智能选择”或“铺砖区”。
2. 如果和碰撞块重叠,智能选择下先点到碰撞块,再点一次切到铺砖区。
3. 拖动铺砖区本体移动。
4. 拖动右下角蓝色手柄调整列数和行数。
5. 右侧属性面板可修改图块集、坐标、列数、行数、图块索引和 fallback 颜色。
### 导入图形素材
1. 点击左侧“导入图形”。
2. 选择图片文件。
3. 编辑器会在 `PropsBack` 图层创建一个 `rect_sprite`
4. 同时自动创建同坐标、同尺寸的 `collision`
5. 选中图形后拖动或缩放,绑定碰撞块会同步移动或缩放。
导入后 `.map` 默认写入:
```text
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. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。
2. 点击右侧“图层上移”或“图层下移”。
3. 编辑器预览和导出都会按当前 `.map` 图层顺序处理。
运行时 `StageRenderer` 也按 `.map``layer` 出现顺序渲染,所以图层顺序会影响游戏内显示。
### 撤回操作
支持撤回以下操作:
- 新增对象。
- 删除对象。
- 移动对象。
- 调整对象尺寸。
- 修改属性。
- 导入图形。
- 图层上移/下移。
快捷键:
```text
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 结构。
### 保存并下载
点击顶部“保存并下载”会下载:
```text
<地图ID>.map
```
下载内容与右侧“导出文本”一致,并保持 `StageMapLoader` 兼容。
普通静态服务模式下,浏览器安全限制不允许直接覆盖项目源文件。当前流程是:
1. 保存并下载 `.map`
2. 手动替换项目内目标地图文件。
3. 重新构建或运行游戏验证。
## 支持的 .map 数据
当前编辑器支持以下核心命令:
```text
map
world
camera
player_spawn
collision
tileset
layer / endlayer
rect
rect_sprite
rect_sprite_src
tile_rect
battle_zone / endbattle_zone
spawn
```
导出的文本不会写入编辑器私有字段。例如图形和碰撞块的绑定关系只存在于编辑器内存中;导出后仍然是普通 `rect_sprite` 和普通 `collision`
重新打开 `.map` 时,如果 `rect_sprite` 和某个 `collision` 坐标尺寸完全一致,编辑器会尝试恢复二者的编辑绑定。
## 当前限制
- 普通静态服务模式不能直接覆盖本地源文件,只能下载 `.map`
- `保存` / `一键部署` 需要使用 `node tools/map_editor/server.js` 启动本地可写服务。
- 导入图片的真实文件仍需手动放入项目资源目录。
- `.map` 中存在于 `game/assets/...``assets/...` 贴图路径会自动预览。
- 如果导入图片后没有把真实文件放到 `game/assets/...` 对应路径,重新打开 `.map` 后会显示 fallback 色块。
- 暂未实现重做。
- 暂未实现多选和批量移动。
- 暂未实现 tile palette 和 tileset 图片预览。
- 暂未实现一键运行游戏预览。
## 后续扩展建议
- 增加正式服务器保存接口,部署后直接保存 `.map` 到服务器文件或数据库。
- 增加 tileset 图片预览和 tile palette。
- 增加多选、复制、粘贴、批量移动。
- 增加自动检查:视觉铺砖区与碰撞块错位提示。
- 增加一键打开 Windows 版游戏做快速预览。
- 增加导入素材落位检查,提示图片是否已经存在于 `game/assets`
+123
View File
@@ -0,0 +1,123 @@
# 地图编辑器
这是一个网页地图编辑器,用来编辑游戏使用的 `.map` 文本文件。
完整工具说明见:
```text
tools/map_editor/DOCUMENTATION.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 资源目录。
- `保存并下载`:仍然下载 `<地图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` 文件。
## 当前能力
- 打开 `.map` 文本文件。
- 可视化世界范围、摄像机范围、玩家出生点、碰撞块、场景矩形、铺砖区、战斗区和敌人出生点。
- 从画布或对象列表选择对象。
- 在画布上拖动对象,支持网格吸附。
- 通过“编辑目标”过滤画布选择对象,默认“智能选择”把碰撞块当作上层覆盖;同位置对象第一次点选碰撞块,再点一次切到下层铺砖区或图形。
- 选中矩形对象后,拖动右下角蓝色手柄可以调整宽高。
- 通过“导入图形”选择图片,自动生成一个 `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` 会清空撤回历史。
## 调整碰撞块
1. 在左侧“编辑目标”里选择“智能选择”或“碰撞块”。
2. 在画布上点击红色碰撞框,或从对象列表选择对应碰撞块;智能选择下碰撞块会优先命中。
3. 拖动碰撞块本体可以移动位置。
4. 拖动右下角蓝色手柄可以调整宽高。
5. 也可以在右侧属性面板直接修改 `X``Y``W``H`
## 调整铺砖区
1. 在左侧“编辑目标”里选择“智能选择”或“铺砖区”。
2. 如果铺砖区和碰撞块重叠,智能选择下先点到碰撞块,再在同一区域点一次会切到铺砖区;也可以直接切到“铺砖区”。
3. 拖动铺砖区本体可以移动位置。
4. 拖动右下角蓝色手柄可以调整列数和行数。
5. 如果要编辑同位置碰撞体,再把“编辑目标”切到“碰撞块”。
## 导入和调整图形
1. 点击左侧“导入图形”,选择一个图片素材。
2. 编辑器会把它添加到 `PropsBack` 图层,并在同一位置创建同尺寸碰撞块。
3. 新图形会自动选中,左侧“编辑目标”会切到“场景矩形”。
4. 拖动图形本体可以移动图形和绑定碰撞块。
5. 拖动右下角蓝色手柄可以调整图形和绑定碰撞块尺寸。
6. 也可以在右侧属性面板修改 `X``Y``W``H`、颜色和贴图路径。
7. 如果图形来自 `rect_sprite_src`,属性面板还会显示 `源 X``源 Y``源 W``源 H`,用于调整图集裁切区域。
静态网页不能自动把图片复制进项目目录。导入后 `.map` 默认写入:
```text
assets/stage/stage_01/props/<图片文件名>
```
实际运行游戏前,需要把图片素材放到这个路径下。
## 调整图层顺序
1. 在对象列表中选择一个图层,或选择该图层下的图形/铺砖区。
2. 点击右侧“图层上移”或“图层下移”。
3. 编辑器会按当前图层顺序预览和导出 `.map`
## 当前限制
- 浏览器安全限制不允许静态网页直接覆盖本地源文件。当前需要先下载 `.map`,再手动替换项目里的地图文件;部署到服务器后可以增加服务端保存接口。
- 如果 `.map` 里的贴图路径指向 `assets/...` 且文件存在于 `game/assets/...`,重新打开后会自动预览图片。
- 如果导入图片后没有把真实文件放入 `game/assets/...` 对应路径,重新打开后会回退为颜色块。
- 暂未实现图块集图片预览,铺砖区当前使用 fallback 颜色显示。
- 暂未实现重做和多选。
File diff suppressed because it is too large Load Diff
+128
View File
@@ -0,0 +1,128 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NS Unknown Game 地图编辑器</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<header class="app-header">
<div>
<h1>地图编辑器</h1>
<p>编辑游戏运行时使用的 Frostbite2D .map 文本地图。</p>
</div>
<div class="header-actions">
<button id="undoButton" type="button" disabled>撤回</button>
<button id="loadSampleButton" type="button">加载 stage_01.map</button>
<label class="file-button">
打开 .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>
<main class="editor-shell">
<aside class="panel left-panel">
<section class="tool-card">
<div class="section-title">
<h2>地图设置</h2>
<span>基础参数</span>
</div>
<div class="field-grid">
<label>
地图 ID
<input id="mapIdInput" type="text">
</label>
<label>
世界宽
<input id="worldWidthInput" type="number" step="1">
</label>
<label>
世界高
<input id="worldHeightInput" type="number" step="1">
</label>
<label>
网格
<input id="gridSizeInput" type="number" step="1" min="1" value="10">
</label>
<label>
编辑目标
<select id="hitFilterInput">
<option value="all">智能选择</option>
<option value="collision">碰撞块</option>
<option value="tile">铺砖区</option>
<option value="zone">战斗区</option>
<option value="spawn">出生点</option>
<option value="rect">场景矩形</option>
</select>
</label>
</div>
</section>
<section class="tool-card">
<div class="section-title">
<h2>新增对象</h2>
<span>放到当前视图中心</span>
</div>
<div class="button-grid">
<button id="addTilesetButton" type="button">图块集</button>
<button id="addLayerButton" type="button">图层</button>
<button id="addCollisionButton" type="button">碰撞块</button>
<button id="addTileRectButton" type="button">铺砖区</button>
<label class="file-button">
导入图形
<input id="graphicFileInput" type="file" accept="image/*">
</label>
<button id="addBattleZoneButton" type="button">战斗区</button>
<button id="addSpawnButton" type="button">出生点</button>
</div>
</section>
<section class="tool-card object-section">
<div class="section-title">
<h2>对象列表</h2>
<span>点击选择后可拖动</span>
</div>
<div id="objectList" class="object-list"></div>
</section>
</aside>
<section class="canvas-panel">
<div class="toolbar">
<span id="statusText">就绪</span>
<span id="viewText"></span>
</div>
<canvas id="mapCanvas" width="1280" height="720"></canvas>
</section>
<aside class="panel right-panel">
<section>
<h2>选中对象</h2>
<div id="selectionSummary" class="selection-summary">未选择对象。</div>
<div id="propertyPanel" class="property-panel"></div>
<div class="layer-actions">
<button id="moveLayerUpButton" type="button">图层上移</button>
<button id="moveLayerDownButton" type="button">图层下移</button>
</div>
<button id="deleteButton" type="button" class="danger-button">删除选中对象</button>
</section>
<section>
<h2>图块集</h2>
<div id="tilesetList" class="tileset-list"></div>
</section>
<section>
<h2>导出文本</h2>
<textarea id="outputText" spellcheck="false"></textarea>
</section>
</aside>
</main>
<script src="app.js"></script>
</body>
</html>
+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);
}
+281
View File
@@ -0,0 +1,281 @@
:root {
color-scheme: dark;
--bg: #101214;
--panel: #181b1f;
--panel-2: #20242a;
--panel-3: #14171b;
--line: #343a42;
--text: #e8edf2;
--muted: #9aa4af;
--accent: #54a8ff;
--accent-2: #9ed06a;
--danger: #ff6868;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", system-ui, sans-serif;
overflow: hidden;
}
button,
input,
textarea,
select {
font: inherit;
}
button,
.file-button {
border: 1px solid var(--line);
background: var(--panel-2);
color: var(--text);
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
}
button:disabled {
color: #68727d;
border-color: #272c33;
background: #15181d;
cursor: not-allowed;
}
button:hover,
.file-button:hover {
border-color: var(--accent);
}
button:disabled:hover {
border-color: #272c33;
}
.primary-button {
border-color: #3977c9;
background: #17365f;
}
.app-header {
height: 72px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 16px;
border-bottom: 1px solid var(--line);
background: #0d0f12;
}
.app-header h1 {
margin: 0;
font-size: 20px;
font-weight: 700;
}
.app-header p {
margin: 4px 0 0;
color: var(--muted);
font-size: 13px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.file-button input {
display: none;
}
.editor-shell {
height: calc(100vh - 72px);
display: grid;
grid-template-columns: 300px minmax(420px, 1fr) 340px;
}
.panel {
background: var(--panel);
border-right: 1px solid var(--line);
overflow: auto;
padding: 12px;
}
.right-panel {
border-right: 0;
border-left: 1px solid var(--line);
}
section + section {
margin-top: 12px;
}
h2 {
margin: 0;
font-size: 13px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.tool-card {
border: 1px solid var(--line);
background: var(--panel-3);
border-radius: 6px;
padding: 10px;
}
.section-title {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.section-title span {
color: var(--muted);
font-size: 11px;
}
.field-grid,
.property-panel {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
label {
display: grid;
gap: 4px;
color: var(--muted);
font-size: 12px;
}
input,
select,
textarea {
width: 100%;
min-width: 0;
border: 1px solid var(--line);
background: #0f1114;
color: var(--text);
border-radius: 5px;
padding: 7px 8px;
}
.button-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.button-grid button,
.button-grid .file-button {
text-align: center;
}
.object-section {
min-height: 0;
}
.object-list,
.tileset-list {
display: grid;
gap: 6px;
}
.object-item,
.tileset-item {
border: 1px solid var(--line);
background: #111418;
border-radius: 6px;
padding: 8px;
cursor: pointer;
text-align: left;
}
.object-item:hover {
border-color: var(--accent);
}
.object-item.active {
border-color: var(--accent);
background: #122236;
}
.object-title {
font-size: 13px;
font-weight: 600;
}
.object-meta {
margin-top: 3px;
color: var(--muted);
font-size: 12px;
}
.canvas-panel {
min-width: 0;
display: grid;
grid-template-rows: 40px 1fr;
background: #0b0d10;
}
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
border-bottom: 1px solid var(--line);
color: var(--muted);
font-size: 13px;
}
#mapCanvas {
width: 100%;
height: 100%;
display: block;
background: #0e1115;
cursor: crosshair;
}
.selection-summary {
min-height: 34px;
color: var(--muted);
font-size: 13px;
margin-bottom: 10px;
}
.danger-button {
width: 100%;
margin-top: 10px;
color: #ffd8d8;
border-color: #633;
}
.danger-button:hover {
border-color: var(--danger);
}
.layer-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
#outputText {
height: 220px;
resize: vertical;
white-space: pre;
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12px;
}
+11 -6
View File
@@ -44,8 +44,10 @@ if is_plat("windows") then
add_packages("zlib") add_packages("zlib")
add_syslinks("imm32") add_syslinks("imm32")
after_build(function (target) after_build(function (target)
os.cp(path.join(os.projectdir(), "game/assets"), local assetsdir = path.join(target:targetdir(), "assets")
path.join(target:targetdir(), "assets")) os.rm(assetsdir)
os.mkdir(assetsdir)
os.cp(path.join(os.projectdir(), "game/assets/*"), assetsdir)
end) end)
target_end() target_end()
end end
@@ -60,9 +62,11 @@ if is_plat("switch") then
add_ldflags("-specs=" .. path.join(os.getenv("DEVKITPRO") or "C:/devkitPro", "libnx/switch.specs"), "-fPIE", {force = true}) add_ldflags("-specs=" .. path.join(os.getenv("DEVKITPRO") or "C:/devkitPro", "libnx/switch.specs"), "-fPIE", {force = true})
after_build(function (target) after_build(function (target)
local outdir = path.join(target:targetdir(), "switch_game") local outdir = path.join(target:targetdir(), "switch_game")
local assetsdir = path.join(outdir, "assets")
os.mkdir(outdir) os.mkdir(outdir)
os.cp(path.join(os.projectdir(), "game/assets"), os.rm(assetsdir)
path.join(outdir, "assets")) os.mkdir(assetsdir)
os.cp(path.join(os.projectdir(), "game/assets/*"), assetsdir)
local devkitpro = os.getenv("DEVKITPRO") or "C:/devkitPro" local devkitpro = os.getenv("DEVKITPRO") or "C:/devkitPro"
local tools = path.join(devkitpro, "tools/bin") local tools = path.join(devkitpro, "tools/bin")
@@ -70,10 +74,11 @@ if is_plat("switch") then
local nacp = path.join(outdir, "control.nacp") local nacp = path.join(outdir, "control.nacp")
local nro = path.join(outdir, "NSUnknownGame.nro") local nro = path.join(outdir, "NSUnknownGame.nro")
local romfsdir = path.join(outdir, "romfs") local romfsdir = path.join(outdir, "romfs")
local romfsassetsdir = path.join(romfsdir, "assets")
os.rm(romfsdir) os.rm(romfsdir)
os.mkdir(romfsdir) os.mkdir(romfsdir)
os.cp(path.join(os.projectdir(), "game/assets"), os.mkdir(romfsassetsdir)
path.join(romfsdir, "assets")) os.cp(path.join(os.projectdir(), "game/assets/*"), romfsassetsdir)
os.execv(path.join(tools, "nacptool.exe"), { os.execv(path.join(tools, "nacptool.exe"), {
"--create", "NS Unknown Game", "Frostbite2D", "1.0.0", nacp "--create", "NS Unknown Game", "Frostbite2D", "1.0.0", nacp
}) })