474 lines
16 KiB
C++
474 lines
16 KiB
C++
#include "TmxMap.h"
|
|
|
|
#include <algorithm>
|
|
#include <cctype>
|
|
#include <cmath>
|
|
#include <pugixml.hpp>
|
|
#include <sstream>
|
|
#include <stdexcept>
|
|
|
|
namespace {
|
|
|
|
constexpr std::uint32_t FlippedHorizontally = 0x80000000u;
|
|
constexpr std::uint32_t FlippedVertically = 0x40000000u;
|
|
constexpr std::uint32_t FlippedDiagonally = 0x20000000u;
|
|
constexpr std::uint32_t FlippedHex120 = 0x10000000u;
|
|
|
|
std::string Trim(std::string value)
|
|
{
|
|
auto first = std::find_if_not(value.begin(), value.end(), [](unsigned char ch) { return std::isspace(ch); });
|
|
auto last = std::find_if_not(value.rbegin(), value.rend(), [](unsigned char ch) { return std::isspace(ch); }).base();
|
|
if (first >= last) {
|
|
return {};
|
|
}
|
|
return std::string(first, last);
|
|
}
|
|
|
|
std::string LowerCopy(std::string value)
|
|
{
|
|
std::transform(value.begin(), value.end(), value.begin(), [](unsigned char ch) {
|
|
return static_cast<char>(std::tolower(ch));
|
|
});
|
|
return value;
|
|
}
|
|
|
|
std::filesystem::path NormalizePath(const std::filesystem::path& base, const char* relative)
|
|
{
|
|
return std::filesystem::weakly_canonical(base / relative);
|
|
}
|
|
|
|
std::map<std::string, std::string> ReadProperties(const pugi::xml_node& node)
|
|
{
|
|
std::map<std::string, std::string> result;
|
|
for (pugi::xml_node property : node.child("properties").children("property")) {
|
|
const std::string name = property.attribute("name").as_string();
|
|
const char* valueAttr = property.attribute("value").as_string(nullptr);
|
|
if (valueAttr) {
|
|
result[name] = valueAttr;
|
|
} else {
|
|
result[name] = property.text().as_string();
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
std::vector<TmxPoint> ParsePoints(const std::string& points);
|
|
|
|
std::vector<TmxCollisionShape> ParseCollisionShapes(const pugi::xml_node& tile)
|
|
{
|
|
std::vector<TmxCollisionShape> shapes;
|
|
for (pugi::xml_node object : tile.child("objectgroup").children("object")) {
|
|
TmxCollisionShape shape;
|
|
shape.x = object.attribute("x").as_float();
|
|
shape.y = object.attribute("y").as_float();
|
|
shape.width = object.attribute("width").as_float();
|
|
shape.height = object.attribute("height").as_float();
|
|
if (pugi::xml_node polygon = object.child("polygon")) {
|
|
shape.polygon = ParsePoints(polygon.attribute("points").as_string());
|
|
} else if (pugi::xml_node polyline = object.child("polyline")) {
|
|
shape.polygon = ParsePoints(polyline.attribute("points").as_string());
|
|
}
|
|
if (!shape.polygon.empty() || shape.width > 0.0f || shape.height > 0.0f) {
|
|
shapes.push_back(std::move(shape));
|
|
}
|
|
}
|
|
return shapes;
|
|
}
|
|
|
|
TmxTileset ParseTilesetNode(const pugi::xml_node& node, std::uint32_t firstGid, const std::filesystem::path& baseDir)
|
|
{
|
|
TmxTileset tileset;
|
|
tileset.firstGid = firstGid;
|
|
tileset.name = node.attribute("name").as_string();
|
|
tileset.tileWidth = node.attribute("tilewidth").as_int();
|
|
tileset.tileHeight = node.attribute("tileheight").as_int();
|
|
tileset.tileCount = node.attribute("tilecount").as_int();
|
|
tileset.columns = node.attribute("columns").as_int();
|
|
|
|
pugi::xml_node image = node.child("image");
|
|
if (!image) {
|
|
throw std::runtime_error("Tileset has no image: " + tileset.name);
|
|
}
|
|
tileset.imagePath = NormalizePath(baseDir, image.attribute("source").as_string());
|
|
for (pugi::xml_node tile : node.children("tile")) {
|
|
const auto tileId = static_cast<std::uint32_t>(tile.attribute("id").as_uint());
|
|
std::vector<TmxCollisionShape> collisionShapes = ParseCollisionShapes(tile);
|
|
if (!collisionShapes.empty()) {
|
|
tileset.collisionShapes[tileId] = std::move(collisionShapes);
|
|
}
|
|
|
|
pugi::xml_node animation = tile.child("animation");
|
|
if (!animation) {
|
|
continue;
|
|
}
|
|
std::vector<TmxAnimationFrame> frames;
|
|
for (pugi::xml_node frame : animation.children("frame")) {
|
|
frames.push_back(TmxAnimationFrame{
|
|
static_cast<std::uint32_t>(frame.attribute("tileid").as_uint()),
|
|
frame.attribute("duration").as_int(),
|
|
});
|
|
}
|
|
if (!frames.empty()) {
|
|
tileset.animations[tileId] = std::move(frames);
|
|
}
|
|
}
|
|
return tileset;
|
|
}
|
|
|
|
TmxTileset ParseTileset(const pugi::xml_node& node, const std::filesystem::path& mapDir)
|
|
{
|
|
const auto firstGid = static_cast<std::uint32_t>(node.attribute("firstgid").as_uint());
|
|
const char* source = node.attribute("source").as_string(nullptr);
|
|
if (!source) {
|
|
return ParseTilesetNode(node, firstGid, mapDir);
|
|
}
|
|
|
|
const std::filesystem::path tsxPath = NormalizePath(mapDir, source);
|
|
pugi::xml_document doc;
|
|
pugi::xml_parse_result ok = doc.load_file(tsxPath.c_str());
|
|
if (!ok) {
|
|
throw std::runtime_error("Could not parse TSX: " + tsxPath.string());
|
|
}
|
|
return ParseTilesetNode(doc.child("tileset"), firstGid, tsxPath.parent_path());
|
|
}
|
|
|
|
std::vector<std::uint32_t> ParseCsvGids(const std::string& csv)
|
|
{
|
|
std::vector<std::uint32_t> gids;
|
|
std::stringstream stream(csv);
|
|
std::string item;
|
|
while (std::getline(stream, item, ',')) {
|
|
item = Trim(item);
|
|
if (!item.empty()) {
|
|
gids.push_back(static_cast<std::uint32_t>(std::stoul(item)));
|
|
}
|
|
}
|
|
return gids;
|
|
}
|
|
|
|
std::vector<TmxPoint> ParsePoints(const std::string& points)
|
|
{
|
|
std::vector<TmxPoint> out;
|
|
std::stringstream stream(points);
|
|
std::string pair;
|
|
while (stream >> pair) {
|
|
const std::size_t comma = pair.find(',');
|
|
if (comma == std::string::npos) {
|
|
continue;
|
|
}
|
|
TmxPoint point;
|
|
point.x = std::stof(pair.substr(0, comma));
|
|
point.y = std::stof(pair.substr(comma + 1));
|
|
out.push_back(point);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace {
|
|
|
|
std::map<std::string, TmxMap>& TmxMapCache()
|
|
{
|
|
static std::map<std::string, TmxMap> cache;
|
|
return cache;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
std::uint32_t CleanGid(std::uint32_t gid)
|
|
{
|
|
return gid & ~(FlippedHorizontally | FlippedVertically | FlippedDiagonally | FlippedHex120);
|
|
}
|
|
|
|
TmxMap LoadTmxMap(const std::filesystem::path& path)
|
|
{
|
|
pugi::xml_document doc;
|
|
pugi::xml_parse_result ok = doc.load_file(path.c_str());
|
|
if (!ok) {
|
|
throw std::runtime_error("Could not parse TMX: " + path.string());
|
|
}
|
|
|
|
pugi::xml_node root = doc.child("map");
|
|
if (!root) {
|
|
throw std::runtime_error("TMX file has no map root: " + path.string());
|
|
}
|
|
|
|
TmxMap map;
|
|
map.sourcePath = std::filesystem::weakly_canonical(path);
|
|
map.width = root.attribute("width").as_int();
|
|
map.height = root.attribute("height").as_int();
|
|
map.tileWidth = root.attribute("tilewidth").as_int();
|
|
map.tileHeight = root.attribute("tileheight").as_int();
|
|
map.properties = ReadProperties(root);
|
|
|
|
const std::filesystem::path mapDir = map.sourcePath.parent_path();
|
|
for (pugi::xml_node child : root.children()) {
|
|
const std::string nodeName = child.name();
|
|
if (nodeName == "tileset") {
|
|
map.tilesets.push_back(ParseTileset(child, mapDir));
|
|
} else if (nodeName == "layer") {
|
|
pugi::xml_node data = child.child("data");
|
|
const std::string encoding = data.attribute("encoding").as_string();
|
|
if (encoding != "csv") {
|
|
continue;
|
|
}
|
|
TmxLayer layer;
|
|
layer.name = child.attribute("name").as_string();
|
|
layer.width = child.attribute("width").as_int();
|
|
layer.height = child.attribute("height").as_int();
|
|
layer.visible = child.attribute("visible").as_int(1) != 0;
|
|
layer.opacity = child.attribute("opacity").as_float(1.0f);
|
|
layer.gids = ParseCsvGids(data.text().as_string());
|
|
map.layers.push_back(std::move(layer));
|
|
} else if (nodeName == "objectgroup") {
|
|
for (pugi::xml_node object : child.children("object")) {
|
|
TmxObject out;
|
|
out.id = object.attribute("id").as_int();
|
|
out.name = object.attribute("name").as_string();
|
|
out.type = object.attribute("type").as_string();
|
|
out.x = object.attribute("x").as_float();
|
|
out.y = object.attribute("y").as_float();
|
|
out.width = object.attribute("width").as_float();
|
|
out.height = object.attribute("height").as_float();
|
|
if (pugi::xml_node polygon = object.child("polygon")) {
|
|
out.polygon = ParsePoints(polygon.attribute("points").as_string());
|
|
} else if (pugi::xml_node polyline = object.child("polyline")) {
|
|
out.polygon = ParsePoints(polyline.attribute("points").as_string());
|
|
}
|
|
out.properties = ReadProperties(object);
|
|
map.objects.push_back(std::move(out));
|
|
}
|
|
}
|
|
}
|
|
|
|
std::sort(map.tilesets.begin(), map.tilesets.end(), [](const TmxTileset& a, const TmxTileset& b) {
|
|
return a.firstGid < b.firstGid;
|
|
});
|
|
|
|
return map;
|
|
}
|
|
|
|
const TmxMap& LoadTmxMapCached(const std::filesystem::path& path)
|
|
{
|
|
const std::filesystem::path canonical = std::filesystem::weakly_canonical(path);
|
|
const std::string key = canonical.string();
|
|
std::map<std::string, TmxMap>& cache = TmxMapCache();
|
|
auto [it, inserted] = cache.try_emplace(key);
|
|
if (inserted) {
|
|
it->second = LoadTmxMap(canonical);
|
|
}
|
|
return it->second;
|
|
}
|
|
|
|
void ClearTmxMapCache()
|
|
{
|
|
TmxMapCache().clear();
|
|
}
|
|
|
|
const TmxLayer* FindLayer(const TmxMap& map, const std::string& name)
|
|
{
|
|
auto it = std::find_if(map.layers.begin(), map.layers.end(), [&](const TmxLayer& layer) {
|
|
return layer.name == name;
|
|
});
|
|
return it == map.layers.end() ? nullptr : &*it;
|
|
}
|
|
|
|
const TmxObject* FindObject(const TmxMap& map, const std::string& name)
|
|
{
|
|
auto it = std::find_if(map.objects.begin(), map.objects.end(), [&](const TmxObject& object) {
|
|
return object.name == name;
|
|
});
|
|
return it == map.objects.end() ? nullptr : &*it;
|
|
}
|
|
|
|
const TmxTileset* FindTilesetForGid(const TmxMap& map, std::uint32_t gid)
|
|
{
|
|
gid = CleanGid(gid);
|
|
const TmxTileset* found = nullptr;
|
|
for (const TmxTileset& tileset : map.tilesets) {
|
|
if (gid >= tileset.firstGid) {
|
|
found = &tileset;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
return found;
|
|
}
|
|
|
|
namespace {
|
|
|
|
std::uint32_t LayerGidAt(const TmxLayer& layer, int x, int y)
|
|
{
|
|
if (x < 0 || y < 0 || x >= layer.width || y >= layer.height) {
|
|
return 0;
|
|
}
|
|
const std::size_t index = static_cast<std::size_t>(y * layer.width + x);
|
|
return index < layer.gids.size() ? CleanGid(layer.gids[index]) : 0;
|
|
}
|
|
|
|
bool PointInRect(float x, float y, const TmxCollisionShape& shape)
|
|
{
|
|
return x >= shape.x
|
|
&& y >= shape.y
|
|
&& x < shape.x + shape.width
|
|
&& y < shape.y + shape.height;
|
|
}
|
|
|
|
bool PointOnSegment(float px, float py, TmxPoint a, TmxPoint b)
|
|
{
|
|
const float cross = (px - a.x) * (b.y - a.y) - (py - a.y) * (b.x - a.x);
|
|
if (std::abs(cross) > 0.001f) {
|
|
return false;
|
|
}
|
|
return px >= std::min(a.x, b.x) - 0.001f
|
|
&& px <= std::max(a.x, b.x) + 0.001f
|
|
&& py >= std::min(a.y, b.y) - 0.001f
|
|
&& py <= std::max(a.y, b.y) + 0.001f;
|
|
}
|
|
|
|
bool PointInPolygon(float x, float y, const TmxCollisionShape& shape)
|
|
{
|
|
if (shape.polygon.size() < 3) {
|
|
return false;
|
|
}
|
|
|
|
std::vector<TmxPoint> points;
|
|
points.reserve(shape.polygon.size());
|
|
for (const TmxPoint& point : shape.polygon) {
|
|
points.push_back({shape.x + point.x, shape.y + point.y});
|
|
}
|
|
|
|
bool inside = false;
|
|
for (std::size_t i = 0, j = points.size() - 1; i < points.size(); j = i++) {
|
|
const TmxPoint a = points[i];
|
|
const TmxPoint b = points[j];
|
|
if (PointOnSegment(x, y, a, b)) {
|
|
return true;
|
|
}
|
|
const bool crosses = ((a.y > y) != (b.y > y))
|
|
&& (x < (b.x - a.x) * (y - a.y) / (b.y - a.y) + a.x);
|
|
if (crosses) {
|
|
inside = !inside;
|
|
}
|
|
}
|
|
return inside;
|
|
}
|
|
|
|
bool PointInCollisionShape(float x, float y, const TmxCollisionShape& shape)
|
|
{
|
|
if (!shape.polygon.empty()) {
|
|
return PointInPolygon(x, y, shape);
|
|
}
|
|
return PointInRect(x, y, shape);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
bool IsPointBlockedByCollision(const TmxMap& map, float worldX, float worldY)
|
|
{
|
|
if (worldX < 0.0f || worldY < 0.0f
|
|
|| worldX >= static_cast<float>(map.width * map.tileWidth)
|
|
|| worldY >= static_cast<float>(map.height * map.tileHeight)) {
|
|
return true;
|
|
}
|
|
|
|
if (map.tileWidth <= 0 || map.tileHeight <= 0) {
|
|
return false;
|
|
}
|
|
|
|
const int tileX = static_cast<int>(std::floor(worldX / static_cast<float>(map.tileWidth)));
|
|
const int tileY = static_cast<int>(std::floor(worldY / static_cast<float>(map.tileHeight)));
|
|
for (const TmxLayer& layer : map.layers) {
|
|
if (!layer.visible || !IsTmxCollisionLayer(layer.name)) {
|
|
continue;
|
|
}
|
|
const std::uint32_t gid = LayerGidAt(layer, tileX, tileY);
|
|
if (gid == 0) {
|
|
continue;
|
|
}
|
|
const TmxTileset* tileset = FindTilesetForGid(map, gid);
|
|
if (!tileset) {
|
|
continue;
|
|
}
|
|
const std::uint32_t localTileId = gid - tileset->firstGid;
|
|
const auto shapes = tileset->collisionShapes.find(localTileId);
|
|
if (shapes == tileset->collisionShapes.end() || shapes->second.empty()) {
|
|
return true;
|
|
}
|
|
|
|
const float localX = (worldX - static_cast<float>(tileX * map.tileWidth))
|
|
* static_cast<float>(std::max(1, tileset->tileWidth)) / static_cast<float>(map.tileWidth);
|
|
const float localY = (worldY - static_cast<float>(tileY * map.tileHeight))
|
|
* static_cast<float>(std::max(1, tileset->tileHeight)) / static_cast<float>(map.tileHeight);
|
|
for (const TmxCollisionShape& shape : shapes->second) {
|
|
if (PointInCollisionShape(localX, localY, shape)) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool IsTmxCollisionLayer(const std::string& layerName)
|
|
{
|
|
return LowerCopy(layerName).find("collision") != std::string::npos;
|
|
}
|
|
|
|
bool IsTmxOverlayLayer(const std::string& layerName)
|
|
{
|
|
const std::string lower = LowerCopy(layerName);
|
|
return lower.find("fringe") != std::string::npos || lower.find("over") != std::string::npos;
|
|
}
|
|
|
|
std::uint32_t AnimatedLocalTileId(const TmxTileset& tileset, std::uint32_t localTileId, int elapsedMs)
|
|
{
|
|
const auto it = tileset.animations.find(localTileId);
|
|
if (it == tileset.animations.end() || it->second.empty()) {
|
|
return localTileId;
|
|
}
|
|
|
|
int totalDuration = 0;
|
|
for (const TmxAnimationFrame& frame : it->second) {
|
|
totalDuration += std::max(0, frame.durationMs);
|
|
}
|
|
if (totalDuration <= 0) {
|
|
return localTileId;
|
|
}
|
|
|
|
int cursor = std::max(0, elapsedMs) % totalDuration;
|
|
for (const TmxAnimationFrame& frame : it->second) {
|
|
const int duration = std::max(0, frame.durationMs);
|
|
if (cursor < duration) {
|
|
return frame.tileId;
|
|
}
|
|
cursor -= duration;
|
|
}
|
|
return it->second.back().tileId;
|
|
}
|
|
|
|
TileBounds VisibleTileBounds(
|
|
const TmxMap& map,
|
|
float cameraTargetX,
|
|
float cameraTargetY,
|
|
float screenWidth,
|
|
float screenHeight,
|
|
float zoom,
|
|
int paddingTiles)
|
|
{
|
|
const float safeZoom = std::max(0.1f, zoom);
|
|
const float halfWorldWidth = screenWidth / safeZoom * 0.5f;
|
|
const float halfWorldHeight = screenHeight / safeZoom * 0.5f;
|
|
const float minWorldX = cameraTargetX - halfWorldWidth;
|
|
const float maxWorldX = cameraTargetX + halfWorldWidth;
|
|
const float minWorldY = cameraTargetY - halfWorldHeight;
|
|
const float maxWorldY = cameraTargetY + halfWorldHeight;
|
|
|
|
TileBounds bounds;
|
|
bounds.minX = std::clamp(static_cast<int>(std::floor(minWorldX / static_cast<float>(map.tileWidth))) - paddingTiles, 0, std::max(0, map.width - 1));
|
|
bounds.maxX = std::clamp(static_cast<int>(std::ceil(maxWorldX / static_cast<float>(map.tileWidth))) + paddingTiles, 0, std::max(0, map.width - 1));
|
|
bounds.minY = std::clamp(static_cast<int>(std::floor(minWorldY / static_cast<float>(map.tileHeight))) - paddingTiles, 0, std::max(0, map.height - 1));
|
|
bounds.maxY = std::clamp(static_cast<int>(std::ceil(maxWorldY / static_cast<float>(map.tileHeight))) + paddingTiles, 0, std::max(0, map.height - 1));
|
|
return bounds;
|
|
}
|