Load stage map into level definition
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user