diff --git a/BUILD.md b/BUILD.md index 5efaf90..33dd22c 100644 --- a/BUILD.md +++ b/BUILD.md @@ -121,6 +121,8 @@ Switch 版依赖不走 XMake 的桌面包,而是使用 devkitPro 的 portlibs - SDL2_ttf - GLM - zlib +- HarfBuzz +- bzip2 - libnx ### 2.2 编译 Switch 引擎静态库 @@ -302,6 +304,7 @@ romfs:/assets - `Frostbite2DGameSwitch` 可成功编译为 ELF/NRO - PC 和 Switch 共用同一套 GLES 300 es shader - Windows 游戏目标仍可正常编译和启动 +- devkitPro 当前版本的 `SDL2_ttf` / `freetype` 需要静态链接 `harfbuzz` 和 `bz2`,该链接项已写入 `engine.lua` ## 3. 常见问题 @@ -355,3 +358,13 @@ romfs:/assets/character/male/*.png 旧版本曾只把资源复制到 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 +``` diff --git a/engine.lua b/engine.lua index b7f3592..96e74d6 100644 --- a/engine.lua +++ b/engine.lua @@ -46,7 +46,7 @@ function add_frostbite2d_target() add_includedirs(path.join(portlibs, "include/freetype2"), {public = true}) add_includedirs(path.join(libnx, "include"), {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}) else add_packages("libsdl2", {public = true}) diff --git a/game/assets/ASSETS.md b/game/assets/ASSETS.md new file mode 100644 index 0000000..579a6ac --- /dev/null +++ b/game/assets/ASSETS.md @@ -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` | diff --git a/game/assets/ImagePacks2/.gitkeep b/game/assets/ImagePacks2/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/game/assets/ImagePacks2/.gitkeep @@ -0,0 +1 @@ + diff --git a/game/assets/SoundPacks/.gitkeep b/game/assets/SoundPacks/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/game/assets/SoundPacks/.gitkeep @@ -0,0 +1 @@ + diff --git a/game/assets/audio/.gitkeep b/game/assets/audio/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/game/assets/audio/.gitkeep @@ -0,0 +1 @@ + diff --git a/game/assets/fonts/.gitkeep b/game/assets/fonts/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/game/assets/fonts/.gitkeep @@ -0,0 +1 @@ + diff --git a/game/assets/music/.gitkeep b/game/assets/music/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/game/assets/music/.gitkeep @@ -0,0 +1 @@ + diff --git a/game/assets/sounds/.gitkeep b/game/assets/sounds/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/game/assets/sounds/.gitkeep @@ -0,0 +1 @@ + diff --git a/game/assets/stage/stage_01/ASSETS.md b/game/assets/stage/stage_01/ASSETS.md index 191f505..063e64a 100644 --- a/game/assets/stage/stage_01/ASSETS.md +++ b/game/assets/stage/stage_01/ASSETS.md @@ -12,7 +12,8 @@ This directory is reserved for the first playable side-scrolling ACT stage. | Props sheet | `props/stage_01_props.png` | Crates, signs, lamps, blockers, exits. | TBD | | Hit spark | `effects/hit_spark.png` | Small combat impact effect. | TBD | | Dust | `effects/dust.png` | Run, jump, and landing dust. | TBD | -| Music / ambience | `audio/` | Optional loopable stage audio. | TBD | +| Music / ambience | `../../music/stage_01.ogg` | Optional loopable stage audio cue. | TBD | +| Stage SFX | `../../sounds/` | Optional one-shot SFX cues. | TBD | ## Import Rules diff --git a/game/docs/README.md b/game/docs/README.md index 20c35f2..e723ec9 100644 --- a/game/docs/README.md +++ b/game/docs/README.md @@ -10,6 +10,7 @@ ## References +- `../assets/ASSETS.md`:全局资源槽位,包含字体、音频、PVF、NPK、SoundPack 和角色动画 fallback 路径。 - `controls.md`:玩家操作、平台输入映射和开发调试快捷键。 - `engine_capability_audit.md`:引擎已有能力复盘,以及游戏层应优先复用的系统。 - `game_skeleton.md`:当前游戏骨架如何接入引擎基础能力。 diff --git a/game/docs/engine_capability_audit.md b/game/docs/engine_capability_audit.md index e455e80..d1778a7 100644 --- a/game/docs/engine_capability_audit.md +++ b/game/docs/engine_capability_audit.md @@ -4,11 +4,11 @@ ## 结论 -当前游戏层已经用了引擎的核心框架,但地图和角色动画仍有明显“游戏层自造/硬编码”倾向。 +当前游戏层已经用了引擎核心框架,并且已把地图、动画、UI、音频、存档和 NPK/SoundPack 资源入口接到真实调用路径。 -最重要的调整是:**场景地图不要继续在 C++ 里手写 `Rect`,下一步应接入 `.map` 文件加载,并优先通过引擎 `Asset` / `PvfArchive` / `ScriptParser` 读取和解析。** +最重要的调整已经完成:场景地图不再以 C++ 硬编码为主,`stage_01.map` 通过 `Asset` / `PvfArchive` / `ScriptParser` 读取入口进入 `LevelDefinition`。 -当前第一轮骨架接入已完成:游戏启动时通过 `GameServices` 探测/初始化 `AudioSystem`、`PvfArchive`、`NpkArchive`、`SoundPackArchive`、`AudioDatabase` 和 `SaveSystem` 状态,并通过 `StageMapResource` + `StageMapLoader` 接入 `.map` 资源读取和解析入口。详细状态见 `game_skeleton.md`。 +当前骨架接入状态:游戏启动时通过 `GameServices` 探测/初始化 `AudioSystem`、`FontManager`、`PvfArchive`、`NpkArchive`、`SoundPackArchive`、`AudioDatabase` 和 `SaveSystem` 状态;`PlayerActor` 已接入 `Animation`/NPK/PNG 优先级;`WhiteboxScene` 已接入 `UIScene`、音频 cue 和 debug 存档。详细状态见 `game_skeleton.md`。 推荐方向: @@ -60,6 +60,7 @@ - `StageRenderer` 已复用引擎 Renderer 和 Texture。 - `CameraController` 使用引擎 `Camera` 做位置控制。 +- `GameUiOverlay` 通过 `UIScene` 进入 UI scene stack。 - 游戏入口已设置像素风渲染风格。 需要调整: @@ -79,6 +80,7 @@ - `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` 检查可选资源。 下一步: @@ -122,19 +124,19 @@ 当前状态: -- 玩家现在使用 `Sprite::createFromFile` 加载 PNG spritesheet。 -- 玩家动画帧切换在 `PlayerActor` 内手写。 -- 还没有使用 `Animation`、`.ani`、`.als`、PVF 动画数据或 NPK 图包。 +- 玩家动画加载顺序为 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 仍需要少量手写帧切换。 下一步建议: -- 在地图框架后,角色动画应迁移到引擎 `Animation`。 -- 若素材来自 NPK/PVF 体系,优先用 `Animation + NpkArchive + parseAniFromPvf`。 -- 若当前临时素材仍是 PNG spritesheet,可以短期保留,但不要扩展成复杂自研动画状态系统。 +- 为玩家和敌人补齐 PVF `.ani` 或 NPK IMG 后,逐步移除 PNG fallback 的手写帧切换。 +- 将 attack/damage box 从 `Animation::GetCurrentFrameInfo()` 接入战斗 hitbox。 ### Audio / Save @@ -149,12 +151,29 @@ 当前状态: -- 游戏层尚未使用音频和存档。 +- `GameAudio` 已接入 `Sound`、`Music` 和 `AudioDatabase` 查询。 +- `WhiteboxScene` 会尝试播放 `stage_01` BGM 和 battle zone 进入音效。 +- `PlayerActor` 会尝试播放跳跃、攻击音效。 +- `GameSave` 已用 `SaveSystem` 保存 debug overlay 开关。 后续原则: -- 音效、BGM、环境音直接接引擎音频系统。 -- 设置、进度、调试存档优先用 `SaveSystem`,不要在游戏层重新写文件存档服务。 +- 正式音效、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 @@ -198,20 +217,20 @@ - 继续扩展 `.map` 的 tileset/tile entry 表达。 - 逐步把 `level_visual` 的长色块替换成 tile 数据。 -### 2. 手写动画帧切换 +### 2. PNG fallback 手写动画帧切换 文件: - `game/src/actor/player_actor.cpp` -问题: +当前状态: -- idle/run/punch 都在 C++ 里硬编码帧数、帧时间和 source rect。 +- 已接入 `Animation` 和 NPK 优先路径。 +- 临时 PNG fallback 仍在 C++ 里硬编码帧数、帧时间和 source rect。 建议: -- 先记录为短期可接受。 -- 后续迁移到引擎 `Animation`,尤其当角色素材进入 PVF/NPK/ANI 体系后。 +- 等正式 `.ani` / `.img` 资源补齐后,删除 PNG fallback 的手写帧切换。 ### 3. 游戏层贴图缓存 diff --git a/game/docs/game_skeleton.md b/game/docs/game_skeleton.md index 5b08a23..a92952e 100644 --- a/game/docs/game_skeleton.md +++ b/game/docs/game_skeleton.md @@ -28,13 +28,26 @@ 游戏层新增 `GameServices`,只做游戏项目需要的引擎服务接入和状态记录: - 初始化 `AudioSystem`。 +- 初始化 `FontManager`,有 `assets/fonts/debug.ttf` 或 `assets/fonts/ui.ttf` 时注册 debug 字体。 - 探测并打开可选 `Script.pvf`。 - 初始化 `NpkArchive`。 - 初始化 `SoundPackArchive`。 - 探测并加载可选 `AudioDatabase`。 - 记录 `SaveSystem` 是否已由引擎初始化。 -这些服务是可选能力。缺少 `Script.pvf`、`ImagePacks2`、`SoundPacks` 或 `audio.xml` 时,游戏仍然使用当前 PNG/白盒 fallback 启动。 +这些服务是可选能力。缺少 `Script.pvf`、`ImagePacks2`、`SoundPacks`、字体、音频或 `audio.xml` 时,游戏仍然使用当前 PNG/白盒 fallback 启动。 + +## 引擎能力接入点 + +当前游戏骨架已把以下引擎能力接入到真实调用路径: + +- `Animation`:`PlayerActor` 会优先尝试 PVF `.ani`,失败后回退 NPK IMG,再回退 PNG spritesheet。 +- `NpkArchive` / `Sprite::createFromNpk`:角色资源和后续场景素材可从 `assets/ImagePacks2/*.npk` 进入。 +- `TextSprite` / `UIScene`:`GameUiOverlay` 已接入 UI scene stack;有字体时显示文字状态,没有字体时用色条 fallback。 +- `Sound` / `Music` / `AudioDatabase`:`GameAudio` 已支持 cue id,优先使用 `audio.xml`,否则读取 `assets/sounds/*.wav` 和 `assets/music/*.ogg`。 +- `SaveSystem`:`GameSave` 已保存/读取 debug overlay 开关状态。 + +资源槽位见 `../assets/ASSETS.md`。 ## 地图资源入口 @@ -80,9 +93,9 @@ endbattle_zone ## 仍待迁移的能力 -- 玩家动画:从手写 spritesheet 帧切换迁移到引擎 `Animation` / PVF ANI。 -- 场景视觉资源:如果素材进入 NPK,迁移到 `NpkArchive` / `Sprite::createFromNpk`。 -- 音频:使用 `AudioSystem`、`Sound`、`Music`、`AudioDatabase`。 +- 玩家动画:当前已接入 `Animation` 优先路径,但临时 PNG fallback 仍保留手写帧切换,等待正式 PVF/NPK 动画资源。 +- 场景视觉资源:`StageRenderer` 支持普通 PNG;若素材进入 NPK,后续补 stage layer 的 NPK sprite path。 +- 音频:调用路径已接入,等待正式音频资源和 `audio.xml`。 - 地图:后续可把 `.map` 扩展为工具导出格式或 PVF 脚本格式,当前 C++ 地图只作为 fallback。 - 输入配置:通过 `Asset` 或 PVF 读取 `InputConfig`。 diff --git a/game/docs/planning/scene_build_plan.md b/game/docs/planning/scene_build_plan.md index d98aea2..afb460e 100644 --- a/game/docs/planning/scene_build_plan.md +++ b/game/docs/planning/scene_build_plan.md @@ -273,15 +273,24 @@ game/assets/stage/stage_01/ 第一轮引擎能力接入已完成:`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 entry 约定,先不做编辑器,只保证工具或手写数据能表达重复地砖。 2. 在 `StageRenderer` 中增加 tile 绘制路径,复用当前 `Texture` 加载和 source rect 支持。 3. 把 `level_visual` 的长色块逐步替换成 tile entries,碰撞仍独立保留在 `collision`。 -4. 给 map loader 增加更严格的错误日志,例如重复 block、空 layer、battle zone 无 spawn 的提示。 -5. 补一个轻量 map 验收流程:启动时日志确认加载 `.map`,RomFS/Windows 产物确认存在 `assets/map/stage_01.map`。 +4. 给 map loader 增加更严格的错误日志,例如重复 block、空 layer、battle zone 无 spawn、tile 引用不存在的提示。 +5. 补一个轻量 map 验收流程:启动时日志确认加载 `.map`,Windows/Switch 产物确认存在 `assets/map/stage_01.map` 和全局素材槽位。 ### Debug 快捷键 diff --git a/game/docs/scene_acceptance.md b/game/docs/scene_acceptance.md index 8a621ea..1e3a818 100644 --- a/game/docs/scene_acceptance.md +++ b/game/docs/scene_acceptance.md @@ -14,6 +14,9 @@ - Battle zones:`zone_01_intro`、`zone_02_platform`、`zone_03_exit` - 资源槽位:`game/assets/stage/stage_01/` - 地图资源:`game/assets/map/stage_01.map` +- 全局资源槽位:`game/assets/ASSETS.md` +- UI overlay:`GameUiOverlay`,有字体时使用 `TextSprite`,无字体时使用 fallback 色条。 +- 音频 cue:`stage_01`、`player_jump`、`player_attack`、`battle_zone_enter`。 ## 构建检查 @@ -37,6 +40,8 @@ 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 ``` ### Switch @@ -56,6 +61,8 @@ 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 ``` ## 手动跑图检查 @@ -72,6 +79,8 @@ Test-Path build\switch\aarch64\release\switch_game\romfs\assets\map\stage_01.map - battle zone 触发框和 camera bounds。 - 敌人出生点。 6. 从起点移动到终点附近,确认所有平台、props、前景遮挡和背景层没有闪烁或黑屏。 +7. 确认 UI overlay 可见;若没有字体资源,应显示左上角色条 fallback。 +8. 切换 F1/F3/F4/F5/F6 后重启,确认 debug 开关能通过 `SaveSystem` 记住。 ## 当前验收结果 @@ -82,6 +91,7 @@ Test-Path build\switch\aarch64\release\switch_game\romfs\assets\map\stage_01.map - Switch `Frostbite2DGameSwitch` 构建:通过。 - Switch RomFS:已包含 character、shader、`stage_01` 资源槽位和 `assets/map/stage_01.map`。 - 地图加载:`stage_01.map` 已作为第一关主数据源;C++ 白盒数据仅作为 fallback。 +- 引擎能力接入:`Animation`/NPK/PNG 角色资源优先级、`UIScene`/`TextSprite` UI、`Sound`/`Music` cue、`SaveSystem` debug 存档已接入。 - 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。 ## 后续补充 diff --git a/game/src/actor/player_actor.cpp b/game/src/actor/player_actor.cpp index 6b1bd12..5a0fe60 100644 --- a/game/src/actor/player_actor.cpp +++ b/game/src/actor/player_actor.cpp @@ -1,6 +1,7 @@ #include "player_actor.h" #include "../core/asset_paths.h" +#include "../core/game_audio.h" #include "../core/game_config.h" #include @@ -8,13 +9,6 @@ namespace ns_game { -namespace { - -constexpr float kSpriteOffsetX = -32.0f; -constexpr float kSpriteOffsetY = -24.0f; - -} // namespace - PlayerActor::PlayerActor() { SetSize(64.0f, 104.0f); SetTopLeftPosition(160.0f, 380.0f); @@ -26,29 +20,29 @@ void PlayerActor::SetMovementWorld(const PlatformWorld* world) { } void PlayerActor::loadSprites() { - idleSprite_ = frostbite2D::Sprite::createFromFile(assets::kMaleIdle); - runSprite_ = frostbite2D::Sprite::createFromFile(assets::kMaleRun); - punchSprite_ = frostbite2D::Sprite::createFromFile(assets::kMalePunch); + idleAsset_ = LoadPlayerAnimationAsset(assets::kMaleIdleAni, + assets::kMaleIdleImg, + 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 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); - sprite->SetVisible(false); - AddChild(sprite); - }; - - configure(idleSprite_); - configure(runSprite_); - configure(punchSprite_); + addAnimationAsset(idleAsset_); + addAnimationAsset(runAsset_); + addAnimationAsset(punchAsset_); 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) { deltaTime = std::min(deltaTime, 1.0f / 30.0f); updateInput(); @@ -68,11 +62,13 @@ void PlayerActor::updateInput() { if (input.jumpPressed && grounded_) { velocity_.y = config::kPlayerJumpSpeed; grounded_ = false; + GameAudio::Get().PlaySoundCue("player_jump"); } if (input.attackPressed) { attackTimer_ = config::kAttackDuration; setAnimState(AnimState::Punch); + GameAudio::Get().PlaySoundCue("player_attack"); } } @@ -117,43 +113,57 @@ void PlayerActor::updateAnimation(float deltaTime) { frameIndex_ = (frameIndex_ + 1) % frameCount; } - auto sprite = activeSprite(); - if (sprite) { - sprite->SetSourceRect( + PlayerAnimationAsset* asset = activeAsset(); + if (!asset) { + return; + } + + if (asset->animation) { + asset->animation->SetDirection(facingLeft_ ? -1 : 1); + } + if (asset->sprite) { + asset->sprite->SetSourceRect( frostbite2D::Rect(frameIndex_ * config::kPlayerFrameSize, 0.0f, config::kPlayerFrameSize, config::kPlayerFrameSize)); - sprite->SetFlippedX(facingLeft_); + asset->sprite->SetFlippedX(facingLeft_); } } void PlayerActor::setAnimState(AnimState state) { - if (animState_ != state) { + const bool stateChanged = animState_ != state; + if (stateChanged) { animState_ = state; animTimer_ = 0.0f; frameIndex_ = 0; } - if (idleSprite_) { - idleSprite_->SetVisible(state == AnimState::Idle); - } - if (runSprite_) { - runSprite_->SetVisible(state == AnimState::Run); - } - if (punchSprite_) { - punchSprite_->SetVisible(state == AnimState::Punch); - } + auto setVisible = [stateChanged](PlayerAnimationAsset& asset, bool visible) { + if (asset.animation) { + asset.animation->SetVisible(visible); + if (visible && stateChanged) { + asset.animation->Reset(); + } + } + if (asset.sprite) { + asset.sprite->SetVisible(visible); + } + }; + + setVisible(idleAsset_, state == AnimState::Idle); + setVisible(runAsset_, state == AnimState::Run); + setVisible(punchAsset_, state == AnimState::Punch); } -frostbite2D::Ptr PlayerActor::activeSprite() const { +PlayerAnimationAsset* PlayerActor::activeAsset() { switch (animState_) { case AnimState::Run: - return runSprite_; + return &runAsset_; case AnimState::Punch: - return punchSprite_; + return &punchAsset_; case AnimState::Idle: default: - return idleSprite_; + return &idleAsset_; } } diff --git a/game/src/actor/player_actor.h b/game/src/actor/player_actor.h index c297414..bc3833a 100644 --- a/game/src/actor/player_actor.h +++ b/game/src/actor/player_actor.h @@ -2,9 +2,9 @@ #include "../core/input_state.h" #include "../movement/platform_world.h" +#include "player_animation_assets.h" #include -#include #include namespace ns_game { @@ -24,18 +24,19 @@ private: }; void loadSprites(); + void addAnimationAsset(PlayerAnimationAsset& asset); void updateInput(); void updatePhysics(float deltaTime); void updateAnimation(float deltaTime); void setAnimState(AnimState state); - frostbite2D::Ptr activeSprite() const; + PlayerAnimationAsset* activeAsset(); const PlatformWorld* movementWorld_ = nullptr; InputStateProvider inputProvider_; PlatformMover platformMover_; - frostbite2D::Ptr idleSprite_; - frostbite2D::Ptr runSprite_; - frostbite2D::Ptr punchSprite_; + PlayerAnimationAsset idleAsset_; + PlayerAnimationAsset runAsset_; + PlayerAnimationAsset punchAsset_; frostbite2D::Vec2 velocity_; AnimState animState_ = AnimState::Idle; diff --git a/game/src/actor/player_animation_assets.cpp b/game/src/actor/player_animation_assets.cpp new file mode 100644 index 0000000..6fd4fb9 --- /dev/null +++ b/game/src/actor/player_animation_assets.cpp @@ -0,0 +1,64 @@ +#include "player_animation_assets.h" + +#include +#include +#include + +#include + +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(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 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 diff --git a/game/src/actor/player_animation_assets.h b/game/src/actor/player_animation_assets.h new file mode 100644 index 0000000..92686c7 --- /dev/null +++ b/game/src/actor/player_animation_assets.h @@ -0,0 +1,24 @@ +#pragma once + +#include "../core/game_config.h" + +#include +#include + +#include + +namespace ns_game { + +struct PlayerAnimationAsset { + frostbite2D::Ptr animation; + frostbite2D::Ptr 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 sprite); + +} // namespace ns_game diff --git a/game/src/core/asset_paths.h b/game/src/core/asset_paths.h index a40d9f9..8caf56a 100644 --- a/game/src/core/asset_paths.h +++ b/game/src/core/asset_paths.h @@ -9,4 +9,12 @@ constexpr const char* kMaleRun = constexpr const char* kMalePunch = "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 diff --git a/game/src/core/game_audio.cpp b/game/src/core/game_audio.cpp new file mode 100644 index 0000000..2391d54 --- /dev/null +++ b/game/src/core/game_audio.cpp @@ -0,0 +1,99 @@ +#include "game_audio.h" + +#include "game_services.h" + +#include +#include +#include + +#include + +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 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 music = LoadMusic(path); + if (!music) { + return; + } + + currentMusic_ = music; + currentMusic_->fadeIn(250); +} + +frostbite2D::Ptr +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 +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 diff --git a/game/src/core/game_audio.h b/game/src/core/game_audio.h new file mode 100644 index 0000000..73c34b0 --- /dev/null +++ b/game/src/core/game_audio.h @@ -0,0 +1,30 @@ +#pragma once + +#include +#include + +#include +#include + +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 LoadSound(const std::string& path) const; + frostbite2D::Ptr LoadMusic(const std::string& path) const; + std::string ResolveAudioPath(const std::string& cueId) const; + + std::unordered_map> + soundCache_; + frostbite2D::Ptr currentMusic_; +}; + +} // namespace ns_game diff --git a/game/src/core/game_save.cpp b/game/src/core/game_save.cpp new file mode 100644 index 0000000..d312c71 --- /dev/null +++ b/game/src/core/game_save.cpp @@ -0,0 +1,70 @@ +#include "game_save.h" + +#include + +#include +#include + +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 GameSave::LoadDebugFlags() const { + const std::optional payload = + frostbite2D::SaveSystem::get().loadSlotPayload(kDebugSlot); + if (!payload || !payload->is_object()) { + return std::nullopt; + } + return fromJson(*payload); +} + +} // namespace ns_game diff --git a/game/src/core/game_save.h b/game/src/core/game_save.h new file mode 100644 index 0000000..bffc527 --- /dev/null +++ b/game/src/core/game_save.h @@ -0,0 +1,20 @@ +#pragma once + +#include "../core/debug_flags.h" + +#include + +namespace ns_game { + +class GameSave { +public: + static GameSave& Get(); + + void SaveDebugFlags(const DebugFlags& flags); + std::optional LoadDebugFlags() const; + +private: + GameSave() = default; +}; + +} // namespace ns_game diff --git a/game/src/core/game_services.cpp b/game/src/core/game_services.cpp index fd4fa30..d935ce2 100644 --- a/game/src/core/game_services.cpp +++ b/game/src/core/game_services.cpp @@ -1,6 +1,7 @@ #include "game_services.h" #include +#include #include #include #include @@ -42,14 +43,17 @@ void GameServices::Initialize() { status_.saveInitialized = frostbite2D::SaveSystem::get().listSlots().size() > 0; initializeAudio(); + initializeFonts(); initializePvfArchive(); initializeNpkArchive(); initializeSoundPackArchive(); initializeAudioDatabase(); - SDL_Log("GameServices: audio=%d save=%d pvf=%d npk=%d soundpack=%d audiodb=%d", + 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, @@ -63,6 +67,24 @@ void GameServices::initializeAudio() { 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) { diff --git a/game/src/core/game_services.h b/game/src/core/game_services.h index 4eecace..eb6bc3e 100644 --- a/game/src/core/game_services.h +++ b/game/src/core/game_services.h @@ -5,6 +5,8 @@ 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; @@ -25,6 +27,7 @@ private: GameServices() = default; void initializeAudio(); + void initializeFonts(); void initializePvfArchive(); void initializeNpkArchive(); void initializeSoundPackArchive(); diff --git a/game/src/scene/whitebox_scene.cpp b/game/src/scene/whitebox_scene.cpp index 9fa7517..c50835e 100644 --- a/game/src/scene/whitebox_scene.cpp +++ b/game/src/scene/whitebox_scene.cpp @@ -1,11 +1,15 @@ #include "whitebox_scene.h" +#include "../core/game_audio.h" #include "../core/game_config.h" +#include "../core/game_save.h" +#include "../core/game_services.h" #include #include #include #include +#include #include namespace ns_game { @@ -13,6 +17,9 @@ namespace ns_game { void WhiteboxScene::onEnter() { frostbite2D::Scene::onEnter(); level_ = CreateWhiteboxLevel(); + if (auto savedDebugFlags = GameSave::Get().LoadDebugFlags()) { + debugFlags_ = *savedDebugFlags; + } battleZoneStates_.clear(); battleZoneStates_.resize(level_.battleZones.size()); activeBattleZoneIndex_ = -1; @@ -22,6 +29,11 @@ void WhiteboxScene::onEnter() { player_->SetMovementWorld(&level_.collision); AddChild(player_); configureCamera(); + + uiOverlay_ = frostbite2D::MakePtr(); + frostbite2D::SceneManager::get().ReplaceUIScene(uiOverlay_); + updateUiOverlay(); + GameAudio::Get().PlayMusicCue("stage_01"); } void WhiteboxScene::Update(float deltaTime) { @@ -29,6 +41,7 @@ void WhiteboxScene::Update(float deltaTime) { updateBattleZones(); updateCameraBoundsBlend(deltaTime); updateCamera(deltaTime); + updateUiOverlay(); } bool WhiteboxScene::OnEvent(const frostbite2D::Event& event) { @@ -107,15 +120,19 @@ bool WhiteboxScene::handleDebugKey(frostbite2D::KeyCode keyCode) { 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; @@ -124,6 +141,7 @@ bool WhiteboxScene::handleDebugKey(frostbite2D::KeyCode keyCode) { void WhiteboxScene::toggleDebugOverlay() { debugFlags_.enabled = !debugFlags_.enabled; + persistDebugFlags(); } void WhiteboxScene::beginCameraBoundsBlend( @@ -197,6 +215,7 @@ void WhiteboxScene::updateBattleZones() { runtime.triggered = true; activeBattleZoneIndex_ = static_cast(i); beginCameraBoundsBlend(level_.battleZones[i].cameraBounds); + GameAudio::Get().PlaySoundCue("battle_zone_enter"); break; } } @@ -214,4 +233,20 @@ void WhiteboxScene::updateCamera(float deltaTime) { 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 diff --git a/game/src/scene/whitebox_scene.h b/game/src/scene/whitebox_scene.h index 394d539..4583d86 100644 --- a/game/src/scene/whitebox_scene.h +++ b/game/src/scene/whitebox_scene.h @@ -4,6 +4,7 @@ #include "../core/debug_flags.h" #include "../data/level_definition.h" #include "../debug/debug_overlay.h" +#include "../ui/game_ui_overlay.h" #include "camera_controller.h" #include "../stage/stage_renderer.h" @@ -37,6 +38,8 @@ private: void updateCameraBoundsBlend(float deltaTime); void updateBattleZones(); void updateCamera(float deltaTime); + void updateUiOverlay(); + void persistDebugFlags(); LevelDefinition level_; DebugFlags debugFlags_; @@ -51,6 +54,7 @@ private: CameraController cameraController_; StageRenderer stageRenderer_; DebugOverlay debugOverlay_; + frostbite2D::Ptr uiOverlay_; frostbite2D::Ptr player_; }; diff --git a/game/src/ui/game_ui_overlay.cpp b/game/src/ui/game_ui_overlay.cpp new file mode 100644 index 0000000..c8f31e2 --- /dev/null +++ b/game/src/ui/game_ui_overlay.cpp @@ -0,0 +1,77 @@ +#include "game_ui_overlay.h" + +#include "../core/game_config.h" + +#include +#include + +#include + +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 diff --git a/game/src/ui/game_ui_overlay.h b/game/src/ui/game_ui_overlay.h new file mode 100644 index 0000000..5ebed3f --- /dev/null +++ b/game/src/ui/game_ui_overlay.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../core/debug_flags.h" +#include "../core/game_services.h" + +#include +#include +#include + +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 statusText_; + bool textReady_ = false; +}; + +} // namespace ns_game