Tiled XML/JSON import support

This commit is contained in:
John McCardle 2026-02-06 21:43:03 -05:00
commit b093e087e1
18 changed files with 3040 additions and 0 deletions

6
.gitmodules vendored
View file

@ -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

View file

@ -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)

1
modules/RapidXML Submodule

@ -0,0 +1 @@
Subproject commit 3a42082084509e9efb58dcef17b1ad5860dab6ac

1
modules/json Submodule

@ -0,0 +1 @@
Subproject commit 21b53746c9d73d314d5de454e2e7cddd20cbbe5d

332
src/tiled/PyTileMapFile.cpp Normal file
View file

@ -0,0 +1,332 @@
#include "PyTileMapFile.h"
#include "PyTileSetFile.h"
#include "TiledParse.h"
#include "McRFPy_Doc.h"
#include "GridLayers.h"
#include <cstring>
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<TileMapData>();
}
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<char**>(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("<TileMapFile (uninitialized)>");
}
return PyUnicode_FromFormat("<TileMapFile %dx%d, %d tilesets, %d tile layers, %d object layers>",
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<TileSetData>(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<char**>(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<int>(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}
};

81
src/tiled/PyTileMapFile.h Normal file
View file

@ -0,0 +1,81 @@
#pragma once
#include "Python.h"
#include "TiledTypes.h"
#include <memory>
// Python object structure
typedef struct PyTileMapFileObject {
PyObject_HEAD
std::shared_ptr<mcrf::tiled::TileMapData> 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

234
src/tiled/PyTileSetFile.cpp Normal file
View file

@ -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<TileSetData>();
}
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<char**>(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("<TileSetFile (uninitialized)>");
}
return PyUnicode_FromFormat("<TileSetFile '%s' (%d tiles, %dx%d)>",
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<int>(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<int>(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}
};

79
src/tiled/PyTileSetFile.h Normal file
View file

@ -0,0 +1,79 @@
#pragma once
#include "Python.h"
#include "TiledTypes.h"
#include <memory>
// Python object structure
typedef struct PyTileSetFileObject {
PyObject_HEAD
std::shared_ptr<mcrf::tiled::TileSetData> 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

266
src/tiled/PyWangSet.cpp Normal file
View file

@ -0,0 +1,266 @@
#include "PyWangSet.h"
#include "TiledParse.h"
#include "WangResolve.h"
#include "McRFPy_Doc.h"
#include "PyDiscreteMap.h"
#include "GridLayers.h"
#include <cstring>
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<TileSetData> 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<TileSetData>(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("<WangSet '%s' type='%s' colors=%d>",
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<char>(toupper(static_cast<unsigned char>(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<int> 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<char**>(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<int> 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}
};

72
src/tiled/PyWangSet.h Normal file
View file

@ -0,0 +1,72 @@
#pragma once
#include "Python.h"
#include "TiledTypes.h"
#include <memory>
// 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<mcrf::tiled::TileSetData> 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<mcrf::tiled::TileSetData> 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

772
src/tiled/TiledParse.cpp Normal file
View file

@ -0,0 +1,772 @@
#include "TiledParse.h"
#include "RapidXML/rapidxml.hpp"
#include <fstream>
#include <sstream>
#include <stdexcept>
#include <algorithm>
#include <filesystem>
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<std::string, PropertyValue> convertProperties(
const std::vector<RawProperty>& raw_props) {
std::unordered_map<std::string, PropertyValue> result;
for (const auto& rp : raw_props) {
result[rp.name] = convertProperty(rp);
}
return result;
}
// ============================================================
// WangSet packing
// ============================================================
uint64_t WangSet::packWangId(const std::array<int, 8>& 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<uint64_t>(id[i] & 0xFF)) << (i * 8);
}
return packed;
}
// ============================================================
// XML property parsing (shared by TSX and TMX)
// ============================================================
static void parseXmlProperties(rapidxml::xml_node<>* parent, std::vector<RawProperty>& 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 <tileset> 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<int, 8> 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<RawProperty>& 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<bool>() ? "true" : "false";
} else if (val.is_number_integer()) {
rp.type = "int";
rp.value = std::to_string(val.get<int>());
} else if (val.is_number_float()) {
rp.type = "float";
rp.value = std::to_string(val.get<float>());
} else if (val.is_string()) {
rp.value = val.get<std::string>();
}
}
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<int, 8> 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<int>();
}
}
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<TileSetData> buildTileSet(const RawTileSet& raw, const std::string& source_path) {
auto ts = std::make_shared<TileSetData>();
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 <map> 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<uint32_t>(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<RawProperty> 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<uint32_t>());
}
}
}
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<TileMapData> buildTileMap(const RawTileMap& raw, const std::string& source_path) {
auto tm = std::make_shared<TileMapData>();
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<TileSetData> 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<TileMapData> 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<bool>());
}
if (j.is_number_integer()) {
return PyLong_FromLongLong(j.get<long long>());
}
if (j.is_number_float()) {
return PyFloat_FromDouble(j.get<double>());
}
if (j.is_string()) {
const std::string& s = j.get_ref<const std::string&>();
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<decltype(arg)>;
if constexpr (std::is_same_v<T, bool>) {
return PyBool_FromLong(arg);
} else if constexpr (std::is_same_v<T, int>) {
return PyLong_FromLong(arg);
} else if constexpr (std::is_same_v<T, float>) {
return PyFloat_FromDouble(arg);
} else if constexpr (std::is_same_v<T, std::string>) {
return PyUnicode_FromStringAndSize(arg.c_str(), arg.size());
}
Py_RETURN_NONE;
}, val);
}
PyObject* propertiesToPython(const std::unordered_map<std::string, PropertyValue>& 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

24
src/tiled/TiledParse.h Normal file
View file

@ -0,0 +1,24 @@
#pragma once
#include "TiledTypes.h"
#include <Python.h>
namespace mcrf {
namespace tiled {
// Load a tileset from .tsx or .tsj (auto-detect by extension)
std::shared_ptr<TileSetData> loadTileSet(const std::string& path);
// Load a tilemap from .tmx or .tmj (auto-detect by extension)
std::shared_ptr<TileMapData> 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<std::string, PropertyValue>& props);
} // namespace tiled
} // namespace mcrf

186
src/tiled/TiledTypes.h Normal file
View file

@ -0,0 +1,186 @@
#pragma once
#include <string>
#include <vector>
#include <array>
#include <unordered_map>
#include <variant>
#include <memory>
#include <cstdint>
#include <nlohmann/json.hpp>
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<RawProperty> properties;
std::vector<std::pair<int, int>> 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<int, 8> wang_id;
};
struct RawWangSet {
std::string name;
std::string type; // "corner", "edge", "mixed"
std::vector<RawWangColor> colors;
std::vector<RawWangTile> 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<RawProperty> properties;
std::vector<RawTile> tiles;
std::vector<RawWangSet> 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<RawProperty> properties;
std::vector<uint32_t> 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<RawProperty> properties;
std::vector<RawTileSetRef> tileset_refs;
std::vector<RawLayer> layers;
};
// ============================================================
// Final (built) types — what Python bindings expose
// ============================================================
using PropertyValue = std::variant<bool, int, float, std::string>;
struct KeyFrame {
int tile_id;
int duration_ms;
};
struct TileInfo {
int id;
std::unordered_map<std::string, PropertyValue> properties;
std::vector<KeyFrame> 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<WangColor> colors;
// Maps packed wang_id → tile_id for O(1) lookup
std::unordered_map<uint64_t, int> wang_lookup;
static uint64_t packWangId(const std::array<int, 8>& 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<std::string, PropertyValue> properties;
std::unordered_map<int, TileInfo> tile_info;
std::vector<WangSet> wang_sets;
};
struct TileLayerData {
std::string name;
int width = 0;
int height = 0;
bool visible = true;
float opacity = 1.0f;
std::vector<uint32_t> global_gids;
};
struct ObjectLayerData {
std::string name;
bool visible = true;
float opacity = 1.0f;
nlohmann::json objects;
std::unordered_map<std::string, PropertyValue> 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<std::string, PropertyValue> properties;
struct TileSetRef {
int firstgid;
std::shared_ptr<TileSetData> tileset;
};
std::vector<TileSetRef> tilesets;
std::vector<TileLayerData> tile_layers;
std::vector<ObjectLayerData> object_layers;
};
} // namespace tiled
} // namespace mcrf

142
src/tiled/WangResolve.cpp Normal file
View file

@ -0,0 +1,142 @@
#include "WangResolve.h"
#include <array>
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<int> resolveWangTerrain(
const uint8_t* terrain_data, int width, int height,
const WangSet& wang_set)
{
std::vector<int> 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<int, 8> 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<int, 8> 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<int, 8> 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

17
src/tiled/WangResolve.h Normal file
View file

@ -0,0 +1,17 @@
#pragma once
#include "TiledTypes.h"
#include <vector>
#include <cstdint>
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<int> resolveWangTerrain(
const uint8_t* terrain_data, int width, int height,
const WangSet& wang_set);
} // namespace tiled
} // namespace mcrf

View file

@ -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)

View file

@ -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")

View file

@ -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()