diff --git a/game/assets/map/stage_01.map b/game/assets/map/stage_01.map index 0079ad2..42ae66f 100644 --- a/game/assets/map/stage_01.map +++ b/game/assets/map/stage_01.map @@ -1,10 +1,68 @@ -# stage_01.map -# -# 当前文件先作为地图资源链占位:Asset 可以读取它,后续 StageMapLoader 会把 -# world/camera/player/layers/collision/battle_zones 等字段解析成 LevelDefinition。 -# 现阶段 WhiteboxScene 仍使用 C++ fallback 白盒地图,避免未完成 parser 影响构建。 - map stage_01 world 3200 720 camera 0 0 3200 720 player_spawn 160 380 + +collision 0 600 3200 80 +collision 230 500 220 28 +collision 560 430 260 28 +collision 930 510 260 28 +collision 1250 420 230 28 +collision 1760 500 300 28 +collision 2180 455 260 28 +collision 2650 515 360 28 + +layer background_far BackgroundFar 0.25 +rect 0 0 3200 720 0.08 0.10 0.14 1.0 +rect 360 120 420 220 0.13 0.16 0.21 1.0 +rect 1380 90 560 260 0.12 0.15 0.20 1.0 +rect 2440 140 520 210 0.13 0.16 0.21 1.0 +endlayer + +layer background_mid BackgroundMid 0.55 +rect 80 360 280 240 0.18 0.22 0.27 1.0 +rect 1020 310 340 290 0.18 0.22 0.27 1.0 +rect 2020 330 360 270 0.18 0.22 0.27 1.0 +rect 2860 280 260 320 0.18 0.22 0.27 1.0 +endlayer + +layer level_visual LevelVisual 1.0 +rect 0 600 3200 80 0.62 0.68 0.73 1.0 +rect 230 500 220 28 0.62 0.68 0.73 1.0 +rect 560 430 260 28 0.62 0.68 0.73 1.0 +rect 930 510 260 28 0.62 0.68 0.73 1.0 +rect 1250 420 230 28 0.62 0.68 0.73 1.0 +rect 1760 500 300 28 0.62 0.68 0.73 1.0 +rect 2180 455 260 28 0.62 0.68 0.73 1.0 +rect 2650 515 360 28 0.62 0.68 0.73 1.0 +endlayer + +layer props_back PropsBack 1.0 +rect 118 392 88 208 0.26 0.30 0.34 1.0 +rect 780 250 72 180 0.26 0.30 0.34 1.0 +rect 1440 300 80 300 0.26 0.30 0.34 1.0 +rect 1880 360 110 240 0.26 0.30 0.34 1.0 +rect 2520 390 92 210 0.26 0.30 0.34 1.0 +rect 3040 315 82 285 0.26 0.30 0.34 1.0 +endlayer + +layer props_front PropsFront 1.0 +rect 520 580 220 34 0.14 0.17 0.18 0.90 +rect 1510 570 280 42 0.14 0.17 0.18 0.90 +rect 2580 572 260 38 0.14 0.17 0.18 0.90 +endlayer + +battle_zone zone_01_intro 520 260 560 340 0 0 1940 720 +spawn zone_01_enemy_a 760 560 +spawn zone_01_enemy_b 980 560 +endbattle_zone + +battle_zone zone_02_platform 1420 250 680 350 660 0 2300 720 +spawn zone_02_enemy_a 1620 560 +spawn zone_02_enemy_b 1900 560 +endbattle_zone + +battle_zone zone_03_exit 2380 250 720 350 1600 0 1600 720 +spawn zone_03_enemy_a 2580 560 +spawn zone_03_enemy_b 2860 560 +endbattle_zone diff --git a/game/docs/engine_capability_audit.md b/game/docs/engine_capability_audit.md index e598386..e455e80 100644 --- a/game/docs/engine_capability_audit.md +++ b/game/docs/engine_capability_audit.md @@ -8,7 +8,7 @@ 最重要的调整是:**场景地图不要继续在 C++ 里手写 `Rect`,下一步应接入 `.map` 文件加载,并优先通过引擎 `Asset` / `PvfArchive` / `ScriptParser` 读取和解析。** -当前第一轮骨架接入已完成:游戏启动时通过 `GameServices` 探测/初始化 `AudioSystem`、`PvfArchive`、`NpkArchive`、`SoundPackArchive`、`AudioDatabase` 和 `SaveSystem` 状态,并通过 `StageMapResource` 接入 `.map` 资源读取入口。详细状态见 `game_skeleton.md`。 +当前第一轮骨架接入已完成:游戏启动时通过 `GameServices` 探测/初始化 `AudioSystem`、`PvfArchive`、`NpkArchive`、`SoundPackArchive`、`AudioDatabase` 和 `SaveSystem` 状态,并通过 `StageMapResource` + `StageMapLoader` 接入 `.map` 资源读取和解析入口。详细状态见 `game_skeleton.md`。 推荐方向: @@ -74,15 +74,15 @@ - `Asset` 被引擎纹理加载间接使用。 - Windows / Switch 的资源根目录、RomFS 路径已经由引擎 `Asset` 解析。 -未充分使用: +当前状态: -- 游戏层目前没有直接用 `Asset::readTextFile` / `readBinaryFile` 读取地图和配置。 -- `stage_01` 地图仍写死在 C++。 +- `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` 文件。 下一步: -- `.map` 普通文件先用 `Asset` 读取。 -- 同一套 loader 后续支持从 PVF 中读取。 +- 继续把输入配置、角色动画配置等文本/脚本配置接入 `Asset` 或 PVF。 ### PVF / ScriptParser @@ -98,8 +98,9 @@ 当前状态: -- 游戏层还没使用。 -- 引擎已经具备从 `Script.pvf` 按逻辑路径取脚本文件的能力。 +- `StageMapResource` 已能在 `PvfArchive` 打开后尝试读取 `map/stage_01.map`。 +- 若资源像 PVF 二进制脚本,会先用 `ScriptParser` 解析出 script values。 +- 若资源是 PVF 文本,会作为普通 `.map` 文本交给 `StageMapLoader`。 - `ScriptParser` 支持 PVF 脚本二进制格式,不是普通文本 parser。 地图结论: @@ -185,20 +186,17 @@ - `game/src/data/whitebox_level.cpp` -问题: +当前状态: -- 世界尺寸、平台、视觉层、props、battle zone、spawn point 全部写在 C++。 -- 这不适合后续反复调地图,也不适合你要的 `.map` 工作流。 +- 已新增 `game/assets/map/stage_01.map`。 +- 已新增 `StageMapLoader`,把 `.map` 文本解析成 `LevelDefinition`。 +- `CreateWhiteboxLevel()` 已改为优先加载 `.map`,失败时才回退 C++ 白盒数据。 +- C++ 中保留的世界尺寸、平台、视觉层、props、battle zone、spawn point 现在只作为 fallback。 -建议: +后续建议: -- 新增 `StageMapLoader`。 -- 创建 `game/assets/stage/stage_01/stage_01.map` 或 `game/assets/map/stage_01.map`。 -- 让 `CreateWhiteboxLevel()` 临时变成: - -```cpp -return LoadStageMap("map/stage_01.map").value_or(CreateFallbackWhiteboxLevel()); -``` +- 继续扩展 `.map` 的 tileset/tile entry 表达。 +- 逐步把 `level_visual` 的长色块替换成 tile 数据。 ### 2. 手写动画帧切换 @@ -245,13 +243,13 @@ return LoadStageMap("map/stage_01.map").value_or(CreateFallbackWhiteboxLevel()); ### 加载优先级 -第一阶段先支持普通资源文件: +第一阶段已支持普通资源文件: ```text Asset::readTextFile("map/stage_01.map") ``` -第二阶段支持 PVF: +第二阶段已预留 PVF: ```text PvfArchive::getFileContent("map/stage_01.map") @@ -312,10 +310,10 @@ StageMapLoader ## 推荐执行顺序 -1. `StageMapResource`:定义地图文件路径、普通资源读取和 PVF 读取优先级。 -2. `StageMapLoader`:把 `.map` 转成 `LevelDefinition`。 -3. `stage_01.map`:把当前 `whitebox_level.cpp` 数据迁移成外部地图文件。 -4. `CreateWhiteboxLevel()`:改为加载 `stage_01.map`,失败时回退旧白盒数据。 +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` 和场景计划。 diff --git a/game/docs/game_skeleton.md b/game/docs/game_skeleton.md index d4f9cff..5b08a23 100644 --- a/game/docs/game_skeleton.md +++ b/game/docs/game_skeleton.md @@ -38,10 +38,11 @@ ## 地图资源入口 -新增 `StageMapResource`: +新增 `StageMapResource` 和 `StageMapLoader`: ```text LoadStageMapResource("map/stage_01.map") +LoadStageMap("map/stage_01.map") ``` 读取优先级: @@ -53,22 +54,36 @@ LoadStageMapResource("map/stage_01.map") 当前状态: -- 已新增 `game/assets/map/stage_01.map` 占位文件。 -- `CreateWhiteboxLevel()` 会读取地图资源并记录来源。 -- 地图内容尚未接管 `LevelDefinition`,当前仍使用 C++ 白盒数据作为 fallback。 +- `game/assets/map/stage_01.map` 已接管第一关主数据。 +- `StageMapLoader` 会把 `.map` 文本解析成 `LevelDefinition`。 +- 已迁移世界尺寸、玩家出生点、相机边界、碰撞平台、视觉层、props、battle zone 和 spawn point。 +- `CreateWhiteboxLevel()` 优先读取 `.map`,读取或解析失败时才使用 C++ 白盒 fallback。 +- Windows 和 Switch 构建会把 `game/assets/*` 复制到产物 `assets/`,保证运行时 `assets/map/stage_01.map` 路径可用。 -下一步: +`.map` 当前支持的命令: -- 实现 `StageMapLoader`,把 `.map` 转成 `LevelDefinition`。 -- 将 `whitebox_level.cpp` 中的硬编码平台、props、battle zone 迁移到 `.map`。 -- 失败时保留 `CreateFallbackWhiteboxLevel()`。 +```text +map +world +camera +player_spawn +collision +layer +rect +rect_sprite +rect_sprite_src +endlayer +battle_zone +spawn +endbattle_zone +``` ## 仍待迁移的能力 - 玩家动画:从手写 spritesheet 帧切换迁移到引擎 `Animation` / PVF ANI。 - 场景视觉资源:如果素材进入 NPK,迁移到 `NpkArchive` / `Sprite::createFromNpk`。 - 音频:使用 `AudioSystem`、`Sound`、`Music`、`AudioDatabase`。 -- 地图:完成 `.map` parser,不再用 C++ 手写地图。 +- 地图:后续可把 `.map` 扩展为工具导出格式或 PVF 脚本格式,当前 C++ 地图只作为 fallback。 - 输入配置:通过 `Asset` 或 PVF 读取 `InputConfig`。 ## 原则 diff --git a/game/docs/planning/scene_build_plan.md b/game/docs/planning/scene_build_plan.md index cf4d572..d98aea2 100644 --- a/game/docs/planning/scene_build_plan.md +++ b/game/docs/planning/scene_build_plan.md @@ -269,9 +269,19 @@ game/assets/stage/stage_01/ 当前场景骨架已经达到“可直接放素材”的工程状态。后续素材接入主要修改 `game/assets/stage/stage_01/` 和关卡 layer 数据,不需要再重写 `WhiteboxScene` 主流程。 -下一步地图框架调整:优先复用引擎 `Asset`、`PvfArchive` 和 `ScriptParser` 能力,把地图从 C++ 硬编码迁移到 `.map` 外部资源文件。详细复盘见 `../engine_capability_audit.md`。 +地图框架调整已完成第一步:优先复用引擎 `Asset`、`PvfArchive` 和 `ScriptParser` 能力,`stage_01.map` 已从占位文件升级为第一关主数据源。`StageMapLoader` 会把 `.map` 解析成 `LevelDefinition`,C++ 白盒地图仅作为读取或解析失败时的 fallback。详细复盘见 `../engine_capability_audit.md`。 -第一轮引擎能力接入已完成:`GameServices` 统一接入音频、PVF、NPK、SoundPack、AudioDB、SaveSystem 状态;`StageMapResource` 已能读取 `map/stage_01.map`,当前仍使用白盒 C++ 地图作为 fallback。完整骨架说明见 `../game_skeleton.md`。 +第一轮引擎能力接入已完成:`GameServices` 统一接入音频、PVF、NPK、SoundPack、AudioDB、SaveSystem 状态;`StageMapResource` 负责从 Asset/PVF 读取 `map/stage_01.map`,`StageMapLoader` 负责转成游戏层 `LevelDefinition`。完整骨架说明见 `../game_skeleton.md`。 + +### 下一轮建议任务 + +继续沿着“优先使用引擎能力”的方向,把地图从色块灰盒推进到可放素材的完整骨架: + +1. 给 `.map` 增加 tileset/tile entry 约定,先不做编辑器,只保证工具或手写数据能表达重复地砖。 +2. 在 `StageRenderer` 中增加 tile 绘制路径,复用当前 `Texture` 加载和 source rect 支持。 +3. 把 `level_visual` 的长色块逐步替换成 tile entries,碰撞仍独立保留在 `collision`。 +4. 给 map loader 增加更严格的错误日志,例如重复 block、空 layer、battle zone 无 spawn 的提示。 +5. 补一个轻量 map 验收流程:启动时日志确认加载 `.map`,RomFS/Windows 产物确认存在 `assets/map/stage_01.map`。 ### Debug 快捷键 diff --git a/game/docs/scene_acceptance.md b/game/docs/scene_acceptance.md index ac7c24e..8a621ea 100644 --- a/game/docs/scene_acceptance.md +++ b/game/docs/scene_acceptance.md @@ -5,13 +5,15 @@ ## 验收范围 - 场景入口:`WhiteboxScene` -- 关卡数据:`CreateWhiteboxLevel()` +- 关卡数据:`game/assets/map/stage_01.map` +- 关卡 fallback:`CreateWhiteboxLevel()` 内置 C++ 白盒数据 - 世界尺寸:`3200 x 720` - 玩家出生点:`(160, 380)` - 关卡边界:`0 -> 3200` - 视口:`1280 x 720` - Battle zones:`zone_01_intro`、`zone_02_platform`、`zone_03_exit` - 资源槽位:`game/assets/stage/stage_01/` +- 地图资源:`game/assets/map/stage_01.map` ## 构建检查 @@ -31,6 +33,12 @@ 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 +``` + ### Switch ```powershell @@ -44,6 +52,12 @@ if (!$p.HasExited) { Stop-Process -Id $p.Id -Force } build\switch\aarch64\release\switch_game\NSUnknownGame.nro ``` +RomFS 地图检查: + +```powershell +Test-Path build\switch\aarch64\release\switch_game\romfs\assets\map\stage_01.map +``` + ## 手动跑图检查 操作映射见 `controls.md`。 @@ -66,7 +80,8 @@ build\switch\aarch64\release\switch_game\NSUnknownGame.nro - Windows release 构建:通过。 - Windows 3 秒短启动:通过。 - Switch `Frostbite2DGameSwitch` 构建:通过。 -- Switch RomFS:已包含 character、shader 和 `stage_01` 资源槽位。 +- Switch RomFS:已包含 character、shader、`stage_01` 资源槽位和 `assets/map/stage_01.map`。 +- 地图加载:`stage_01.map` 已作为第一关主数据源;C++ 白盒数据仅作为 fallback。 - 手动跑图:未在本轮执行,等待后续素材/战斗区域进一步接入时复测。 ## 后续补充 diff --git a/game/src/data/stage_map_loader.cpp b/game/src/data/stage_map_loader.cpp new file mode 100644 index 0000000..4d0e98e --- /dev/null +++ b/game/src/data/stage_map_loader.cpp @@ -0,0 +1,396 @@ +#include "stage_map_loader.h" + +#include "stage_map_resource.h" + +#include + +#include +#include + +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 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 +parseRect(const std::vector& 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 +parseColor(const std::vector& 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 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 tokenize(const std::string& line) { + std::istringstream stream(line); + std::vector 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& tokens) { + const std::string& command = tokens[0]; + + if (command == "map") { + if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 2) { + return warnLine(path, lineNumber, "expected: map "); + } + context.hasMap = true; + return true; + } + + if (command == "world") { + if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 3) { + return warnLine(path, lineNumber, "expected: world "); + } + 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 "); + } + 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 "); + } + 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 "); + } + 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 == "layer") { + if (!ensureNoOpenBlock(context, path, lineNumber) || tokens.size() != 4) { + return warnLine(path, lineNumber, + "expected: layer "); + } + 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 "); + } + 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 == "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 " + : "expected: rect_sprite "); + } + + 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 "); + } + 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 "); + } + 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 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 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 LoadStageMap(const std::string& path) { + const std::optional 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 level = parseMapText(*resource); + if (!level) { + return std::nullopt; + } + + SDL_Log("StageMapLoader: loaded %s from %s (%zu platforms, %zu layers, %zu zones)", + resource->path.c_str(), sourceName(resource->source), + level->collision.platforms.size(), level->layers.size(), + level->battleZones.size()); + return level; +} + +} // namespace ns_game diff --git a/game/src/data/stage_map_loader.h b/game/src/data/stage_map_loader.h new file mode 100644 index 0000000..699259d --- /dev/null +++ b/game/src/data/stage_map_loader.h @@ -0,0 +1,12 @@ +#pragma once + +#include "level_definition.h" + +#include +#include + +namespace ns_game { + +std::optional LoadStageMap(const std::string& path); + +} // namespace ns_game diff --git a/game/src/data/whitebox_level.cpp b/game/src/data/whitebox_level.cpp index 74d1855..f3e866b 100644 --- a/game/src/data/whitebox_level.cpp +++ b/game/src/data/whitebox_level.cpp @@ -1,21 +1,13 @@ #include "level_definition.h" -#include "stage_map_resource.h" +#include "stage_map_loader.h" #include namespace ns_game { -LevelDefinition CreateWhiteboxLevel() { - if (auto mapResource = LoadStageMapResource("map/stage_01.map")) { - SDL_Log("CreateWhiteboxLevel: loaded %s map resource: %s", - mapResource->source == StageMapResourceSource::AssetText ? "asset" - : mapResource->source == StageMapResourceSource::PvfText ? "pvf-text" - : mapResource->source == StageMapResourceSource::PvfBinaryScript - ? "pvf-script" - : "unknown", - mapResource->path.c_str()); - } +namespace { +LevelDefinition CreateFallbackWhiteboxLevel() { LevelDefinition level; level.worldWidth = 3200.0f; level.worldHeight = 720.0f; @@ -146,4 +138,16 @@ LevelDefinition CreateWhiteboxLevel() { 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 diff --git a/xmake.lua b/xmake.lua index 9d8faf0..5d53fdd 100644 --- a/xmake.lua +++ b/xmake.lua @@ -44,8 +44,10 @@ if is_plat("windows") then add_packages("zlib") add_syslinks("imm32") after_build(function (target) - os.cp(path.join(os.projectdir(), "game/assets"), - path.join(target:targetdir(), "assets")) + 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 @@ -60,9 +62,11 @@ if is_plat("switch") then add_ldflags("-specs=" .. path.join(os.getenv("DEVKITPRO") or "C:/devkitPro", "libnx/switch.specs"), "-fPIE", {force = true}) after_build(function (target) local outdir = path.join(target:targetdir(), "switch_game") + local assetsdir = path.join(outdir, "assets") os.mkdir(outdir) - os.cp(path.join(os.projectdir(), "game/assets"), - path.join(outdir, "assets")) + 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") @@ -70,10 +74,11 @@ if is_plat("switch") then 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.cp(path.join(os.projectdir(), "game/assets"), - path.join(romfsdir, "assets")) + 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 })