From b093e087e15ae7f7dcc4f23f934f7d9047796505 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Fri, 6 Feb 2026 21:43:03 -0500 Subject: [PATCH] Tiled XML/JSON import support --- .gitmodules | 6 + CMakeLists.txt | 3 + modules/RapidXML | 1 + modules/json | 1 + src/tiled/PyTileMapFile.cpp | 332 ++++++++++++ src/tiled/PyTileMapFile.h | 81 +++ src/tiled/PyTileSetFile.cpp | 234 ++++++++ src/tiled/PyTileSetFile.h | 79 +++ src/tiled/PyWangSet.cpp | 266 +++++++++ src/tiled/PyWangSet.h | 72 +++ src/tiled/TiledParse.cpp | 772 +++++++++++++++++++++++++++ src/tiled/TiledParse.h | 24 + src/tiled/TiledTypes.h | 186 +++++++ src/tiled/WangResolve.cpp | 142 +++++ src/tiled/WangResolve.h | 17 + tests/demo/screens/tiled_analysis.py | 167 ++++++ tests/demo/screens/tiled_demo.py | 504 +++++++++++++++++ tests/unit/wang_resolve_test.py | 153 ++++++ 18 files changed, 3040 insertions(+) create mode 160000 modules/RapidXML create mode 160000 modules/json create mode 100644 src/tiled/PyTileMapFile.cpp create mode 100644 src/tiled/PyTileMapFile.h create mode 100644 src/tiled/PyTileSetFile.cpp create mode 100644 src/tiled/PyTileSetFile.h create mode 100644 src/tiled/PyWangSet.cpp create mode 100644 src/tiled/PyWangSet.h create mode 100644 src/tiled/TiledParse.cpp create mode 100644 src/tiled/TiledParse.h create mode 100644 src/tiled/TiledTypes.h create mode 100644 src/tiled/WangResolve.cpp create mode 100644 src/tiled/WangResolve.h create mode 100644 tests/demo/screens/tiled_analysis.py create mode 100644 tests/demo/screens/tiled_demo.py create mode 100644 tests/unit/wang_resolve_test.py diff --git a/.gitmodules b/.gitmodules index 239bdda..1dccd8f 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,3 +14,9 @@ path = modules/libtcod-headless url = git@github.com:jmccardle/libtcod-headless.git branch = 2.2.1-headless +[submodule "modules/RapidXML"] + path = modules/RapidXML + url = https://github.com/Fe-Bell/RapidXML +[submodule "modules/json"] + path = modules/json + url = git@github.com:nlohmann/json.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 704b9c1..ed399d8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,6 +52,9 @@ include_directories(SYSTEM ${CMAKE_SOURCE_DIR}/deps/libtcod) include_directories(${CMAKE_SOURCE_DIR}/src) include_directories(${CMAKE_SOURCE_DIR}/src/3d) include_directories(${CMAKE_SOURCE_DIR}/src/platform) +include_directories(${CMAKE_SOURCE_DIR}/src/tiled) +include_directories(${CMAKE_SOURCE_DIR}/modules/RapidXML) +include_directories(${CMAKE_SOURCE_DIR}/modules/json/single_include) # Python includes: use different paths for Windows vs Linux vs Emscripten if(EMSCRIPTEN) diff --git a/modules/RapidXML b/modules/RapidXML new file mode 160000 index 0000000..3a42082 --- /dev/null +++ b/modules/RapidXML @@ -0,0 +1 @@ +Subproject commit 3a42082084509e9efb58dcef17b1ad5860dab6ac diff --git a/modules/json b/modules/json new file mode 160000 index 0000000..21b5374 --- /dev/null +++ b/modules/json @@ -0,0 +1 @@ +Subproject commit 21b53746c9d73d314d5de454e2e7cddd20cbbe5d diff --git a/src/tiled/PyTileMapFile.cpp b/src/tiled/PyTileMapFile.cpp new file mode 100644 index 0000000..f9c6aa1 --- /dev/null +++ b/src/tiled/PyTileMapFile.cpp @@ -0,0 +1,332 @@ +#include "PyTileMapFile.h" +#include "PyTileSetFile.h" +#include "TiledParse.h" +#include "McRFPy_Doc.h" +#include "GridLayers.h" +#include + +using namespace mcrf::tiled; + +// ============================================================ +// Type lifecycle +// ============================================================ + +PyObject* PyTileMapFile::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { + auto* self = (PyTileMapFileObject*)type->tp_alloc(type, 0); + if (self) { + new (&self->data) std::shared_ptr(); + } + return (PyObject*)self; +} + +int PyTileMapFile::init(PyTileMapFileObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"path", nullptr}; + const char* path = nullptr; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &path)) + return -1; + + try { + self->data = loadTileMap(path); + } catch (const std::exception& e) { + PyErr_Format(PyExc_IOError, "Failed to load tilemap: %s", e.what()); + return -1; + } + + return 0; +} + +void PyTileMapFile::dealloc(PyTileMapFileObject* self) { + self->data.~shared_ptr(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyTileMapFile::repr(PyObject* obj) { + auto* self = (PyTileMapFileObject*)obj; + if (!self->data) { + return PyUnicode_FromString(""); + } + return PyUnicode_FromFormat("", + self->data->width, self->data->height, + (int)self->data->tilesets.size(), + (int)self->data->tile_layers.size(), + (int)self->data->object_layers.size()); +} + +// ============================================================ +// Properties +// ============================================================ + +PyObject* PyTileMapFile::get_width(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->width); +} + +PyObject* PyTileMapFile::get_height(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->height); +} + +PyObject* PyTileMapFile::get_tile_width(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_width); +} + +PyObject* PyTileMapFile::get_tile_height(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_height); +} + +PyObject* PyTileMapFile::get_orientation(PyTileMapFileObject* self, void*) { + return PyUnicode_FromString(self->data->orientation.c_str()); +} + +PyObject* PyTileMapFile::get_properties(PyTileMapFileObject* self, void*) { + return propertiesToPython(self->data->properties); +} + +PyObject* PyTileMapFile::get_tileset_count(PyTileMapFileObject* self, void*) { + return PyLong_FromLong(self->data->tilesets.size()); +} + +PyObject* PyTileMapFile::get_tile_layer_names(PyTileMapFileObject* self, void*) { + PyObject* list = PyList_New(self->data->tile_layers.size()); + if (!list) return NULL; + for (size_t i = 0; i < self->data->tile_layers.size(); i++) { + PyObject* name = PyUnicode_FromString(self->data->tile_layers[i].name.c_str()); + if (!name) { Py_DECREF(list); return NULL; } + PyList_SET_ITEM(list, i, name); + } + return list; +} + +PyObject* PyTileMapFile::get_object_layer_names(PyTileMapFileObject* self, void*) { + PyObject* list = PyList_New(self->data->object_layers.size()); + if (!list) return NULL; + for (size_t i = 0; i < self->data->object_layers.size(); i++) { + PyObject* name = PyUnicode_FromString(self->data->object_layers[i].name.c_str()); + if (!name) { Py_DECREF(list); return NULL; } + PyList_SET_ITEM(list, i, name); + } + return list; +} + +// ============================================================ +// Methods +// ============================================================ + +PyObject* PyTileMapFile::tileset(PyTileMapFileObject* self, PyObject* args) { + int index; + if (!PyArg_ParseTuple(args, "i", &index)) + return NULL; + + if (index < 0 || index >= (int)self->data->tilesets.size()) { + PyErr_Format(PyExc_IndexError, "Tileset index %d out of range (0..%d)", + index, (int)self->data->tilesets.size() - 1); + return NULL; + } + + const auto& ref = self->data->tilesets[index]; + + // Create a TileSetFile wrapping the existing parsed data + auto* ts_type = &mcrfpydef::PyTileSetFileType; + auto* ts = (PyTileSetFileObject*)ts_type->tp_alloc(ts_type, 0); + if (!ts) return NULL; + new (&ts->data) std::shared_ptr(ref.tileset); + + // Return (firstgid, TileSetFile) + PyObject* result = Py_BuildValue("(iN)", ref.firstgid, (PyObject*)ts); + return result; +} + +PyObject* PyTileMapFile::tile_layer_data(PyTileMapFileObject* self, PyObject* args) { + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) + return NULL; + + for (const auto& tl : self->data->tile_layers) { + if (tl.name == name) { + PyObject* list = PyList_New(tl.global_gids.size()); + if (!list) return NULL; + for (size_t i = 0; i < tl.global_gids.size(); i++) { + PyList_SET_ITEM(list, i, PyLong_FromUnsignedLong(tl.global_gids[i])); + } + return list; + } + } + + PyErr_Format(PyExc_KeyError, "No tile layer named '%s'", name); + return NULL; +} + +PyObject* PyTileMapFile::resolve_gid(PyTileMapFileObject* self, PyObject* args) { + unsigned int gid; + if (!PyArg_ParseTuple(args, "I", &gid)) + return NULL; + + if (gid == 0) { + // GID 0 = empty tile + return Py_BuildValue("(ii)", -1, -1); + } + + // Strip flip flags (top 3 bits of a 32-bit GID) + uint32_t clean_gid = gid & 0x1FFFFFFF; + + // Find which tileset this GID belongs to (tilesets sorted by firstgid) + int ts_index = -1; + for (int i = (int)self->data->tilesets.size() - 1; i >= 0; i--) { + if (clean_gid >= (uint32_t)self->data->tilesets[i].firstgid) { + ts_index = i; + break; + } + } + + if (ts_index < 0) { + return Py_BuildValue("(ii)", -1, -1); + } + + int local_id = clean_gid - self->data->tilesets[ts_index].firstgid; + return Py_BuildValue("(ii)", ts_index, local_id); +} + +PyObject* PyTileMapFile::object_layer(PyTileMapFileObject* self, PyObject* args) { + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) + return NULL; + + for (const auto& ol : self->data->object_layers) { + if (ol.name == name) { + return jsonToPython(ol.objects); + } + } + + PyErr_Format(PyExc_KeyError, "No object layer named '%s'", name); + return NULL; +} + +PyObject* PyTileMapFile::apply_to_tile_layer(PyTileMapFileObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"tile_layer", "layer_name", "tileset_index", nullptr}; + PyObject* tlayer_obj; + const char* layer_name; + int tileset_index = 0; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Os|i", const_cast(keywords), + &tlayer_obj, &layer_name, &tileset_index)) + return NULL; + + // Validate TileLayer + // Check type by name since PyTileLayerType is static-per-TU + const char* type_name = Py_TYPE(tlayer_obj)->tp_name; + if (!type_name || strcmp(type_name, "mcrfpy.TileLayer") != 0) { + PyErr_SetString(PyExc_TypeError, "First argument must be a TileLayer"); + return NULL; + } + + // Find the tile layer data + const TileLayerData* tld = nullptr; + for (const auto& tl : self->data->tile_layers) { + if (tl.name == layer_name) { + tld = &tl; + break; + } + } + if (!tld) { + PyErr_Format(PyExc_KeyError, "No tile layer named '%s'", layer_name); + return NULL; + } + + if (tileset_index < 0 || tileset_index >= (int)self->data->tilesets.size()) { + PyErr_Format(PyExc_IndexError, "Tileset index %d out of range", tileset_index); + return NULL; + } + + int firstgid = self->data->tilesets[tileset_index].firstgid; + auto* tlayer = (PyTileLayerObject*)tlayer_obj; + + int w = tld->width; + int h = tld->height; + for (int y = 0; y < h && y < tlayer->data->grid_y; y++) { + for (int x = 0; x < w && x < tlayer->data->grid_x; x++) { + uint32_t gid = tld->global_gids[y * w + x]; + if (gid == 0) { + tlayer->data->at(x, y) = -1; // empty + continue; + } + uint32_t clean_gid = gid & 0x1FFFFFFF; + int local_id = static_cast(clean_gid) - firstgid; + if (local_id >= 0) { + tlayer->data->at(x, y) = local_id; + } + } + } + tlayer->data->markDirty(); + + Py_RETURN_NONE; +} + +// ============================================================ +// Method/GetSet tables +// ============================================================ + +PyMethodDef PyTileMapFile::methods[] = { + {"tileset", (PyCFunction)PyTileMapFile::tileset, METH_VARARGS, + MCRF_METHOD(TileMapFile, tileset, + MCRF_SIG("(index: int)", "tuple[int, TileSetFile]"), + MCRF_DESC("Get a referenced tileset by index."), + MCRF_ARGS_START + MCRF_ARG("index", "Tileset index (0-based)") + MCRF_RETURNS("Tuple of (firstgid, TileSetFile).") + )}, + {"tile_layer_data", (PyCFunction)PyTileMapFile::tile_layer_data, METH_VARARGS, + MCRF_METHOD(TileMapFile, tile_layer_data, + MCRF_SIG("(name: str)", "list[int]"), + MCRF_DESC("Get raw global GID data for a tile layer."), + MCRF_ARGS_START + MCRF_ARG("name", "Name of the tile layer") + MCRF_RETURNS("Flat list of global GIDs (0 = empty tile).") + MCRF_RAISES("KeyError", "If no tile layer with that name exists") + )}, + {"resolve_gid", (PyCFunction)PyTileMapFile::resolve_gid, METH_VARARGS, + MCRF_METHOD(TileMapFile, resolve_gid, + MCRF_SIG("(gid: int)", "tuple[int, int]"), + MCRF_DESC("Resolve a global tile ID to tileset index and local tile ID."), + MCRF_ARGS_START + MCRF_ARG("gid", "Global tile ID from tile_layer_data()") + MCRF_RETURNS("Tuple of (tileset_index, local_tile_id). (-1, -1) for empty/invalid.") + )}, + {"object_layer", (PyCFunction)PyTileMapFile::object_layer, METH_VARARGS, + MCRF_METHOD(TileMapFile, object_layer, + MCRF_SIG("(name: str)", "list[dict]"), + MCRF_DESC("Get objects from an object layer as Python dicts."), + MCRF_ARGS_START + MCRF_ARG("name", "Name of the object layer") + MCRF_RETURNS("List of dicts with object properties (id, name, x, y, width, height, etc.).") + MCRF_RAISES("KeyError", "If no object layer with that name exists") + )}, + {"apply_to_tile_layer", (PyCFunction)PyTileMapFile::apply_to_tile_layer, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(TileMapFile, apply_to_tile_layer, + MCRF_SIG("(tile_layer: TileLayer, layer_name: str, tileset_index: int = 0)", "None"), + MCRF_DESC("Resolve GIDs and write sprite indices into a TileLayer."), + MCRF_ARGS_START + MCRF_ARG("tile_layer", "Target TileLayer to write into") + MCRF_ARG("layer_name", "Name of the tile layer in this map") + MCRF_ARG("tileset_index", "Which tileset to resolve GIDs against (default 0)") + )}, + {NULL} +}; + +PyGetSetDef PyTileMapFile::getsetters[] = { + {"width", (getter)PyTileMapFile::get_width, NULL, + MCRF_PROPERTY(width, "Map width in tiles (int, read-only)."), NULL}, + {"height", (getter)PyTileMapFile::get_height, NULL, + MCRF_PROPERTY(height, "Map height in tiles (int, read-only)."), NULL}, + {"tile_width", (getter)PyTileMapFile::get_tile_width, NULL, + MCRF_PROPERTY(tile_width, "Tile width in pixels (int, read-only)."), NULL}, + {"tile_height", (getter)PyTileMapFile::get_tile_height, NULL, + MCRF_PROPERTY(tile_height, "Tile height in pixels (int, read-only)."), NULL}, + {"orientation", (getter)PyTileMapFile::get_orientation, NULL, + MCRF_PROPERTY(orientation, "Map orientation, e.g. 'orthogonal' (str, read-only)."), NULL}, + {"properties", (getter)PyTileMapFile::get_properties, NULL, + MCRF_PROPERTY(properties, "Custom map properties as a dict (read-only)."), NULL}, + {"tileset_count", (getter)PyTileMapFile::get_tileset_count, NULL, + MCRF_PROPERTY(tileset_count, "Number of referenced tilesets (int, read-only)."), NULL}, + {"tile_layer_names", (getter)PyTileMapFile::get_tile_layer_names, NULL, + MCRF_PROPERTY(tile_layer_names, "List of tile layer names (read-only)."), NULL}, + {"object_layer_names", (getter)PyTileMapFile::get_object_layer_names, NULL, + MCRF_PROPERTY(object_layer_names, "List of object layer names (read-only)."), NULL}, + {NULL} +}; diff --git a/src/tiled/PyTileMapFile.h b/src/tiled/PyTileMapFile.h new file mode 100644 index 0000000..36a879c --- /dev/null +++ b/src/tiled/PyTileMapFile.h @@ -0,0 +1,81 @@ +#pragma once +#include "Python.h" +#include "TiledTypes.h" +#include + +// Python object structure +typedef struct PyTileMapFileObject { + PyObject_HEAD + std::shared_ptr data; +} PyTileMapFileObject; + +// Python binding class +class PyTileMapFile { +public: + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static int init(PyTileMapFileObject* self, PyObject* args, PyObject* kwds); + static void dealloc(PyTileMapFileObject* self); + static PyObject* repr(PyObject* obj); + + // Read-only properties + static PyObject* get_width(PyTileMapFileObject* self, void* closure); + static PyObject* get_height(PyTileMapFileObject* self, void* closure); + static PyObject* get_tile_width(PyTileMapFileObject* self, void* closure); + static PyObject* get_tile_height(PyTileMapFileObject* self, void* closure); + static PyObject* get_orientation(PyTileMapFileObject* self, void* closure); + static PyObject* get_properties(PyTileMapFileObject* self, void* closure); + static PyObject* get_tileset_count(PyTileMapFileObject* self, void* closure); + static PyObject* get_tile_layer_names(PyTileMapFileObject* self, void* closure); + static PyObject* get_object_layer_names(PyTileMapFileObject* self, void* closure); + + // Methods + static PyObject* tileset(PyTileMapFileObject* self, PyObject* args); + static PyObject* tile_layer_data(PyTileMapFileObject* self, PyObject* args); + static PyObject* resolve_gid(PyTileMapFileObject* self, PyObject* args); + static PyObject* object_layer(PyTileMapFileObject* self, PyObject* args); + static PyObject* apply_to_tile_layer(PyTileMapFileObject* self, PyObject* args, PyObject* kwds); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +// Type definition in mcrfpydef namespace +namespace mcrfpydef { + +inline PyTypeObject PyTileMapFileType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.TileMapFile", + .tp_basicsize = sizeof(PyTileMapFileObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyTileMapFile::dealloc, + .tp_repr = PyTileMapFile::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "TileMapFile(path: str)\n\n" + "Load a Tiled map file (.tmx or .tmj).\n\n" + "Parses the map and its referenced tilesets, providing access to tile layers,\n" + "object layers, and GID resolution.\n\n" + "Args:\n" + " path: Path to the .tmx or .tmj map file.\n\n" + "Properties:\n" + " width (int, read-only): Map width in tiles.\n" + " height (int, read-only): Map height in tiles.\n" + " tile_width (int, read-only): Tile width in pixels.\n" + " tile_height (int, read-only): Tile height in pixels.\n" + " orientation (str, read-only): Map orientation (e.g. 'orthogonal').\n" + " properties (dict, read-only): Custom map properties.\n" + " tileset_count (int, read-only): Number of referenced tilesets.\n" + " tile_layer_names (list, read-only): Names of tile layers.\n" + " object_layer_names (list, read-only): Names of object layers.\n\n" + "Example:\n" + " tm = mcrfpy.TileMapFile('map.tmx')\n" + " data = tm.tile_layer_data('Ground')\n" + " tm.apply_to_tile_layer(my_tile_layer, 'Ground')\n" + ), + .tp_methods = nullptr, // Set before PyType_Ready + .tp_getset = nullptr, // Set before PyType_Ready + .tp_init = (initproc)PyTileMapFile::init, + .tp_new = PyTileMapFile::pynew, +}; + +} // namespace mcrfpydef diff --git a/src/tiled/PyTileSetFile.cpp b/src/tiled/PyTileSetFile.cpp new file mode 100644 index 0000000..9748aa6 --- /dev/null +++ b/src/tiled/PyTileSetFile.cpp @@ -0,0 +1,234 @@ +#include "PyTileSetFile.h" +#include "TiledParse.h" +#include "PyWangSet.h" +#include "McRFPy_Doc.h" + +using namespace mcrf::tiled; + +// ============================================================ +// Type lifecycle +// ============================================================ + +PyObject* PyTileSetFile::pynew(PyTypeObject* type, PyObject* args, PyObject* kwds) { + auto* self = (PyTileSetFileObject*)type->tp_alloc(type, 0); + if (self) { + new (&self->data) std::shared_ptr(); + } + return (PyObject*)self; +} + +int PyTileSetFile::init(PyTileSetFileObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"path", nullptr}; + const char* path = nullptr; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s", const_cast(keywords), &path)) + return -1; + + try { + self->data = loadTileSet(path); + } catch (const std::exception& e) { + PyErr_Format(PyExc_IOError, "Failed to load tileset: %s", e.what()); + return -1; + } + + return 0; +} + +void PyTileSetFile::dealloc(PyTileSetFileObject* self) { + self->data.~shared_ptr(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyTileSetFile::repr(PyObject* obj) { + auto* self = (PyTileSetFileObject*)obj; + if (!self->data) { + return PyUnicode_FromString(""); + } + return PyUnicode_FromFormat("", + self->data->name.c_str(), self->data->tile_count, + self->data->tile_width, self->data->tile_height); +} + +// ============================================================ +// Properties (all read-only) +// ============================================================ + +PyObject* PyTileSetFile::get_name(PyTileSetFileObject* self, void*) { + return PyUnicode_FromString(self->data->name.c_str()); +} + +PyObject* PyTileSetFile::get_tile_width(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_width); +} + +PyObject* PyTileSetFile::get_tile_height(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_height); +} + +PyObject* PyTileSetFile::get_tile_count(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->tile_count); +} + +PyObject* PyTileSetFile::get_columns(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->columns); +} + +PyObject* PyTileSetFile::get_margin(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->margin); +} + +PyObject* PyTileSetFile::get_spacing(PyTileSetFileObject* self, void*) { + return PyLong_FromLong(self->data->spacing); +} + +PyObject* PyTileSetFile::get_image_source(PyTileSetFileObject* self, void*) { + return PyUnicode_FromString(self->data->image_source.c_str()); +} + +PyObject* PyTileSetFile::get_properties(PyTileSetFileObject* self, void*) { + return propertiesToPython(self->data->properties); +} + +PyObject* PyTileSetFile::get_wang_sets(PyTileSetFileObject* self, void*) { + PyObject* list = PyList_New(self->data->wang_sets.size()); + if (!list) return NULL; + + for (size_t i = 0; i < self->data->wang_sets.size(); i++) { + PyObject* ws = PyWangSet::create(self->data, static_cast(i)); + if (!ws) { + Py_DECREF(list); + return NULL; + } + PyList_SET_ITEM(list, i, ws); + } + return list; +} + +// ============================================================ +// Methods +// ============================================================ + +PyObject* PyTileSetFile::to_texture(PyTileSetFileObject* self, PyObject* args) { + // Create a PyTexture using the image source and tile dimensions + // Get the Texture type from the mcrfpy module (safe cross-compilation-unit access) + PyObject* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return NULL; + + PyObject* tex_type = PyObject_GetAttrString(mcrfpy_module, "Texture"); + Py_DECREF(mcrfpy_module); + if (!tex_type) return NULL; + + PyObject* tex_args = Py_BuildValue("(sii)", + self->data->image_source.c_str(), + self->data->tile_width, + self->data->tile_height); + if (!tex_args) { Py_DECREF(tex_type); return NULL; } + + PyObject* tex = PyObject_Call(tex_type, tex_args, NULL); + Py_DECREF(tex_type); + Py_DECREF(tex_args); + return tex; +} + +PyObject* PyTileSetFile::tile_info(PyTileSetFileObject* self, PyObject* args) { + int tile_id; + if (!PyArg_ParseTuple(args, "i", &tile_id)) + return NULL; + + auto it = self->data->tile_info.find(tile_id); + if (it == self->data->tile_info.end()) { + Py_RETURN_NONE; + } + + const TileInfo& ti = it->second; + PyObject* dict = PyDict_New(); + if (!dict) return NULL; + + // Properties + PyObject* props = propertiesToPython(ti.properties); + if (!props) { Py_DECREF(dict); return NULL; } + PyDict_SetItemString(dict, "properties", props); + Py_DECREF(props); + + // Animation + PyObject* anim_list = PyList_New(ti.animation.size()); + if (!anim_list) { Py_DECREF(dict); return NULL; } + for (size_t i = 0; i < ti.animation.size(); i++) { + PyObject* frame = Py_BuildValue("(ii)", ti.animation[i].tile_id, ti.animation[i].duration_ms); + if (!frame) { Py_DECREF(anim_list); Py_DECREF(dict); return NULL; } + PyList_SET_ITEM(anim_list, i, frame); + } + PyDict_SetItemString(dict, "animation", anim_list); + Py_DECREF(anim_list); + + return dict; +} + +PyObject* PyTileSetFile::wang_set(PyTileSetFileObject* self, PyObject* args) { + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) + return NULL; + + for (size_t i = 0; i < self->data->wang_sets.size(); i++) { + if (self->data->wang_sets[i].name == name) { + return PyWangSet::create(self->data, static_cast(i)); + } + } + + PyErr_Format(PyExc_KeyError, "No WangSet named '%s'", name); + return NULL; +} + +// ============================================================ +// Method/GetSet tables +// ============================================================ + +PyMethodDef PyTileSetFile::methods[] = { + {"to_texture", (PyCFunction)PyTileSetFile::to_texture, METH_NOARGS, + MCRF_METHOD(TileSetFile, to_texture, + MCRF_SIG("()", "Texture"), + MCRF_DESC("Create a Texture from the tileset image."), + MCRF_RETURNS("A Texture object for use with TileLayer.") + )}, + {"tile_info", (PyCFunction)PyTileSetFile::tile_info, METH_VARARGS, + MCRF_METHOD(TileSetFile, tile_info, + MCRF_SIG("(tile_id: int)", "dict | None"), + MCRF_DESC("Get metadata for a specific tile."), + MCRF_ARGS_START + MCRF_ARG("tile_id", "Local tile ID (0-based)") + MCRF_RETURNS("Dict with 'properties' and 'animation' keys, or None if no metadata.") + )}, + {"wang_set", (PyCFunction)PyTileSetFile::wang_set, METH_VARARGS, + MCRF_METHOD(TileSetFile, wang_set, + MCRF_SIG("(name: str)", "WangSet"), + MCRF_DESC("Look up a WangSet by name."), + MCRF_ARGS_START + MCRF_ARG("name", "Name of the Wang set") + MCRF_RETURNS("The WangSet object.") + MCRF_RAISES("KeyError", "If no WangSet with that name exists") + )}, + {NULL} +}; + +PyGetSetDef PyTileSetFile::getsetters[] = { + {"name", (getter)PyTileSetFile::get_name, NULL, + MCRF_PROPERTY(name, "Tileset name (str, read-only)."), NULL}, + {"tile_width", (getter)PyTileSetFile::get_tile_width, NULL, + MCRF_PROPERTY(tile_width, "Width of each tile in pixels (int, read-only)."), NULL}, + {"tile_height", (getter)PyTileSetFile::get_tile_height, NULL, + MCRF_PROPERTY(tile_height, "Height of each tile in pixels (int, read-only)."), NULL}, + {"tile_count", (getter)PyTileSetFile::get_tile_count, NULL, + MCRF_PROPERTY(tile_count, "Total number of tiles (int, read-only)."), NULL}, + {"columns", (getter)PyTileSetFile::get_columns, NULL, + MCRF_PROPERTY(columns, "Number of columns in tileset image (int, read-only)."), NULL}, + {"margin", (getter)PyTileSetFile::get_margin, NULL, + MCRF_PROPERTY(margin, "Margin around tiles in pixels (int, read-only)."), NULL}, + {"spacing", (getter)PyTileSetFile::get_spacing, NULL, + MCRF_PROPERTY(spacing, "Spacing between tiles in pixels (int, read-only)."), NULL}, + {"image_source", (getter)PyTileSetFile::get_image_source, NULL, + MCRF_PROPERTY(image_source, "Resolved path to the tileset image file (str, read-only)."), NULL}, + {"properties", (getter)PyTileSetFile::get_properties, NULL, + MCRF_PROPERTY(properties, "Custom tileset properties as a dict (read-only)."), NULL}, + {"wang_sets", (getter)PyTileSetFile::get_wang_sets, NULL, + MCRF_PROPERTY(wang_sets, "List of WangSet objects from this tileset (read-only)."), NULL}, + {NULL} +}; diff --git a/src/tiled/PyTileSetFile.h b/src/tiled/PyTileSetFile.h new file mode 100644 index 0000000..ed94985 --- /dev/null +++ b/src/tiled/PyTileSetFile.h @@ -0,0 +1,79 @@ +#pragma once +#include "Python.h" +#include "TiledTypes.h" +#include + +// Python object structure +typedef struct PyTileSetFileObject { + PyObject_HEAD + std::shared_ptr data; +} PyTileSetFileObject; + +// Python binding class +class PyTileSetFile { +public: + static PyObject* pynew(PyTypeObject* type, PyObject* args, PyObject* kwds); + static int init(PyTileSetFileObject* self, PyObject* args, PyObject* kwds); + static void dealloc(PyTileSetFileObject* self); + static PyObject* repr(PyObject* obj); + + // Read-only properties + static PyObject* get_name(PyTileSetFileObject* self, void* closure); + static PyObject* get_tile_width(PyTileSetFileObject* self, void* closure); + static PyObject* get_tile_height(PyTileSetFileObject* self, void* closure); + static PyObject* get_tile_count(PyTileSetFileObject* self, void* closure); + static PyObject* get_columns(PyTileSetFileObject* self, void* closure); + static PyObject* get_margin(PyTileSetFileObject* self, void* closure); + static PyObject* get_spacing(PyTileSetFileObject* self, void* closure); + static PyObject* get_image_source(PyTileSetFileObject* self, void* closure); + static PyObject* get_properties(PyTileSetFileObject* self, void* closure); + static PyObject* get_wang_sets(PyTileSetFileObject* self, void* closure); + + // Methods + static PyObject* to_texture(PyTileSetFileObject* self, PyObject* args); + static PyObject* tile_info(PyTileSetFileObject* self, PyObject* args); + static PyObject* wang_set(PyTileSetFileObject* self, PyObject* args); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; +}; + +// Type definition in mcrfpydef namespace +namespace mcrfpydef { + +inline PyTypeObject PyTileSetFileType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.TileSetFile", + .tp_basicsize = sizeof(PyTileSetFileObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyTileSetFile::dealloc, + .tp_repr = PyTileSetFile::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "TileSetFile(path: str)\n\n" + "Load a Tiled tileset file (.tsx or .tsj).\n\n" + "Parses the tileset and provides access to tile metadata, properties,\n" + "Wang sets, and texture creation.\n\n" + "Args:\n" + " path: Path to the .tsx or .tsj tileset file.\n\n" + "Properties:\n" + " name (str, read-only): Tileset name.\n" + " tile_width (int, read-only): Width of each tile in pixels.\n" + " tile_height (int, read-only): Height of each tile in pixels.\n" + " tile_count (int, read-only): Total number of tiles.\n" + " columns (int, read-only): Number of columns in the tileset image.\n" + " image_source (str, read-only): Resolved path to the tileset image.\n" + " properties (dict, read-only): Custom properties from the tileset.\n" + " wang_sets (list, read-only): List of WangSet objects.\n\n" + "Example:\n" + " ts = mcrfpy.TileSetFile('tileset.tsx')\n" + " texture = ts.to_texture()\n" + " print(f'{ts.name}: {ts.tile_count} tiles')\n" + ), + .tp_methods = nullptr, // Set before PyType_Ready + .tp_getset = nullptr, // Set before PyType_Ready + .tp_init = (initproc)PyTileSetFile::init, + .tp_new = PyTileSetFile::pynew, +}; + +} // namespace mcrfpydef diff --git a/src/tiled/PyWangSet.cpp b/src/tiled/PyWangSet.cpp new file mode 100644 index 0000000..592688c --- /dev/null +++ b/src/tiled/PyWangSet.cpp @@ -0,0 +1,266 @@ +#include "PyWangSet.h" +#include "TiledParse.h" +#include "WangResolve.h" +#include "McRFPy_Doc.h" +#include "PyDiscreteMap.h" +#include "GridLayers.h" +#include + +using namespace mcrf::tiled; + +// ============================================================ +// Helper +// ============================================================ + +const WangSet& PyWangSet::getWangSet(PyWangSetObject* self) { + return self->parent->wang_sets[self->wang_set_index]; +} + +// ============================================================ +// Factory +// ============================================================ + +PyObject* PyWangSet::create(std::shared_ptr parent, int index) { + auto* type = &mcrfpydef::PyWangSetType; + auto* self = (PyWangSetObject*)type->tp_alloc(type, 0); + if (!self) return NULL; + new (&self->parent) std::shared_ptr(parent); + self->wang_set_index = index; + return (PyObject*)self; +} + +// ============================================================ +// Type lifecycle +// ============================================================ + +void PyWangSet::dealloc(PyWangSetObject* self) { + self->parent.~shared_ptr(); + Py_TYPE(self)->tp_free((PyObject*)self); +} + +PyObject* PyWangSet::repr(PyObject* obj) { + auto* self = (PyWangSetObject*)obj; + const auto& ws = getWangSet(self); + const char* type_str = "unknown"; + switch (ws.type) { + case WangSetType::Corner: type_str = "corner"; break; + case WangSetType::Edge: type_str = "edge"; break; + case WangSetType::Mixed: type_str = "mixed"; break; + } + return PyUnicode_FromFormat("", + ws.name.c_str(), type_str, (int)ws.colors.size()); +} + +// ============================================================ +// Properties +// ============================================================ + +PyObject* PyWangSet::get_name(PyWangSetObject* self, void*) { + return PyUnicode_FromString(getWangSet(self).name.c_str()); +} + +PyObject* PyWangSet::get_type(PyWangSetObject* self, void*) { + switch (getWangSet(self).type) { + case WangSetType::Corner: return PyUnicode_FromString("corner"); + case WangSetType::Edge: return PyUnicode_FromString("edge"); + case WangSetType::Mixed: return PyUnicode_FromString("mixed"); + } + return PyUnicode_FromString("unknown"); +} + +PyObject* PyWangSet::get_color_count(PyWangSetObject* self, void*) { + return PyLong_FromLong(getWangSet(self).colors.size()); +} + +PyObject* PyWangSet::get_colors(PyWangSetObject* self, void*) { + const auto& ws = getWangSet(self); + PyObject* list = PyList_New(ws.colors.size()); + if (!list) return NULL; + + for (size_t i = 0; i < ws.colors.size(); i++) { + const auto& wc = ws.colors[i]; + PyObject* dict = Py_BuildValue("{s:s, s:i, s:i, s:f}", + "name", wc.name.c_str(), + "index", wc.index, + "tile_id", wc.tile_id, + "probability", (double)wc.probability); + if (!dict) { + Py_DECREF(list); + return NULL; + } + PyList_SET_ITEM(list, i, dict); + } + return list; +} + +// ============================================================ +// Methods +// ============================================================ + +// Convert a name like "Grass Terrain" to "GRASS_TERRAIN" +static std::string toUpperSnakeCase(const std::string& s) { + std::string result; + result.reserve(s.size()); + for (size_t i = 0; i < s.size(); i++) { + char c = s[i]; + if (c == ' ' || c == '-') { + result += '_'; + } else { + result += static_cast(toupper(static_cast(c))); + } + } + return result; +} + +PyObject* PyWangSet::terrain_enum(PyWangSetObject* self, PyObject*) { + const auto& ws = getWangSet(self); + + // Import IntEnum from enum module + PyObject* enum_module = PyImport_ImportModule("enum"); + if (!enum_module) return NULL; + + PyObject* int_enum = PyObject_GetAttrString(enum_module, "IntEnum"); + Py_DECREF(enum_module); + if (!int_enum) return NULL; + + // Build members dict: NONE=0, then each color + PyObject* members = PyDict_New(); + if (!members) { Py_DECREF(int_enum); return NULL; } + + // NONE = 0 (unset terrain) + PyObject* zero = PyLong_FromLong(0); + PyDict_SetItemString(members, "NONE", zero); + Py_DECREF(zero); + + for (const auto& wc : ws.colors) { + std::string key = toUpperSnakeCase(wc.name); + PyObject* val = PyLong_FromLong(wc.index); + PyDict_SetItemString(members, key.c_str(), val); + Py_DECREF(val); + } + + // Create enum class: IntEnum(ws.name, members) + PyObject* name = PyUnicode_FromString(ws.name.c_str()); + PyObject* args = PyTuple_Pack(2, name, members); + Py_DECREF(name); + Py_DECREF(members); + + PyObject* enum_class = PyObject_Call(int_enum, args, NULL); + Py_DECREF(args); + Py_DECREF(int_enum); + + return enum_class; +} + +PyObject* PyWangSet::resolve(PyWangSetObject* self, PyObject* args) { + PyObject* dmap_obj; + if (!PyArg_ParseTuple(args, "O", &dmap_obj)) + return NULL; + + // Check type by name since static types differ per translation unit + const char* dmap_type_name = Py_TYPE(dmap_obj)->tp_name; + if (!dmap_type_name || strcmp(dmap_type_name, "mcrfpy.DiscreteMap") != 0) { + PyErr_SetString(PyExc_TypeError, "Expected a DiscreteMap object"); + return NULL; + } + + auto* dmap = (PyDiscreteMapObject*)dmap_obj; + const auto& ws = getWangSet(self); + + std::vector result = resolveWangTerrain(dmap->values, dmap->w, dmap->h, ws); + + // Convert to Python list + PyObject* list = PyList_New(result.size()); + if (!list) return NULL; + for (size_t i = 0; i < result.size(); i++) { + PyList_SET_ITEM(list, i, PyLong_FromLong(result[i])); + } + return list; +} + +PyObject* PyWangSet::apply(PyWangSetObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"discrete_map", "tile_layer", nullptr}; + PyObject* dmap_obj; + PyObject* tlayer_obj; + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO", const_cast(keywords), + &dmap_obj, &tlayer_obj)) + return NULL; + + // Validate DiscreteMap (check by name since static types differ per TU) + const char* dmap_tn = Py_TYPE(dmap_obj)->tp_name; + if (!dmap_tn || strcmp(dmap_tn, "mcrfpy.DiscreteMap") != 0) { + PyErr_SetString(PyExc_TypeError, "First argument must be a DiscreteMap"); + return NULL; + } + + // Validate TileLayer + const char* tl_tn = Py_TYPE(tlayer_obj)->tp_name; + if (!tl_tn || strcmp(tl_tn, "mcrfpy.TileLayer") != 0) { + PyErr_SetString(PyExc_TypeError, "Second argument must be a TileLayer"); + return NULL; + } + + auto* dmap = (PyDiscreteMapObject*)dmap_obj; + auto* tlayer = (PyTileLayerObject*)tlayer_obj; + const auto& ws = getWangSet(self); + + // Resolve terrain to tile indices + std::vector tile_ids = resolveWangTerrain(dmap->values, dmap->w, dmap->h, ws); + + // Write into TileLayer + int w = dmap->w; + int h = dmap->h; + for (int y = 0; y < h && y < tlayer->data->grid_y; y++) { + for (int x = 0; x < w && x < tlayer->data->grid_x; x++) { + int tid = tile_ids[y * w + x]; + if (tid >= 0) { + tlayer->data->at(x, y) = tid; + } + } + } + tlayer->data->markDirty(); + + Py_RETURN_NONE; +} + +// ============================================================ +// Method/GetSet tables +// ============================================================ + +PyMethodDef PyWangSet::methods[] = { + {"terrain_enum", (PyCFunction)PyWangSet::terrain_enum, METH_NOARGS, + MCRF_METHOD(WangSet, terrain_enum, + MCRF_SIG("()", "IntEnum"), + MCRF_DESC("Generate a Python IntEnum from this WangSet's terrain colors."), + MCRF_RETURNS("IntEnum class with NONE=0 and one member per color (UPPER_SNAKE_CASE).") + )}, + {"resolve", (PyCFunction)PyWangSet::resolve, METH_VARARGS, + MCRF_METHOD(WangSet, resolve, + MCRF_SIG("(discrete_map: DiscreteMap)", "list[int]"), + MCRF_DESC("Resolve terrain data to tile indices using Wang tile rules."), + MCRF_ARGS_START + MCRF_ARG("discrete_map", "A DiscreteMap with terrain IDs matching this WangSet's colors") + MCRF_RETURNS("List of tile IDs (one per cell). -1 means no matching Wang tile.") + )}, + {"apply", (PyCFunction)PyWangSet::apply, METH_VARARGS | METH_KEYWORDS, + MCRF_METHOD(WangSet, apply, + MCRF_SIG("(discrete_map: DiscreteMap, tile_layer: TileLayer)", "None"), + MCRF_DESC("Resolve terrain and write tile indices directly into a TileLayer."), + MCRF_ARGS_START + MCRF_ARG("discrete_map", "A DiscreteMap with terrain IDs") + MCRF_ARG("tile_layer", "Target TileLayer to write resolved tiles into") + )}, + {NULL} +}; + +PyGetSetDef PyWangSet::getsetters[] = { + {"name", (getter)PyWangSet::get_name, NULL, + MCRF_PROPERTY(name, "Wang set name (str, read-only)."), NULL}, + {"type", (getter)PyWangSet::get_type, NULL, + MCRF_PROPERTY(type, "Wang set type: 'corner', 'edge', or 'mixed' (str, read-only)."), NULL}, + {"color_count", (getter)PyWangSet::get_color_count, NULL, + MCRF_PROPERTY(color_count, "Number of terrain colors (int, read-only)."), NULL}, + {"colors", (getter)PyWangSet::get_colors, NULL, + MCRF_PROPERTY(colors, "List of color dicts with name, index, tile_id, probability (read-only)."), NULL}, + {NULL} +}; diff --git a/src/tiled/PyWangSet.h b/src/tiled/PyWangSet.h new file mode 100644 index 0000000..8641f34 --- /dev/null +++ b/src/tiled/PyWangSet.h @@ -0,0 +1,72 @@ +#pragma once +#include "Python.h" +#include "TiledTypes.h" +#include + +// Python object structure +// Holds a shared_ptr to the parent TileSetData (keeps it alive) + index into wang_sets +typedef struct PyWangSetObject { + PyObject_HEAD + std::shared_ptr parent; + int wang_set_index; +} PyWangSetObject; + +// Python binding class +class PyWangSet { +public: + // Factory: create a PyWangSet from parent tileset + index + static PyObject* create(std::shared_ptr parent, int index); + + static void dealloc(PyWangSetObject* self); + static PyObject* repr(PyObject* obj); + + // Read-only properties + static PyObject* get_name(PyWangSetObject* self, void* closure); + static PyObject* get_type(PyWangSetObject* self, void* closure); + static PyObject* get_color_count(PyWangSetObject* self, void* closure); + static PyObject* get_colors(PyWangSetObject* self, void* closure); + + // Methods + static PyObject* terrain_enum(PyWangSetObject* self, PyObject* args); + static PyObject* resolve(PyWangSetObject* self, PyObject* args); + static PyObject* apply(PyWangSetObject* self, PyObject* args, PyObject* kwds); + + static PyMethodDef methods[]; + static PyGetSetDef getsetters[]; + +private: + // Helper: get the WangSet reference + static const mcrf::tiled::WangSet& getWangSet(PyWangSetObject* self); +}; + +// Type definition in mcrfpydef namespace +namespace mcrfpydef { + +inline PyTypeObject PyWangSetType = { + .ob_base = {.ob_base = {.ob_refcnt = 1, .ob_type = NULL}, .ob_size = 0}, + .tp_name = "mcrfpy.WangSet", + .tp_basicsize = sizeof(PyWangSetObject), + .tp_itemsize = 0, + .tp_dealloc = (destructor)PyWangSet::dealloc, + .tp_repr = PyWangSet::repr, + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_doc = PyDoc_STR( + "WangSet - Wang terrain auto-tile set from a Tiled tileset.\n\n" + "WangSets are obtained from TileSetFile.wang_sets or TileSetFile.wang_set().\n" + "They map abstract terrain types to concrete sprite indices using Tiled's\n" + "Wang tile algorithm.\n\n" + "Properties:\n" + " name (str, read-only): Wang set name.\n" + " type (str, read-only): 'corner', 'edge', or 'mixed'.\n" + " color_count (int, read-only): Number of terrain colors.\n" + " colors (list, read-only): List of color dicts.\n\n" + "Example:\n" + " ws = tileset.wang_set('overworld')\n" + " Terrain = ws.terrain_enum()\n" + " tiles = ws.resolve(discrete_map)\n" + ), + .tp_methods = nullptr, // Set before PyType_Ready + .tp_getset = nullptr, // Set before PyType_Ready +}; + +} // namespace mcrfpydef diff --git a/src/tiled/TiledParse.cpp b/src/tiled/TiledParse.cpp new file mode 100644 index 0000000..4a9673d --- /dev/null +++ b/src/tiled/TiledParse.cpp @@ -0,0 +1,772 @@ +#include "TiledParse.h" +#include "RapidXML/rapidxml.hpp" +#include +#include +#include +#include +#include + +namespace mcrf { +namespace tiled { + +// ============================================================ +// Utility helpers +// ============================================================ + +static std::string readFile(const std::string& path) { + std::ifstream f(path); + if (!f.is_open()) { + throw std::runtime_error("Cannot open file: " + path); + } + std::stringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +static std::string parentDir(const std::string& path) { + std::filesystem::path p(path); + return p.parent_path().string(); +} + +static std::string resolvePath(const std::string& base_dir, const std::string& relative) { + std::filesystem::path p = std::filesystem::path(base_dir) / relative; + return p.lexically_normal().string(); +} + +static bool endsWith(const std::string& str, const std::string& suffix) { + if (suffix.size() > str.size()) return false; + return str.compare(str.size() - suffix.size(), suffix.size(), suffix) == 0; +} + +// Get attribute value or empty string +static std::string xmlAttr(rapidxml::xml_node<>* node, const char* name) { + auto* attr = node->first_attribute(name); + return attr ? std::string(attr->value(), attr->value_size()) : ""; +} + +static int xmlAttrInt(rapidxml::xml_node<>* node, const char* name, int def = 0) { + auto* attr = node->first_attribute(name); + if (!attr) return def; + return std::stoi(std::string(attr->value(), attr->value_size())); +} + +static float xmlAttrFloat(rapidxml::xml_node<>* node, const char* name, float def = 0.0f) { + auto* attr = node->first_attribute(name); + if (!attr) return def; + return std::stof(std::string(attr->value(), attr->value_size())); +} + +// ============================================================ +// Property conversion (Raw → Final) +// ============================================================ + +static PropertyValue convertProperty(const RawProperty& raw) { + if (raw.type == "bool") { + return PropertyValue(raw.value == "true"); + } else if (raw.type == "int") { + return PropertyValue(std::stoi(raw.value)); + } else if (raw.type == "float") { + return PropertyValue(std::stof(raw.value)); + } else { + // Default: string (includes empty type) + return PropertyValue(raw.value); + } +} + +static std::unordered_map convertProperties( + const std::vector& raw_props) { + std::unordered_map result; + for (const auto& rp : raw_props) { + result[rp.name] = convertProperty(rp); + } + return result; +} + +// ============================================================ +// WangSet packing +// ============================================================ + +uint64_t WangSet::packWangId(const std::array& id) { + // Pack 8 values (each 0-255) into 64-bit integer + // Each value gets 8 bits + uint64_t packed = 0; + for (int i = 0; i < 8; i++) { + packed |= (static_cast(id[i] & 0xFF)) << (i * 8); + } + return packed; +} + +// ============================================================ +// XML property parsing (shared by TSX and TMX) +// ============================================================ + +static void parseXmlProperties(rapidxml::xml_node<>* parent, std::vector& out) { + auto* props_node = parent->first_node("properties"); + if (!props_node) return; + for (auto* prop = props_node->first_node("property"); prop; prop = prop->next_sibling("property")) { + RawProperty rp; + rp.name = xmlAttr(prop, "name"); + rp.type = xmlAttr(prop, "type"); + rp.value = xmlAttr(prop, "value"); + // Some properties have value as node text instead of attribute + if (rp.value.empty() && prop->value_size() > 0) { + rp.value = std::string(prop->value(), prop->value_size()); + } + out.push_back(std::move(rp)); + } +} + +// ============================================================ +// TSX parser (XML tileset) +// ============================================================ + +static RawTileSet parseTSX(const std::string& path) { + std::string text = readFile(path); + rapidxml::xml_document<> doc; + doc.parse<0>(text.data()); + + auto* tileset_node = doc.first_node("tileset"); + if (!tileset_node) { + throw std::runtime_error("No element in: " + path); + } + + RawTileSet raw; + raw.name = xmlAttr(tileset_node, "name"); + raw.tile_width = xmlAttrInt(tileset_node, "tilewidth"); + raw.tile_height = xmlAttrInt(tileset_node, "tileheight"); + raw.tile_count = xmlAttrInt(tileset_node, "tilecount"); + raw.columns = xmlAttrInt(tileset_node, "columns"); + raw.margin = xmlAttrInt(tileset_node, "margin"); + raw.spacing = xmlAttrInt(tileset_node, "spacing"); + + // Image element + auto* image_node = tileset_node->first_node("image"); + if (image_node) { + raw.image_source = xmlAttr(image_node, "source"); + raw.image_width = xmlAttrInt(image_node, "width"); + raw.image_height = xmlAttrInt(image_node, "height"); + } + + // Properties + parseXmlProperties(tileset_node, raw.properties); + + // Tile elements (for per-tile properties and animations) + for (auto* tile = tileset_node->first_node("tile"); tile; tile = tile->next_sibling("tile")) { + RawTile rt; + rt.id = xmlAttrInt(tile, "id"); + parseXmlProperties(tile, rt.properties); + + // Animation frames + auto* anim = tile->first_node("animation"); + if (anim) { + for (auto* frame = anim->first_node("frame"); frame; frame = frame->next_sibling("frame")) { + int tid = xmlAttrInt(frame, "tileid"); + int dur = xmlAttrInt(frame, "duration"); + rt.animation_frames.emplace_back(tid, dur); + } + } + raw.tiles.push_back(std::move(rt)); + } + + // Wang sets + auto* wangsets_node = tileset_node->first_node("wangsets"); + if (wangsets_node) { + for (auto* ws = wangsets_node->first_node("wangset"); ws; ws = ws->next_sibling("wangset")) { + RawWangSet rws; + rws.name = xmlAttr(ws, "name"); + rws.type = xmlAttr(ws, "type"); + + // Wang colors (1-indexed by position in list) + int color_idx = 1; + for (auto* wc = ws->first_node("wangcolor"); wc; wc = wc->next_sibling("wangcolor")) { + RawWangColor rwc; + rwc.name = xmlAttr(wc, "name"); + rwc.color_index = color_idx++; + rwc.tile_id = xmlAttrInt(wc, "tile"); + rwc.probability = xmlAttrFloat(wc, "probability", 1.0f); + rws.colors.push_back(std::move(rwc)); + } + + // Wang tiles + for (auto* wt = ws->first_node("wangtile"); wt; wt = wt->next_sibling("wangtile")) { + RawWangTile rwt; + rwt.tile_id = xmlAttrInt(wt, "tileid"); + // Parse wangid: comma-separated 8 integers + std::string wid_str = xmlAttr(wt, "wangid"); + std::array wid = {}; + std::istringstream iss(wid_str); + std::string token; + int idx = 0; + while (std::getline(iss, token, ',') && idx < 8) { + wid[idx++] = std::stoi(token); + } + rwt.wang_id = wid; + rws.tiles.push_back(std::move(rwt)); + } + + raw.wang_sets.push_back(std::move(rws)); + } + } + + return raw; +} + +// ============================================================ +// TSJ parser (JSON tileset) +// ============================================================ + +static void parseJsonProperties(const nlohmann::json& j, std::vector& out) { + if (!j.contains("properties") || !j["properties"].is_array()) return; + for (const auto& prop : j["properties"]) { + RawProperty rp; + rp.name = prop.value("name", ""); + rp.type = prop.value("type", ""); + // Value can be different JSON types + if (prop.contains("value")) { + const auto& val = prop["value"]; + if (val.is_boolean()) { + rp.type = "bool"; + rp.value = val.get() ? "true" : "false"; + } else if (val.is_number_integer()) { + rp.type = "int"; + rp.value = std::to_string(val.get()); + } else if (val.is_number_float()) { + rp.type = "float"; + rp.value = std::to_string(val.get()); + } else if (val.is_string()) { + rp.value = val.get(); + } + } + out.push_back(std::move(rp)); + } +} + +static RawTileSet parseTSJ(const std::string& path) { + std::string text = readFile(path); + nlohmann::json j = nlohmann::json::parse(text); + + RawTileSet raw; + raw.name = j.value("name", ""); + raw.tile_width = j.value("tilewidth", 0); + raw.tile_height = j.value("tileheight", 0); + raw.tile_count = j.value("tilecount", 0); + raw.columns = j.value("columns", 0); + raw.margin = j.value("margin", 0); + raw.spacing = j.value("spacing", 0); + raw.image_source = j.value("image", ""); + raw.image_width = j.value("imagewidth", 0); + raw.image_height = j.value("imageheight", 0); + + parseJsonProperties(j, raw.properties); + + // Tiles + if (j.contains("tiles") && j["tiles"].is_array()) { + for (const auto& tile : j["tiles"]) { + RawTile rt; + rt.id = tile.value("id", 0); + parseJsonProperties(tile, rt.properties); + if (tile.contains("animation") && tile["animation"].is_array()) { + for (const auto& frame : tile["animation"]) { + int tid = frame.value("tileid", 0); + int dur = frame.value("duration", 0); + rt.animation_frames.emplace_back(tid, dur); + } + } + raw.tiles.push_back(std::move(rt)); + } + } + + // Wang sets + if (j.contains("wangsets") && j["wangsets"].is_array()) { + for (const auto& ws : j["wangsets"]) { + RawWangSet rws; + rws.name = ws.value("name", ""); + rws.type = ws.value("type", ""); + + if (ws.contains("colors") && ws["colors"].is_array()) { + int ci = 1; // Tiled wang colors are 1-indexed + for (const auto& wc : ws["colors"]) { + RawWangColor rwc; + rwc.name = wc.value("name", ""); + rwc.color_index = ci++; + rwc.tile_id = wc.value("tile", -1); + rwc.probability = wc.value("probability", 1.0f); + rws.colors.push_back(std::move(rwc)); + } + } + + if (ws.contains("wangtiles") && ws["wangtiles"].is_array()) { + for (const auto& wt : ws["wangtiles"]) { + RawWangTile rwt; + rwt.tile_id = wt.value("tileid", 0); + std::array wid = {}; + if (wt.contains("wangid") && wt["wangid"].is_array()) { + for (int i = 0; i < 8 && i < (int)wt["wangid"].size(); i++) { + wid[i] = wt["wangid"][i].get(); + } + } + rwt.wang_id = wid; + rws.tiles.push_back(std::move(rwt)); + } + } + + raw.wang_sets.push_back(std::move(rws)); + } + } + + return raw; +} + +// ============================================================ +// Builder: RawTileSet → TileSetData +// ============================================================ + +static std::shared_ptr buildTileSet(const RawTileSet& raw, const std::string& source_path) { + auto ts = std::make_shared(); + ts->source_path = source_path; + ts->name = raw.name; + ts->tile_width = raw.tile_width; + ts->tile_height = raw.tile_height; + ts->tile_count = raw.tile_count; + ts->columns = raw.columns; + ts->margin = raw.margin; + ts->spacing = raw.spacing; + ts->image_width = raw.image_width; + ts->image_height = raw.image_height; + + // Resolve image path relative to tileset file + std::string base_dir = parentDir(source_path); + ts->image_source = resolvePath(base_dir, raw.image_source); + + // Convert properties + ts->properties = convertProperties(raw.properties); + + // Convert tile info + for (const auto& rt : raw.tiles) { + TileInfo ti; + ti.id = rt.id; + ti.properties = convertProperties(rt.properties); + for (const auto& [tid, dur] : rt.animation_frames) { + ti.animation.push_back({tid, dur}); + } + ts->tile_info[ti.id] = std::move(ti); + } + + // Convert wang sets + for (const auto& rws : raw.wang_sets) { + WangSet ws; + ws.name = rws.name; + if (rws.type == "corner") ws.type = WangSetType::Corner; + else if (rws.type == "edge") ws.type = WangSetType::Edge; + else ws.type = WangSetType::Mixed; + + for (const auto& rwc : rws.colors) { + WangColor wc; + wc.name = rwc.name; + wc.index = rwc.color_index; + wc.tile_id = rwc.tile_id; + wc.probability = rwc.probability; + ws.colors.push_back(std::move(wc)); + } + + // Build lookup table + for (const auto& rwt : rws.tiles) { + uint64_t key = WangSet::packWangId(rwt.wang_id); + ws.wang_lookup[key] = rwt.tile_id; + } + + ts->wang_sets.push_back(std::move(ws)); + } + + return ts; +} + +// ============================================================ +// TMX parser (XML tilemap) +// ============================================================ + +static RawTileMap parseTMX(const std::string& path) { + std::string text = readFile(path); + rapidxml::xml_document<> doc; + doc.parse<0>(text.data()); + + auto* map_node = doc.first_node("map"); + if (!map_node) { + throw std::runtime_error("No element in: " + path); + } + + RawTileMap raw; + raw.width = xmlAttrInt(map_node, "width"); + raw.height = xmlAttrInt(map_node, "height"); + raw.tile_width = xmlAttrInt(map_node, "tilewidth"); + raw.tile_height = xmlAttrInt(map_node, "tileheight"); + raw.orientation = xmlAttr(map_node, "orientation"); + + parseXmlProperties(map_node, raw.properties); + + // Tileset references + for (auto* ts = map_node->first_node("tileset"); ts; ts = ts->next_sibling("tileset")) { + RawTileSetRef ref; + ref.firstgid = xmlAttrInt(ts, "firstgid"); + ref.source = xmlAttr(ts, "source"); + raw.tileset_refs.push_back(std::move(ref)); + } + + // Layers + for (auto* child = map_node->first_node(); child; child = child->next_sibling()) { + std::string node_name(child->name(), child->name_size()); + + if (node_name == "layer") { + RawLayer layer; + layer.name = xmlAttr(child, "name"); + layer.type = "tilelayer"; + layer.width = xmlAttrInt(child, "width"); + layer.height = xmlAttrInt(child, "height"); + std::string vis = xmlAttr(child, "visible"); + layer.visible = vis.empty() || vis != "0"; + layer.opacity = xmlAttrFloat(child, "opacity", 1.0f); + parseXmlProperties(child, layer.properties); + + // Parse CSV tile data + auto* data_node = child->first_node("data"); + if (data_node) { + std::string encoding = xmlAttr(data_node, "encoding"); + if (!encoding.empty() && encoding != "csv") { + throw std::runtime_error("Unsupported tile data encoding: " + encoding + + " (only CSV supported). File: " + path); + } + std::string csv(data_node->value(), data_node->value_size()); + std::istringstream iss(csv); + std::string token; + while (std::getline(iss, token, ',')) { + // Trim whitespace + auto start = token.find_first_not_of(" \t\r\n"); + if (start == std::string::npos) continue; + auto end = token.find_last_not_of(" \t\r\n"); + token = token.substr(start, end - start + 1); + if (!token.empty()) { + layer.tile_data.push_back(static_cast(std::stoul(token))); + } + } + } + + raw.layers.push_back(std::move(layer)); + } + else if (node_name == "objectgroup") { + RawLayer layer; + layer.name = xmlAttr(child, "name"); + layer.type = "objectgroup"; + std::string vis = xmlAttr(child, "visible"); + layer.visible = vis.empty() || vis != "0"; + layer.opacity = xmlAttrFloat(child, "opacity", 1.0f); + parseXmlProperties(child, layer.properties); + + // Convert XML objects to JSON for uniform Python interface + nlohmann::json objects_arr = nlohmann::json::array(); + for (auto* obj = child->first_node("object"); obj; obj = obj->next_sibling("object")) { + nlohmann::json obj_json; + std::string id_str = xmlAttr(obj, "id"); + if (!id_str.empty()) obj_json["id"] = std::stoi(id_str); + std::string name = xmlAttr(obj, "name"); + if (!name.empty()) obj_json["name"] = name; + std::string type = xmlAttr(obj, "type"); + if (!type.empty()) obj_json["type"] = type; + std::string x_str = xmlAttr(obj, "x"); + if (!x_str.empty()) obj_json["x"] = std::stof(x_str); + std::string y_str = xmlAttr(obj, "y"); + if (!y_str.empty()) obj_json["y"] = std::stof(y_str); + std::string w_str = xmlAttr(obj, "width"); + if (!w_str.empty()) obj_json["width"] = std::stof(w_str); + std::string h_str = xmlAttr(obj, "height"); + if (!h_str.empty()) obj_json["height"] = std::stof(h_str); + std::string rot_str = xmlAttr(obj, "rotation"); + if (!rot_str.empty()) obj_json["rotation"] = std::stof(rot_str); + std::string visible_str = xmlAttr(obj, "visible"); + if (!visible_str.empty()) obj_json["visible"] = (visible_str != "0"); + + // Object properties + std::vector obj_props; + parseXmlProperties(obj, obj_props); + if (!obj_props.empty()) { + nlohmann::json props_json; + for (const auto& rp : obj_props) { + if (rp.type == "bool") props_json[rp.name] = (rp.value == "true"); + else if (rp.type == "int") props_json[rp.name] = std::stoi(rp.value); + else if (rp.type == "float") props_json[rp.name] = std::stof(rp.value); + else props_json[rp.name] = rp.value; + } + obj_json["properties"] = props_json; + } + + // Check for point/ellipse/polygon sub-elements + if (obj->first_node("point")) { + obj_json["point"] = true; + } + if (obj->first_node("ellipse")) { + obj_json["ellipse"] = true; + } + auto* polygon_node = obj->first_node("polygon"); + if (polygon_node) { + std::string points_str = xmlAttr(polygon_node, "points"); + nlohmann::json points_arr = nlohmann::json::array(); + std::istringstream pss(points_str); + std::string pt; + while (pss >> pt) { + auto comma = pt.find(','); + if (comma != std::string::npos) { + nlohmann::json point; + point["x"] = std::stof(pt.substr(0, comma)); + point["y"] = std::stof(pt.substr(comma + 1)); + points_arr.push_back(point); + } + } + obj_json["polygon"] = points_arr; + } + + objects_arr.push_back(std::move(obj_json)); + } + layer.objects_json = objects_arr; + + raw.layers.push_back(std::move(layer)); + } + } + + return raw; +} + +// ============================================================ +// TMJ parser (JSON tilemap) +// ============================================================ + +static RawTileMap parseTMJ(const std::string& path) { + std::string text = readFile(path); + nlohmann::json j = nlohmann::json::parse(text); + + RawTileMap raw; + raw.width = j.value("width", 0); + raw.height = j.value("height", 0); + raw.tile_width = j.value("tilewidth", 0); + raw.tile_height = j.value("tileheight", 0); + raw.orientation = j.value("orientation", "orthogonal"); + + parseJsonProperties(j, raw.properties); + + // Tileset references + if (j.contains("tilesets") && j["tilesets"].is_array()) { + for (const auto& ts : j["tilesets"]) { + RawTileSetRef ref; + ref.firstgid = ts.value("firstgid", 0); + ref.source = ts.value("source", ""); + raw.tileset_refs.push_back(std::move(ref)); + } + } + + // Layers + if (j.contains("layers") && j["layers"].is_array()) { + for (const auto& layer_json : j["layers"]) { + RawLayer layer; + layer.name = layer_json.value("name", ""); + layer.type = layer_json.value("type", ""); + layer.width = layer_json.value("width", 0); + layer.height = layer_json.value("height", 0); + layer.visible = layer_json.value("visible", true); + layer.opacity = layer_json.value("opacity", 1.0f); + + parseJsonProperties(layer_json, layer.properties); + + if (layer.type == "tilelayer") { + if (layer_json.contains("data") && layer_json["data"].is_array()) { + for (const auto& val : layer_json["data"]) { + layer.tile_data.push_back(val.get()); + } + } + } + else if (layer.type == "objectgroup") { + if (layer_json.contains("objects")) { + layer.objects_json = layer_json["objects"]; + } + } + + raw.layers.push_back(std::move(layer)); + } + } + + return raw; +} + +// ============================================================ +// Builder: RawTileMap → TileMapData +// ============================================================ + +static std::shared_ptr buildTileMap(const RawTileMap& raw, const std::string& source_path) { + auto tm = std::make_shared(); + tm->source_path = source_path; + tm->width = raw.width; + tm->height = raw.height; + tm->tile_width = raw.tile_width; + tm->tile_height = raw.tile_height; + tm->orientation = raw.orientation; + tm->properties = convertProperties(raw.properties); + + // Load referenced tilesets + std::string base_dir = parentDir(source_path); + for (const auto& ref : raw.tileset_refs) { + TileMapData::TileSetRef ts_ref; + ts_ref.firstgid = ref.firstgid; + std::string ts_path = resolvePath(base_dir, ref.source); + ts_ref.tileset = loadTileSet(ts_path); + tm->tilesets.push_back(std::move(ts_ref)); + } + + // Separate tile layers from object layers + for (const auto& rl : raw.layers) { + if (rl.type == "tilelayer") { + TileLayerData tld; + tld.name = rl.name; + tld.width = rl.width; + tld.height = rl.height; + tld.visible = rl.visible; + tld.opacity = rl.opacity; + tld.global_gids = rl.tile_data; + tm->tile_layers.push_back(std::move(tld)); + } + else if (rl.type == "objectgroup") { + ObjectLayerData old; + old.name = rl.name; + old.visible = rl.visible; + old.opacity = rl.opacity; + old.objects = rl.objects_json; + old.properties = convertProperties(rl.properties); + tm->object_layers.push_back(std::move(old)); + } + } + + return tm; +} + +// ============================================================ +// Public API: auto-detect and load +// ============================================================ + +std::shared_ptr loadTileSet(const std::string& path) { + std::string abs_path = std::filesystem::absolute(path).string(); + RawTileSet raw; + if (endsWith(abs_path, ".tsx")) { + raw = parseTSX(abs_path); + } else if (endsWith(abs_path, ".tsj") || endsWith(abs_path, ".json")) { + raw = parseTSJ(abs_path); + } else { + throw std::runtime_error("Unknown tileset format (expected .tsx or .tsj): " + path); + } + return buildTileSet(raw, abs_path); +} + +std::shared_ptr loadTileMap(const std::string& path) { + std::string abs_path = std::filesystem::absolute(path).string(); + RawTileMap raw; + if (endsWith(abs_path, ".tmx")) { + raw = parseTMX(abs_path); + } else if (endsWith(abs_path, ".tmj") || endsWith(abs_path, ".json")) { + raw = parseTMJ(abs_path); + } else { + throw std::runtime_error("Unknown tilemap format (expected .tmx or .tmj): " + path); + } + return buildTileMap(raw, abs_path); +} + +// ============================================================ +// JSON → Python conversion (for object layers) +// ============================================================ + +PyObject* jsonToPython(const nlohmann::json& j) { + if (j.is_null()) { + Py_RETURN_NONE; + } + if (j.is_boolean()) { + return PyBool_FromLong(j.get()); + } + if (j.is_number_integer()) { + return PyLong_FromLongLong(j.get()); + } + if (j.is_number_float()) { + return PyFloat_FromDouble(j.get()); + } + if (j.is_string()) { + const std::string& s = j.get_ref(); + return PyUnicode_FromStringAndSize(s.c_str(), s.size()); + } + if (j.is_array()) { + PyObject* list = PyList_New(j.size()); + if (!list) return NULL; + for (size_t i = 0; i < j.size(); i++) { + PyObject* item = jsonToPython(j[i]); + if (!item) { + Py_DECREF(list); + return NULL; + } + PyList_SET_ITEM(list, i, item); // steals ref + } + return list; + } + if (j.is_object()) { + PyObject* dict = PyDict_New(); + if (!dict) return NULL; + for (auto it = j.begin(); it != j.end(); ++it) { + PyObject* val = jsonToPython(it.value()); + if (!val) { + Py_DECREF(dict); + return NULL; + } + if (PyDict_SetItemString(dict, it.key().c_str(), val) < 0) { + Py_DECREF(val); + Py_DECREF(dict); + return NULL; + } + Py_DECREF(val); + } + return dict; + } + Py_RETURN_NONE; +} + +// ============================================================ +// PropertyValue → Python conversion +// ============================================================ + +PyObject* propertyValueToPython(const PropertyValue& val) { + return std::visit([](auto&& arg) -> PyObject* { + using T = std::decay_t; + if constexpr (std::is_same_v) { + return PyBool_FromLong(arg); + } else if constexpr (std::is_same_v) { + return PyLong_FromLong(arg); + } else if constexpr (std::is_same_v) { + return PyFloat_FromDouble(arg); + } else if constexpr (std::is_same_v) { + return PyUnicode_FromStringAndSize(arg.c_str(), arg.size()); + } + Py_RETURN_NONE; + }, val); +} + +PyObject* propertiesToPython(const std::unordered_map& props) { + PyObject* dict = PyDict_New(); + if (!dict) return NULL; + for (const auto& [key, val] : props) { + PyObject* py_val = propertyValueToPython(val); + if (!py_val) { + Py_DECREF(dict); + return NULL; + } + if (PyDict_SetItemString(dict, key.c_str(), py_val) < 0) { + Py_DECREF(py_val); + Py_DECREF(dict); + return NULL; + } + Py_DECREF(py_val); + } + return dict; +} + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/TiledParse.h b/src/tiled/TiledParse.h new file mode 100644 index 0000000..02233f4 --- /dev/null +++ b/src/tiled/TiledParse.h @@ -0,0 +1,24 @@ +#pragma once +#include "TiledTypes.h" +#include + +namespace mcrf { +namespace tiled { + +// Load a tileset from .tsx or .tsj (auto-detect by extension) +std::shared_ptr loadTileSet(const std::string& path); + +// Load a tilemap from .tmx or .tmj (auto-detect by extension) +std::shared_ptr loadTileMap(const std::string& path); + +// Convert nlohmann::json to Python object (for object layers) +PyObject* jsonToPython(const nlohmann::json& j); + +// Convert PropertyValue to Python object +PyObject* propertyValueToPython(const PropertyValue& val); + +// Convert a properties map to Python dict +PyObject* propertiesToPython(const std::unordered_map& props); + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/TiledTypes.h b/src/tiled/TiledTypes.h new file mode 100644 index 0000000..0c30fbf --- /dev/null +++ b/src/tiled/TiledTypes.h @@ -0,0 +1,186 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include + +namespace mcrf { +namespace tiled { + +// ============================================================ +// Raw intermediate structs — populated by thin XML/JSON parsers +// ============================================================ + +struct RawProperty { + std::string name; + std::string type; // "bool", "int", "float", "string" (or empty = string) + std::string value; +}; + +struct RawTile { + int id; + std::vector properties; + std::vector> animation_frames; // (tile_id, duration_ms) +}; + +struct RawWangColor { + std::string name; + int color_index; + int tile_id; + float probability; +}; + +struct RawWangTile { + int tile_id; + std::array wang_id; +}; + +struct RawWangSet { + std::string name; + std::string type; // "corner", "edge", "mixed" + std::vector colors; + std::vector tiles; +}; + +struct RawTileSet { + std::string name; + std::string image_source; + int tile_width = 0; + int tile_height = 0; + int tile_count = 0; + int columns = 0; + int margin = 0; + int spacing = 0; + int image_width = 0; + int image_height = 0; + std::vector properties; + std::vector tiles; + std::vector wang_sets; +}; + +struct RawTileSetRef { + int firstgid; + std::string source; +}; + +struct RawLayer { + std::string name; + std::string type; // "tilelayer", "objectgroup" + int width = 0; + int height = 0; + bool visible = true; + float opacity = 1.0f; + std::vector properties; + std::vector tile_data; + nlohmann::json objects_json; +}; + +struct RawTileMap { + int width = 0; + int height = 0; + int tile_width = 0; + int tile_height = 0; + std::string orientation; // "orthogonal", etc. + std::vector properties; + std::vector tileset_refs; + std::vector layers; +}; + +// ============================================================ +// Final (built) types — what Python bindings expose +// ============================================================ + +using PropertyValue = std::variant; + +struct KeyFrame { + int tile_id; + int duration_ms; +}; + +struct TileInfo { + int id; + std::unordered_map properties; + std::vector animation; +}; + +enum class WangSetType { + Corner, + Edge, + Mixed +}; + +struct WangColor { + std::string name; + int index; + int tile_id; + float probability; +}; + +struct WangSet { + std::string name; + WangSetType type; + std::vector colors; + // Maps packed wang_id → tile_id for O(1) lookup + std::unordered_map wang_lookup; + + static uint64_t packWangId(const std::array& id); +}; + +struct TileSetData { + std::string name; + std::string source_path; // Filesystem path of the .tsx/.tsj file + std::string image_source; // Resolved path to image file + int tile_width = 0; + int tile_height = 0; + int tile_count = 0; + int columns = 0; + int margin = 0; + int spacing = 0; + int image_width = 0; + int image_height = 0; + std::unordered_map properties; + std::unordered_map tile_info; + std::vector wang_sets; +}; + +struct TileLayerData { + std::string name; + int width = 0; + int height = 0; + bool visible = true; + float opacity = 1.0f; + std::vector global_gids; +}; + +struct ObjectLayerData { + std::string name; + bool visible = true; + float opacity = 1.0f; + nlohmann::json objects; + std::unordered_map properties; +}; + +struct TileMapData { + std::string source_path; // Filesystem path of the .tmx/.tmj file + int width = 0; + int height = 0; + int tile_width = 0; + int tile_height = 0; + std::string orientation; + std::unordered_map properties; + + struct TileSetRef { + int firstgid; + std::shared_ptr tileset; + }; + std::vector tilesets; + std::vector tile_layers; + std::vector object_layers; +}; + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/WangResolve.cpp b/src/tiled/WangResolve.cpp new file mode 100644 index 0000000..5970502 --- /dev/null +++ b/src/tiled/WangResolve.cpp @@ -0,0 +1,142 @@ +#include "WangResolve.h" +#include + +namespace mcrf { +namespace tiled { + +// Helper: get terrain at (x, y), return 0 for out-of-bounds +static inline int getTerrain(const uint8_t* data, int w, int h, int x, int y) { + if (x < 0 || x >= w || y < 0 || y >= h) return 0; + return data[y * w + x]; +} + +// For corner wang sets: each corner is at the junction of 4 cells. +// The corner terrain is the max index among those cells (standard Tiled convention: +// higher-index terrain "wins" at shared corners). +static inline int cornerTerrain(int a, int b, int c, int d) { + int m = a; + if (b > m) m = b; + if (c > m) m = c; + if (d > m) m = d; + return m; +} + +std::vector resolveWangTerrain( + const uint8_t* terrain_data, int width, int height, + const WangSet& wang_set) +{ + std::vector result(width * height, -1); + + if (wang_set.type == WangSetType::Corner) { + // Corner set: wangid layout is [top, TR, right, BR, bottom, BL, left, TL] + // For corner sets, only even indices matter: [_, TR, _, BR, _, BL, _, TL] + // i.e. indices 1, 3, 5, 7 + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + // Top-left corner: junction of (x-1,y-1), (x,y-1), (x-1,y), (x,y) + int tl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y-1), + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y)); + + // Top-right corner: junction of (x,y-1), (x+1,y-1), (x,y), (x+1,y) + int tr = cornerTerrain( + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x+1, y-1), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y)); + + // Bottom-right corner: junction of (x,y), (x+1,y), (x,y+1), (x+1,y+1) + int br = cornerTerrain( + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y), + getTerrain(terrain_data, width, height, x, y+1), + getTerrain(terrain_data, width, height, x+1, y+1)); + + // Bottom-left corner: junction of (x-1,y), (x,y), (x-1,y+1), (x,y+1) + int bl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x-1, y+1), + getTerrain(terrain_data, width, height, x, y+1)); + + // Pack: [0, TR, 0, BR, 0, BL, 0, TL] + std::array wid = {0, tr, 0, br, 0, bl, 0, tl}; + uint64_t key = WangSet::packWangId(wid); + + auto it = wang_set.wang_lookup.find(key); + if (it != wang_set.wang_lookup.end()) { + result[y * width + x] = it->second; + } + } + } + } + else if (wang_set.type == WangSetType::Edge) { + // Edge set: wangid layout is [top, TR, right, BR, bottom, BL, left, TL] + // For edge sets, only even indices matter: [top, _, right, _, bottom, _, left, _] + // i.e. indices 0, 2, 4, 6 + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int top = getTerrain(terrain_data, width, height, x, y-1); + int right = getTerrain(terrain_data, width, height, x+1, y); + int bottom = getTerrain(terrain_data, width, height, x, y+1); + int left = getTerrain(terrain_data, width, height, x-1, y); + + // Pack: [top, 0, right, 0, bottom, 0, left, 0] + std::array wid = {top, 0, right, 0, bottom, 0, left, 0}; + uint64_t key = WangSet::packWangId(wid); + + auto it = wang_set.wang_lookup.find(key); + if (it != wang_set.wang_lookup.end()) { + result[y * width + x] = it->second; + } + } + } + } + else { + // Mixed: use all 8 values (both edges and corners) + for (int y = 0; y < height; y++) { + for (int x = 0; x < width; x++) { + int top = getTerrain(terrain_data, width, height, x, y-1); + int right = getTerrain(terrain_data, width, height, x+1, y); + int bottom = getTerrain(terrain_data, width, height, x, y+1); + int left = getTerrain(terrain_data, width, height, x-1, y); + + int tl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y-1), + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y)); + int tr = cornerTerrain( + getTerrain(terrain_data, width, height, x, y-1), + getTerrain(terrain_data, width, height, x+1, y-1), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y)); + int br = cornerTerrain( + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x+1, y), + getTerrain(terrain_data, width, height, x, y+1), + getTerrain(terrain_data, width, height, x+1, y+1)); + int bl = cornerTerrain( + getTerrain(terrain_data, width, height, x-1, y), + getTerrain(terrain_data, width, height, x, y), + getTerrain(terrain_data, width, height, x-1, y+1), + getTerrain(terrain_data, width, height, x, y+1)); + + std::array wid = {top, tr, right, br, bottom, bl, left, tl}; + uint64_t key = WangSet::packWangId(wid); + + auto it = wang_set.wang_lookup.find(key); + if (it != wang_set.wang_lookup.end()) { + result[y * width + x] = it->second; + } + } + } + } + + return result; +} + +} // namespace tiled +} // namespace mcrf diff --git a/src/tiled/WangResolve.h b/src/tiled/WangResolve.h new file mode 100644 index 0000000..b2aad82 --- /dev/null +++ b/src/tiled/WangResolve.h @@ -0,0 +1,17 @@ +#pragma once +#include "TiledTypes.h" +#include +#include + +namespace mcrf { +namespace tiled { + +// Resolve terrain data (from DiscreteMap) to tile indices using a WangSet. +// Returns a vector of tile IDs (one per cell). -1 means no matching tile found. +// terrain_data: row-major uint8 array, width*height elements +std::vector resolveWangTerrain( + const uint8_t* terrain_data, int width, int height, + const WangSet& wang_set); + +} // namespace tiled +} // namespace mcrf diff --git a/tests/demo/screens/tiled_analysis.py b/tests/demo/screens/tiled_analysis.py new file mode 100644 index 0000000..3bc9e09 --- /dev/null +++ b/tests/demo/screens/tiled_analysis.py @@ -0,0 +1,167 @@ +# tiled_analysis.py - Wang set adjacency analysis utility +# Prints adjacency graph, terrain chains, and valid/invalid pair counts +# for exploring tileset Wang transition rules. +# +# Usage: +# cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_analysis.py + +import mcrfpy +import sys +from collections import defaultdict + +# -- Configuration -------------------------------------------------------- +PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1" +TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx" +WANG_SET_NAME = "overworld" + + +def analyze_wang_set(tileset, wang_set_name): + """Analyze a Wang set and print adjacency information.""" + ws = tileset.wang_set(wang_set_name) + T = ws.terrain_enum() + + print("=" * 60) + print(f"Wang Set Analysis: {ws.name}") + print(f" Type: {ws.type}") + print(f" Colors: {ws.color_count}") + print("=" * 60) + + # List all terrains + terrains = [t for t in T if t != T.NONE] + print(f"\nTerrains ({len(terrains)}):") + for t in terrains: + print(f" {t.value:3d}: {t.name}") + + # Test all pairs for valid Wang transitions using 2x2 grids + adjacency = defaultdict(set) # terrain -> set of valid neighbors + valid_pairs = [] + invalid_pairs = [] + + for a in terrains: + for b in terrains: + if b.value <= a.value: + continue + # Create a 2x2 map: columns of A and B + dm = mcrfpy.DiscreteMap((2, 2)) + dm.set(0, 0, a) + dm.set(1, 0, b) + dm.set(0, 1, a) + dm.set(1, 1, b) + results = ws.resolve(dm) + has_invalid = any(r == -1 for r in results) + if not has_invalid: + valid_pairs.append((a, b)) + adjacency[a.name].add(b.name) + adjacency[b.name].add(a.name) + else: + invalid_pairs.append((a, b)) + + # Print adjacency graph + print(f"\nAdjacency Graph ({len(valid_pairs)} valid pairs):") + print("-" * 40) + for t in terrains: + neighbors = sorted(adjacency.get(t.name, set())) + if neighbors: + print(f" {t.name}") + for n in neighbors: + print(f" <-> {n}") + else: + print(f" {t.name} (ISOLATED - no valid neighbors)") + + # Find terrain chains (connected components) + print(f"\nTerrain Chains (connected components):") + print("-" * 40) + visited = set() + chains = [] + + def bfs(start): + chain = [] + queue = [start] + while queue: + node = queue.pop(0) + if node in visited: + continue + visited.add(node) + chain.append(node) + for neighbor in sorted(adjacency.get(node, set())): + if neighbor not in visited: + queue.append(neighbor) + return chain + + for t in terrains: + if t.name not in visited: + chain = bfs(t.name) + if chain: + chains.append(chain) + + for i, chain in enumerate(chains): + print(f"\n Chain {i+1}: {len(chain)} terrains") + for name in chain: + neighbors = sorted(adjacency.get(name, set())) + connections = ", ".join(neighbors) if neighbors else "(none)" + print(f" {name} -> [{connections}]") + + # Find linear paths within chains + print(f"\nLinear Paths (degree-1 endpoints to degree-1 endpoints):") + print("-" * 40) + for chain in chains: + # Find nodes with degree 1 (endpoints) or degree > 2 (hubs) + endpoints = [n for n in chain if len(adjacency.get(n, set())) == 1] + hubs = [n for n in chain if len(adjacency.get(n, set())) > 2] + + if endpoints: + print(f" Endpoints: {', '.join(endpoints)}") + if hubs: + print(f" Hubs: {', '.join(hubs)} (branch points)") + + # Trace from each endpoint + for ep in endpoints: + path = [ep] + current = ep + prev = None + while True: + neighbors = adjacency.get(current, set()) - {prev} if prev else adjacency.get(current, set()) + if len(neighbors) == 0: + break + if len(neighbors) > 1: + path.append(f"({current} branches)") + break + nxt = list(neighbors)[0] + path.append(nxt) + prev = current + current = nxt + if len(adjacency.get(current, set())) != 2: + break # reached endpoint or hub + print(f" Path: {' -> '.join(path)}") + + # Summary statistics + total_possible = len(terrains) * (len(terrains) - 1) // 2 + print(f"\nSummary:") + print(f" Total terrain types: {len(terrains)}") + print(f" Valid transitions: {len(valid_pairs)} / {total_possible} " + f"({100*len(valid_pairs)/total_possible:.1f}%)") + print(f" Invalid transitions: {len(invalid_pairs)}") + print(f" Connected components: {len(chains)}") + + # Print invalid pairs for reference + if invalid_pairs: + print(f"\nInvalid Pairs ({len(invalid_pairs)}):") + for a, b in invalid_pairs: + print(f" {a.name} X {b.name}") + + return valid_pairs, invalid_pairs, chains + + +def main(): + print("Loading tileset...") + tileset = mcrfpy.TileSetFile(TSX_PATH) + print(f" {tileset.name}: {tileset.tile_count} tiles " + f"({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)") + + analyze_wang_set(tileset, WANG_SET_NAME) + print("\nDone!") + + +if __name__ == "__main__": + main() + sys.exit(0) diff --git a/tests/demo/screens/tiled_demo.py b/tests/demo/screens/tiled_demo.py new file mode 100644 index 0000000..e59eb48 --- /dev/null +++ b/tests/demo/screens/tiled_demo.py @@ -0,0 +1,504 @@ +# tiled_demo.py - Visual demo of Tiled integration +# Shows premade maps, Wang auto-tiling, and procgen terrain +# +# Usage: +# Headless: cd build && ./mcrogueface --headless --exec ../tests/demo/screens/tiled_demo.py +# Interactive: cd build && ./mcrogueface --exec ../tests/demo/screens/tiled_demo.py + +import mcrfpy +from mcrfpy import automation +import sys + +# -- Asset Paths ------------------------------------------------------- +PUNY_BASE = "/home/john/Development/7DRL2026_Liber_Noster_jmccardle/assets_sources/PUNY_WORLD_v1/PUNY_WORLD_v1" +TSX_PATH = PUNY_BASE + "/Tiled/punyworld-overworld-tiles.tsx" + +# -- Load Shared Assets ------------------------------------------------ +print("Loading Puny World tileset...") +tileset = mcrfpy.TileSetFile(TSX_PATH) +texture = tileset.to_texture() +overworld_ws = tileset.wang_set("overworld") +Terrain = overworld_ws.terrain_enum() + +print(f" Tileset: {tileset.name}") +print(f" Tiles: {tileset.tile_count} ({tileset.columns} cols, {tileset.tile_width}x{tileset.tile_height}px)") +print(f" Wang set: {overworld_ws.name} ({overworld_ws.type}, {overworld_ws.color_count} colors)") +print(f" Terrain enum members: {[t.name for t in Terrain]}") + +# -- Helper: Iterative terrain expansion ---------------------------------- +def iterative_terrain(hm, wang_set, width, height, passes): + """Build a DiscreteMap by iteratively splitting terrains outward from + a valid binary map. Each pass splits one terrain on each end of the + chain, validates with wang_set.resolve(), and reverts invalid cells + to their previous value. + + hm: HeightMap (normalized 0-1) + passes: list of (threshold, lo_old, lo_new, hi_old, hi_new) tuples. + Each pass says: cells currently == lo_old with height < threshold + become lo_new; cells currently == hi_old with height >= threshold + become hi_new. + + Returns (DiscreteMap, stats_dict). + """ + dm = mcrfpy.DiscreteMap((width, height)) + + # Pass 0: binary split - everything is one of two terrains + p0 = passes[0] + thresh, lo_terrain, hi_terrain = p0 + for y in range(height): + for x in range(width): + if hm.get(x, y) < thresh: + dm.set(x, y, int(lo_terrain)) + else: + dm.set(x, y, int(hi_terrain)) + + # Validate pass 0 and fix any invalid cells (rare edge cases like + # checkerboard patterns at the binary boundary) + results = wang_set.resolve(dm) + inv = sum(1 for r in results if r == -1) + if inv > 0: + # Fix by flipping invalid cells to the other terrain + for y in range(height): + for x in range(width): + if results[y * width + x] == -1: + val = dm.get(x, y) + if val == int(lo_terrain): + dm.set(x, y, int(hi_terrain)) + else: + dm.set(x, y, int(lo_terrain)) + results2 = wang_set.resolve(dm) + inv = sum(1 for r in results2 if r == -1) + stats = {"pass0_invalid": inv} + + # Subsequent passes: split outward + for pi, (thresh, lo_old, lo_new, hi_old, hi_new) in enumerate(passes[1:], 1): + # Save current state so we can revert invalid cells + prev = [dm.get(x, y) for y in range(height) for x in range(width)] + + # Track which cells were changed this pass + changed = set() + for y in range(height): + for x in range(width): + val = dm.get(x, y) + h = hm.get(x, y) + if val == int(lo_old) and h < thresh: + dm.set(x, y, int(lo_new)) + changed.add((x, y)) + elif val == int(hi_old) and h >= thresh: + dm.set(x, y, int(hi_new)) + changed.add((x, y)) + + # Iteratively revert changed cells that cause invalid tiles. + # A changed cell should be reverted if: + # - It is itself invalid, OR + # - It is a neighbor of an invalid UN-changed cell (it broke + # a pre-existing valid cell by being placed next to it) + dirs8 = [(-1,-1),(0,-1),(1,-1),(-1,0),(1,0),(-1,1),(0,1),(1,1)] + total_reverted = 0 + for revert_round in range(30): + results = wang_set.resolve(dm) + to_revert = set() + for y in range(height): + for x in range(width): + if results[y * width + x] != -1: + continue + if (x, y) in changed: + # This changed cell is invalid - revert it + to_revert.add((x, y)) + else: + # Pre-existing cell is now invalid - revert its + # changed neighbors to restore it + for dx, dy in dirs8: + nx, ny = x+dx, y+dy + if (nx, ny) in changed: + to_revert.add((nx, ny)) + + if not to_revert: + break + + for (x, y) in to_revert: + dm.set(x, y, prev[y * width + x]) + changed.discard((x, y)) + total_reverted += 1 + + results_final = wang_set.resolve(dm) + remaining = sum(1 for r in results_final if r == -1) + stats[f"pass{pi}_kept"] = len(changed) + stats[f"pass{pi}_reverted"] = total_reverted + stats[f"pass{pi}_remaining"] = remaining + + return dm, stats + + +# -- Helper: Info Panel ------------------------------------------------- +def make_info_panel(scene, lines, x=560, y=60, w=220, h=None): + """Create a semi-transparent info panel with text lines.""" + if h is None: + h = len(lines) * 22 + 20 + panel = mcrfpy.Frame(pos=(x, y), size=(w, h), + fill_color=mcrfpy.Color(20, 20, 30, 220), + outline_color=mcrfpy.Color(80, 80, 120), + outline=1.5) + scene.children.append(panel) + for i, text in enumerate(lines): + cap = mcrfpy.Caption(text=text, pos=(10, 10 + i * 22)) + cap.fill_color = mcrfpy.Color(200, 200, 220) + panel.children.append(cap) + return panel + + +# ====================================================================== +# SCREEN 1: Premade Tiled Map +# ====================================================================== +print("\nSetting up Screen 1: Premade Map...") +scene1 = mcrfpy.Scene("tiled_premade") + +bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) +scene1.children.append(bg1) + +title1 = mcrfpy.Caption(text="Premade Tiled Map (50x50, 3 layers)", pos=(20, 10)) +title1.fill_color = mcrfpy.Color(255, 255, 255) +scene1.children.append(title1) + +# Load samplemap1 +tm1 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap1.tmj") +print(f" Map: {tm1.width}x{tm1.height}, layers: {tm1.tile_layer_names}") + +grid1 = mcrfpy.Grid(grid_size=(tm1.width, tm1.height), + pos=(20, 50), size=(520, 520), layers=[]) +grid1.fill_color = mcrfpy.Color(30, 30, 50) + +# Add a tile layer for each map layer, bottom-up z ordering +layer_names_1 = tm1.tile_layer_names +for i, name in enumerate(layer_names_1): + z = -(len(layer_names_1) - i) + layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture) + grid1.add_layer(layer) + tm1.apply_to_tile_layer(layer, name, tileset_index=0) + print(f" Applied layer '{name}' (z_index={z})") + +# Center camera on map center (pixels = tiles * tile_size) +grid1.center = (tm1.width * tileset.tile_width // 2, + tm1.height * tileset.tile_height // 2) +scene1.children.append(grid1) + +make_info_panel(scene1, [ + f"Tileset: {tileset.name}", + f"Tile size: {tileset.tile_width}x{tileset.tile_height}", + f"Tile count: {tileset.tile_count}", + f"Map size: {tm1.width}x{tm1.height}", + "", + "Layers:", +] + [f" {name}" for name in layer_names_1] + [ + "", + "Wang sets:", + f" {overworld_ws.name} ({overworld_ws.type})", + f" pathways (edge)", +]) + +nav1 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) +nav1.fill_color = mcrfpy.Color(120, 120, 150) +scene1.children.append(nav1) + + +# ====================================================================== +# SCREEN 2: Procedural Wang Auto-Tile (2-layer approach) +# ====================================================================== +print("\nSetting up Screen 2: Procgen Wang Terrain (2-layer)...") +scene2 = mcrfpy.Scene("tiled_procgen") + +bg2 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) +scene2.children.append(bg2) + +title2 = mcrfpy.Caption(text="Procgen Wang Auto-Tile (60x60, 2 layers)", pos=(20, 10)) +title2.fill_color = mcrfpy.Color(255, 255, 255) +scene2.children.append(title2) + +W, H = 60, 60 +T = Terrain # shorthand + +# Generate terrain heightmap using NoiseSource +noise = mcrfpy.NoiseSource(dimensions=2, seed=42) +hm = noise.sample(size=(W, H), mode="fbm", octaves=4, world_size=(4.0, 4.0)) +hm.normalize(0.0, 1.0) + +# -- Base terrain: iterative expansion from binary map -- +# Pass 0: binary split at median -> SEAWATER_LIGHT / SAND +# Pass 1: split outward -> SEAWATER_MEDIUM from LIGHT, GRASS from SAND +# Pass 2: split outward -> SEAWATER_DEEP from MEDIUM, CLIFF from GRASS +base_passes = [ + # Pass 0: (threshold, lo_terrain, hi_terrain) + (0.45, T.SEAWATER_LIGHT, T.SAND), + # Pass 1+: (threshold, lo_old, lo_new, hi_old, hi_new) + (0.30, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS), + (0.20, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF), +] +base_dm, base_stats = iterative_terrain(hm, overworld_ws, W, H, base_passes) +base_dm.enum_type = T +print(f" Base terrain stats: {base_stats}") + +# -- Tree overlay: separate noise, binary TREES/AIR -- +tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=999) +tree_hm = tree_noise.sample(size=(W, H), mode="fbm", octaves=3, world_size=(6.0, 6.0)) +tree_hm.normalize(0.0, 1.0) + +overlay_dm = mcrfpy.DiscreteMap((W, H)) +overlay_dm.enum_type = T +for y in range(H): + for x in range(W): + base_val = base_dm.get(x, y) + tree_h = tree_hm.get(x, y) + # Trees only on GRASS, driven by separate noise + if base_val == int(T.GRASS) and tree_h > 0.45: + overlay_dm.set(x, y, int(T.TREES)) + else: + overlay_dm.set(x, y, int(T.AIR)) + +# Validate overlay and revert invalid to AIR +overlay_results = overworld_ws.resolve(overlay_dm) +overlay_reverted = 0 +for y in range(H): + for x in range(W): + if overlay_results[y * W + x] == -1: + overlay_dm.set(x, y, int(T.AIR)) + overlay_reverted += 1 +print(f" Overlay: {overlay_reverted} tree cells reverted to AIR") + +# Count terrain distribution +terrain_counts = {} +for t in T: + if t == T.NONE: + continue + c = base_dm.count(int(t)) + if c > 0: + terrain_counts[t.name] = c +tree_count = overlay_dm.count(int(T.TREES)) +terrain_counts["TREES(overlay)"] = tree_count + +print(f" Terrain distribution: {terrain_counts}") + +# Create grid with 2 layers and apply Wang auto-tiling +grid2 = mcrfpy.Grid(grid_size=(W, H), pos=(20, 50), size=(520, 520), layers=[]) +grid2.fill_color = mcrfpy.Color(30, 30, 50) + +base_layer2 = mcrfpy.TileLayer(name="base", z_index=-2, texture=texture) +grid2.add_layer(base_layer2) +overworld_ws.apply(base_dm, base_layer2) + +overlay_layer2 = mcrfpy.TileLayer(name="trees", z_index=-1, texture=texture) +grid2.add_layer(overlay_layer2) +overworld_ws.apply(overlay_dm, overlay_layer2) + +# Post-process overlay: AIR resolves to an opaque tile, set to -1 (transparent) +for y in range(H): + for x in range(W): + if overlay_dm.get(x, y) == int(T.AIR): + overlay_layer2.set((x, y), -1) + +grid2.center = (W * tileset.tile_width // 2, H * tileset.tile_height // 2) +scene2.children.append(grid2) + +# Info panel +info_lines = [ + "Iterative terrain expansion", + f"Seed: 42 (base), 999 (trees)", + f"Grid: {W}x{H}, 2 layers", + "", + "Base (3 passes):", +] +for name in ["SEAWATER_DEEP", "SEAWATER_MEDIUM", "SEAWATER_LIGHT", + "SAND", "GRASS", "CLIFF"]: + count = terrain_counts.get(name, 0) + info_lines.append(f" {name}: {count}") +info_lines.append("") +info_lines.append("Tree Overlay:") +info_lines.append(f" TREES: {tree_count}") +info_lines.append(f" reverted: {overlay_reverted}") + +make_info_panel(scene2, info_lines) + +nav2 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) +nav2.fill_color = mcrfpy.Color(120, 120, 150) +scene2.children.append(nav2) + + +# ====================================================================== +# SCREEN 3: Side-by-Side Comparison +# ====================================================================== +print("\nSetting up Screen 3: Side-by-Side...") +scene3 = mcrfpy.Scene("tiled_compare") + +bg3 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(10, 10, 15)) +scene3.children.append(bg3) + +title3 = mcrfpy.Caption(text="Premade vs Procedural", pos=(20, 10)) +title3.fill_color = mcrfpy.Color(255, 255, 255) +scene3.children.append(title3) + +# Left: Premade map (samplemap2, 30x30) +tm2 = mcrfpy.TileMapFile(PUNY_BASE + "/Tiled/samplemap2.tmj") +print(f" Map2: {tm2.width}x{tm2.height}, layers: {tm2.tile_layer_names}") + +left_label = mcrfpy.Caption(text="Premade (samplemap2)", pos=(20, 38)) +left_label.fill_color = mcrfpy.Color(180, 220, 255) +scene3.children.append(left_label) + +grid_left = mcrfpy.Grid(grid_size=(tm2.width, tm2.height), + pos=(20, 60), size=(380, 380), layers=[]) +grid_left.fill_color = mcrfpy.Color(30, 30, 50) + +for i, name in enumerate(tm2.tile_layer_names): + z = -(len(tm2.tile_layer_names) - i) + layer = mcrfpy.TileLayer(name=name, z_index=z, texture=texture) + grid_left.add_layer(layer) + tm2.apply_to_tile_layer(layer, name, tileset_index=0) + +grid_left.center = (tm2.width * tileset.tile_width // 2, + tm2.height * tileset.tile_height // 2) +scene3.children.append(grid_left) + +# Right: Procgen island +right_label = mcrfpy.Caption(text="Procgen Island (2-layer Wang)", pos=(420, 38)) +right_label.fill_color = mcrfpy.Color(180, 255, 220) +scene3.children.append(right_label) + +IW, IH = 30, 30 +island_noise = mcrfpy.NoiseSource(dimensions=2, seed=7777) +island_hm = island_noise.sample(size=(IW, IH), mode="fbm", octaves=3, world_size=(3.0, 3.0)) +island_hm.normalize(0.0, 1.0) + +# Create island shape: attenuate edges with radial gradient +for y in range(IH): + for x in range(IW): + dx = (x - IW / 2.0) / (IW / 2.0) + dy = (y - IH / 2.0) / (IH / 2.0) + dist = (dx * dx + dy * dy) ** 0.5 + falloff = max(0.0, 1.0 - dist * 1.2) + h = island_hm.get(x, y) * falloff + island_hm[x, y] = h + +island_hm.normalize(0.0, 1.0) + +# Iterative base terrain expansion (same technique as Screen 2) +island_passes_def = [ + (0.40, T.SEAWATER_LIGHT, T.SAND), + (0.25, T.SEAWATER_LIGHT, T.SEAWATER_MEDIUM, T.SAND, T.GRASS), + (0.15, T.SEAWATER_MEDIUM, T.SEAWATER_DEEP, T.GRASS, T.CLIFF), +] +island_base_dm, island_stats = iterative_terrain( + island_hm, overworld_ws, IW, IH, island_passes_def) +island_base_dm.enum_type = T +print(f" Island base stats: {island_stats}") + +# Tree overlay with separate noise +island_tree_noise = mcrfpy.NoiseSource(dimensions=2, seed=8888) +island_tree_hm = island_tree_noise.sample( + size=(IW, IH), mode="fbm", octaves=3, world_size=(4.0, 4.0)) +island_tree_hm.normalize(0.0, 1.0) + +island_overlay_dm = mcrfpy.DiscreteMap((IW, IH)) +island_overlay_dm.enum_type = T +for y in range(IH): + for x in range(IW): + base_val = island_base_dm.get(x, y) + tree_h = island_tree_hm.get(x, y) + if base_val == int(T.GRASS) and tree_h > 0.50: + island_overlay_dm.set(x, y, int(T.TREES)) + else: + island_overlay_dm.set(x, y, int(T.AIR)) + +# Validate overlay +island_ov_results = overworld_ws.resolve(island_overlay_dm) +for y in range(IH): + for x in range(IW): + if island_ov_results[y * IW + x] == -1: + island_overlay_dm.set(x, y, int(T.AIR)) + +grid_right = mcrfpy.Grid(grid_size=(IW, IH), + pos=(420, 60), size=(380, 380), layers=[]) +grid_right.fill_color = mcrfpy.Color(30, 30, 50) + +island_base_layer = mcrfpy.TileLayer(name="island_base", z_index=-2, texture=texture) +grid_right.add_layer(island_base_layer) +overworld_ws.apply(island_base_dm, island_base_layer) + +island_overlay_layer = mcrfpy.TileLayer(name="island_trees", z_index=-1, texture=texture) +grid_right.add_layer(island_overlay_layer) +overworld_ws.apply(island_overlay_dm, island_overlay_layer) + +# Post-process: make AIR cells transparent +for y in range(IH): + for x in range(IW): + if island_overlay_dm.get(x, y) == int(T.AIR): + island_overlay_layer.set((x, y), -1) + +grid_right.center = (IW * tileset.tile_width // 2, IH * tileset.tile_height // 2) +scene3.children.append(grid_right) + +# Info for both +make_info_panel(scene3, [ + "Left: Premade Map", + f" samplemap2.tmj", + f" {tm2.width}x{tm2.height}, {len(tm2.tile_layer_names)} layers", + "", + "Right: Procgen Island", + f" {IW}x{IH}, seed=7777", + " Iterative terrain expansion", + " 2-layer Wang auto-tile", + "", + "Same tileset, same engine", + "Different workflows", +], x=200, y=460, w=400, h=None) + +nav3 = mcrfpy.Caption(text="[1] Premade [2] Procgen [3] Side-by-Side [ESC] Quit", pos=(20, 740)) +nav3.fill_color = mcrfpy.Color(120, 120, 150) +scene3.children.append(nav3) + + +# ====================================================================== +# Navigation & Screenshots +# ====================================================================== +scenes = [scene1, scene2, scene3] +scene_names = ["premade", "procgen", "compare"] + +# Keyboard navigation (all scenes share the same handler) +def on_key(key, action): + if action != mcrfpy.InputState.PRESSED: + return + if key == mcrfpy.Key.NUM_1: + mcrfpy.current_scene = scene1 + elif key == mcrfpy.Key.NUM_2: + mcrfpy.current_scene = scene2 + elif key == mcrfpy.Key.NUM_3: + mcrfpy.current_scene = scene3 + elif key == mcrfpy.Key.ESCAPE: + mcrfpy.exit() + +for s in scenes: + s.on_key = on_key + +# Detect headless mode and take screenshots synchronously +is_headless = False +try: + win = mcrfpy.Window.get() + is_headless = "headless" in str(win).lower() +except: + is_headless = True + +if is_headless: + # Headless: use step() to advance simulation and take screenshots directly + for i, (sc, name) in enumerate(zip(scenes, scene_names)): + mcrfpy.current_scene = sc + # Step a few frames to let the scene render + for _ in range(3): + mcrfpy.step(0.016) + fname = f"tiled_demo_{name}.png" + automation.screenshot(fname) + print(f" Screenshot: {fname}") + print("\nAll screenshots captured. Done!") + sys.exit(0) +else: + # Interactive: start on screen 1 + mcrfpy.current_scene = scene1 + print("\nTiled Demo ready!") + print("Press [1] [2] [3] to switch screens, [ESC] to quit") diff --git a/tests/unit/wang_resolve_test.py b/tests/unit/wang_resolve_test.py new file mode 100644 index 0000000..21107a3 --- /dev/null +++ b/tests/unit/wang_resolve_test.py @@ -0,0 +1,153 @@ +"""Unit tests for WangSet terrain_enum, resolve, and apply""" +import mcrfpy +import sys + +PASS_COUNT = 0 +FAIL_COUNT = 0 + +def check(condition, msg): + global PASS_COUNT, FAIL_COUNT + if condition: + PASS_COUNT += 1 + print(f" PASS: {msg}") + else: + FAIL_COUNT += 1 + print(f" FAIL: {msg}") + +def test_terrain_enum(): + """Test IntEnum generation from WangSet colors""" + print("=== Terrain Enum ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + Terrain = ws.terrain_enum() + + check(Terrain is not None, "terrain_enum() returns something") + check(hasattr(Terrain, "NONE"), "has NONE member") + check(hasattr(Terrain, "GRASS"), "has GRASS member") + check(hasattr(Terrain, "DIRT"), "has DIRT member") + check(int(Terrain.NONE) == 0, f"NONE = {int(Terrain.NONE)}") + check(int(Terrain.GRASS) == 1, f"GRASS = {int(Terrain.GRASS)}") + check(int(Terrain.DIRT) == 2, f"DIRT = {int(Terrain.DIRT)}") + + # Check it's an IntEnum + import enum + check(issubclass(Terrain, enum.IntEnum), "is IntEnum subclass") + return Terrain + +def test_enum_with_discrete_map(Terrain): + """Test that terrain enum is compatible with DiscreteMap""" + print("\n=== Enum + DiscreteMap ===") + dm = mcrfpy.DiscreteMap((4, 4)) + dm.enum_type = Terrain + check(dm.enum_type == Terrain, "DiscreteMap accepts terrain enum") + + # Set values using enum + dm.set(0, 0, Terrain.GRASS) + dm.set(1, 0, Terrain.DIRT) + val = dm.get(0, 0) + check(int(val) == int(Terrain.GRASS), f"get(0,0) = {val}") + val = dm.get(1, 0) + check(int(val) == int(Terrain.DIRT), f"get(1,0) = {val}") + +def test_resolve_uniform(): + """Test resolve with uniform terrain""" + print("\n=== Resolve Uniform ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # All grass (terrain ID 1) + dm = mcrfpy.DiscreteMap((3, 3)) + dm.fill(1) # All grass + + tiles = ws.resolve(dm) + check(isinstance(tiles, list), f"resolve returns list: {type(tiles)}") + check(len(tiles) == 9, f"resolve length = {len(tiles)}") + + # All cells should map to the "all grass corners" tile (id=0) + # wangid [0,1,0,1,0,1,0,1] = tile 0 + # Note: border cells will see 0 (NONE) on their outer edges, so may not match + # Center cell (1,1) sees all grass neighbors -> should be tile 0 + center = tiles[4] # (1,1) in 3x3 + check(center == 0, f"center tile (uniform grass) = {center}") + +def test_resolve_mixed(): + """Test resolve with mixed terrain""" + print("\n=== Resolve Mixed ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # Create a 3x3 grid: grass everywhere except center = dirt + dm = mcrfpy.DiscreteMap((3, 3)) + dm.fill(1) # All grass + dm.set(1, 1, 2) # Center = dirt + + tiles = ws.resolve(dm) + check(len(tiles) == 9, f"resolve length = {len(tiles)}") + + # The center cell has grass neighbors and is dirt itself + # Corners depend on the max of surrounding cells + center = tiles[4] + # Center: all 4 corners should be max(dirt, grass neighbors) = 2 (dirt) + # wangid [0,2,0,2,0,2,0,2] = tile 1 (all-dirt) + check(center == 1, f"center (dirt surrounded by grass) = {center}") + +def test_resolve_returns_negative_for_unknown(): + """Test that unknown wangid combinations return -1""" + print("\n=== Unknown WangID ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # Use terrain ID 3 which doesn't exist in the wang set + dm = mcrfpy.DiscreteMap((2, 2)) + dm.fill(3) # Terrain 3 not in wang set + + tiles = ws.resolve(dm) + # All should be -1 since terrain 3 has no matching wangid + all_neg = all(t == -1 for t in tiles) + check(all_neg, f"all tiles = -1 for unknown terrain: {tiles}") + +def test_resolve_border_handling(): + """Test that border cells handle out-of-bounds correctly""" + print("\n=== Border Handling ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + + # 1x1 grid - all neighbors are out-of-bounds (0) + dm = mcrfpy.DiscreteMap((1, 1)) + dm.set(0, 0, 1) # Single grass cell + + tiles = ws.resolve(dm) + check(len(tiles) == 1, f"1x1 resolve length = {len(tiles)}") + # Corner terrain: max(0, 0, 0, grass) = 1 for each corner -> all grass + # wangid [0,1,0,1,0,1,0,1] = tile 0 + check(tiles[0] == 0, f"1x1 grass tile = {tiles[0]}") + +def test_wang_set_repr(): + """Test WangSet repr""" + print("\n=== WangSet Repr ===") + ts = mcrfpy.TileSetFile("../tests/assets/tiled/test_tileset.tsx") + ws = ts.wang_set("terrain") + r = repr(ws) + check("WangSet" in r, f"repr contains 'WangSet': {r}") + check("terrain" in r, f"repr contains name: {r}") + check("corner" in r, f"repr contains type: {r}") + +def main(): + Terrain = test_terrain_enum() + test_enum_with_discrete_map(Terrain) + test_resolve_uniform() + test_resolve_mixed() + test_resolve_returns_negative_for_unknown() + test_resolve_border_handling() + test_wang_set_repr() + + print(f"\n{'='*40}") + print(f"Results: {PASS_COUNT} passed, {FAIL_COUNT} failed") + if FAIL_COUNT > 0: + sys.exit(1) + else: + print("ALL TESTS PASSED") + sys.exit(0) + +if __name__ == "__main__": + main()