Wire engine feature services into game skeleton

This commit is contained in:
2026-06-09 22:58:42 +08:00
parent d353e91f5e
commit d41f9d1096
30 changed files with 680 additions and 77 deletions
+13
View File
@@ -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
```
+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(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})
+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 @@
+1
View File
@@ -0,0 +1 @@
+1
View File
@@ -0,0 +1 @@
+2 -1
View File
@@ -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
+1
View File
@@ -10,6 +10,7 @@
## References
- `../assets/ASSETS.md`:全局资源槽位,包含字体、音频、PVF、NPK、SoundPack 和角色动画 fallback 路径。
- `controls.md`:玩家操作、平台输入映射和开发调试快捷键。
- `engine_capability_audit.md`:引擎已有能力复盘,以及游戏层应优先复用的系统。
- `game_skeleton.md`:当前游戏骨架如何接入引擎基础能力。
+37 -18
View File
@@ -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. 游戏层贴图缓存
+17 -4
View File
@@ -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`
+12 -3
View File
@@ -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 快捷键
+10
View File
@@ -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 存档已接入。
- 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。
## 后续补充
+54 -44
View File
@@ -1,6 +1,7 @@
#include "player_actor.h"
#include "../core/asset_paths.h"
#include "../core/game_audio.h"
#include "../core/game_config.h"
#include <algorithm>
@@ -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<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);
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<frostbite2D::Sprite> 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_;
}
}
+6 -5
View File
@@ -2,9 +2,9 @@
#include "../core/input_state.h"
#include "../movement/platform_world.h"
#include "player_animation_assets.h"
#include <frostbite2D/2d/actor.h>
#include <frostbite2D/2d/sprite.h>
#include <frostbite2D/types/type_math.h>
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<frostbite2D::Sprite> activeSprite() const;
PlayerAnimationAsset* activeAsset();
const PlatformWorld* movementWorld_ = nullptr;
InputStateProvider inputProvider_;
PlatformMover platformMover_;
frostbite2D::Ptr<frostbite2D::Sprite> idleSprite_;
frostbite2D::Ptr<frostbite2D::Sprite> runSprite_;
frostbite2D::Ptr<frostbite2D::Sprite> punchSprite_;
PlayerAnimationAsset idleAsset_;
PlayerAnimationAsset runAsset_;
PlayerAnimationAsset punchAsset_;
frostbite2D::Vec2 velocity_;
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 =
"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
+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
+23 -1
View File
@@ -1,6 +1,7 @@
#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>
@@ -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) {
+3
View File
@@ -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();
+35
View File
@@ -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 <frostbite2D/core/application.h>
#include <frostbite2D/event/key_event.h>
#include <frostbite2D/graphics/camera.h>
#include <frostbite2D/graphics/renderer.h>
#include <frostbite2D/scene/scene_manager.h>
#include <algorithm>
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<GameUiOverlay>();
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<int>(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
+4
View File
@@ -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<GameUiOverlay> uiOverlay_;
frostbite2D::Ptr<PlayerActor> player_;
};
+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