Files
Loke/src/world/TmxMap.cpp
T
2026-06-03 17:04:06 +08:00

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;
}