Load stage map into level definition

This commit is contained in:
2026-06-09 22:16:19 +08:00
parent 45a8712b00
commit 3fc9caa80b
9 changed files with 574 additions and 61 deletions
+396
View File
@@ -0,0 +1,396 @@
#include "stage_map_loader.h"
#include "stage_map_resource.h"
#include <SDL2/SDL.h>
#include <charconv>
#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<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 == "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 == "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;
}
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
+12
View File
@@ -0,0 +1,12 @@
#pragma once
#include "level_definition.h"
#include <optional>
#include <string>
namespace ns_game {
std::optional<LevelDefinition> LoadStageMap(const std::string& path);
} // namespace ns_game
+15 -11
View File
@@ -1,21 +1,13 @@
#include "level_definition.h"
#include "stage_map_resource.h"
#include "stage_map_loader.h"
#include <SDL2/SDL.h>
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