Compare commits
46 Commits
5525343656
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 0cd665198b | |||
| c6d42ff0e0 | |||
| 21f633658c | |||
| 57caced672 | |||
| bc300f4ff9 | |||
| 7d0f115fcb | |||
| dddae61bd9 | |||
| 8daf5d27aa | |||
| 8050ab2c5e | |||
| 078e131381 | |||
| d46bb4dae0 | |||
| d652d576e3 | |||
| d7bc9e6a24 | |||
| 759abfd527 | |||
| fb54c9f207 | |||
| e1429f3046 | |||
| 51a3776494 | |||
| 0257c8058e | |||
| d41f9d1096 | |||
| d353e91f5e | |||
| 3fc9caa80b | |||
| 45a8712b00 | |||
| 562f9618c4 | |||
| f5cbb5731c | |||
| 61249e2a29 | |||
| 3a19005bbf | |||
| 0a59d20ffa | |||
| cf7b7dfb5a | |||
| 3e2e25eb93 | |||
| 493b2bd3b8 | |||
| 3a56db3dc7 | |||
| c1096f0ed5 | |||
| 47936abed0 | |||
| 8a89346978 | |||
| 51f1bdd166 | |||
| 3c5a4968ba | |||
| d5e876e542 | |||
| 8bbb847a1b | |||
| c87b245cea | |||
| e8b05e93b9 | |||
| b78aaa5cad | |||
| cc75b94c44 | |||
| ce815ed58b | |||
| b6bc29ae19 | |||
| 3db02af593 | |||
| e52cf94336 |
@@ -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 引擎静态库
|
||||||
@@ -237,6 +239,73 @@ Frostbite2D Switch smoke test
|
|||||||
Press PLUS to exit.
|
Press PLUS to exit.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 2.4 编译 Switch 游戏框架 NRO
|
||||||
|
|
||||||
|
当前游戏框架已经接入 Switch 构建目标:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Frostbite2DGameSwitch
|
||||||
|
```
|
||||||
|
|
||||||
|
该目标和 Windows 共用:
|
||||||
|
|
||||||
|
```text
|
||||||
|
game\src\main.cpp
|
||||||
|
```
|
||||||
|
|
||||||
|
入口内部通过 `__SWITCH__` 编译分支处理平台差异,运行同一套
|
||||||
|
`WhiteboxScene`、`StageRenderer`、`DebugOverlay`、`PlayerActor` 和
|
||||||
|
`LevelDefinition` 游戏框架。
|
||||||
|
|
||||||
|
构建命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PATH='C:\devkitPro\devkitA64\bin;C:\devkitPro\tools\bin;C:\devkitPro\msys2\usr\bin;C:\Program Files\CMake\bin;C:\Program Files\xmake;' + $env:PATH
|
||||||
|
$env:DEVKITPRO='C:\devkitPro'
|
||||||
|
$env:DEVKITA64='C:\devkitPro\devkitA64'
|
||||||
|
|
||||||
|
& '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\Frostbite2DGame.elf
|
||||||
|
build\switch\aarch64\release\switch_game\NSUnknownGame.nro
|
||||||
|
build\switch\aarch64\release\switch_game\control.nacp
|
||||||
|
build\switch\aarch64\release\switch_game\assets\
|
||||||
|
```
|
||||||
|
|
||||||
|
`xmake.lua` 会在构建后自动:
|
||||||
|
|
||||||
|
- 复制 `game\assets` 到 `build\switch\aarch64\release\switch_game\assets`
|
||||||
|
- 生成 `control.nacp`
|
||||||
|
- 调用 `elf2nro` 生成带元数据的 `NSUnknownGame.nro`
|
||||||
|
- 将 `game\assets` 复制到临时 `romfs\assets` 后内嵌到 NRO 中,因此运行时可以直接读取
|
||||||
|
`romfs:/assets/...`
|
||||||
|
|
||||||
|
Switch 运行时资源目录当前优先由引擎解析为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
romfs:/assets
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 RomFS 挂载失败,才会回退到:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/switch/Frostbite2D/NS Unknown Game/assets
|
||||||
|
```
|
||||||
|
|
||||||
|
构建输出目录中的 `switch_game\assets` 是调试和外部部署用的资源副本。
|
||||||
|
|
||||||
|
当前已验证:
|
||||||
|
|
||||||
|
- `Frostbite2DGameSwitch` 可成功编译为 ELF/NRO
|
||||||
|
- PC 和 Switch 共用同一套 GLES 300 es shader
|
||||||
|
- Windows 游戏目标仍可正常编译和启动
|
||||||
|
- devkitPro 当前版本的 `SDL2_ttf` / `freetype` 需要静态链接 `harfbuzz` 和 `bz2`,该链接项已写入 `engine.lua`
|
||||||
|
|
||||||
## 3. 常见问题
|
## 3. 常见问题
|
||||||
|
|
||||||
### 3.1 切换平台后编译异常
|
### 3.1 切换平台后编译异常
|
||||||
@@ -267,3 +336,35 @@ Frostbite2D_smoke_meta.nro
|
|||||||
|
|
||||||
不要优先使用裸的 `Frostbite2D_smoke.nro`。
|
不要优先使用裸的 `Frostbite2D_smoke.nro`。
|
||||||
|
|
||||||
|
### 3.4 Switch 游戏 NRO 启动后资源加载失败或黑屏
|
||||||
|
|
||||||
|
优先确认 `NSUnknownGame.nro` 是新构建的 RomFS 版本。构建时 `elf2nro`
|
||||||
|
命令应包含:
|
||||||
|
|
||||||
|
```text
|
||||||
|
--romfsdir=<build>\switch_game\romfs
|
||||||
|
```
|
||||||
|
|
||||||
|
NRO 内部应能访问:
|
||||||
|
|
||||||
|
```text
|
||||||
|
romfs:/assets/shaders/shaders.json
|
||||||
|
romfs:/assets/shaders/sprite.vert
|
||||||
|
romfs:/assets/shaders/sprite.frag
|
||||||
|
romfs:/assets/shaders/colored_quad.vert
|
||||||
|
romfs:/assets/shaders/colored_quad.frag
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -253,6 +253,11 @@ private:
|
|||||||
bool running_ = false;
|
bool running_ = false;
|
||||||
bool paused_ = false;
|
bool paused_ = false;
|
||||||
bool shouldQuit_ = false;
|
bool shouldQuit_ = false;
|
||||||
|
bool sdlInitialized_ = false;
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
bool switchPlatformInitialized_ = false;
|
||||||
|
bool switchRomfsMounted_ = false;
|
||||||
|
#endif
|
||||||
bool textInputEnabled_ = false;
|
bool textInputEnabled_ = false;
|
||||||
bool immEnabled_ = false;
|
bool immEnabled_ = false;
|
||||||
int textInputRequestCount_ = 0;
|
int textInputRequestCount_ = 0;
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ private:
|
|||||||
fs::path toPath(const std::string &path) const;
|
fs::path toPath(const std::string &path) const;
|
||||||
std::string fromPath(const fs::path &path) const;
|
std::string fromPath(const fs::path &path) const;
|
||||||
std::string resolveFullPath(const std::string &path) const;
|
std::string resolveFullPath(const std::string &path) const;
|
||||||
|
bool isDevicePath(const std::string &path) const;
|
||||||
|
|
||||||
std::string workingDirectory_;
|
std::string workingDirectory_;
|
||||||
std::string assetRoot_;
|
std::string assetRoot_;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
#include <frostbite2D/scene/scene_manager.h>
|
#include <frostbite2D/scene/scene_manager.h>
|
||||||
#include <frostbite2D/types/type_math.h>
|
#include <frostbite2D/types/type_math.h>
|
||||||
#include <frostbite2D/utils/startup_trace.h>
|
#include <frostbite2D/utils/startup_trace.h>
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
#include <switch.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
namespace frostbite2D {
|
namespace frostbite2D {
|
||||||
|
|
||||||
@@ -55,6 +58,7 @@ bool Application::init(const AppConfig& config) {
|
|||||||
{
|
{
|
||||||
ScopedStartupTrace stageTrace("switchInit");
|
ScopedStartupTrace stageTrace("switchInit");
|
||||||
switchInit();
|
switchInit();
|
||||||
|
switchPlatformInitialized_ = true;
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
@@ -77,7 +81,11 @@ bool Application::init(const AppConfig& config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void Application::shutdown() {
|
void Application::shutdown() {
|
||||||
if (!initialized_)
|
if (!initialized_ && !sdlInitialized_
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
&& !switchPlatformInitialized_ && !switchRomfsMounted_
|
||||||
|
#endif
|
||||||
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
running_ = false;
|
running_ = false;
|
||||||
@@ -112,17 +120,25 @@ void Application::shutdown() {
|
|||||||
window_ = nullptr;
|
window_ = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 平台相关清理
|
if (sdlInitialized_) {
|
||||||
|
SDL_Quit();
|
||||||
|
sdlInitialized_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
#ifdef __SWITCH__
|
#ifdef __SWITCH__
|
||||||
switchShutdown();
|
if (switchRomfsMounted_) {
|
||||||
|
romfsExit();
|
||||||
|
switchRomfsMounted_ = false;
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// 退出 SDL(添加重复调用保护)
|
// 平台相关清理
|
||||||
static bool sdlQuitCalled = false;
|
#ifdef __SWITCH__
|
||||||
if (!sdlQuitCalled) {
|
if (switchPlatformInitialized_) {
|
||||||
SDL_Quit();
|
switchShutdown();
|
||||||
sdlQuitCalled = true;
|
switchPlatformInitialized_ = false;
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
ObjectRegistry::get().clear();
|
ObjectRegistry::get().clear();
|
||||||
|
|
||||||
@@ -150,13 +166,22 @@ bool Application::initCoreModules() {
|
|||||||
SDL_Log("Asset working directory: %s", workingDir.c_str());
|
SDL_Log("Asset working directory: %s", workingDir.c_str());
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
|
if (!switchRomfsMounted_ && R_SUCCEEDED(romfsInit())) {
|
||||||
|
switchRomfsMounted_ = true;
|
||||||
|
asset.setWorkingDirectory("romfs:/");
|
||||||
|
} else {
|
||||||
asset.setWorkingDirectory("/switch/Frostbite2D/" + config_.appName);
|
asset.setWorkingDirectory("/switch/Frostbite2D/" + config_.appName);
|
||||||
|
}
|
||||||
SDL_Log("Asset working directory: %s", asset.getWorkingDirectory().c_str());
|
SDL_Log("Asset working directory: %s", asset.getWorkingDirectory().c_str());
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
{
|
{
|
||||||
ScopedStartupTrace stageTrace("SaveSystem::init");
|
ScopedStartupTrace stageTrace("SaveSystem::init");
|
||||||
if (!SaveSystem::get().init()) {
|
SaveSystemConfig saveConfig;
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
saveConfig.saveRoot = "sdmc:/switch/NSUnknownGame/save";
|
||||||
|
#endif
|
||||||
|
if (!SaveSystem::get().init(saveConfig)) {
|
||||||
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to initialize save system");
|
SDL_LogError(SDL_LOG_CATEGORY_APPLICATION, "Failed to initialize save system");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -168,6 +193,7 @@ bool Application::initCoreModules() {
|
|||||||
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
|
SDL_Log("Failed to initialize SDL: %s", SDL_GetError());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
sdlInitialized_ = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disable text input by default. UI widgets should enable it explicitly.
|
// Disable text input by default. UI widgets should enable it explicitly.
|
||||||
@@ -178,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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,23 @@ Asset &Asset::get() {
|
|||||||
return instance;
|
return instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Asset::isDevicePath(const std::string &path) const {
|
||||||
|
auto colon = path.find(':');
|
||||||
|
if (colon == std::string::npos || colon == 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < colon; ++i) {
|
||||||
|
unsigned char ch = static_cast<unsigned char>(path[i]);
|
||||||
|
if (!std::isalnum(ch) && ch != '_' && ch != '-') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return colon + 1 < path.size() &&
|
||||||
|
(path[colon + 1] == '/' || path[colon + 1] == '\\');
|
||||||
|
}
|
||||||
|
|
||||||
fs::path Asset::toPath(const std::string &path) const {
|
fs::path Asset::toPath(const std::string &path) const {
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
return fs::u8path(path);
|
return fs::u8path(path);
|
||||||
@@ -34,7 +51,7 @@ std::string Asset::resolveFullPath(const std::string &path) const {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fs::path p = toPath(path);
|
fs::path p = toPath(path);
|
||||||
if (p.is_absolute()) {
|
if (p.is_absolute() || isDevicePath(path)) {
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ function add_frostbite2d_target()
|
|||||||
local libnx = path.join(devkitpro, "libnx")
|
local libnx = path.join(devkitpro, "libnx")
|
||||||
|
|
||||||
add_cxflags("-D__SWITCH__", "-march=armv8-a+crc+crypto", "-mtune=cortex-a57", "-mtp=soft", {public = true})
|
add_cxflags("-D__SWITCH__", "-march=armv8-a+crc+crypto", "-mtune=cortex-a57", "-mtp=soft", {public = true})
|
||||||
add_cxflags("-ffunction-sections", "-fdata-sections", {public = true})
|
add_cxflags("-ffunction-sections", "-fdata-sections", "-fPIE", {public = true})
|
||||||
add_cxflags("-specs=" .. path.join(libnx, "switch.specs"), {public = true, force = true})
|
add_cxflags("-specs=" .. path.join(libnx, "switch.specs"), {public = true, force = true})
|
||||||
add_ldflags("-specs=" .. path.join(libnx, "switch.specs"), {public = true, force = true})
|
add_ldflags("-specs=" .. path.join(libnx, "switch.specs"), {public = true, force = true})
|
||||||
|
|
||||||
@@ -46,8 +46,8 @@ 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", {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})
|
||||||
add_packages("libsdl2_image", {public = true})
|
add_packages("libsdl2_image", {public = true})
|
||||||
|
|||||||
@@ -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` |
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
FragColor = v_color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
layout (location = 0) in vec2 a_position;
|
||||||
|
layout (location = 1) in vec2 a_texCoord;
|
||||||
|
layout (location = 2) in vec4 a_color;
|
||||||
|
|
||||||
|
uniform mat4 u_view;
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
|
||||||
|
out vec4 v_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
v_color = a_color;
|
||||||
|
gl_Position = u_projection * u_view * vec4(a_position, 0.0, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"shaders": {
|
||||||
|
"sprite": {
|
||||||
|
"vertex": "sprite.vert",
|
||||||
|
"fragment": "sprite.frag"
|
||||||
|
},
|
||||||
|
"colored_quad": {
|
||||||
|
"vertex": "colored_quad.vert",
|
||||||
|
"fragment": "colored_quad.frag"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
in vec2 v_texCoord;
|
||||||
|
in vec4 v_color;
|
||||||
|
|
||||||
|
uniform sampler2D u_texture;
|
||||||
|
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
FragColor = texture(u_texture, v_texCoord) * v_color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#version 300 es
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
layout (location = 0) in vec2 a_position;
|
||||||
|
layout (location = 1) in vec2 a_texCoord;
|
||||||
|
layout (location = 2) in vec4 a_color;
|
||||||
|
|
||||||
|
uniform mat4 u_view;
|
||||||
|
uniform mat4 u_projection;
|
||||||
|
|
||||||
|
out vec2 v_texCoord;
|
||||||
|
out vec4 v_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
v_texCoord = a_texCoord;
|
||||||
|
v_color = a_color;
|
||||||
|
gl_Position = u_projection * u_view * vec4(a_position, 0.0, 1.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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 @@
|
|||||||
|
|
||||||
|
After Width: | Height: | Size: 4.8 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
After Width: | Height: | Size: 4.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
|
After Width: | Height: | Size: 2.3 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
@@ -0,0 +1,40 @@
|
|||||||
|
# Game Docs
|
||||||
|
|
||||||
|
本文档目录只放游戏层相关文档;引擎、构建和仓库级说明仍保留在项目根目录。
|
||||||
|
|
||||||
|
## Planning
|
||||||
|
|
||||||
|
- `planning/development_plan.md`:横板 2D ACT 清版游戏开发总计划,包含里程碑、玩法阶段、技术阶段和风险。
|
||||||
|
- `planning/game_architecture_board.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
|
||||||
|
|
||||||
|
- 文件名使用小写 `snake_case`。
|
||||||
|
- 长期有效的计划放入 `planning/`。
|
||||||
|
- 临时调研、实验记录后续单独放入 `research/` 或 `notes/`,避免混在根目录。
|
||||||
@@ -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 和模拟器输入问题。
|
||||||
@@ -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,不作为长期地图生产方式。
|
||||||
@@ -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,不作为长期生产方式。
|
||||||
@@ -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/`,不要塞进模块文档。
|
||||||
@@ -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 和动画资源加载链路。
|
||||||
@@ -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 支持。
|
||||||
@@ -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 和权限。
|
||||||
@@ -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 清场。
|
||||||
@@ -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 状态接入更完整的调试面板。
|
||||||
@@ -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++。
|
||||||
@@ -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 显示。
|
||||||
|
- 增加地图加载错误和资源缺失提示面板。
|
||||||
@@ -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 显示。
|
||||||
@@ -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 版做快速预览。
|
||||||
@@ -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 的移动查询接口。
|
||||||
@@ -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 模块。
|
||||||
@@ -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,再迁移当前局部贴图缓存。
|
||||||
@@ -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。
|
||||||
@@ -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:主角移动和测试地图。
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
# 游戏整体架构计划:思维导图与白板
|
||||||
|
|
||||||
|
目标:把项目从“能跑的白盒 Demo”推进为可持续扩展的横板 2D ACT 清版游戏工程。本文先作为整体架构白板,后续每个模块再拆成独立任务文档。
|
||||||
|
|
||||||
|
## 思维导图
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
mindmap
|
||||||
|
root((横板 2D ACT 游戏))
|
||||||
|
核心体验
|
||||||
|
横向推进
|
||||||
|
清版战斗
|
||||||
|
平台移动
|
||||||
|
近战连段
|
||||||
|
关卡节奏
|
||||||
|
游戏循环
|
||||||
|
登录入口
|
||||||
|
房间匹配
|
||||||
|
进入关卡
|
||||||
|
探索与移动
|
||||||
|
触发战斗区域
|
||||||
|
击败敌人
|
||||||
|
解锁推进
|
||||||
|
局内重置
|
||||||
|
结算与升级
|
||||||
|
底层框架
|
||||||
|
客户端服务端分离
|
||||||
|
基础热更
|
||||||
|
日志输出
|
||||||
|
异常捕获
|
||||||
|
资源异步加载
|
||||||
|
容错兜底
|
||||||
|
角色系统
|
||||||
|
玩家
|
||||||
|
输入
|
||||||
|
移动
|
||||||
|
跳跃
|
||||||
|
攻击
|
||||||
|
受击
|
||||||
|
动画状态机
|
||||||
|
血量
|
||||||
|
敌人
|
||||||
|
巡逻
|
||||||
|
追击
|
||||||
|
攻击
|
||||||
|
受击硬直
|
||||||
|
死亡
|
||||||
|
固定数值
|
||||||
|
NPC与可交互物
|
||||||
|
对话
|
||||||
|
机关
|
||||||
|
可破坏物
|
||||||
|
战斗系统
|
||||||
|
Hitbox
|
||||||
|
Hurtbox
|
||||||
|
伤害结算
|
||||||
|
击退
|
||||||
|
霸体
|
||||||
|
连击窗口
|
||||||
|
命中特效
|
||||||
|
调试显示
|
||||||
|
关卡系统
|
||||||
|
场景数据
|
||||||
|
碰撞数据
|
||||||
|
视觉层
|
||||||
|
战斗区域
|
||||||
|
敌人出生点
|
||||||
|
镜头限制
|
||||||
|
出入口
|
||||||
|
渲染与资源
|
||||||
|
Sprite
|
||||||
|
Tileset
|
||||||
|
背景视差
|
||||||
|
前景遮挡
|
||||||
|
特效
|
||||||
|
音频
|
||||||
|
资源清单
|
||||||
|
数据驱动
|
||||||
|
动画表
|
||||||
|
角色参数
|
||||||
|
敌人配置
|
||||||
|
关卡配置
|
||||||
|
攻击配置
|
||||||
|
掉落配置
|
||||||
|
联网原型
|
||||||
|
房间匹配
|
||||||
|
2到4人联机
|
||||||
|
玩家状态同步
|
||||||
|
战斗同步
|
||||||
|
怪物同步
|
||||||
|
断线检测
|
||||||
|
简易重连
|
||||||
|
工具与调试
|
||||||
|
白盒编辑
|
||||||
|
碰撞可视化
|
||||||
|
战斗区域可视化
|
||||||
|
Hitbox可视化
|
||||||
|
联机状态提示
|
||||||
|
简单调试面板
|
||||||
|
帧率与日志
|
||||||
|
构建文档
|
||||||
|
NS平台构建
|
||||||
|
```
|
||||||
|
|
||||||
|
## 白板总览
|
||||||
|
|
||||||
|
```text
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
| Game Loop | ---> | Scene | ---> | Stage |
|
||||||
|
| 关卡进入/推进/结算 | | 场景编排与生命周期 | | 关卡数据与视觉层 |
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
| | |
|
||||||
|
v v v
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
| Actor | ---> | Movement | ---> | Collision |
|
||||||
|
| 玩家/敌人/道具/物体 | | 重力/速度/平台移动 | | 地面/平台/边界 |
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
| |
|
||||||
|
v v
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
| Combat | ---> | Animation | ---> | Render |
|
||||||
|
| Hitbox/伤害/击退 | | 状态机/帧表/过渡 | | Sprite/Tile/特效 |
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
| Data | ---> | Debug | ---> | Build |
|
||||||
|
| 配置表/关卡/参数 | | 可视化/日志/测试 | | PC/Switch/资源复制 |
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
|
|
||||||
|
v
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
| Foundation | ---> | UI | ---> | Network |
|
||||||
|
| 日志/异常/热更/加载 | | 登录/匹配/状态/调试 | | 房间/同步/断线重连 |
|
||||||
|
+----------------------+ +----------------------+ +----------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## 模块白板
|
||||||
|
|
||||||
|
### 1. Core
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 管理游戏级常量、资源路径、输入快照、运行配置。
|
||||||
|
- 隔离 SDL 等底层输入细节,让角色逻辑只消费 `InputState`。
|
||||||
|
- 为后续手柄、Switch Joy-Con、键位配置留接口。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `core/game_config.h`
|
||||||
|
- `core/asset_paths.h`
|
||||||
|
- `core/input_state.*`
|
||||||
|
- `core/debug_flags.h`
|
||||||
|
- `InputStateProvider` 已聚合键盘、SDL GameController 和 Switch 原生 `PadState`。
|
||||||
|
|
||||||
|
当前输入映射:
|
||||||
|
|
||||||
|
- 详见 `../controls.md`。
|
||||||
|
- 当前已覆盖 PC 键盘、SDL GameController 和 Switch `PadState`。
|
||||||
|
- 角色逻辑只消费 `InputState`,不直接读取平台输入 API。
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 增加 `GameContext`,统一传递全局服务。
|
||||||
|
- 增加可配置键位表,让 PC 和 NS 可以分别覆盖默认映射。
|
||||||
|
|
||||||
|
### 2. Scene
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 管理场景生命周期。
|
||||||
|
- 装载关卡数据。
|
||||||
|
- 创建玩家、敌人、道具和场景对象。
|
||||||
|
- 控制镜头、战斗区域、关卡推进。
|
||||||
|
- 支持单局场景重置:重置玩家、敌人、battle zone、掉落和临时状态。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `scene/whitebox_scene.*`
|
||||||
|
- `scene/camera_controller.*`:玩家跟随、dead zone、平滑和世界边界 clamp。
|
||||||
|
- `CameraZoneLock`:玩家进入 battle zone 后摄像机平滑切换到 zone camera bounds,可移动锁定范围会防御性保证不小于视口,当前通过 debug 清场或离开 zone 解锁。
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 抽象 `GameplayScene` 作为通用玩法场景基类。
|
||||||
|
- `WhiteboxScene` 只保留第一关测试内容。
|
||||||
|
- 将 battle zone debug 清场替换为敌人清空自动解锁。
|
||||||
|
- 增加局内 reset 入口,用于卡状态、死亡重开和联机重同步。
|
||||||
|
|
||||||
|
### 3. Stage
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 表达关卡世界:碰撞、视觉层、背景、道具、出生点、镜头范围。
|
||||||
|
- 将白盒数据逐步替换为 tileset 和 props。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `data/level_definition.h`
|
||||||
|
- `data/whitebox_level.cpp`
|
||||||
|
- `movement/platform_world.*`
|
||||||
|
- `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` 色块逐步替换为 tileset、背景图和 props 素材。
|
||||||
|
- 补齐分项 debug 开关和场景验收跑图标准。
|
||||||
|
- 将 battle zone 占位升级为触发、锁镜头、清场解锁逻辑。
|
||||||
|
- 将敌人出生点接入 `EnemyActor`。
|
||||||
|
|
||||||
|
### 4. Actor
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 表达游戏对象:玩家、敌人、道具、投射物、可破坏物。
|
||||||
|
- 每个 Actor 只处理自身状态,不直接管理全局流程。
|
||||||
|
- 玩家和怪物都要支持血量、受击、击退、死亡/销毁基础生命周期。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `actor/player_actor.*`
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 增加 `EnemyActor`。
|
||||||
|
- 增加 `BreakablePropActor`。
|
||||||
|
- 抽出 `CharacterActor` 作为玩家和敌人的共同基类。
|
||||||
|
- 增加 `HealthComponent` 或等价数据结构,统一玩家/怪物血量与死亡判定。
|
||||||
|
- 将玩家动画切换改为数据驱动。
|
||||||
|
|
||||||
|
### 5. Movement
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 处理速度、重力、平台落地、边界限制。
|
||||||
|
- 支持横版手感:左右平移、加速、移动减速、单次跳跃、重力落地、空中姿态锁定。
|
||||||
|
- 后续支持斜坡、墙体、击退、空中状态。
|
||||||
|
- 兜底防卡:防卡地形、防穿模、异常位置重置。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `MovementBody`
|
||||||
|
- `PlatformWorld`
|
||||||
|
- `PlatformMover`
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 支持水平墙体阻挡。
|
||||||
|
- 支持移动加速/减速参数。
|
||||||
|
- 支持击退速度覆盖。
|
||||||
|
- 支持落地事件。
|
||||||
|
- 支持移动调试可视化。
|
||||||
|
- 增加卡地形检测和安全出生点/检查点回退。
|
||||||
|
|
||||||
|
### 6. Combat
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 管理攻击盒、受击盒、伤害结算、击退、硬直、命中特效。
|
||||||
|
- 保证战斗逻辑不散落在 Actor 内部。
|
||||||
|
- 表达普通攻击前摇/后摇、命中窗口、固定伤害数值、受击反馈。
|
||||||
|
- 支持跨玩家攻击命中和怪物状态同步的网络事件接口。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `combat/hitbox.h`
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 增加 `CombatSystem`。
|
||||||
|
- 每帧收集 active hitbox / hurtbox。
|
||||||
|
- 支持 team 过滤、重复命中过滤、伤害事件。
|
||||||
|
- 增加攻击前摇/后摇时间线。
|
||||||
|
- 增加伤害、击退、僵直和血量变化事件。
|
||||||
|
- 增加 debug hitbox 绘制。
|
||||||
|
|
||||||
|
### 7. Animation
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 管理 spritesheet 帧、动画状态、播放速度、循环和过渡。
|
||||||
|
- 避免每个 Actor 手写帧切换。
|
||||||
|
- 与动作状态机配合,保证 idle/移动/跳跃/攻击/受击 状态互斥,杜绝动作冲突。
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
- `PlayerActor` 内部硬编码 idle/run/punch 动画。
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 增加 `AnimationClip`。
|
||||||
|
- 增加 `SpriteAnimator`。
|
||||||
|
- 增加 `CharacterStateMachine`,管理 idle/move/jump/attack/hitstun/dead。
|
||||||
|
- 增加动画配置表。
|
||||||
|
- 攻击动画和 hitbox active frame 绑定。
|
||||||
|
|
||||||
|
### 8. Data
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 将玩法参数从 C++ 硬编码逐步迁移到数据。
|
||||||
|
- 支持关卡、敌人、动画、攻击、掉落配置。
|
||||||
|
- 支持角色/怪物血量、伤害、速度、追击范围等固定数值配置。
|
||||||
|
- 支持后续热更清单:资源版本、配置版本、加载失败兜底资源。
|
||||||
|
|
||||||
|
当前状态:
|
||||||
|
|
||||||
|
- 关卡仍由 `CreateWhiteboxLevel()` 硬编码。
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 先用 C++ struct 稳定字段。
|
||||||
|
- 再迁移到 JSON 或编辑器导出格式。
|
||||||
|
- 资源路径统一记录,避免散落字符串。
|
||||||
|
- 增加配置默认值和缺失字段兜底。
|
||||||
|
|
||||||
|
### 9. Render 与资源
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 绘制角色、关卡、背景、道具、特效和调试图形。
|
||||||
|
- 管理资源目录和运行时拷贝。
|
||||||
|
- 支持场景、角色、贴图的极简异步加载。
|
||||||
|
- 加载失败时使用占位图/色块兜底,避免场景直接崩溃。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `game/assets/character/male/`
|
||||||
|
- `game/assets/shaders/`
|
||||||
|
- `xmake.lua` 自动复制 `game/assets`
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 增加 `game/assets/stage/stage_01/`。
|
||||||
|
- 引入 tileset、背景、props。
|
||||||
|
- 增加视差背景绘制。
|
||||||
|
- 建立素材来源和授权记录。
|
||||||
|
- 增加 `AssetManifest`,记录 stage/character/effect 资源槽位。
|
||||||
|
- 增加异步加载队列和加载中/加载失败调试提示。
|
||||||
|
|
||||||
|
### 10. Debug 与工具
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 提供开发时必要的可视化和状态观察。
|
||||||
|
- 降低调试碰撞、战斗和关卡触发的成本。
|
||||||
|
- 输出基础日志、异常捕获信息、资源加载失败原因、联机状态。
|
||||||
|
- 提供简单调试面板:FPS、场景、玩家状态、网络状态、重置按钮。
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- `debug/debug_overlay.*` 已经提供碰撞、玩家包围盒、battle zone、camera lock 和 spawn point 绘制。
|
||||||
|
- Debug overlay 已支持总开关和分项开关,具体快捷键记录在 `../controls.md`。
|
||||||
|
- 绘制 hitbox / hurtbox。
|
||||||
|
- 输出当前场景、角色状态、FPS。
|
||||||
|
- 增加日志分级和异常状态重置入口。
|
||||||
|
- 增加网络延迟、房间号、连接状态显示。
|
||||||
|
|
||||||
|
### 11. Platform
|
||||||
|
|
||||||
|
职责:
|
||||||
|
|
||||||
|
- 保持 PC 和 NS 共用游戏逻辑。
|
||||||
|
- 只在入口、构建、资源部署、平台输入处做差异化。
|
||||||
|
- 为后续客户端/服务端分离保留平台抽象:本地单机、局域网、公网房间都走统一会话接口。
|
||||||
|
|
||||||
|
已有:
|
||||||
|
|
||||||
|
- `game/src/main.cpp`:统一游戏入口,通过 `__SWITCH__` 区分 Windows/PC 与 Switch/NS。
|
||||||
|
- `Frostbite2DGame`:Windows 游戏目标。
|
||||||
|
- `Frostbite2DGameSwitch`:Switch 游戏目标,生成 `NSUnknownGame.nro`。
|
||||||
|
- Switch NRO 已内嵌 `game/` 为 RomFS,运行时优先读取 `romfs:/assets`。
|
||||||
|
|
||||||
|
下一步:
|
||||||
|
|
||||||
|
- 在 Ryujinx 或真机上验证主循环、shader 和角色纹理。
|
||||||
|
- 如果 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
|
||||||
|
输入
|
||||||
|
SDL Keyboard / Controller / Switch PadState
|
||||||
|
-> InputStateProvider
|
||||||
|
-> InputState
|
||||||
|
-> PlayerActor
|
||||||
|
|
||||||
|
移动
|
||||||
|
PlayerActor / EnemyActor
|
||||||
|
-> MovementBody
|
||||||
|
-> PlatformMover
|
||||||
|
-> PlatformWorld
|
||||||
|
-> 更新 Actor position / grounded
|
||||||
|
|
||||||
|
战斗
|
||||||
|
Actor 当前攻击状态
|
||||||
|
-> 生成 Hitbox
|
||||||
|
-> CombatSystem
|
||||||
|
-> Hurtbox 匹配
|
||||||
|
-> DamageEvent
|
||||||
|
-> Actor 受击/击退/死亡
|
||||||
|
|
||||||
|
关卡
|
||||||
|
LevelDefinition
|
||||||
|
-> Scene 创建 Actor
|
||||||
|
-> StageRenderer 绘制视觉层
|
||||||
|
-> PlatformWorld 提供碰撞
|
||||||
|
-> BattleZone 控制推进
|
||||||
|
|
||||||
|
渲染
|
||||||
|
Scene::Render
|
||||||
|
-> StageRenderer
|
||||||
|
-> Actor::Render
|
||||||
|
-> Effects
|
||||||
|
-> DebugOverlay
|
||||||
|
```
|
||||||
|
|
||||||
|
## 开发路线白板
|
||||||
|
|
||||||
|
### Milestone 1:白盒玩法闭环
|
||||||
|
|
||||||
|
目标:验证 ACT 基础手感。
|
||||||
|
|
||||||
|
- 基础日志、资源加载兜底和调试面板可用。
|
||||||
|
- 玩家移动、跳跃、出拳稳定。
|
||||||
|
- 第一关横向推进可从头跑到尾。
|
||||||
|
- battle zone 可触发、锁镜头、解锁。
|
||||||
|
- 有 1 种敌人占位。
|
||||||
|
- 有 hitbox/hurtbox debug。
|
||||||
|
- 有局内重置入口,能重置玩家、怪物、battle zone 和摄像机。
|
||||||
|
|
||||||
|
### Milestone 2:灰盒战斗验证
|
||||||
|
|
||||||
|
目标:验证清版节奏。
|
||||||
|
|
||||||
|
- `CharacterStateMachine` 管理 idle/move/jump/attack/hitstun/dead。
|
||||||
|
- 玩家 2 到 3 段基础连击。
|
||||||
|
- 攻击前摇/后摇和命中窗口可配置。
|
||||||
|
- 受击僵直、击退、血量变化可见。
|
||||||
|
- 敌人追击、攻击、受击、死亡。
|
||||||
|
- 每个 battle zone 有敌人波次。
|
||||||
|
- 有简单掉落或奖励。
|
||||||
|
- 关卡能完整结算。
|
||||||
|
- 防卡地形、防穿模、防状态卡死有基础兜底。
|
||||||
|
|
||||||
|
### Milestone 3:第一关美术测试
|
||||||
|
|
||||||
|
目标:替换白盒视觉。
|
||||||
|
|
||||||
|
- 场景有 2 到 3 层视差背景。
|
||||||
|
- 地面和平台使用 tileset。
|
||||||
|
- props 丰富空间轮廓。
|
||||||
|
- 保留 debug overlay。
|
||||||
|
- 记录素材来源和授权。
|
||||||
|
|
||||||
|
### Milestone 4:联机原型
|
||||||
|
|
||||||
|
目标:跑通 2 到 4 人多人原型。
|
||||||
|
|
||||||
|
- 有登录/入口 UI。
|
||||||
|
- 有创建房间、加入房间、玩家列表和准备状态。
|
||||||
|
- 支持局域网或公网极简房间匹配。
|
||||||
|
- 服务端同步玩家位置、动作状态、血量。
|
||||||
|
- 服务端同步怪物状态、攻击命中、清场进度。
|
||||||
|
- 有断线检测、简易重连和联机状态提示。
|
||||||
|
|
||||||
|
### Milestone 5:数据化与热更
|
||||||
|
|
||||||
|
目标:降低后续扩展成本。
|
||||||
|
|
||||||
|
- 动画配置数据化。
|
||||||
|
- 攻击参数数据化。
|
||||||
|
- 敌人参数数据化。
|
||||||
|
- 关卡数据逐步外部化。
|
||||||
|
- 资源 manifest、配置版本和 fallback 规则可用。
|
||||||
|
|
||||||
|
## 当前优先级
|
||||||
|
|
||||||
|
当前调整为先做场景。理由:先把第一关的世界宽度、分层数据、镜头范围、视觉层和 debug 标记搭起来,后续 `BattleZone`、敌人刷出和战斗系统才有稳定承载位置。
|
||||||
|
|
||||||
|
新增的底层框架、UI、联网和闭环兜底会作为架构约束提前预留接口,但短期实现仍以“场景骨架 -> ACT 手感 -> 单局闭环”为主。多人联网在 Milestone 4 集中实现,当前阶段只避免把玩法逻辑写死成无法同步的本地状态。
|
||||||
|
|
||||||
|
下一步建议按这个顺序做:
|
||||||
|
|
||||||
|
1. 已完成:`StageLayerSource` 让 `StageLayer` 同时支持色块占位和 sprite 资源路径。
|
||||||
|
2. 已完成:`StageAssetSlots` 建立 `stage_01` 资源槽位和素材来源记录,做到素材可直接落位。
|
||||||
|
3. 已完成:`StageProps` 把后景/前景 props 统一到 layer 数据。
|
||||||
|
4. 已完成:`SceneAcceptanceRun` 建立场景骨架验收流程,记录 PC/Switch 构建和跑图检查项。
|
||||||
|
5. `BattleZone`:把 debug 清场替换为敌人清空自动解锁。
|
||||||
|
6. `EnemyActor`:做第一个敌人占位,接入出生点。
|
||||||
|
7. `CombatSystem`:把 hitbox/hurtbox 真正跑起来。
|
||||||
|
8. `SpriteAnimator`:把玩家动画从硬编码改成组件。
|
||||||
|
9. `InputConfig`:把默认键位/手柄映射迁移到可配置数据。
|
||||||
|
|
||||||
|
## 素材协作提示
|
||||||
|
|
||||||
|
你找素材时优先找:
|
||||||
|
|
||||||
|
- 横版 tileset:地面、平台、墙面。
|
||||||
|
- 远景/中景背景:可以横向拼接。
|
||||||
|
- props:箱子、桶、路障、门、灯、标牌。
|
||||||
|
- 敌人临时素材:有 idle/run/attack/hurt/death 最好。
|
||||||
|
- 特效:命中火花、尘土、落地、破坏碎片。
|
||||||
|
|
||||||
|
素材回来后先不急着追求最终题材,先按 `stage_01` 做一个可玩的美术测试关。
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
# 场景搭建计划
|
||||||
|
|
||||||
|
目标:先把第一关从白盒推进到可替换素材的灰盒/美术测试场景。当前游戏方向暂定为横板 2D ACT 清版,因此场景需要服务于移动、跳跃、攻击判定、敌人刷出、镜头推进和战斗节奏。
|
||||||
|
|
||||||
|
## 1. 当前起点
|
||||||
|
|
||||||
|
已有内容:
|
||||||
|
|
||||||
|
- `WhiteboxScene`:当前白盒场景入口。
|
||||||
|
- `CreateWhiteboxLevel()`:第一版关卡数据,包含平台碰撞和白盒装饰块。
|
||||||
|
- `PlatformWorld` / `PlatformMover`:基础平台碰撞和重力移动。
|
||||||
|
- `PlayerActor`:玩家移动、跳跃、出拳和基础动画。
|
||||||
|
- `Frostbite2DGame`:Windows 游戏目标,可直接构建运行。
|
||||||
|
|
||||||
|
当前白盒只解决“能跑、能跳、能看到关卡结构”。下一步要把场景拆成数据层、视觉层和玩法层。
|
||||||
|
|
||||||
|
第一轮场景骨架进度:
|
||||||
|
|
||||||
|
- `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 bounds,debug 清场后恢复全关卡跟随。
|
||||||
|
- 已修复 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. 第一关场景目标
|
||||||
|
|
||||||
|
第一关不急着做最终题材,先做通用 ACT 测试关:
|
||||||
|
|
||||||
|
- 横向推进,当前 Mossy Cavern 参考图复刻版宽度为 2105 像素。
|
||||||
|
- 主要视口仍用 1280x720。
|
||||||
|
- 主路线先保持连续地面,避免当前平台碰撞不支持台阶/斜坡导致卡关;高度变化主要放在悬空平台上,用于测试跳跃和镜头推进。
|
||||||
|
- 当前按参考图三块主要站立区域预留 3 个战斗区域。
|
||||||
|
- 每个战斗区域有明确的入口、站位空间、敌人刷出点和退出方向。
|
||||||
|
- 保留一个跳跃测试段,用来验证移动手感。
|
||||||
|
- 保留一个窄平台/边缘段,用来验证镜头和碰撞。
|
||||||
|
|
||||||
|
## 3. 场景分层
|
||||||
|
|
||||||
|
后续场景按以下层拆:
|
||||||
|
|
||||||
|
```text
|
||||||
|
background_far 远景天空、城市、山体、洞窟深处等,不参与碰撞
|
||||||
|
background_mid 中景建筑、树、墙面、远处平台,不参与碰撞
|
||||||
|
level_collision 地面、平台、墙体,只负责碰撞
|
||||||
|
level_visual 和碰撞对齐的地面/墙面贴图
|
||||||
|
props_back 柱子、牌子、箱子、草丛等后景装饰
|
||||||
|
actors 玩家、敌人、道具、投射物
|
||||||
|
props_front 前景遮挡物、栏杆、近景草等
|
||||||
|
effects 攻击特效、命中特效、尘土、碎片
|
||||||
|
ui HUD、提示、调试信息
|
||||||
|
```
|
||||||
|
|
||||||
|
白盒阶段先实现:
|
||||||
|
|
||||||
|
- `level_collision`
|
||||||
|
- `level_visual`
|
||||||
|
- `props_back`
|
||||||
|
- `actors`
|
||||||
|
|
||||||
|
前景遮挡和特效后面再加。
|
||||||
|
|
||||||
|
## 4. 素材寻找清单
|
||||||
|
|
||||||
|
你先找这些素材,优先像素风,尺寸最好能和 128x128 角色兼容。
|
||||||
|
|
||||||
|
### 必需素材
|
||||||
|
|
||||||
|
- 地面 tileset:至少包含平地、左边缘、右边缘、平台中段。
|
||||||
|
- 墙面或背景 tileset:用于挡板、台阶、平台侧面。
|
||||||
|
- 远景背景:一张或多张可横向拼接背景。
|
||||||
|
- 中景背景:建筑、树、管线、洞窟墙等可以做视差的图层。
|
||||||
|
- 基础装饰物:箱子、桶、路灯、告示牌、碎石、草丛等。
|
||||||
|
- 关卡边界标识:门、路障、出口牌、传送门等任选。
|
||||||
|
|
||||||
|
### ACT 玩法相关素材
|
||||||
|
|
||||||
|
- 敌人站位/出生点可用的临时标记图。
|
||||||
|
- 可破坏物:木箱、罐子、障碍物。
|
||||||
|
- 攻击命中特效:小型 hit spark。
|
||||||
|
- 落地/跑步尘土:小型 dust effect。
|
||||||
|
|
||||||
|
### 可选素材
|
||||||
|
|
||||||
|
- 前景遮挡:栏杆、柱子、近景树叶。
|
||||||
|
- 光照叠加图:窗口光、路灯光、火光等。
|
||||||
|
- 环境动画:旗子、火焰、流水、机器灯。
|
||||||
|
- 背景音乐和环境音。
|
||||||
|
|
||||||
|
## 5. 素材规格建议
|
||||||
|
|
||||||
|
角色当前是 128x128 帧,场景素材建议:
|
||||||
|
|
||||||
|
- tileset 单格优先 `32x32`、`48x48` 或 `64x64`。
|
||||||
|
- 平台碰撞块可以和视觉块分离,不要求每张图都精准碰撞。
|
||||||
|
- 背景图宽度最好大于 1280,或能无缝横向重复。
|
||||||
|
- PNG 优先,透明背景用于 props 和 effects。
|
||||||
|
- 同一套素材尽量保持统一像素密度。
|
||||||
|
- 文件名使用英文小写和下划线,例如 `factory_floor_tiles.png`。
|
||||||
|
|
||||||
|
建议素材目录:
|
||||||
|
|
||||||
|
```text
|
||||||
|
game/assets/stage/stage_01/
|
||||||
|
├─ background/
|
||||||
|
├─ tiles/
|
||||||
|
├─ props/
|
||||||
|
├─ effects/
|
||||||
|
└─ audio/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 工程实现步骤
|
||||||
|
|
||||||
|
### Step 1:扩展关卡数据结构
|
||||||
|
|
||||||
|
把 `LevelDefinition` 从纯白盒数据扩展为:
|
||||||
|
|
||||||
|
- 世界宽高。
|
||||||
|
- 碰撞平台列表。
|
||||||
|
- 视觉 tile/prop 列表。
|
||||||
|
- 玩家出生点。
|
||||||
|
- 敌人出生点。
|
||||||
|
- 战斗区域触发范围。
|
||||||
|
- 镜头限制范围。
|
||||||
|
|
||||||
|
### Step 2:实现 StageRenderer
|
||||||
|
|
||||||
|
新增 `scene` 或 `stage` 模块,负责绘制:
|
||||||
|
|
||||||
|
- 背景色。
|
||||||
|
- 远景背景。
|
||||||
|
- 中景背景。
|
||||||
|
- tile 层。
|
||||||
|
- prop 层。
|
||||||
|
- debug 碰撞层。
|
||||||
|
|
||||||
|
第一版可以继续用 `Renderer::drawQuad` 和 `Sprite`,不急着做 tilemap 优化。
|
||||||
|
|
||||||
|
### Step 3:替换白盒视觉
|
||||||
|
|
||||||
|
保持碰撞数据不变,先把地面和平台换成 tileset。
|
||||||
|
|
||||||
|
验收标准:
|
||||||
|
|
||||||
|
- 玩家仍然能正常落地和跳跃。
|
||||||
|
- 视觉地面和碰撞地面误差不超过 4 像素。
|
||||||
|
- 镜头移动时背景不黑屏、不闪烁。
|
||||||
|
|
||||||
|
### Step 4:加入视差背景
|
||||||
|
|
||||||
|
远景和中景随相机不同速度移动:
|
||||||
|
|
||||||
|
- 远景速度:相机速度的 20% 到 35%。
|
||||||
|
- 中景速度:相机速度的 45% 到 65%。
|
||||||
|
- 地面和角色速度:100%。
|
||||||
|
|
||||||
|
### Step 5:加入战斗区域数据
|
||||||
|
|
||||||
|
当前 Mossy Cavern 参考图复刻版标出 3 个 battle zone:
|
||||||
|
|
||||||
|
- `zone_01_left_platform`
|
||||||
|
- `zone_02_center_platform`
|
||||||
|
- `zone_03_right_platform`
|
||||||
|
|
||||||
|
每个区域包含:
|
||||||
|
|
||||||
|
- 触发矩形。
|
||||||
|
- 敌人刷出点。
|
||||||
|
- 镜头锁定范围。
|
||||||
|
- 清场后解锁出口。
|
||||||
|
|
||||||
|
第一版可以只画 debug 框,不立刻做敌人。
|
||||||
|
|
||||||
|
### Step 6:加入 debug 显示开关
|
||||||
|
|
||||||
|
需要能显示/隐藏:
|
||||||
|
|
||||||
|
- 碰撞矩形。
|
||||||
|
- 玩家 hurtbox。
|
||||||
|
- 攻击 hitbox。
|
||||||
|
- 敌人出生点。
|
||||||
|
- battle zone。
|
||||||
|
|
||||||
|
具体 debug 快捷键统一记录在 `../controls.md`。
|
||||||
|
|
||||||
|
## 7. 第一版验收标准
|
||||||
|
|
||||||
|
第一阶段完成时应该达到:
|
||||||
|
|
||||||
|
- 能启动 `Frostbite2DGame`。
|
||||||
|
- 第一关世界尺寸为 `2105 x 720`,与 `4HibZC.jpg` 等比映射后的宽高一致。
|
||||||
|
- 参考图底图能完整显示,且横向摄像机不露出世界外黑边。
|
||||||
|
- 至少 4 个平台碰撞块,对齐参考图中的左侧、左下、中央、右侧可站平台。
|
||||||
|
- 当前视觉先以 1 张完整参考底图表达,不再沿用上一版拼接地图。
|
||||||
|
- 至少 3 个 battle zone debug 标记。
|
||||||
|
- 场景资源路径集中在 `game/assets/stage/stage_01/`。
|
||||||
|
- 文档记录已使用素材来源和授权信息。
|
||||||
|
|
||||||
|
## 8. 素材回来后的落地顺序
|
||||||
|
|
||||||
|
素材准备好后按这个顺序处理:
|
||||||
|
|
||||||
|
1. 整理素材到 `game/assets/stage/stage_01/`。
|
||||||
|
2. 记录素材来源和授权。
|
||||||
|
3. 先接背景图,验证相机移动。
|
||||||
|
4. 再接地面/平台 tiles,验证碰撞对齐。
|
||||||
|
5. 再接 props,丰富场景轮廓。
|
||||||
|
6. 最后接 effects 和音频。
|
||||||
|
|
||||||
|
## 9. 当前执行顺序
|
||||||
|
|
||||||
|
本轮开始先做场景,不等待最终素材。第一阶段目标是把第一关变成可替换素材的灰盒场景骨架,让后续素材只需要填到既定层级和数据结构里。
|
||||||
|
|
||||||
|
### 先做工程骨架
|
||||||
|
|
||||||
|
1. 已完成:扩展 `LevelDefinition`,加入世界尺寸、玩家出生点、镜头限制、敌人出生点、battle zone 占位数据。
|
||||||
|
2. 已完成:扩展 `StageRenderer`,支持背景层、地面/平台视觉层、后景 props,第一版继续用色块。
|
||||||
|
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 玩法。下一步围绕地图编辑器继续微调碰撞对齐,并逐步把完整参考底图拆成可编辑图层。
|
||||||
@@ -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 绘制纳入本流程。
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
#include "player_actor.h"
|
||||||
|
|
||||||
|
#include "../core/asset_paths.h"
|
||||||
|
#include "../core/game_audio.h"
|
||||||
|
#include "../core/game_config.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
PlayerActor::PlayerActor() {
|
||||||
|
SetSize(64.0f, 104.0f);
|
||||||
|
SetTopLeftPosition(160.0f, 380.0f);
|
||||||
|
loadSprites();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerActor::SetMovementWorld(const PlatformWorld* world) {
|
||||||
|
movementWorld_ = world;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerActor::loadSprites() {
|
||||||
|
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);
|
||||||
|
|
||||||
|
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();
|
||||||
|
updatePhysics(deltaTime);
|
||||||
|
updateAnimation(deltaTime);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerActor::updateInput() {
|
||||||
|
const InputState input = inputProvider_.Sample();
|
||||||
|
velocity_.x = input.moveX * config::kPlayerMoveSpeed;
|
||||||
|
if (input.moveX < 0.0f) {
|
||||||
|
facingLeft_ = true;
|
||||||
|
} else if (input.moveX > 0.0f) {
|
||||||
|
facingLeft_ = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerActor::updatePhysics(float deltaTime) {
|
||||||
|
if (!movementWorld_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
MovementBody body;
|
||||||
|
body.position = GetTopLeftPosition();
|
||||||
|
body.size = GetSize();
|
||||||
|
body.velocity = velocity_;
|
||||||
|
body.grounded = grounded_;
|
||||||
|
|
||||||
|
body = platformMover_.Step(body, *movementWorld_, config::kGravity, deltaTime);
|
||||||
|
velocity_ = body.velocity;
|
||||||
|
grounded_ = body.grounded;
|
||||||
|
SetTopLeftPosition(body.position);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerActor::updateAnimation(float deltaTime) {
|
||||||
|
if (attackTimer_ > 0.0f) {
|
||||||
|
attackTimer_ = std::max(0.0f, attackTimer_ - deltaTime);
|
||||||
|
setAnimState(AnimState::Punch);
|
||||||
|
} else if (std::abs(velocity_.x) > 1.0f) {
|
||||||
|
setAnimState(AnimState::Run);
|
||||||
|
} else {
|
||||||
|
setAnimState(AnimState::Idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
int frameCount = 10;
|
||||||
|
float frameDuration = 0.1f;
|
||||||
|
if (animState_ == AnimState::Idle) {
|
||||||
|
frameDuration = 0.16f;
|
||||||
|
} else if (animState_ == AnimState::Punch) {
|
||||||
|
frameDuration = 0.07f;
|
||||||
|
}
|
||||||
|
|
||||||
|
animTimer_ += deltaTime;
|
||||||
|
while (animTimer_ >= frameDuration) {
|
||||||
|
animTimer_ -= frameDuration;
|
||||||
|
frameIndex_ = (frameIndex_ + 1) % frameCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
asset->sprite->SetFlippedX(facingLeft_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void PlayerActor::setAnimState(AnimState state) {
|
||||||
|
const bool stateChanged = animState_ != state;
|
||||||
|
if (stateChanged) {
|
||||||
|
animState_ = state;
|
||||||
|
animTimer_ = 0.0f;
|
||||||
|
frameIndex_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerAnimationAsset* PlayerActor::activeAsset() {
|
||||||
|
switch (animState_) {
|
||||||
|
case AnimState::Run:
|
||||||
|
return &runAsset_;
|
||||||
|
case AnimState::Punch:
|
||||||
|
return &punchAsset_;
|
||||||
|
case AnimState::Idle:
|
||||||
|
default:
|
||||||
|
return &idleAsset_;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../core/input_state.h"
|
||||||
|
#include "../movement/platform_world.h"
|
||||||
|
#include "player_animation_assets.h"
|
||||||
|
|
||||||
|
#include <frostbite2D/2d/actor.h>
|
||||||
|
#include <frostbite2D/types/type_math.h>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
class PlayerActor : public frostbite2D::Actor {
|
||||||
|
public:
|
||||||
|
PlayerActor();
|
||||||
|
|
||||||
|
void SetMovementWorld(const PlatformWorld* world);
|
||||||
|
void OnUpdate(float deltaTime) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
enum class AnimState {
|
||||||
|
Idle,
|
||||||
|
Run,
|
||||||
|
Punch
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadSprites();
|
||||||
|
void addAnimationAsset(PlayerAnimationAsset& asset);
|
||||||
|
void updateInput();
|
||||||
|
void updatePhysics(float deltaTime);
|
||||||
|
void updateAnimation(float deltaTime);
|
||||||
|
void setAnimState(AnimState state);
|
||||||
|
PlayerAnimationAsset* activeAsset();
|
||||||
|
|
||||||
|
const PlatformWorld* movementWorld_ = nullptr;
|
||||||
|
InputStateProvider inputProvider_;
|
||||||
|
PlatformMover platformMover_;
|
||||||
|
PlayerAnimationAsset idleAsset_;
|
||||||
|
PlayerAnimationAsset runAsset_;
|
||||||
|
PlayerAnimationAsset punchAsset_;
|
||||||
|
|
||||||
|
frostbite2D::Vec2 velocity_;
|
||||||
|
AnimState animState_ = AnimState::Idle;
|
||||||
|
float animTimer_ = 0.0f;
|
||||||
|
float attackTimer_ = 0.0f;
|
||||||
|
int frameIndex_ = 0;
|
||||||
|
bool facingLeft_ = false;
|
||||||
|
bool grounded_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <frostbite2D/types/type_math.h>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
enum class Team {
|
||||||
|
Player,
|
||||||
|
Enemy
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Hitbox {
|
||||||
|
frostbite2D::Rect bounds;
|
||||||
|
Team ownerTeam = Team::Player;
|
||||||
|
int damage = 1;
|
||||||
|
float activeSeconds = 0.0f;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Hurtbox {
|
||||||
|
frostbite2D::Rect bounds;
|
||||||
|
Team ownerTeam = Team::Enemy;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline bool CanHit(const Hitbox& hitbox, const Hurtbox& hurtbox) {
|
||||||
|
return hitbox.ownerTeam != hurtbox.ownerTeam &&
|
||||||
|
hitbox.bounds.intersects(hurtbox.bounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace ns_game::assets {
|
||||||
|
|
||||||
|
constexpr const char* kMaleIdle =
|
||||||
|
"assets/character/male/Male_spritesheet_idle.png";
|
||||||
|
constexpr const char* kMaleRun =
|
||||||
|
"assets/character/male/Male_spritesheet_run.png";
|
||||||
|
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
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
struct DebugFlags {
|
||||||
|
bool enabled = true;
|
||||||
|
bool showStageGrid = true;
|
||||||
|
bool showCollision = true;
|
||||||
|
bool showActorBounds = true;
|
||||||
|
bool showBattleZones = true;
|
||||||
|
bool showSpawnPoints = true;
|
||||||
|
bool showHitboxes = false;
|
||||||
|
bool showEnemies = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace ns_game::config {
|
||||||
|
|
||||||
|
constexpr int kVirtualWidth = 1280;
|
||||||
|
constexpr int kVirtualHeight = 720;
|
||||||
|
constexpr float kPlayerFrameSize = 128.0f;
|
||||||
|
constexpr float kPlayerMoveSpeed = 300.0f;
|
||||||
|
constexpr float kPlayerJumpSpeed = -620.0f;
|
||||||
|
constexpr float kGravity = 1800.0f;
|
||||||
|
constexpr float kAttackDuration = 0.28f;
|
||||||
|
|
||||||
|
} // namespace ns_game::config
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
#include "input_state.h"
|
||||||
|
|
||||||
|
#include <SDL2/SDL.h>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
#include <switch.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
constexpr Sint16 kControllerAxisDeadZone = 10000;
|
||||||
|
|
||||||
|
bool isDown(const Uint8* keys, SDL_Scancode key) {
|
||||||
|
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
|
||||||
|
|
||||||
|
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() {
|
||||||
|
ensureController();
|
||||||
|
|
||||||
|
const Uint8* keys = SDL_GetKeyboardState(nullptr);
|
||||||
|
InputState state;
|
||||||
|
bool moveLeftHeld = false;
|
||||||
|
bool moveRightHeld = false;
|
||||||
|
|
||||||
|
if (isDown(keys, SDL_SCANCODE_A) || isDown(keys, SDL_SCANCODE_LEFT)) {
|
||||||
|
moveLeftHeld = true;
|
||||||
|
}
|
||||||
|
if (isDown(keys, SDL_SCANCODE_D) || isDown(keys, SDL_SCANCODE_RIGHT)) {
|
||||||
|
moveRightHeld = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool jumpHeld =
|
||||||
|
isDown(keys, SDL_SCANCODE_SPACE) || isDown(keys, SDL_SCANCODE_W) ||
|
||||||
|
isDown(keys, SDL_SCANCODE_UP);
|
||||||
|
bool attackHeld =
|
||||||
|
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.attackPressed = attackHeld && !attackHeldLastFrame_;
|
||||||
|
|
||||||
|
jumpHeldLastFrame_ = jumpHeld;
|
||||||
|
attackHeldLastFrame_ = attackHeld;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <SDL2/SDL_gamecontroller.h>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
struct InputState {
|
||||||
|
float moveX = 0.0f;
|
||||||
|
bool jumpPressed = false;
|
||||||
|
bool attackPressed = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
class InputStateProvider {
|
||||||
|
public:
|
||||||
|
InputStateProvider();
|
||||||
|
~InputStateProvider();
|
||||||
|
|
||||||
|
InputStateProvider(const InputStateProvider&) = delete;
|
||||||
|
InputStateProvider& operator=(const InputStateProvider&) = delete;
|
||||||
|
|
||||||
|
InputState Sample();
|
||||||
|
|
||||||
|
private:
|
||||||
|
void ensureController();
|
||||||
|
|
||||||
|
SDL_GameController* controller_ = nullptr;
|
||||||
|
bool jumpHeldLastFrame_ = false;
|
||||||
|
bool attackHeldLastFrame_ = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../movement/platform_world.h"
|
||||||
|
#include <frostbite2D/types/type_color.h>
|
||||||
|
#include <frostbite2D/types/type_math.h>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
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::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 {
|
||||||
|
float worldWidth = 0.0f;
|
||||||
|
float worldHeight = 0.0f;
|
||||||
|
frostbite2D::Vec2 playerSpawn;
|
||||||
|
frostbite2D::Rect cameraBounds;
|
||||||
|
PlatformWorld collision;
|
||||||
|
std::vector<TileSetDefinition> tileSets;
|
||||||
|
std::vector<StageLayer> layers;
|
||||||
|
std::vector<BattleZoneDefinition> battleZones;
|
||||||
|
};
|
||||||
|
|
||||||
|
LevelDefinition CreateWhiteboxLevel();
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
#include "level_definition.h"
|
||||||
|
#include "stage_map_loader.h"
|
||||||
|
|
||||||
|
#include <SDL2/SDL.h>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
LevelDefinition CreateFallbackWhiteboxLevel() {
|
||||||
|
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.levelRight = level.worldWidth;
|
||||||
|
level.collision.platforms = {
|
||||||
|
frostbite2D::Rect(0.0f, 600.0f, 3200.0f, 80.0f),
|
||||||
|
frostbite2D::Rect(230.0f, 500.0f, 220.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(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.layers = {
|
||||||
|
{"background_far",
|
||||||
|
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::Color(0.26f, 0.30f, 0.34f, 1.0f),
|
||||||
|
StageLayerRole::PropsBack},
|
||||||
|
{frostbite2D::Rect(1440.0f, 300.0f, 80.0f, 300.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // 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
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#include "debug_overlay.h"
|
||||||
|
|
||||||
|
#include <frostbite2D/graphics/renderer.h>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void drawRect(const frostbite2D::Rect& rect, const frostbite2D::Color& color) {
|
||||||
|
frostbite2D::Renderer::get().drawQuad(rect, color);
|
||||||
|
}
|
||||||
|
|
||||||
|
void drawOutline(const frostbite2D::Rect& rect, const frostbite2D::Color& color,
|
||||||
|
float thickness = 2.0f) {
|
||||||
|
drawRect(frostbite2D::Rect(rect.left(), rect.top(), rect.width(), thickness),
|
||||||
|
color);
|
||||||
|
drawRect(frostbite2D::Rect(rect.left(), rect.bottom() - thickness,
|
||||||
|
rect.width(), thickness),
|
||||||
|
color);
|
||||||
|
drawRect(frostbite2D::Rect(rect.left(), rect.top(), thickness, rect.height()),
|
||||||
|
color);
|
||||||
|
drawRect(frostbite2D::Rect(rect.right() - thickness, rect.top(), thickness,
|
||||||
|
rect.height()),
|
||||||
|
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
|
||||||
|
|
||||||
|
void DebugOverlay::Render(const LevelDefinition& level, const DebugFlags& flags,
|
||||||
|
const frostbite2D::Rect* playerBounds,
|
||||||
|
const std::vector<BattleZoneDebugInfo>*
|
||||||
|
battleZoneDebug) const {
|
||||||
|
if (!flags.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flags.showCollision) {
|
||||||
|
drawCollision(level);
|
||||||
|
}
|
||||||
|
if (flags.showBattleZones) {
|
||||||
|
drawBattleZones(level, battleZoneDebug);
|
||||||
|
}
|
||||||
|
if (flags.showSpawnPoints) {
|
||||||
|
drawSpawnPoints(level);
|
||||||
|
}
|
||||||
|
if (flags.showActorBounds && playerBounds) {
|
||||||
|
drawActorBounds(*playerBounds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DebugOverlay::drawCollision(const LevelDefinition& level) const {
|
||||||
|
for (const auto& platform : level.collision.platforms) {
|
||||||
|
drawOutline(platform, frostbite2D::Color(0.1f, 0.95f, 0.45f, 0.85f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DebugOverlay::drawActorBounds(const frostbite2D::Rect& bounds) const {
|
||||||
|
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
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../core/debug_flags.h"
|
||||||
|
#include "../data/level_definition.h"
|
||||||
|
|
||||||
|
#include <frostbite2D/types/type_math.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
class DebugOverlay {
|
||||||
|
public:
|
||||||
|
void Render(const LevelDefinition& level, const DebugFlags& flags,
|
||||||
|
const frostbite2D::Rect* playerBounds,
|
||||||
|
const std::vector<BattleZoneDebugInfo>* battleZoneDebug) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void drawCollision(const LevelDefinition& level) 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
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
#include "scene/whitebox_scene.h"
|
||||||
|
#include "core/game_config.h"
|
||||||
|
#include "core/game_services.h"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
#ifndef __SWITCH__
|
||||||
|
#define SDL_MAIN_HANDLED
|
||||||
|
#else
|
||||||
|
#include <switch.h>
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <frostbite2D/core/application.h>
|
||||||
|
#include <frostbite2D/scene/scene_manager.h>
|
||||||
|
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
namespace {
|
||||||
|
void showSwitchInitFailure() {
|
||||||
|
consoleInit(nullptr);
|
||||||
|
|
||||||
|
PadState pad;
|
||||||
|
padInitializeDefault(&pad);
|
||||||
|
|
||||||
|
std::puts("NS Unknown Game");
|
||||||
|
std::puts("");
|
||||||
|
std::puts("Switch init failed.");
|
||||||
|
std::puts("Check nxlink/Ryujinx logs for the failed init stage.");
|
||||||
|
std::puts("");
|
||||||
|
std::puts("Press + to exit.");
|
||||||
|
|
||||||
|
while (appletMainLoop()) {
|
||||||
|
padUpdate(&pad);
|
||||||
|
if (padGetButtonsDown(&pad) & HidNpadButton_Plus) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
consoleUpdate(nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
consoleExit(nullptr);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
#endif
|
||||||
|
|
||||||
|
int main(int argc, char** argv) {
|
||||||
|
(void)argc;
|
||||||
|
(void)argv;
|
||||||
|
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
padConfigureInput(1, HidNpadStyleSet_NpadStandard);
|
||||||
|
#endif
|
||||||
|
|
||||||
|
auto config = frostbite2D::AppConfig::createDefault();
|
||||||
|
config.appName = "NS Unknown Game";
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
config.windowConfig.title = "NS Unknown Game";
|
||||||
|
config.windowConfig.width = ns_game::config::kVirtualWidth;
|
||||||
|
config.windowConfig.height = ns_game::config::kVirtualHeight;
|
||||||
|
config.windowConfig.resizable = false;
|
||||||
|
#else
|
||||||
|
config.windowConfig.title = "NS Unknown Game - Whitebox";
|
||||||
|
config.windowConfig.width = 1280;
|
||||||
|
config.windowConfig.height = 720;
|
||||||
|
config.windowConfig.resizable = true;
|
||||||
|
#endif
|
||||||
|
config.windowConfig.vsync = true;
|
||||||
|
config.useVirtualResolution = true;
|
||||||
|
config.virtualWidth = ns_game::config::kVirtualWidth;
|
||||||
|
config.virtualHeight = ns_game::config::kVirtualHeight;
|
||||||
|
config.resolutionMode = frostbite2D::ResolutionScaleMode::Fit;
|
||||||
|
config.renderStyleProfile = frostbite2D::RenderStyleProfileId::PixelArt2D;
|
||||||
|
config.maxFps = 60;
|
||||||
|
|
||||||
|
auto& app = frostbite2D::Application::get();
|
||||||
|
if (!app.init(config)) {
|
||||||
|
#ifdef __SWITCH__
|
||||||
|
std::puts("NS Unknown Game Switch init failed.");
|
||||||
|
showSwitchInitFailure();
|
||||||
|
#else
|
||||||
|
std::puts("NS Unknown Game init failed.");
|
||||||
|
#endif
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ns_game::GameServices::Get().Initialize();
|
||||||
|
|
||||||
|
app.run([]() {
|
||||||
|
frostbite2D::SceneManager::get().ReplaceScene(
|
||||||
|
frostbite2D::MakePtr<ns_game::WhiteboxScene>());
|
||||||
|
});
|
||||||
|
|
||||||
|
app.shutdown();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#include "platform_world.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
MovementBody PlatformMover::Step(const MovementBody& body,
|
||||||
|
const PlatformWorld& world, float gravity,
|
||||||
|
float deltaTime) const {
|
||||||
|
MovementBody next = body;
|
||||||
|
const frostbite2D::Vec2 previousPosition = next.position;
|
||||||
|
|
||||||
|
next.velocity.y += gravity * deltaTime;
|
||||||
|
next.position += next.velocity * deltaTime;
|
||||||
|
next.grounded = false;
|
||||||
|
|
||||||
|
const float previousBottom = previousPosition.y + next.size.y;
|
||||||
|
const float newBottom = next.position.y + next.size.y;
|
||||||
|
|
||||||
|
for (const auto& platform : world.platforms) {
|
||||||
|
const bool horizontallyOverlaps =
|
||||||
|
next.position.x + next.size.x > platform.left() &&
|
||||||
|
next.position.x < platform.right();
|
||||||
|
const bool wasAbove = previousBottom <= platform.top() + 4.0f;
|
||||||
|
const bool crossedTop = newBottom >= platform.top();
|
||||||
|
if (next.velocity.y >= 0.0f && horizontallyOverlaps && wasAbove &&
|
||||||
|
crossedTop) {
|
||||||
|
next.position.y = platform.top() - next.size.y;
|
||||||
|
next.velocity.y = 0.0f;
|
||||||
|
next.grounded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next.position.x =
|
||||||
|
std::clamp(next.position.x, world.levelLeft, world.levelRight - next.size.x);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <frostbite2D/types/type_math.h>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
struct PlatformWorld {
|
||||||
|
float levelLeft = 0.0f;
|
||||||
|
float levelRight = 1280.0f;
|
||||||
|
std::vector<frostbite2D::Rect> platforms;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MovementBody {
|
||||||
|
frostbite2D::Vec2 position;
|
||||||
|
frostbite2D::Vec2 size;
|
||||||
|
frostbite2D::Vec2 velocity;
|
||||||
|
bool grounded = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
class PlatformMover {
|
||||||
|
public:
|
||||||
|
MovementBody Step(const MovementBody& body, const PlatformWorld& world,
|
||||||
|
float gravity, float deltaTime) const;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
#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 {
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
player_ = frostbite2D::MakePtr<PlayerActor>();
|
||||||
|
player_->SetTopLeftPosition(level_.playerSpawn);
|
||||||
|
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) {
|
||||||
|
frostbite2D::Scene::Update(deltaTime);
|
||||||
|
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() {
|
||||||
|
stageRenderer_.Render(level_, debugFlags_);
|
||||||
|
frostbite2D::Scene::Render();
|
||||||
|
stageRenderer_.RenderForeground(level_);
|
||||||
|
const frostbite2D::Rect playerBounds =
|
||||||
|
player_ ? player_->GetWorldBounds() : frostbite2D::Rect::Zero();
|
||||||
|
const std::vector<BattleZoneDebugInfo> battleZoneDebug =
|
||||||
|
buildBattleZoneDebugInfo();
|
||||||
|
debugOverlay_.Render(level_, debugFlags_, player_ ? &playerBounds : nullptr,
|
||||||
|
&battleZoneDebug);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frostbite2D::Rect playerBounds = player_->GetWorldBounds();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (size_t i = 0; i < level_.battleZones.size(); ++i) {
|
||||||
|
BattleZoneRuntime& runtime = battleZoneStates_[i];
|
||||||
|
if (runtime.cleared ||
|
||||||
|
!level_.battleZones[i].trigger.intersects(playerBounds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../actor/player_actor.h"
|
||||||
|
#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"
|
||||||
|
|
||||||
|
#include <frostbite2D/scene/scene.h>
|
||||||
|
#include <frostbite2D/types/type_math.h>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
class WhiteboxScene : public frostbite2D::Scene {
|
||||||
|
public:
|
||||||
|
void onEnter() override;
|
||||||
|
void Update(float deltaTime) override;
|
||||||
|
bool OnEvent(const frostbite2D::Event& event) override;
|
||||||
|
void Render() override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
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_;
|
||||||
|
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_;
|
||||||
|
DebugOverlay debugOverlay_;
|
||||||
|
frostbite2D::Ptr<GameUiOverlay> uiOverlay_;
|
||||||
|
frostbite2D::Ptr<PlayerActor> player_;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace ns_game
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
#include "stage_renderer.h"
|
||||||
|
|
||||||
|
#include "../core/game_config.h"
|
||||||
|
|
||||||
|
#include <frostbite2D/graphics/camera.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 {
|
||||||
|
|
||||||
|
void drawRect(const frostbite2D::Rect& rect, const frostbite2D::Color& 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) {
|
||||||
|
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
|
||||||
|
|
||||||
|
void StageRenderer::Render(const LevelDefinition& level,
|
||||||
|
const DebugFlags& debugFlags) const {
|
||||||
|
drawBackground(level);
|
||||||
|
for (const StageLayer& layer : level.layers) {
|
||||||
|
if (layer.role == StageLayerRole::PropsFront) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
drawLayer(level, layer);
|
||||||
|
}
|
||||||
|
if (debugFlags.enabled && debugFlags.showStageGrid) {
|
||||||
|
drawGrid(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 {
|
||||||
|
drawRect(frostbite2D::Rect(level.collision.levelLeft, 0.0f,
|
||||||
|
worldWidthOf(level), config::kVirtualHeight),
|
||||||
|
frostbite2D::Color(0.10f, 0.12f, 0.14f, 1.0f));
|
||||||
|
}
|
||||||
|
|
||||||
|
void StageRenderer::drawGrid(const LevelDefinition& level) const {
|
||||||
|
const float worldWidth = worldWidthOf(level);
|
||||||
|
for (float x = level.collision.levelLeft; x <= level.collision.levelRight;
|
||||||
|
x += 80.0f) {
|
||||||
|
drawRect(frostbite2D::Rect(x, 0.0f, 1.0f, config::kVirtualHeight),
|
||||||
|
frostbite2D::Color(0.20f, 0.22f, 0.25f, 1.0f));
|
||||||
|
}
|
||||||
|
for (float y = 0.0f; y <= config::kVirtualHeight; y += 80.0f) {
|
||||||
|
drawRect(frostbite2D::Rect(level.collision.levelLeft, y, worldWidth, 1.0f),
|
||||||
|
frostbite2D::Color(0.20f, 0.22f, 0.25f, 1.0f));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void StageRenderer::drawLayer(const LevelDefinition& level,
|
||||||
|
const StageLayer& layer) const {
|
||||||
|
(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);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const StageRect& rect : layer.rects) {
|
||||||
|
frostbite2D::Rect bounds(rect.bounds.left() + parallaxOffset.x,
|
||||||
|
rect.bounds.top() + parallaxOffset.y,
|
||||||
|
rect.bounds.width(), rect.bounds.height());
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "../data/level_definition.h"
|
||||||
|
#include "../core/debug_flags.h"
|
||||||
|
|
||||||
|
#include <frostbite2D/graphics/texture.h>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace ns_game {
|
||||||
|
|
||||||
|
class StageRenderer {
|
||||||
|
public:
|
||||||
|
void Render(const LevelDefinition& level, const DebugFlags& debugFlags) const;
|
||||||
|
void RenderForeground(const LevelDefinition& level) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void drawBackground(const LevelDefinition& level) const;
|
||||||
|
void drawGrid(const LevelDefinition& level) const;
|
||||||
|
void drawLayer(const LevelDefinition& level, const StageLayer& layer) 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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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`。
|
||||||
@@ -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 颜色显示。
|
||||||
|
- 暂未实现重做和多选。
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -31,4 +31,63 @@ if is_plat("windows") then
|
|||||||
add_packages("zlib")
|
add_packages("zlib")
|
||||||
add_syslinks("imm32")
|
add_syslinks("imm32")
|
||||||
target_end()
|
target_end()
|
||||||
|
|
||||||
|
target("Frostbite2DGame")
|
||||||
|
set_kind("binary")
|
||||||
|
add_files("game/src/**.cpp")
|
||||||
|
add_deps("Frostbite2D")
|
||||||
|
add_packages("libsdl2")
|
||||||
|
add_packages("libsdl2_image")
|
||||||
|
add_packages("libsdl2_mixer")
|
||||||
|
add_packages("libsdl2_ttf")
|
||||||
|
add_packages("glm")
|
||||||
|
add_packages("zlib")
|
||||||
|
add_syslinks("imm32")
|
||||||
|
after_build(function (target)
|
||||||
|
local assetsdir = path.join(target:targetdir(), "assets")
|
||||||
|
os.rm(assetsdir)
|
||||||
|
os.mkdir(assetsdir)
|
||||||
|
os.cp(path.join(os.projectdir(), "game/assets/*"), assetsdir)
|
||||||
|
end)
|
||||||
|
target_end()
|
||||||
|
end
|
||||||
|
|
||||||
|
if is_plat("switch") then
|
||||||
|
target("Frostbite2DGameSwitch")
|
||||||
|
set_kind("binary")
|
||||||
|
set_filename("Frostbite2DGame.elf")
|
||||||
|
add_files("game/src/**.cpp")
|
||||||
|
add_deps("Frostbite2D")
|
||||||
|
add_cxflags("-D__SWITCH__", "-fPIE", {public = true})
|
||||||
|
add_ldflags("-specs=" .. path.join(os.getenv("DEVKITPRO") or "C:/devkitPro", "libnx/switch.specs"), "-fPIE", {force = true})
|
||||||
|
after_build(function (target)
|
||||||
|
local outdir = path.join(target:targetdir(), "switch_game")
|
||||||
|
local assetsdir = path.join(outdir, "assets")
|
||||||
|
os.mkdir(outdir)
|
||||||
|
os.rm(assetsdir)
|
||||||
|
os.mkdir(assetsdir)
|
||||||
|
os.cp(path.join(os.projectdir(), "game/assets/*"), assetsdir)
|
||||||
|
|
||||||
|
local devkitpro = os.getenv("DEVKITPRO") or "C:/devkitPro"
|
||||||
|
local tools = path.join(devkitpro, "tools/bin")
|
||||||
|
local libnx = path.join(devkitpro, "libnx")
|
||||||
|
local nacp = path.join(outdir, "control.nacp")
|
||||||
|
local nro = path.join(outdir, "NSUnknownGame.nro")
|
||||||
|
local romfsdir = path.join(outdir, "romfs")
|
||||||
|
local romfsassetsdir = path.join(romfsdir, "assets")
|
||||||
|
os.rm(romfsdir)
|
||||||
|
os.mkdir(romfsdir)
|
||||||
|
os.mkdir(romfsassetsdir)
|
||||||
|
os.cp(path.join(os.projectdir(), "game/assets/*"), romfsassetsdir)
|
||||||
|
os.execv(path.join(tools, "nacptool.exe"), {
|
||||||
|
"--create", "NS Unknown Game", "Frostbite2D", "1.0.0", nacp
|
||||||
|
})
|
||||||
|
os.execv(path.join(tools, "elf2nro.exe"), {
|
||||||
|
target:targetfile(), nro,
|
||||||
|
"--nacp=" .. nacp,
|
||||||
|
"--icon=" .. path.join(libnx, "default_icon.jpg"),
|
||||||
|
"--romfsdir=" .. romfsdir
|
||||||
|
})
|
||||||
|
end)
|
||||||
|
target_end()
|
||||||
end
|
end
|
||||||
|
|||||||