diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 1865eac69d..40c53ec813 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -97,3 +97,7 @@ jobs: - name: Run Tiled TMJ format tests working-directory: ./build/debug run: ./tactile-tiled-tmj-format-test + + - name: Run runtime tests + working-directory: ./build/debug + run: ./tactile-runtime-test diff --git a/.github/workflows/ubuntu.yml b/.github/workflows/ubuntu.yml index 547919c2b4..00ed7d9faa 100644 --- a/.github/workflows/ubuntu.yml +++ b/.github/workflows/ubuntu.yml @@ -131,3 +131,7 @@ jobs: - name: Run Tiled TMJ format tests working-directory: ./build/debug run: ./tactile-tiled-tmj-format-test + + - name: Run runtime tests + working-directory: ./build/debug + run: ./tactile-runtime-test diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 6d0f3bfd73..33d33fa717 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -99,3 +99,8 @@ jobs: working-directory: ./build/debug shell: cmd run: tactile-tiled-tmj-format-test.exe + + - name: Run runtime tests + working-directory: ./build/debug + shell: cmd + run: tactile-runtime-test.exe diff --git a/source/base/lib/inc/tactile/base/debug/error_code.hpp b/source/base/lib/inc/tactile/base/debug/error_code.hpp index 5bc2d2900f..4b15aad70e 100644 --- a/source/base/lib/inc/tactile/base/debug/error_code.hpp +++ b/source/base/lib/inc/tactile/base/debug/error_code.hpp @@ -53,6 +53,9 @@ enum class ErrorCode : int /** A parse operation failed. */ kParseError, + /** A write operation failed. */ + kWriteError, + /** A compression operation failed. */ kCouldNotCompress, @@ -78,6 +81,7 @@ constexpr auto to_string(const ErrorCode errc) noexcept -> std::string_view case ErrorCode::kBadFileCopy: return "file copy error"; case ErrorCode::kBadImage: return "invalid image"; case ErrorCode::kParseError: return "parse error"; + case ErrorCode::kWriteError: return "write error"; case ErrorCode::kCouldNotCompress: return "could not compress"; case ErrorCode::kCouldNotDecompress: return "could not decompress"; } diff --git a/source/plugins/tiled_tmx/CMakeLists.txt b/source/plugins/tiled_tmx/CMakeLists.txt index 056349625e..3ed42aed58 100644 --- a/source/plugins/tiled_tmx/CMakeLists.txt +++ b/source/plugins/tiled_tmx/CMakeLists.txt @@ -1,5 +1,6 @@ project(tactile-tiled-tmx-format CXX) +find_path(CPPCODEC_INCLUDE_DIRS "cppcodec/base32_crockford.hpp") find_package(pugixml CONFIG REQUIRED) add_subdirectory("lib") diff --git a/source/plugins/tiled_tmx/lib/CMakeLists.txt b/source/plugins/tiled_tmx/lib/CMakeLists.txt index b0b3ddb641..cb68c22bc2 100644 --- a/source/plugins/tiled_tmx/lib/CMakeLists.txt +++ b/source/plugins/tiled_tmx/lib/CMakeLists.txt @@ -1,29 +1,40 @@ -project(tactile-tiled-tmx-format-lib CXX) +project(tactile-tiled-tmx-lib CXX) -add_library(tactile-tiled-tmx-format SHARED) -add_library(tactile::tiled_tmx_format ALIAS tactile-tiled-tmx-format) +add_library(tactile-tiled-tmx SHARED) +add_library(tactile::tiled_tmx ALIAS tactile-tiled-tmx) -target_sources(tactile-tiled-tmx-format +target_sources(tactile-tiled-tmx PRIVATE - "src/tiled_tmx_format_plugin.cpp" + "src/tmx_common.cpp" + "src/tmx_format_parser.cpp" + "src/tmx_format_plugin.cpp" + "src/tmx_format_save_visitor.cpp" + "src/tmx_save_format.cpp" PUBLIC FILE_SET "HEADERS" BASE_DIRS "inc" FILES "inc/tactile/tiled_tmx/api.hpp" - "inc/tactile/tiled_tmx/tiled_tmx_format_plugin.hpp" + "inc/tactile/tiled_tmx/tmx_common.hpp" + "inc/tactile/tiled_tmx/tmx_format_parser.hpp" + "inc/tactile/tiled_tmx/tmx_format_plugin.hpp" + "inc/tactile/tiled_tmx/tmx_format_save_visitor.hpp" + "inc/tactile/tiled_tmx/tmx_save_format.hpp" ) -tactile_prepare_target(tactile-tiled-tmx-format) +tactile_prepare_target(tactile-tiled-tmx) -target_compile_definitions(tactile-tiled-tmx-format +target_compile_definitions(tactile-tiled-tmx PRIVATE "TACTILE_BUILDING_TILED_TMX_FORMAT" ) -target_link_libraries(tactile-tiled-tmx-format +target_include_directories(tactile-tiled-tmx + PRIVATE + "${CPPCODEC_INCLUDE_DIRS}" + ) + +target_link_libraries(tactile-tiled-tmx PUBLIC tactile::base tactile::runtime - - PRIVATE pugixml::pugixml ) diff --git a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/api.hpp b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/api.hpp index df4bf69023..834036b970 100644 --- a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/api.hpp +++ b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/api.hpp @@ -5,7 +5,7 @@ #include "tactile/base/prelude.hpp" #ifdef TACTILE_BUILDING_TILED_TMX_FORMAT - #define TACTILE_TMX_FORMAT_API TACTILE_DLL_EXPORT + #define TACTILE_TILED_TMX_API TACTILE_DLL_EXPORT #else - #define TACTILE_TMX_FORMAT_API TACTILE_DLL_IMPORT + #define TACTILE_TILED_TMX_API TACTILE_DLL_IMPORT #endif diff --git a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tiled_tmx_format_plugin.hpp b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tiled_tmx_format_plugin.hpp deleted file mode 100644 index 1385c6df00..0000000000 --- a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tiled_tmx_format_plugin.hpp +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) - -#pragma once - -#include "tactile/base/prelude.hpp" -#include "tactile/base/runtime/plugin.hpp" -#include "tactile/tiled_tmx/api.hpp" - -namespace tactile { - -class TACTILE_TMX_FORMAT_API TiledTmxFormatPlugin final : public IPlugin -{ - public: - void load(IRuntime* runtime) override; - - void unload() override; - - private: - IRuntime* mRuntime {}; -}; - -extern "C" -{ - TACTILE_TMX_FORMAT_API auto tactile_make_plugin() -> IPlugin*; - TACTILE_TMX_FORMAT_API void tactile_free_plugin(IPlugin* plugin); -} - -} // namespace tactile diff --git a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_common.hpp b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_common.hpp new file mode 100644 index 0000000000..be4a5e9fb7 --- /dev/null +++ b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_common.hpp @@ -0,0 +1,173 @@ +#pragma once + +#include // contains +#include // signed_integral, unsigned_integral, floating_point, same_as +#include // expected, unexpected +#include // path +#include // numeric_limits +#include // optional, nullopt +#include // span +#include // string +#include // string_view +#include // move + +#include + +#include "tactile/base/debug/error_code.hpp" +#include "tactile/base/io/compress/compression_format_id.hpp" +#include "tactile/base/layer/layer_type.hpp" +#include "tactile/base/meta/attribute_type.hpp" +#include "tactile/base/numeric/conversion.hpp" +#include "tactile/tiled_tmx/api.hpp" + +namespace tactile::tiled_tmx { + +[[nodiscard]] +TACTILE_TILED_TMX_API auto read_xml_document(const std::filesystem::path& path) + -> std::expected; + +[[nodiscard]] +TACTILE_TILED_TMX_API auto save_xml_document(const pugi::xml_document& document, + const std::filesystem::path& path) + -> std::expected; + +[[nodiscard]] +TACTILE_TILED_TMX_API auto read_property_type(std::string_view name) + -> std::expected; + +[[nodiscard]] +TACTILE_TILED_TMX_API auto get_property_type_name(AttributeType type) -> const char*; + +[[nodiscard]] +TACTILE_TILED_TMX_API auto read_layer_type(std::string_view name) + -> std::expected; + +[[nodiscard]] +TACTILE_TILED_TMX_API auto get_layer_type_name(LayerType type) -> const char*; + +[[nodiscard]] +TACTILE_TILED_TMX_API auto read_compression_format(std::string_view name) + -> std::expected, ErrorCode>; + +[[nodiscard]] +TACTILE_TILED_TMX_API auto get_compression_format_name(CompressionFormatId format) // + -> const char*; + +template Parser> +[[nodiscard]] auto read_nodes(const pugi::xml_node& parent_node, + const std::span node_names, + const Parser& value_parser) + -> std::expected, ErrorCode> +{ + std::vector values {}; + + for (const auto& node : parent_node.children()) { + if (!std::ranges::contains(node_names, node.name())) { + continue; + } + + auto value = value_parser(node); + + if (!value.has_value()) { + return std::unexpected {value.error()}; + } + + values.push_back(std::move(*value)); + } + + return std::move(values); +} + +template Parser> +[[nodiscard]] auto read_nodes(const pugi::xml_node& parent_node, + const std::string_view node_name, + const Parser& value_parser) + -> std::expected, ErrorCode> +{ + return read_nodes(parent_node, std::span {&node_name, 1}, value_parser); +} + +template +[[nodiscard]] auto read_attr(const pugi::xml_node& node, const char* name) -> std::optional; + +template <> +[[nodiscard]] inline auto read_attr(const pugi::xml_node& node, const char* name) + -> std::optional +{ + if (const auto* str = node.attribute(name).as_string(nullptr)) { + return std::string {str}; + } + + return std::nullopt; +} + +template <> +[[nodiscard]] inline auto read_attr(const pugi::xml_node& node, const char* name) + -> std::optional +{ + if (const char* str = node.attribute(name).as_string(nullptr)) { + const std::string_view str_view {str}; + + if (str_view == "true") { + return true; + } + + if (str_view == "false") { + return false; + } + } + + return std::nullopt; +} + +template + requires(!std::same_as) +[[nodiscard]] auto read_attr(const pugi::xml_node& node, const char* name) -> std::optional +{ + constexpr auto sentinel = std::numeric_limits::max(); + + if (const auto value = node.attribute(name).as_llong(sentinel); value != sentinel) { + return narrow(value); + } + + return std::nullopt; +} + +template + requires(!std::same_as) +[[nodiscard]] auto read_attr(const pugi::xml_node& node, const char* name) -> std::optional +{ + constexpr auto sentinel = std::numeric_limits::max(); + + if (const auto value = node.attribute(name).as_ullong(sentinel); value != sentinel) { + return narrow(value); + } + + return std::nullopt; +} + +template +[[nodiscard]] auto read_attr(const pugi::xml_node& node, const char* name) -> std::optional +{ + constexpr auto sentinel = std::numeric_limits::max(); + + if (const auto value = node.attribute(name).as_double(sentinel); value != sentinel) { + return static_cast(value); + } + + return std::nullopt; +} + +template +[[nodiscard]] auto read_attr_to(const pugi::xml_node& node, const char* name, T& result) + -> std::expected +{ + if (auto value = read_attr(node, name)) { + result = std::move(*value); + return {}; + } + + return std::unexpected {ErrorCode::kParseError}; +} + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_parser.hpp b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_parser.hpp new file mode 100644 index 0000000000..10e7b39040 --- /dev/null +++ b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_parser.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include // expected +#include // path + +#include "tactile/base/debug/error_code.hpp" +#include "tactile/base/io/save/save_format.hpp" +#include "tactile/base/runtime/runtime.hpp" +#include "tactile/tiled_tmx/api.hpp" + +namespace tactile::tiled_tmx { + +/** + * Attempts to parse a single Tiled TMX map. + * + * \param runtime The associated runtime. + * \param map_path The file path to the TMX map. + * \param options The configured read options. + * + * \return + * The parsed map if successful; an error code otherwise. + * + * \see https://doc.mapeditor.org/en/stable/reference/tmx-map-format/#map + */ +[[nodiscard]] +TACTILE_TILED_TMX_API auto parse_map(const IRuntime& runtime, + const std::filesystem::path& map_path, + const SaveFormatReadOptions& options) + -> std::expected; + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_plugin.hpp b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_plugin.hpp new file mode 100644 index 0000000000..33f27b80db --- /dev/null +++ b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_plugin.hpp @@ -0,0 +1,29 @@ +// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) + +#pragma once + +#include "tactile/base/io/save/save_format.hpp" +#include "tactile/base/runtime/plugin.hpp" +#include "tactile/tiled_tmx/api.hpp" + +namespace tactile::tiled_tmx { + +class TACTILE_TILED_TMX_API TmxFormatPlugin final : public IPlugin +{ + public: + void load(IRuntime* runtime) override; + + void unload() override; + + private: + IRuntime* m_runtime {}; + std::unique_ptr m_format {}; +}; + +extern "C" +{ + TACTILE_TILED_TMX_API auto tactile_make_plugin() -> IPlugin*; + TACTILE_TILED_TMX_API void tactile_free_plugin(IPlugin* plugin); +} + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_save_visitor.hpp b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_save_visitor.hpp new file mode 100644 index 0000000000..9983b5ecab --- /dev/null +++ b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_format_save_visitor.hpp @@ -0,0 +1,66 @@ +#pragma once + +#include // size_t +#include // string +#include // unordered_map +#include // vector + +#include + +#include "tactile/base/document/document_visitor.hpp" +#include "tactile/base/document/tileset_view.hpp" +#include "tactile/base/io/save/save_format.hpp" +#include "tactile/base/runtime/runtime.hpp" +#include "tactile/tiled_tmx/api.hpp" + +namespace tactile::tiled_tmx { + +struct TmxTilesetDocument final +{ + std::string source; + pugi::xml_document document; +}; + +class TACTILE_TILED_TMX_API TmxFormatSaveVisitor final : public IDocumentVisitor +{ + public: + TmxFormatSaveVisitor(IRuntime* runtime, SaveFormatWriteOptions options); + + [[nodiscard]] + auto visit(const IComponentView& component) -> std::expected override; + + [[nodiscard]] + auto visit(const IMapView& map) -> std::expected override; + + [[nodiscard]] + auto visit(const ILayerView& layer) -> std::expected override; + + [[nodiscard]] + auto visit(const IObjectView& object) -> std::expected override; + + [[nodiscard]] + auto visit(const ITilesetView& tileset) -> std::expected override; + + [[nodiscard]] + auto visit(const ITileView& tile) -> std::expected override; + + [[nodiscard]] + auto get_map_xml_document() const -> const pugi::xml_document&; + + [[nodiscard]] + auto get_tileset_xml_documents() const -> const std::vector&; + + private: + IRuntime* m_runtime; + SaveFormatWriteOptions m_options; + pugi::xml_document m_map_document; + pugi::xml_node m_map_node; + std::vector m_layer_nodes; + std::vector m_tileset_documents; + std::unordered_map m_tileset_nodes; + + [[nodiscard]] + auto _get_tile_node(const ITilesetView& tileset, TileIndex tile_index) -> pugi::xml_node; +}; + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_save_format.hpp b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_save_format.hpp new file mode 100644 index 0000000000..a93bd61a72 --- /dev/null +++ b/source/plugins/tiled_tmx/lib/inc/tactile/tiled_tmx/tmx_save_format.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include "tactile/base/io/save/save_format.hpp" +#include "tactile/base/runtime/runtime.hpp" +#include "tactile/tiled_tmx/api.hpp" + +namespace tactile::tiled_tmx { + +/** + * Implements the Tiled TMX save format. + * + * \see https://doc.mapeditor.org/en/stable/reference/tmx-map-format/ + */ +class TACTILE_TILED_TMX_API TmxSaveFormat final : public ISaveFormat +{ + public: + explicit TmxSaveFormat(IRuntime* runtime); + + [[nodiscard]] + auto load_map(const std::filesystem::path& map_path, + const SaveFormatReadOptions& options) const + -> std::expected override; + + [[nodiscard]] + auto save_map(const IMapView& map, const SaveFormatWriteOptions& options) const + -> std::expected override; + + private: + IRuntime* m_runtime; +}; + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/src/tiled_tmx_format_plugin.cpp b/source/plugins/tiled_tmx/lib/src/tiled_tmx_format_plugin.cpp deleted file mode 100644 index f553aea7ed..0000000000 --- a/source/plugins/tiled_tmx/lib/src/tiled_tmx_format_plugin.cpp +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) - -#include "tactile/tiled_tmx/tiled_tmx_format_plugin.hpp" - -#include // nothrow - -#include "tactile/runtime/logging.hpp" - -namespace tactile { - -void TiledTmxFormatPlugin::load(IRuntime* runtime) -{ - runtime::log(LogLevel::kTrace, "Loading Tiled TMX format plugin"); - mRuntime = runtime; -} - -void TiledTmxFormatPlugin::unload() -{ - runtime::log(LogLevel::kTrace, "Unloading Tiled TMX format plugin"); - mRuntime = nullptr; -} - -auto tactile_make_plugin() -> IPlugin* -{ - return new (std::nothrow) TiledTmxFormatPlugin {}; -} - -void tactile_free_plugin(IPlugin* plugin) -{ - delete plugin; -} - -} // namespace tactile diff --git a/source/plugins/tiled_tmx/lib/src/tmx_common.cpp b/source/plugins/tiled_tmx/lib/src/tmx_common.cpp new file mode 100644 index 0000000000..3559d3752a --- /dev/null +++ b/source/plugins/tiled_tmx/lib/src/tmx_common.cpp @@ -0,0 +1,176 @@ +// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) + +#include "tactile//tiled_tmx/tmx_common.hpp" + +#include // ifstream +#include // invalid_argument + +#include "tactile/runtime/logging.hpp" + +namespace tactile::tiled_tmx { +namespace { + +inline constexpr const char* kStringPropertyTypeName = "string"; +inline constexpr const char* kIntPropertyTypeName = "int"; +inline constexpr const char* kFloatPropertyTypeName = "float"; +inline constexpr const char* kBoolPropertyTypeName = "bool"; +inline constexpr const char* kColorPropertyTypeName = "color"; +inline constexpr const char* kPathPropertyTypeName = "file"; +inline constexpr const char* kObjectPropertyTypeName = "object"; + +inline constexpr const char* kTileLayerTypeName = "layer"; +inline constexpr const char* kObjectLayerTypeName = "objectgroup"; +inline constexpr const char* kGroupLayerTypeName = "group"; + +} // namespace + +auto read_xml_document(const std::filesystem::path& path) + -> std::expected +{ + runtime::log(LogLevel::kTrace, "Parsing XML document at {}", path.string()); + + std::ifstream stream {path, std::ios::in}; + if (!stream.good()) { + return std::unexpected {ErrorCode::kBadFileStream}; + } + + constexpr auto parse_options = pugi::parse_default | pugi::parse_trim_pcdata; + + pugi::xml_document xml_document {}; + const auto load_result = xml_document.load(stream, parse_options); + + if (load_result.status != pugi::status_ok) { + runtime::log(LogLevel::kError, "XML parse error: {}", load_result.description()); + return std::unexpected {ErrorCode::kParseError}; + } + + return xml_document; +} + +auto save_xml_document(const pugi::xml_document& document, const std::filesystem::path& path) + -> std::expected +{ + runtime::log(LogLevel::kTrace, "Saving XML document to {}", path.string()); + + if (!document.save_file(path.c_str(), " ")) { + runtime::log(LogLevel::kError, "Could not save XML document"); + return std::unexpected {ErrorCode::kWriteError}; + } + + return {}; +} + +auto read_property_type(const std::string_view name) -> std::expected +{ + if (name == kStringPropertyTypeName) { + return AttributeType::kStr; + } + + if (name == kIntPropertyTypeName) { + return AttributeType::kInt; + } + + if (name == kFloatPropertyTypeName) { + return AttributeType::kFloat; + } + + if (name == kBoolPropertyTypeName) { + return AttributeType::kBool; + } + + if (name == kColorPropertyTypeName) { + return AttributeType::kColor; + } + + if (name == kPathPropertyTypeName) { + return AttributeType::kPath; + } + + if (name == kObjectPropertyTypeName) { + return AttributeType::kObject; + } + + runtime::log(LogLevel::kError, "Unsupported property type '{}'", name); + return std::unexpected {ErrorCode::kNotSupported}; +} + +auto get_property_type_name(const AttributeType type) -> const char* +{ + switch (type) { + case AttributeType::kStr: + case AttributeType::kInt2: + case AttributeType::kInt3: + case AttributeType::kInt4: + case AttributeType::kFloat2: + case AttributeType::kFloat3: [[fallthrough]]; + case AttributeType::kFloat4: return kStringPropertyTypeName; + case AttributeType::kInt: return kIntPropertyTypeName; + case AttributeType::kFloat: return kFloatPropertyTypeName; + case AttributeType::kBool: return kBoolPropertyTypeName; + case AttributeType::kPath: return kPathPropertyTypeName; + case AttributeType::kColor: return kColorPropertyTypeName; + case AttributeType::kObject: return kObjectPropertyTypeName; + } + + throw std::invalid_argument {"bad attribute type"}; +} + +auto read_layer_type(const std::string_view name) -> std::expected +{ + if (name == kTileLayerTypeName) { + return LayerType::kTileLayer; + } + + if (name == kObjectLayerTypeName) { + return LayerType::kObjectLayer; + } + + if (name == kGroupLayerTypeName) { + return LayerType::kGroupLayer; + } + + runtime::log(LogLevel::kError, "Unsupported layer type '{}'", name); + return std::unexpected {ErrorCode::kNotSupported}; +} + +auto get_layer_type_name(const LayerType type) -> const char* +{ + switch (type) { + case LayerType::kTileLayer: return kTileLayerTypeName; + case LayerType::kObjectLayer: return kObjectLayerTypeName; + case LayerType::kGroupLayer: return kGroupLayerTypeName; + } + + throw std::invalid_argument {"bad layer type"}; +} + +auto read_compression_format(const std::string_view name) + -> std::expected, ErrorCode> +{ + if (name == "") { + return std::nullopt; + } + + if (name == "zlib") { + return CompressionFormatId::kZlib; + } + + if (name == "zstd") { + return CompressionFormatId::kZstd; + } + + runtime::log(LogLevel::kError, "Unsupported compression format '{}'", name); + return std::unexpected {ErrorCode::kNotSupported}; +} + +auto get_compression_format_name(const CompressionFormatId format) -> const char* +{ + switch (format) { + case CompressionFormatId::kZlib: return "zlib"; + case CompressionFormatId::kZstd: return "zstd"; + } + + throw std::invalid_argument {"bad compression format id"}; +} + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/src/tmx_format_parser.cpp b/source/plugins/tiled_tmx/lib/src/tmx_format_parser.cpp new file mode 100644 index 0000000000..2b7f2be1a1 --- /dev/null +++ b/source/plugins/tiled_tmx/lib/src/tmx_format_parser.cpp @@ -0,0 +1,613 @@ +// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) + +#include "tactile/tiled_tmx/tmx_format_parser.hpp" + +#include // array +#include // from_chars +#include // size_t +#include // uint8_t +#include // strcmp +#include // optional +#include // invalid_argument +#include // string +#include // string_view +#include // move + +#include +#include + +#include "tactile/base/container/string.hpp" +#include "tactile/base/io/compress/compression_format.hpp" +#include "tactile/base/io/tile_io.hpp" +#include "tactile/base/log/log_level.hpp" +#include "tactile/base/util/tile_matrix.hpp" +#include "tactile/runtime/logging.hpp" +#include "tactile/tiled_tmx/tmx_common.hpp" + +namespace tactile::tiled_tmx { +namespace { + +enum class TmxTileEncoding : std::uint8_t +{ + kTileNodes, + kCsv, + kBase64, +}; + +[[nodiscard]] +auto _read_property(const pugi::xml_node& node, const AttributeType type) + -> std::expected +{ + switch (type) { + case AttributeType::kStr: { + if (auto read_value = read_attr(node, "value")) { + return Attribute {std::move(*read_value)}; + } + break; + } + case AttributeType::kInt: { + if (const auto read_value = read_attr(node, "value")) { + return Attribute {*read_value}; + } + break; + } + case AttributeType::kFloat: { + if (const auto read_value = read_attr(node, "value")) { + return Attribute {*read_value}; + } + break; + } + case AttributeType::kBool: { + if (const auto read_value = read_attr(node, "value")) { + return Attribute {*read_value}; + } + break; + } + case AttributeType::kPath: { + if (const auto read_value = read_attr(node, "value")) { + return Attribute {Attribute::path_type {*read_value}}; + } + break; + } + case AttributeType::kColor: { + if (const auto read_value = read_attr(node, "value")) { + const auto color = read_value->size() == 9 ? parse_color_argb(*read_value) + : parse_color_rgb(*read_value); + if (color.has_value()) { + return Attribute {*color}; + } + } + break; + } + case AttributeType::kObject: { + if (const auto read_value = + read_attr(node, "value")) { + return Attribute {Attribute::objref_type {*read_value}}; + } + break; + } + case AttributeType::kInt2: + case AttributeType::kInt3: + case AttributeType::kInt4: + case AttributeType::kFloat2: + case AttributeType::kFloat3: + case AttributeType::kFloat4: + default: break; + } + + throw std::invalid_argument {"bad attribute type"}; +} + +[[nodiscard]] +auto _read_named_attribute(const pugi::xml_node& property_node) + -> std::expected +{ + ir::NamedAttribute named_attribute {}; + + return read_attr_to(property_node, "name", named_attribute.name) + .and_then([&] { + // TMX properties are implicitly strings if type is missing + const char* type_name = property_node.attribute("type").as_string("string"); + return read_property_type(type_name); + }) + .and_then([&](const AttributeType property_type) { + return _read_property(property_node, property_type); + }) + .transform([&](Attribute&& value) { + named_attribute.value = std::move(value); + return std::move(named_attribute); + }); +} + +[[nodiscard]] +auto _read_metadata(const pugi::xml_node& node, ir::Metadata& meta) + -> std::expected +{ + const auto properties_node = node.child("properties"); + return read_nodes(properties_node, "property", &_read_named_attribute) + .and_then([&](std::vector&& properties) { + meta.properties = std::move(properties); + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_tileset_image_node(const pugi::xml_node& image_node, ir::Tileset& tileset) + -> std::expected +{ + std::string source {}; + return read_attr_to(image_node, "width", tileset.image_size[0]) + .and_then([&] { return read_attr_to(image_node, "height", tileset.image_size[1]); }) + .and_then([&] { return read_attr_to(image_node, "source", source); }) + .and_then([&] { + tileset.image_path = source; + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_animation_frame(const pugi::xml_node& frame_node) + -> std::expected +{ + ir::AnimationFrame frame {}; + std::chrono::milliseconds::rep duration {}; + + return read_attr_to(frame_node, "tileid", frame.tile_index) + .and_then([&] { return read_attr_to(frame_node, "duration", duration); }) + .transform([&] { + frame.duration = std::chrono::milliseconds {duration}; + return std::move(frame); + }); +} + +[[nodiscard]] +auto _read_tile_animation(const pugi::xml_node& animation_node, ir::Tile& tile) + -> std::expected +{ + return read_nodes(animation_node, "frame", &_read_animation_frame) + .and_then([&](std::vector&& frames) { + tile.animation = std::move(frames); + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_object_layer(const pugi::xml_node& layer_node, std::vector& objects) + -> std::expected; + +[[nodiscard]] +auto _read_tileset_tile(const pugi::xml_node& tile_node) -> std::expected +{ + ir::Tile tile {}; + return read_attr_to(tile_node, "id", tile.index) + .and_then([&] { + const auto animation_node = tile_node.child("animation"); + return _read_tile_animation(animation_node, tile); + }) + .and_then([&] { return _read_metadata(tile_node, tile.meta); }) + .and_then([&] { + const auto layer_node = tile_node.child("objectgroup"); + return _read_object_layer(layer_node, tile.objects); + }) + .transform([&] { return std::move(tile); }); +} + +[[nodiscard]] +auto _read_tileset_tiles(const pugi::xml_node& tileset_node, ir::Tileset& tileset) + -> std::expected +{ + return read_nodes(tileset_node, "tile", &_read_tileset_tile) + .and_then([&](std::vector&& tiles) { + tileset.tiles = std::move(tiles); + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_common_tileset_attributes(const pugi::xml_node& tileset_node, ir::Tileset& tileset) + -> std::expected +{ + return _read_metadata(tileset_node, tileset.meta) + .and_then([&] { return read_attr_to(tileset_node, "name", tileset.meta.name); }) + .and_then([&] { return read_attr_to(tileset_node, "tilewidth", tileset.tile_size[0]); }) + .and_then([&] { return read_attr_to(tileset_node, "tileheight", tileset.tile_size[1]); }) + .and_then([&] { return read_attr_to(tileset_node, "tilecount", tileset.tile_count); }) + .and_then([&] { return read_attr_to(tileset_node, "columns", tileset.column_count); }) + .and_then([&] { + const auto image_node = tileset_node.child("image"); + return _read_tileset_image_node(image_node, tileset); + }) + .and_then([&] { return _read_tileset_tiles(tileset_node, tileset); }); +} + +[[nodiscard]] +auto _read_embedded_tileset(const pugi::xml_node& tileset_node) + -> std::expected +{ + ir::Tileset tileset {}; + tileset.is_embedded = true; + + return _read_common_tileset_attributes(tileset_node, tileset).transform([&] { + return std::move(tileset); + }); +} + +[[nodiscard]] +auto _read_external_tileset(const std::filesystem::path& path) + -> std::expected +{ + ir::Tileset tileset {}; + tileset.is_embedded = false; + + return read_xml_document(path) + .and_then([&](const pugi::xml_document& document) -> std::expected { + const auto tileset_node = document.child("tileset"); + return _read_common_tileset_attributes(tileset_node, tileset); + }) + .transform([&] { return std::move(tileset); }); +} + +[[nodiscard]] +auto _read_tileset_ref(const pugi::xml_node& tileset_ref_node, + const SaveFormatReadOptions& options) + -> std::expected +{ + ir::TilesetRef tileset_ref {}; + return read_attr_to(tileset_ref_node, "firstgid", tileset_ref.first_tile_id) + .and_then([&] { + const auto source = read_attr(tileset_ref_node, "source"); + return source.has_value() ? _read_external_tileset(options.base_dir / *source) + : _read_embedded_tileset(tileset_ref_node); + }) + .transform([&](ir::Tileset&& tileset) { + tileset_ref.tileset = std::move(tileset); + return std::move(tileset_ref); + }); +} + +[[nodiscard]] +auto _read_tilesets(const pugi::xml_node& map_node, + const SaveFormatReadOptions& options, + ir::Map& map) -> std::expected +{ + const auto tileset_ref_parser = [&](const pugi::xml_node& tileset_ref_node) { + return _read_tileset_ref(tileset_ref_node, options); + }; + + return read_nodes(map_node, "tileset", tileset_ref_parser) + .and_then([&](std::vector&& tileset_refs) { + map.tilesets = std::move(tileset_refs); + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_tile_layer_data_encoding(const pugi::xml_node& data_node) + -> std::expected +{ + const auto* encoding = data_node.attribute("encoding").as_string(nullptr); + + if (encoding != nullptr) { + if (std::strcmp(encoding, "csv") == 0) { + return TmxTileEncoding::kCsv; + } + + if (std::strcmp(encoding, "base64") == 0) { + return TmxTileEncoding::kBase64; + } + + runtime::log(LogLevel::kError, "Unsupported tile encoding '{}'", encoding); + return std::unexpected {ErrorCode::kNotSupported}; + } + + return TmxTileEncoding::kTileNodes; +} + +[[nodiscard]] +auto _read_tile_nodes_data(const pugi::xml_node& data_node, const Extent2D& extent) + -> std::expected +{ + auto tile_matrix = make_tile_matrix(extent); + + std::size_t index {0}; + for (const auto& tile_node : data_node.children("tile")) { + const auto position = Index2D::from_1d(index, extent.cols); + + const auto read_result = + read_attr_to(tile_node, "gid", tile_matrix[position.y][position.x]); + if (!read_result.has_value()) { + return std::unexpected {read_result.error()}; + } + + ++index; + } + + return tile_matrix; +} + +[[nodiscard]] +auto _read_csv_tile_data(const pugi::xml_node& data_node, const Extent2D& extent) + -> std::expected +{ + const auto data_node_text = data_node.text(); + + auto tile_matrix = make_tile_matrix(extent); + std::size_t tile_index {}; + + const auto split_ok = + visit_tokens(data_node_text.get(), '\n', [&](const std::string_view csv_row) { + return visit_tokens(csv_row, ',', [&](const std::string_view token) { + TileID tile_id {}; + + const auto parse_tile_id_result = + std::from_chars(token.data(), token.data() + token.size(), tile_id); + if (parse_tile_id_result.ec != std::errc {}) { + return false; + } + + const auto position = Index2D::from_1d(tile_index, extent.cols); + tile_matrix[position.y][position.x] = tile_id; + + ++tile_index; + return true; + }); + }); + + if (!split_ok) { + runtime::log(LogLevel::kError, "Could not parse CSV tile data"); + return std::unexpected {ErrorCode::kParseError}; + } + + return tile_matrix; +} + +[[nodiscard]] +auto _read_base64_tile_data(const IRuntime& runtime, + const pugi::xml_node& data_node, + const Extent2D& extent, + ir::TileFormat& tile_format) + -> std::expected +{ + const char* compression = data_node.attribute("compression").as_string(); + + const auto compression_format_id = read_compression_format(compression); + if (!compression_format_id.has_value()) { + return std::unexpected {compression_format_id.error()}; + } + + tile_format.encoding = TileEncoding::kBase64; + tile_format.compression = *compression_format_id; + + const auto data_node_text = data_node.text(); + const std::string_view encoded_tile_data {data_node_text.get()}; + + auto decoded_tile_data = base64::decode(encoded_tile_data); + + ByteStream raw_tile_matrix {}; + if (tile_format.compression.has_value()) { + const auto* compression_format = runtime.get_compression_format(*tile_format.compression); + + if (!compression_format) { + runtime::log(LogLevel::kError, "No suitable compression plugin available"); + return std::unexpected {ErrorCode::kNotSupported}; + } + + const auto decompressed_tile_data = compression_format->decompress(decoded_tile_data); + if (!decompressed_tile_data.has_value()) { + return std::unexpected {decompressed_tile_data.error()}; + } + + raw_tile_matrix = std::move(*decompressed_tile_data); + } + else { + raw_tile_matrix = std::move(decoded_tile_data); + } + + auto tile_matrix = parse_raw_tile_matrix(raw_tile_matrix, extent, TileIdFormat::kTiled); + if (!tile_matrix) { + return std::unexpected {ErrorCode::kParseError}; + } + + return std::move(*tile_matrix); +} + +[[nodiscard]] +auto _read_tile_layer_data(const IRuntime& runtime, + const pugi::xml_node& data_node, + ir::Layer& layer, + ir::TileFormat& tile_format) -> std::expected +{ + tile_format.encoding = TileEncoding::kPlainText; + tile_format.compression = std::nullopt; + + return _read_tile_layer_data_encoding(data_node) + .and_then([&](const TmxTileEncoding encoding) { + switch (encoding) { + case TmxTileEncoding::kTileNodes: { + return _read_tile_nodes_data(data_node, layer.extent); + } + case TmxTileEncoding::kCsv: { + return _read_csv_tile_data(data_node, layer.extent); + } + case TmxTileEncoding::kBase64: { + return _read_base64_tile_data(runtime, data_node, layer.extent, tile_format); + } + default: throw std::invalid_argument {"bad tile encoding"}; + } + }) + .and_then([&](TileMatrix&& tile_matrix) { + layer.tiles = std::move(tile_matrix); + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_tile_layer(const IRuntime& runtime, + const pugi::xml_node& layer_node, + ir::Layer& layer, + ir::TileFormat& tile_format) -> std::expected +{ + return read_attr_to(layer_node, "width", layer.extent.cols) + .and_then([&] { return read_attr_to(layer_node, "height", layer.extent.rows); }) + .and_then([&] { + const auto data_node = layer_node.child("data"); + return _read_tile_layer_data(runtime, data_node, layer, tile_format); + }); +} + +[[nodiscard]] +auto _read_object(const pugi::xml_node& object_node) -> std::expected +{ + ir::Object object {}; + + object.meta.name = read_attr(object_node, "name").value_or(""); + object.tag = read_attr(object_node, "type").value_or(""); + object.position[0] = read_attr(object_node, "x").value_or(0.0f); + object.position[1] = read_attr(object_node, "y").value_or(0.0f); + object.size[0] = read_attr(object_node, "width").value_or(0.0f); + object.size[1] = read_attr(object_node, "height").value_or(0.0f); + object.visible = read_attr(object_node, "visible").value_or(true); + + if (!object_node.child("point").empty()) { + object.type = ObjectType::kPoint; + } + else if (!object_node.child("ellipse").empty()) { + object.type = ObjectType::kEllipse; + } + else { + object.type = ObjectType::kRect; + } + + return read_attr_to(object_node, "id", object.id) + .and_then([&] { return _read_metadata(object_node, object.meta); }) + .transform([&] { return std::move(object); }); +} + +[[nodiscard]] +auto _read_object_layer(const pugi::xml_node& layer_node, std::vector& objects) + -> std::expected +{ + return read_nodes(layer_node, "object", &_read_object) + .and_then([&](std::vector&& read_objects) { + objects = std::move(read_objects); + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_layers(const IRuntime& runtime, + const pugi::xml_node& root_node, + std::vector& layers, + ir::TileFormat& tile_format) -> std::expected; + +[[nodiscard]] +auto _read_group_layer(const IRuntime& runtime, + const pugi::xml_node& layer_node, + ir::Layer& layer, + ir::TileFormat& tile_format) -> std::expected +{ + return _read_layers(runtime, layer_node, layer.layers, tile_format); +} + +[[nodiscard]] +auto _read_layer(const IRuntime& runtime, + const pugi::xml_node& layer_node, + ir::TileFormat& tile_format) -> std::expected +{ + ir::Layer layer {}; + return read_attr_to(layer_node, "id", layer.id) + .and_then([&] { return read_attr_to(layer_node, "name", layer.meta.name); }) + .and_then([&] { return read_attr_to(layer_node, "name", layer.meta.name); }) + .and_then([&] { + layer.opacity = read_attr(layer_node, "opacity").value_or(1.0f); + return std::expected {}; + }) + .and_then([&] { + layer.visible = read_attr(layer_node, "visible").value_or(true); + return std::expected {}; + }) + .and_then([&] { return read_layer_type(layer_node.name()); }) + .and_then([&](const LayerType type) { + layer.type = type; + switch (type) { + case LayerType::kTileLayer: { + return _read_tile_layer(runtime, layer_node, layer, tile_format); + } + case LayerType::kObjectLayer: { + return _read_object_layer(layer_node, layer.objects); + } + case LayerType::kGroupLayer: { + return _read_group_layer(runtime, layer_node, layer, tile_format); + } + default: throw std::invalid_argument {"bad layer type"}; + } + }) + .and_then([&] { return _read_metadata(layer_node, layer.meta); }) + .transform([&] { return std::move(layer); }); +} + +[[nodiscard]] +auto _read_layers(const IRuntime& runtime, + const pugi::xml_node& root_node, + std::vector& layers, + ir::TileFormat& tile_format) -> std::expected +{ + using namespace std::string_view_literals; + constexpr std::array layer_node_names = {"layer"sv, "objectgroup"sv, "group"sv}; + + const auto layer_parser = + [&](const pugi::xml_node& layer_node) -> std::expected { + return _read_layer(runtime, layer_node, tile_format); + }; + + return read_nodes(root_node, layer_node_names, layer_parser) + .and_then([&](std::vector&& read_layers) { + layers = std::move(read_layers); + return std::expected {}; + }); +} + +[[nodiscard]] +auto _read_map(std::string map_name, + const IRuntime& runtime, + const pugi::xml_node& map_node, + const SaveFormatReadOptions& options) -> std::expected +{ + ir::Map map {}; + map.meta.name = std::move(map_name); + + if (read_attr(map_node, "orientation") != "orthogonal") { + runtime::log(LogLevel::kError, "Non-orthogonal maps are not supported"); + return std::unexpected {ErrorCode::kNotSupported}; + } + + if (read_attr(map_node, "infinite").value_or(false)) { + runtime::log(LogLevel::kError, "Infinite maps are not supported"); + return std::unexpected {ErrorCode::kNotSupported}; + } + + return read_attr_to(map_node, "tilewidth", map.tile_size[0]) + .and_then([&] { return read_attr_to(map_node, "tileheight", map.tile_size[1]); }) + .and_then([&] { return read_attr_to(map_node, "width", map.extent.cols); }) + .and_then([&] { return read_attr_to(map_node, "height", map.extent.rows); }) + .and_then([&] { return read_attr_to(map_node, "nextlayerid", map.next_layer_id); }) + .and_then([&] { return read_attr_to(map_node, "nextobjectid", map.next_object_id); }) + .and_then([&] { return _read_tilesets(map_node, options, map); }) + .and_then([&] { return _read_layers(runtime, map_node, map.layers, map.tile_format); }) + .and_then([&] { return _read_metadata(map_node, map.meta); }) + .transform([&] { return std::move(map); }); +} + +} // namespace + +auto parse_map(const IRuntime& runtime, + const std::filesystem::path& map_path, + const SaveFormatReadOptions& options) -> std::expected +{ + return read_xml_document(map_path).and_then([&](const pugi::xml_document& map_document) { + const auto map_node = map_document.child("map"); + return _read_map(map_path.filename(), runtime, map_node, options); + }); +} + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/src/tmx_format_plugin.cpp b/source/plugins/tiled_tmx/lib/src/tmx_format_plugin.cpp new file mode 100644 index 0000000000..4c5dece515 --- /dev/null +++ b/source/plugins/tiled_tmx/lib/src/tmx_format_plugin.cpp @@ -0,0 +1,42 @@ +// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) + +#include "tactile/tiled_tmx/tmx_format_plugin.hpp" + +#include // nothrow + +#include "tactile/base/runtime/runtime.hpp" +#include "tactile/runtime/logging.hpp" +#include "tactile/tiled_tmx/tmx_save_format.hpp" + +namespace tactile::tiled_tmx { + +void TmxFormatPlugin::load(IRuntime* runtime) +{ + runtime::log(LogLevel::kTrace, "Loading Tiled TMX format plugin"); + m_runtime = runtime; + + m_format = std::make_unique(m_runtime); + m_runtime->set_save_format(SaveFormatId::kTiledTmx, m_format.get()); +} + +void TmxFormatPlugin::unload() +{ + runtime::log(LogLevel::kTrace, "Unloading Tiled TMX format plugin"); + + m_runtime->set_save_format(SaveFormatId::kTiledTmx, nullptr); + m_format.reset(); + + m_runtime = nullptr; +} + +auto tactile_make_plugin() -> IPlugin* +{ + return new (std::nothrow) TmxFormatPlugin {}; +} + +void tactile_free_plugin(IPlugin* plugin) +{ + delete plugin; +} + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/src/tmx_format_save_visitor.cpp b/source/plugins/tiled_tmx/lib/src/tmx_format_save_visitor.cpp new file mode 100644 index 0000000000..c17e4a85ff --- /dev/null +++ b/source/plugins/tiled_tmx/lib/src/tmx_format_save_visitor.cpp @@ -0,0 +1,510 @@ +// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) + +#include "tactile/tiled_tmx/tmx_format_save_visitor.hpp" + +#include // relative +#include // format +#include // stringstream +#include // invalid_argument +#include // move + +#include + +#include "tactile/base/document/layer_view.hpp" +#include "tactile/base/document/map_view.hpp" +#include "tactile/base/document/meta_view.hpp" +#include "tactile/base/document/object_view.hpp" +#include "tactile/base/document/tile_view.hpp" +#include "tactile/base/document/tileset_view.hpp" +#include "tactile/base/io/compress/compression_format.hpp" +#include "tactile/base/numeric/literals.hpp" +#include "tactile/runtime/logging.hpp" +#include "tactile/tiled_tmx/tmx_common.hpp" + +namespace tactile::tiled_tmx { +namespace { + +void _append_property_attr(pugi::xml_node node, const Attribute& property) +{ + auto value_attribute = node.append_attribute("value"); + + switch (property.get_type()) { + case AttributeType::kStr: { + value_attribute.set_value(property.as_string().c_str()); + break; + } + case AttributeType::kInt: { + value_attribute.set_value(property.as_int()); + break; + } + case AttributeType::kInt2: { + const auto& i2 = property.as_int2(); + + const auto str = std::format("{};{}", i2.x(), i2.y()); + value_attribute.set_value(str.c_str()); + + break; + } + case AttributeType::kInt3: { + const auto& i3 = property.as_int3(); + + const auto str = std::format("{};{};{}", i3.x(), i3.y(), i3.z()); + value_attribute.set_value(str.c_str()); + + break; + } + case AttributeType::kInt4: { + const auto& i4 = property.as_int4(); + + const auto str = std::format("{};{};{};{}", i4.x(), i4.y(), i4.z(), i4.w()); + value_attribute.set_value(str.c_str()); + + break; + } + case AttributeType::kFloat: { + value_attribute.set_value(property.as_float()); + break; + } + case AttributeType::kFloat2: { + const auto& f2 = property.as_float2(); + + const auto str = std::format("{};{}", f2.x(), f2.y()); + value_attribute.set_value(str.c_str()); + + break; + } + case AttributeType::kFloat3: { + const auto& f3 = property.as_float3(); + + const auto str = std::format("{};{};{}", f3.x(), f3.y(), f3.z()); + value_attribute.set_value(str.c_str()); + + break; + } + case AttributeType::kFloat4: { + const auto& f4 = property.as_float4(); + + const auto str = std::format("{};{};{};{}", f4.x(), f4.y(), f4.z(), f4.w()); + value_attribute.set_value(str.c_str()); + + break; + } + case AttributeType::kBool: { + value_attribute.set_value(property.as_bool()); + break; + } + case AttributeType::kPath: { + const auto path_str = property.as_path().string(); + value_attribute.set_value(path_str.c_str()); + break; + } + case AttributeType::kColor: { + const auto argb_str = to_string(property.as_color(), ColorFormat::kArgb); + value_attribute.set_value(argb_str.c_str()); + break; + } + case AttributeType::kObject: { + value_attribute.set_value(property.as_object().value); + break; + } + default: throw std::invalid_argument {"bad attribute type"}; + } +} + +void _append_properties_node(pugi::xml_node node, const IMetaView& meta) +{ + const auto count = meta.property_count(); + if (count < 1) { + return; + } + + auto properties_node = node.append_child("properties"); + + for (auto index = 0_uz; index < count; ++index) { + const auto& [name, value] = meta.get_property(index); + + auto property_node = properties_node.append_child("property"); + property_node.append_attribute("name").set_value(name.c_str()); + + // Properties with no type attribute are assumed to be string properties + const auto type = value.get_type(); + if (type != AttributeType::kStr && !value.is_vector()) { + const auto* prop_type_name = get_property_type_name(type); + property_node.append_attribute("type").set_value(prop_type_name); + } + + _append_property_attr(property_node, value); + } +} + +void _add_csv_tile_data(pugi::xml_node node, const ILayerView& layer) +{ + node.append_attribute("encoding").set_value("csv"); + + std::stringstream stream {}; + + const auto extent = layer.get_extent().value(); + for (auto row = 0_uz; row < extent.rows; ++row) { + for (auto col = 0_uz; col < extent.cols; ++col) { + const Index2D index {col, row}; + const auto tile_id = layer.get_tile(index).value(); + + if (row != 0_uz || col != 0_uz) { + stream << ','; + } + + stream << tile_id; + } + } + + const auto csv_str = stream.str(); + node.text().set(csv_str.c_str()); +} + +[[nodiscard]] +auto _add_base64_tile_data(pugi::xml_node data_node, + const IRuntime& runtime, + const ILayerView& layer) -> std::expected +{ + data_node.append_attribute("encoding").set_value("base64"); + + ByteStream tile_bytes {}; + + const auto extent = layer.get_extent().value(); + tile_bytes.reserve(sizeof(TileID) * extent.rows * extent.cols); + + layer.write_tile_bytes(tile_bytes); + + if (const auto compress_format_id = layer.get_tile_compression()) { + auto* compression_format = runtime.get_compression_format(*compress_format_id); + + if (!compression_format) { + runtime::log(LogLevel::kError, "No suitable compression plugin available"); + return std::unexpected {ErrorCode::kNotSupported}; + } + + if (auto compressed_tile_bytes = compression_format->compress(tile_bytes)) { + tile_bytes = std::move(*compressed_tile_bytes); + } + else { + runtime::log(LogLevel::kError, "Could not compress tile data"); + return std::unexpected {compressed_tile_bytes.error()}; + } + + const char* compress_format_name = get_compression_format_name(*compress_format_id); + data_node.append_attribute("compression").set_value(compress_format_name); + } + + const auto encoded_tile_bytes = base64::encode(tile_bytes); + data_node.text().set(encoded_tile_bytes.c_str()); + + return {}; +} + +void _append_animation_node(pugi::xml_node tile_node, const ITileView& tile) +{ + auto animation_node = tile_node.append_child("animation"); + + const auto frame_count = tile.animation_frame_count(); + for (auto frame_index = 0_uz; frame_index < frame_count; ++frame_index) { + const auto& [frame_tile_index, frame_duration] = tile.get_animation_frame(frame_index); + + auto frame_node = animation_node.append_child("frame"); + frame_node.append_attribute("tileid").set_value(frame_tile_index); + frame_node.append_attribute("duration").set_value(frame_duration.count()); + } +} + +void _append_tileset_image_node(pugi::xml_node tileset_node, + const ITilesetView& tileset, + const SaveFormatWriteOptions& options) +{ + const auto& image_path = tileset.get_image_path(); + const auto image_size = tileset.get_image_size(); + + const auto source = std::filesystem::relative(image_path, options.base_dir).string(); + + auto image_node = tileset_node.append_child("image"); + image_node.append_attribute("source").set_value(source.c_str()); + image_node.append_attribute("width").set_value(image_size.x()); + image_node.append_attribute("height").set_value(image_size.y()); +} + +void _add_common_tileset_data(pugi::xml_node tileset_node, + const ITilesetView& tileset, + const SaveFormatWriteOptions& options) +{ + const auto& meta = tileset.get_meta(); + const auto tile_size = tileset.get_tile_size(); + + tileset_node.append_attribute("name").set_value(meta.get_name().data()); // FIXME c_str + tileset_node.append_attribute("tilewidth").set_value(tile_size.x()); + tileset_node.append_attribute("tileheight").set_value(tile_size.y()); + tileset_node.append_attribute("tilecount").set_value(tileset.tile_count()); + tileset_node.append_attribute("columns").set_value(tileset.column_count()); + + _append_tileset_image_node(tileset_node, tileset, options); + _append_properties_node(tileset_node, meta); +} + +} // namespace + +TmxFormatSaveVisitor::TmxFormatSaveVisitor(IRuntime* runtime, SaveFormatWriteOptions options) + : m_runtime {runtime}, + m_options {std::move(options)}, + m_map_document {}, + m_map_node {}, + m_layer_nodes {}, + m_tileset_documents {} +{} + +auto TmxFormatSaveVisitor::visit(const IComponentView& component) + -> std::expected +{ + return {}; +} + +auto TmxFormatSaveVisitor::visit(const IMapView& map) -> std::expected +{ + const auto layer_count = map.layer_count(); + m_layer_nodes.resize(layer_count); + + if (m_options.use_external_tilesets) { + const auto tileset_count = map.tileset_count(); + m_tileset_documents.reserve(tileset_count); + } + + m_map_node = m_map_document.append_child("map"); + + const auto extent = map.get_extent(); + const auto tile_size = map.get_tile_size(); + m_map_node.append_attribute("version").set_value("1.7"); + m_map_node.append_attribute("tiledversion").set_value("1.9.0"); + m_map_node.append_attribute("orientation").set_value("orthogonal"); + m_map_node.append_attribute("renderorder").set_value("right-down"); + m_map_node.append_attribute("infinite").set_value(false); + m_map_node.append_attribute("tilewidth").set_value(tile_size.x()); + m_map_node.append_attribute("tileheight").set_value(tile_size.y()); + m_map_node.append_attribute("width").set_value(extent.cols); + m_map_node.append_attribute("height").set_value(extent.rows); + m_map_node.append_attribute("nextlayerid").set_value(map.get_next_layer_id()); + m_map_node.append_attribute("nextobjectid").set_value(map.get_next_object_id()); + + _append_properties_node(m_map_node, map.get_meta()); + + return {}; +} + +auto TmxFormatSaveVisitor::visit(const ILayerView& layer) -> std::expected +{ + const auto layer_type = layer.get_type(); + const char* node_name = get_layer_type_name(layer_type); + + pugi::xml_node layer_node {}; + if (const auto* parent_layer = layer.get_parent_layer()) { + auto parent_node = m_layer_nodes.at(parent_layer->get_global_index()); + layer_node = parent_node.append_child(node_name); + } + else { + layer_node = m_map_node.append_child(node_name); + } + + const auto& meta = layer.get_meta(); + layer_node.append_attribute("id").set_value(layer.get_id()); + layer_node.append_attribute("name").set_value(meta.get_name().data()); + + if (const auto opacity = layer.get_opacity(); opacity != 1.0f) { + layer_node.append_attribute("opacity").set_value(opacity); + } + + if (!layer.is_visible()) { + layer_node.append_attribute("visible").set_value(false); + } + + switch (layer_type) { + case LayerType::kTileLayer: { + const auto extent = layer.get_extent().value(); + const auto tile_encoding = layer.get_tile_encoding(); + + layer_node.append_attribute("width").set_value(extent.cols); + layer_node.append_attribute("height").set_value(extent.rows); + + const auto data_node = layer_node.append_child("data"); + switch (tile_encoding) { + case TileEncoding::kPlainText: { + _add_csv_tile_data(data_node, layer); + break; + } + case TileEncoding::kBase64: { + const auto add_base64_tile_data_result = + _add_base64_tile_data(data_node, *m_runtime, layer); + + if (!add_base64_tile_data_result.has_value()) { + return std::unexpected {add_base64_tile_data_result.error()}; + } + + break; + } + default: throw std::invalid_argument {"bad tile encoding"}; + } + + break; + } + case LayerType::kObjectLayer: /* Do nothing */ break; + case LayerType::kGroupLayer: /* Do nothing */ break; + default: throw std::invalid_argument {"bad layer type"}; + } + + _append_properties_node(layer_node, layer.get_meta()); + + const auto layer_index = layer.get_global_index(); + m_layer_nodes.at(layer_index) = std::move(layer_node); + + return {}; +} + +auto TmxFormatSaveVisitor::visit(const IObjectView& object) -> std::expected +{ + pugi::xml_node parent_node {}; + if (const auto* parent_layer = object.get_parent_layer()) { + parent_node = m_layer_nodes.at(parent_layer->get_global_index()); + } + else if (const auto* parent_tile = object.get_parent_tile()) { + const auto tile_node = + _get_tile_node(parent_tile->get_parent_tileset(), parent_tile->get_index()); + parent_node = tile_node.child("objectgroup"); + } + else { + return std::unexpected {ErrorCode::kBadState}; + } + + const auto& meta = object.get_meta(); + + const auto position = object.get_position(); + const auto size = object.get_size(); + const auto type = object.get_type(); + + auto object_node = parent_node.append_child("object"); + object_node.append_attribute("id").set_value(object.get_id()); + + if (const auto name = meta.get_name(); !name.empty()) { + object_node.append_attribute("name").set_value(name.data()); // FIXME c_str + } + + if (const auto tag = object.get_tag(); !tag.empty()) { + object_node.append_attribute("type").set_value(tag.data()); // FIXME c_str + } + + if (position.x() != 0.0f) { + object_node.append_attribute("x").set_value(position.x()); + } + + if (position.y() != 0.0f) { + object_node.append_attribute("y").set_value(position.y()); + } + + if (size.x() != 0.0f) { + object_node.append_attribute("width").set_value(size.x()); + } + + if (size.y() != 0.0f) { + object_node.append_attribute("height").set_value(size.y()); + } + + if (!object.is_visible()) { + object_node.append_attribute("visible").set_value(false); + } + + // Objects are rectangles if no type tag is present + if (type == ObjectType::kPoint) { + object_node.append_child("point"); + } + else if (type == ObjectType::kEllipse) { + object_node.append_child("ellipse"); + } + + _append_properties_node(object_node, meta); + + return {}; +} + +auto TmxFormatSaveVisitor::visit(const ITilesetView& tileset) -> std::expected +{ + const auto first_tile_id = tileset.get_first_tile_id(); + + auto embedded_tileset_node = m_map_node.append_child("tileset"); + embedded_tileset_node.append_attribute("firstgid").set_value(first_tile_id); + + if (m_options.use_external_tilesets) { + auto source = std::format("{}.tsx", tileset.get_filename()); + embedded_tileset_node.append_attribute("source").set_value(source.c_str()); + + auto& external_tileset_document = m_tileset_documents.emplace_back(); + external_tileset_document.source = std::move(source); + + const auto external_tileset_node = + external_tileset_document.document.append_child("tileset"); + _add_common_tileset_data(external_tileset_node, tileset, m_options); + + m_tileset_nodes[first_tile_id] = external_tileset_node; + } + else { + _add_common_tileset_data(embedded_tileset_node, tileset, m_options); + m_tileset_nodes[first_tile_id] = embedded_tileset_node; + } + + return {}; +} + +auto TmxFormatSaveVisitor::visit(const ITileView& tile) -> std::expected +{ + const auto& tileset = tile.get_parent_tileset(); + auto tileset_node = m_tileset_nodes.at(tileset.get_first_tile_id()); + + auto tile_node = tileset_node.append_child("tile"); + tile_node.append_attribute("id").set_value(tile.get_index()); + + if (tile.animation_frame_count() > 0) { + _append_animation_node(tile_node, tile); + } + + if (tile.object_count() > 0) { + tile_node.append_child("objectgroup"); + } + + _append_properties_node(tile_node, tile.get_meta()); + + return {}; +} + +auto TmxFormatSaveVisitor::get_map_xml_document() const -> const pugi::xml_document& +{ + return m_map_document; +} + +auto TmxFormatSaveVisitor::get_tileset_xml_documents() const + -> const std::vector& +{ + return m_tileset_documents; +} + +auto TmxFormatSaveVisitor::_get_tile_node(const ITilesetView& tileset, + const TileIndex tile_index) -> pugi::xml_node +{ + const auto tileset_iter = m_tileset_nodes.find(tileset.get_first_tile_id()); + + if (tileset_iter != m_tileset_nodes.end()) { + const auto& tileset_node = tileset_iter->second; + + for (const auto& tile_node : tileset_node.children("tile")) { + const auto id = tile_node.attribute("id").as_int(-1); + if (id == tile_index) { + return tile_node; + } + } + } + + throw std::invalid_argument {"no such tile node"}; +} + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/lib/src/tmx_save_format.cpp b/source/plugins/tiled_tmx/lib/src/tmx_save_format.cpp new file mode 100644 index 0000000000..053c2d27e3 --- /dev/null +++ b/source/plugins/tiled_tmx/lib/src/tmx_save_format.cpp @@ -0,0 +1,85 @@ +// Copyright (C) 2024 Albin Johansson (GNU General Public License v3.0) + +#include "tactile/tiled_tmx/tmx_save_format.hpp" + +#include // exception + +#include "tactile/base/document/map_view.hpp" +#include "tactile/base/document/meta_view.hpp" +#include "tactile/runtime/logging.hpp" +#include "tactile/tiled_tmx/tmx_common.hpp" +#include "tactile/tiled_tmx/tmx_format_parser.hpp" +#include "tactile/tiled_tmx/tmx_format_save_visitor.hpp" + +namespace tactile::tiled_tmx { + +TmxSaveFormat::TmxSaveFormat(IRuntime* runtime) + : m_runtime {runtime} +{} + +auto TmxSaveFormat::load_map(const std::filesystem::path& map_path, + const SaveFormatReadOptions& options) const + -> std::expected +{ + try { + return parse_map(*m_runtime, map_path, options); + } + catch (const std::exception& error) { + runtime::log(LogLevel::kError, + "An unexpected error occurred during TMX map parsing: {}", + error.what()); + } + catch (...) { + runtime::log(LogLevel::kError, "An unknown error occurred during TMX map parsing"); + } + + return std::unexpected {ErrorCode::kUnknown}; +} + +auto TmxSaveFormat::save_map(const IMapView& map, const SaveFormatWriteOptions& options) const + -> std::expected +{ + try { + const auto* map_path = map.get_path(); + + if (!map_path) { + runtime::log(LogLevel::kError, "Map has no associated file path"); + return std::unexpected {ErrorCode::kBadState}; + } + + TmxFormatSaveVisitor saver {m_runtime, options}; + + return map.accept(saver).and_then([&]() -> std::expected { + const auto& map_document = saver.get_map_xml_document(); + const auto& external_tileset_documents = saver.get_tileset_xml_documents(); + + auto write_result = save_xml_document(map_document, *map_path); + if (!write_result.has_value()) { + return write_result; + } + + for (const auto& [relative_path, tileset_document] : external_tileset_documents) { + const auto tileset_path = options.base_dir / relative_path; + write_result = save_xml_document(tileset_document, tileset_path); + + if (!write_result.has_value()) { + return write_result; + } + } + + return {}; + }); + } + catch (const std::exception& error) { + runtime::log(LogLevel::kError, + "An unexpected error occurred during TMX map emission: {}", + error.what()); + } + catch (...) { + runtime::log(LogLevel::kError, "An unknown error occurred during TMX map emission"); + } + + return std::unexpected {ErrorCode::kUnknown}; +} + +} // namespace tactile::tiled_tmx diff --git a/source/plugins/tiled_tmx/test/CMakeLists.txt b/source/plugins/tiled_tmx/test/CMakeLists.txt index c95fa3ecff..7237216205 100644 --- a/source/plugins/tiled_tmx/test/CMakeLists.txt +++ b/source/plugins/tiled_tmx/test/CMakeLists.txt @@ -1 +1 @@ -project(tactile-tiled-tmx-format-test CXX) +project(tactile-tiled-tmx-test CXX) diff --git a/source/runtime/test/CMakeLists.txt b/source/runtime/test/CMakeLists.txt index 3bdc40d3b8..44451c6c81 100644 --- a/source/runtime/test/CMakeLists.txt +++ b/source/runtime/test/CMakeLists.txt @@ -15,8 +15,16 @@ target_link_libraries(tactile-runtime-test tactile::test_util tactile::null_renderer $<$:tactile::tiled_tmj_format> + $<$:tactile::tiled_tmx> $<$:tactile::zlib_compression> $<$:tactile::zstd_compression> GTest::gtest ) +target_compile_definitions(tactile-runtime-test + PRIVATE + "$<$:TACTILE_HAS_TILED_TMJ>" + "$<$:TACTILE_HAS_TILED_TMX>" + "$<$:TACTILE_HAS_ZLIB>" + "$<$:TACTILE_HAS_ZSTD>" + ) diff --git a/source/runtime/test/src/save_format_roundtrip_test.cpp b/source/runtime/test/src/save_format_roundtrip_test.cpp index 5fb8943f1f..ea03ded1ed 100644 --- a/source/runtime/test/src/save_format_roundtrip_test.cpp +++ b/source/runtime/test/src/save_format_roundtrip_test.cpp @@ -118,7 +118,7 @@ class SaveFormatRoundtripTest : public testing::TestWithParam CommandLineOptions + { + auto options = get_default_command_line_options(); + options.log_level = LogLevel::kTrace; + return options; + } }; #if TACTILE_HAS_TILED_TMJ