grid layer API modernization

This commit is contained in:
John McCardle 2026-02-03 19:52:44 -05:00
commit 001cc6efd6
5 changed files with 977 additions and 184 deletions

View file

@ -803,17 +803,23 @@ PyGetSetDef PyGridLayerAPI::ColorLayer_getsetters[] = {
"Whether the layer is rendered.", NULL}, "Whether the layer is rendered.", NULL},
{"grid_size", (getter)PyGridLayerAPI::ColorLayer_get_grid_size, NULL, {"grid_size", (getter)PyGridLayerAPI::ColorLayer_get_grid_size, NULL,
"Layer dimensions as (width, height) tuple.", NULL}, "Layer dimensions as (width, height) tuple.", NULL},
{"name", (getter)PyGridLayerAPI::ColorLayer_get_name, NULL,
"Layer name (str, read-only). Used for Grid.layer(name) lookup.", NULL},
{"grid", (getter)PyGridLayerAPI::ColorLayer_get_grid,
(setter)PyGridLayerAPI::ColorLayer_set_grid,
"Parent Grid or None. Setting manages layer association and handles lazy allocation.", NULL},
{NULL} {NULL}
}; };
int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, PyObject* kwds) { int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"z_index", "grid_size", NULL}; static const char* kwlist[] = {"z_index", "name", "grid_size", NULL};
int z_index = -1; int z_index = -1;
const char* name_str = nullptr;
PyObject* grid_size_obj = nullptr; PyObject* grid_size_obj = nullptr;
int grid_x = 0, grid_y = 0; int grid_x = 0, grid_y = 0;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iO", const_cast<char**>(kwlist), if (!PyArg_ParseTupleAndKeywords(args, kwds, "|izO", const_cast<char**>(kwlist),
&z_index, &grid_size_obj)) { &z_index, &name_str, &grid_size_obj)) {
return -1; return -1;
} }
@ -836,8 +842,11 @@ int PyGridLayerAPI::ColorLayer_init(PyColorLayerObject* self, PyObject* args, Py
} }
} }
// Create the layer (will be attached to grid via add_layer) // Create the layer (will be attached to grid via add_layer or layer.grid setter)
self->data = std::make_shared<ColorLayer>(z_index, grid_x, grid_y, nullptr); self->data = std::make_shared<ColorLayer>(z_index, grid_x, grid_y, nullptr);
if (name_str) {
self->data->name = name_str;
}
self->grid.reset(); self->grid.reset();
return 0; return 0;
@ -1598,12 +1607,137 @@ PyObject* PyGridLayerAPI::ColorLayer_get_grid_size(PyColorLayerObject* self, voi
return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y); return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y);
} }
PyObject* PyGridLayerAPI::ColorLayer_get_name(PyColorLayerObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
return PyUnicode_FromString(self->data->name.c_str());
}
PyObject* PyGridLayerAPI::ColorLayer_get_grid(PyColorLayerObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
// Check actual parent_grid pointer - it may have been cleared by name collision handling
if (!self->data->parent_grid) {
// Sync Python wrapper's state if the C++ layer was unlinked externally
self->grid.reset();
Py_RETURN_NONE;
}
if (!self->grid) {
Py_RETURN_NONE;
}
// Create Python Grid wrapper for the parent grid
auto* grid_type = (PyTypeObject*)PyObject_GetAttrString(
PyImport_ImportModule("mcrfpy"), "Grid");
if (!grid_type) return NULL;
PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
Py_DECREF(grid_type);
if (!py_grid) return NULL;
py_grid->data = self->grid;
py_grid->weakreflist = NULL;
return (PyObject*)py_grid;
}
int PyGridLayerAPI::ColorLayer_set_grid(PyColorLayerObject* self, PyObject* value, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
// Handle None - unlink from current grid
if (value == Py_None || value == NULL) {
if (self->grid) {
self->data->parent_grid = nullptr;
self->grid->removeLayer(self->data);
self->grid.reset();
}
return 0;
}
// Validate it's a Grid
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return -1;
auto* grid_type = PyObject_GetAttrString(mcrfpy_module, "Grid");
Py_DECREF(mcrfpy_module);
if (!grid_type) return -1;
if (!PyObject_IsInstance(value, grid_type)) {
Py_DECREF(grid_type);
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None");
return -1;
}
Py_DECREF(grid_type);
PyUIGridObject* py_grid = (PyUIGridObject*)value;
// Check if already attached to this grid
if (self->grid.get() == py_grid->data.get()) {
return 0; // Nothing to do
}
// Unlink from old grid if any
if (self->grid) {
self->data->parent_grid = nullptr;
self->grid->removeLayer(self->data);
}
// Check for protected names
if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str());
self->grid.reset();
return -1;
}
// Handle name collision - unlink existing layer with same name
if (!self->data->name.empty()) {
auto existing = py_grid->data->getLayerByName(self->data->name);
if (existing && existing.get() != self->data.get()) {
existing->parent_grid = nullptr;
py_grid->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (self->data->grid_x == 0 && self->data->grid_y == 0) {
self->data->resize(py_grid->data->grid_w, py_grid->data->grid_h);
} else if (self->data->grid_x != py_grid->data->grid_w ||
self->data->grid_y != py_grid->data->grid_h) {
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
self->data->grid_x, self->data->grid_y,
py_grid->data->grid_w, py_grid->data->grid_h);
self->grid.reset();
return -1;
}
// Link to new grid
self->data->parent_grid = py_grid->data.get();
py_grid->data->layers.push_back(self->data);
py_grid->data->layers_need_sort = true;
self->grid = py_grid->data;
return 0;
}
PyObject* PyGridLayerAPI::ColorLayer_repr(PyColorLayerObject* self) { PyObject* PyGridLayerAPI::ColorLayer_repr(PyColorLayerObject* self) {
std::ostringstream ss; std::ostringstream ss;
if (!self->data) { if (!self->data) {
ss << "<ColorLayer (invalid)>"; ss << "<ColorLayer (invalid)>";
} else { } else {
ss << "<ColorLayer z_index=" << self->data->z_index ss << "<ColorLayer";
if (!self->data->name.empty()) {
ss << " '" << self->data->name << "'";
}
ss << " z_index=" << self->data->z_index
<< " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")" << " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")"
<< " visible=" << (self->data->visible ? "True" : "False") << ">"; << " visible=" << (self->data->visible ? "True" : "False") << ">";
} }
@ -1679,18 +1813,24 @@ PyGetSetDef PyGridLayerAPI::TileLayer_getsetters[] = {
"Texture atlas for tile sprites.", NULL}, "Texture atlas for tile sprites.", NULL},
{"grid_size", (getter)PyGridLayerAPI::TileLayer_get_grid_size, NULL, {"grid_size", (getter)PyGridLayerAPI::TileLayer_get_grid_size, NULL,
"Layer dimensions as (width, height) tuple.", NULL}, "Layer dimensions as (width, height) tuple.", NULL},
{"name", (getter)PyGridLayerAPI::TileLayer_get_name, NULL,
"Layer name (str, read-only). Used for Grid.layer(name) lookup.", NULL},
{"grid", (getter)PyGridLayerAPI::TileLayer_get_grid,
(setter)PyGridLayerAPI::TileLayer_set_grid,
"Parent Grid or None. Setting manages layer association and handles lazy allocation.", NULL},
{NULL} {NULL}
}; };
int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyObject* kwds) { int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"z_index", "texture", "grid_size", NULL}; static const char* kwlist[] = {"z_index", "name", "texture", "grid_size", NULL};
int z_index = -1; int z_index = -1;
const char* name_str = nullptr;
PyObject* texture_obj = nullptr; PyObject* texture_obj = nullptr;
PyObject* grid_size_obj = nullptr; PyObject* grid_size_obj = nullptr;
int grid_x = 0, grid_y = 0; int grid_x = 0, grid_y = 0;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iOO", const_cast<char**>(kwlist), if (!PyArg_ParseTupleAndKeywords(args, kwds, "|izOO", const_cast<char**>(kwlist),
&z_index, &texture_obj, &grid_size_obj)) { &z_index, &name_str, &texture_obj, &grid_size_obj)) {
return -1; return -1;
} }
@ -1736,6 +1876,9 @@ int PyGridLayerAPI::TileLayer_init(PyTileLayerObject* self, PyObject* args, PyOb
// Create the layer // Create the layer
self->data = std::make_shared<TileLayer>(z_index, grid_x, grid_y, nullptr, texture); self->data = std::make_shared<TileLayer>(z_index, grid_x, grid_y, nullptr, texture);
if (name_str) {
self->data->name = name_str;
}
self->grid.reset(); self->grid.reset();
return 0; return 0;
@ -2099,12 +2242,137 @@ PyObject* PyGridLayerAPI::TileLayer_get_grid_size(PyTileLayerObject* self, void*
return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y); return Py_BuildValue("(ii)", self->data->grid_x, self->data->grid_y);
} }
PyObject* PyGridLayerAPI::TileLayer_get_name(PyTileLayerObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
return PyUnicode_FromString(self->data->name.c_str());
}
PyObject* PyGridLayerAPI::TileLayer_get_grid(PyTileLayerObject* self, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
// Check actual parent_grid pointer - it may have been cleared by name collision handling
if (!self->data->parent_grid) {
// Sync Python wrapper's state if the C++ layer was unlinked externally
self->grid.reset();
Py_RETURN_NONE;
}
if (!self->grid) {
Py_RETURN_NONE;
}
// Create Python Grid wrapper for the parent grid
auto* grid_type = (PyTypeObject*)PyObject_GetAttrString(
PyImport_ImportModule("mcrfpy"), "Grid");
if (!grid_type) return NULL;
PyUIGridObject* py_grid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
Py_DECREF(grid_type);
if (!py_grid) return NULL;
py_grid->data = self->grid;
py_grid->weakreflist = NULL;
return (PyObject*)py_grid;
}
int PyGridLayerAPI::TileLayer_set_grid(PyTileLayerObject* self, PyObject* value, void* closure) {
if (!self->data) {
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
// Handle None - unlink from current grid
if (value == Py_None || value == NULL) {
if (self->grid) {
self->data->parent_grid = nullptr;
self->grid->removeLayer(self->data);
self->grid.reset();
}
return 0;
}
// Validate it's a Grid
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return -1;
auto* grid_type = PyObject_GetAttrString(mcrfpy_module, "Grid");
Py_DECREF(mcrfpy_module);
if (!grid_type) return -1;
if (!PyObject_IsInstance(value, grid_type)) {
Py_DECREF(grid_type);
PyErr_SetString(PyExc_TypeError, "grid must be a Grid object or None");
return -1;
}
Py_DECREF(grid_type);
PyUIGridObject* py_grid = (PyUIGridObject*)value;
// Check if already attached to this grid
if (self->grid.get() == py_grid->data.get()) {
return 0; // Nothing to do
}
// Unlink from old grid if any
if (self->grid) {
self->data->parent_grid = nullptr;
self->grid->removeLayer(self->data);
}
// Check for protected names
if (!self->data->name.empty() && UIGrid::isProtectedLayerName(self->data->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", self->data->name.c_str());
self->grid.reset();
return -1;
}
// Handle name collision - unlink existing layer with same name
if (!self->data->name.empty()) {
auto existing = py_grid->data->getLayerByName(self->data->name);
if (existing && existing.get() != self->data.get()) {
existing->parent_grid = nullptr;
py_grid->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (self->data->grid_x == 0 && self->data->grid_y == 0) {
self->data->resize(py_grid->data->grid_w, py_grid->data->grid_h);
} else if (self->data->grid_x != py_grid->data->grid_w ||
self->data->grid_y != py_grid->data->grid_h) {
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
self->data->grid_x, self->data->grid_y,
py_grid->data->grid_w, py_grid->data->grid_h);
self->grid.reset();
return -1;
}
// Link to new grid
self->data->parent_grid = py_grid->data.get();
py_grid->data->layers.push_back(std::static_pointer_cast<GridLayer>(self->data));
py_grid->data->layers_need_sort = true;
self->grid = py_grid->data;
return 0;
}
PyObject* PyGridLayerAPI::TileLayer_repr(PyTileLayerObject* self) { PyObject* PyGridLayerAPI::TileLayer_repr(PyTileLayerObject* self) {
std::ostringstream ss; std::ostringstream ss;
if (!self->data) { if (!self->data) {
ss << "<TileLayer (invalid)>"; ss << "<TileLayer (invalid)>";
} else { } else {
ss << "<TileLayer z_index=" << self->data->z_index ss << "<TileLayer";
if (!self->data->name.empty()) {
ss << " '" << self->data->name << "'";
}
ss << " z_index=" << self->data->z_index
<< " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")" << " size=(" << self->data->grid_x << "x" << self->data->grid_y << ")"
<< " visible=" << (self->data->visible ? "True" : "False") << " visible=" << (self->data->visible ? "True" : "False")
<< " texture=" << (self->data->texture ? "set" : "None") << ">"; << " texture=" << (self->data->texture ? "set" : "None") << ">";

View file

@ -212,6 +212,9 @@ public:
static PyObject* ColorLayer_get_visible(PyColorLayerObject* self, void* closure); static PyObject* ColorLayer_get_visible(PyColorLayerObject* self, void* closure);
static int ColorLayer_set_visible(PyColorLayerObject* self, PyObject* value, void* closure); static int ColorLayer_set_visible(PyColorLayerObject* self, PyObject* value, void* closure);
static PyObject* ColorLayer_get_grid_size(PyColorLayerObject* self, void* closure); static PyObject* ColorLayer_get_grid_size(PyColorLayerObject* self, void* closure);
static PyObject* ColorLayer_get_name(PyColorLayerObject* self, void* closure);
static PyObject* ColorLayer_get_grid(PyColorLayerObject* self, void* closure);
static int ColorLayer_set_grid(PyColorLayerObject* self, PyObject* value, void* closure);
static PyObject* ColorLayer_repr(PyColorLayerObject* self); static PyObject* ColorLayer_repr(PyColorLayerObject* self);
// TileLayer methods // TileLayer methods
@ -229,6 +232,9 @@ public:
static PyObject* TileLayer_get_texture(PyTileLayerObject* self, void* closure); static PyObject* TileLayer_get_texture(PyTileLayerObject* self, void* closure);
static int TileLayer_set_texture(PyTileLayerObject* self, PyObject* value, void* closure); static int TileLayer_set_texture(PyTileLayerObject* self, PyObject* value, void* closure);
static PyObject* TileLayer_get_grid_size(PyTileLayerObject* self, void* closure); static PyObject* TileLayer_get_grid_size(PyTileLayerObject* self, void* closure);
static PyObject* TileLayer_get_name(PyTileLayerObject* self, void* closure);
static PyObject* TileLayer_get_grid(PyTileLayerObject* self, void* closure);
static int TileLayer_set_grid(PyTileLayerObject* self, PyObject* value, void* closure);
static PyObject* TileLayer_repr(PyTileLayerObject* self); static PyObject* TileLayer_repr(PyTileLayerObject* self);
// Method and getset arrays // Method and getset arrays
@ -253,21 +259,24 @@ namespace mcrfpydef {
}, },
.tp_repr = (reprfunc)PyGridLayerAPI::ColorLayer_repr, .tp_repr = (reprfunc)PyGridLayerAPI::ColorLayer_repr,
.tp_flags = Py_TPFLAGS_DEFAULT, .tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("ColorLayer(z_index=-1, grid_size=None)\n\n" .tp_doc = PyDoc_STR("ColorLayer(z_index=-1, name=None, grid_size=None)\n\n"
"A grid layer that stores RGBA colors per cell for background/overlay effects.\n\n" "A grid layer that stores RGBA colors per cell for background/overlay effects.\n\n"
"ColorLayers are typically created via Grid.add_layer('color', ...) rather than\n" "ColorLayers can be created standalone and attached to a Grid via add_layer()\n"
"instantiated directly. When attached to a Grid, the layer inherits rendering\n" "or passed to the Grid constructor's layers parameter. Layers with size (0, 0)\n"
"parameters and can participate in FOV (field of view) calculations.\n\n" "automatically resize to match the Grid when attached.\n\n"
"Args:\n" "Args:\n"
" z_index (int): Render order relative to entities. Negative values render\n" " z_index (int): Render order relative to entities. Negative values render\n"
" below entities (as backgrounds), positive values render above entities\n" " below entities (as backgrounds), positive values render above entities\n"
" (as overlays). Default: -1 (background)\n" " (as overlays). Default: -1 (background)\n"
" grid_size (tuple): Dimensions as (width, height). If None, the layer will\n" " name (str): Layer name for Grid.layer(name) lookup. Default: None\n"
" inherit the parent Grid's dimensions when attached. Default: None\n\n" " grid_size (tuple): Dimensions as (width, height). If None or (0, 0), the\n"
" layer will auto-resize when attached to a Grid. Default: None\n\n"
"Attributes:\n" "Attributes:\n"
" z_index (int): Layer z-order relative to entities (read/write)\n" " z_index (int): Layer z-order relative to entities (read/write)\n"
" name (str): Layer name for lookup (read-only)\n"
" visible (bool): Whether layer is rendered (read/write)\n" " visible (bool): Whether layer is rendered (read/write)\n"
" grid_size (tuple): Layer dimensions as (width, height) (read-only)\n\n" " grid_size (tuple): Layer dimensions as (width, height) (read-only)\n"
" grid (Grid): Parent Grid or None. Setting manages layer association.\n\n"
"Methods:\n" "Methods:\n"
" at(x, y) -> Color: Get the color at cell position (x, y)\n" " at(x, y) -> Color: Get the color at cell position (x, y)\n"
" set(x, y, color): Set the color at cell position (x, y)\n" " set(x, y, color): Set the color at cell position (x, y)\n"
@ -276,11 +285,10 @@ namespace mcrfpydef {
" draw_fov(...): Draw FOV-based visibility colors\n" " draw_fov(...): Draw FOV-based visibility colors\n"
" apply_perspective(entity, ...): Bind layer to entity for automatic FOV updates\n\n" " apply_perspective(entity, ...): Bind layer to entity for automatic FOV updates\n\n"
"Example:\n" "Example:\n"
" grid = mcrfpy.Grid(grid_size=(20, 15), texture=my_texture,\n" " fog = mcrfpy.ColorLayer(z_index=-1, name='fog')\n"
" pos=(50, 50), size=(640, 480))\n" " grid = mcrfpy.Grid(grid_size=(20, 15), layers=[fog])\n"
" layer = grid.add_layer('color', z_index=-1)\n" " fog.fill(mcrfpy.Color(40, 40, 40)) # Dark gray background\n"
" layer.fill(mcrfpy.Color(40, 40, 40)) # Dark gray background\n" " grid.layer('fog').set(5, 5, mcrfpy.Color(255, 0, 0, 128))"),
" layer.set(5, 5, mcrfpy.Color(255, 0, 0, 128)) # Semi-transparent red cell"),
.tp_methods = PyGridLayerAPI::ColorLayer_methods, .tp_methods = PyGridLayerAPI::ColorLayer_methods,
.tp_getset = PyGridLayerAPI::ColorLayer_getsetters, .tp_getset = PyGridLayerAPI::ColorLayer_getsetters,
.tp_init = (initproc)PyGridLayerAPI::ColorLayer_init, .tp_init = (initproc)PyGridLayerAPI::ColorLayer_init,
@ -304,25 +312,28 @@ namespace mcrfpydef {
}, },
.tp_repr = (reprfunc)PyGridLayerAPI::TileLayer_repr, .tp_repr = (reprfunc)PyGridLayerAPI::TileLayer_repr,
.tp_flags = Py_TPFLAGS_DEFAULT, .tp_flags = Py_TPFLAGS_DEFAULT,
.tp_doc = PyDoc_STR("TileLayer(z_index=-1, texture=None, grid_size=None)\n\n" .tp_doc = PyDoc_STR("TileLayer(z_index=-1, name=None, texture=None, grid_size=None)\n\n"
"A grid layer that stores sprite indices per cell for tile-based rendering.\n\n" "A grid layer that stores sprite indices per cell for tile-based rendering.\n\n"
"TileLayers are typically created via Grid.add_layer('tile', ...) rather than\n" "TileLayers can be created standalone and attached to a Grid via add_layer()\n"
"instantiated directly. Each cell stores an integer index into the layer's\n" "or passed to the Grid constructor's layers parameter. Layers with size (0, 0)\n"
"sprite atlas texture. An index of -1 means no tile (transparent/empty).\n\n" "automatically resize to match the Grid when attached.\n\n"
"Args:\n" "Args:\n"
" z_index (int): Render order relative to entities. Negative values render\n" " z_index (int): Render order relative to entities. Negative values render\n"
" below entities (as backgrounds), positive values render above entities\n" " below entities (as backgrounds), positive values render above entities\n"
" (as overlays). Default: -1 (background)\n" " (as overlays). Default: -1 (background)\n"
" name (str): Layer name for Grid.layer(name) lookup. Default: None\n"
" texture (Texture): Sprite atlas containing tile images. The texture's\n" " texture (Texture): Sprite atlas containing tile images. The texture's\n"
" sprite_size determines individual tile dimensions. Required for\n" " sprite_size determines individual tile dimensions. Required for\n"
" rendering; can be set after creation. Default: None\n" " rendering; can be set after creation. Default: None\n"
" grid_size (tuple): Dimensions as (width, height). If None, the layer will\n" " grid_size (tuple): Dimensions as (width, height). If None or (0, 0), the\n"
" inherit the parent Grid's dimensions when attached. Default: None\n\n" " layer will auto-resize when attached to a Grid. Default: None\n\n"
"Attributes:\n" "Attributes:\n"
" z_index (int): Layer z-order relative to entities (read/write)\n" " z_index (int): Layer z-order relative to entities (read/write)\n"
" name (str): Layer name for lookup (read-only)\n"
" visible (bool): Whether layer is rendered (read/write)\n" " visible (bool): Whether layer is rendered (read/write)\n"
" texture (Texture): Sprite atlas for tile images (read/write)\n" " texture (Texture): Sprite atlas for tile images (read/write)\n"
" grid_size (tuple): Layer dimensions as (width, height) (read-only)\n\n" " grid_size (tuple): Layer dimensions as (width, height) (read-only)\n"
" grid (Grid): Parent Grid or None. Setting manages layer association.\n\n"
"Methods:\n" "Methods:\n"
" at(x, y) -> int: Get the tile index at cell position (x, y)\n" " at(x, y) -> int: Get the tile index at cell position (x, y)\n"
" set(x, y, index): Set the tile index at cell position (x, y)\n" " set(x, y, index): Set the tile index at cell position (x, y)\n"
@ -332,12 +343,10 @@ namespace mcrfpydef {
" -1: No tile (transparent/empty cell)\n" " -1: No tile (transparent/empty cell)\n"
" 0+: Index into the texture's sprite atlas (row-major order)\n\n" " 0+: Index into the texture's sprite atlas (row-major order)\n\n"
"Example:\n" "Example:\n"
" grid = mcrfpy.Grid(grid_size=(20, 15), texture=my_texture,\n" " terrain = mcrfpy.TileLayer(z_index=-2, name='terrain', texture=tileset)\n"
" pos=(50, 50), size=(640, 480))\n" " grid = mcrfpy.Grid(grid_size=(20, 15), layers=[terrain])\n"
" layer = grid.add_layer('tile', z_index=1, texture=overlay_texture)\n" " terrain.fill(0) # Fill with tile index 0\n"
" layer.fill(-1) # Clear layer (all transparent)\n" " grid.layer('terrain').set(5, 5, 42) # Place tile 42 at (5, 5)"),
" layer.set(5, 5, 42) # Place tile index 42 at position (5, 5)\n"
" layer.fill_rect(0, 0, 20, 1, 10) # Top row filled with tile 10"),
.tp_methods = PyGridLayerAPI::TileLayer_methods, .tp_methods = PyGridLayerAPI::TileLayer_methods,
.tp_getset = PyGridLayerAPI::TileLayer_getsetters, .tp_getset = PyGridLayerAPI::TileLayer_getsetters,
.tp_init = (initproc)PyGridLayerAPI::TileLayer_init, .tp_init = (initproc)PyGridLayerAPI::TileLayer_init,

View file

@ -924,51 +924,185 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
self->data->click_register(click_handler); self->data->click_register(click_handler);
} }
// #150 - Handle layers dict // #150 - Handle layers parameter
// Default: {"tilesprite": "tile"} when layers not provided // Default: single TileLayer named "tilesprite" when layers not provided
// Empty dict: no rendering layers (entity storage + pathfinding only) // Empty list/None: no rendering layers (entity storage + pathfinding only)
// List of layer objects: add each layer with lazy allocation
if (layers_obj == nullptr) { if (layers_obj == nullptr) {
// Default layer: single TileLayer named "tilesprite" (z_index -1 = below entities) // Default layer: single TileLayer named "tilesprite" (z_index -1 = below entities)
self->data->addTileLayer(-1, texture_ptr, "tilesprite"); self->data->addTileLayer(-1, texture_ptr, "tilesprite");
} else if (layers_obj != Py_None) { } else if (layers_obj != Py_None) {
if (!PyDict_Check(layers_obj)) { // Accept any iterable of layer objects
PyErr_SetString(PyExc_TypeError, "layers must be a dict mapping names to types ('color' or 'tile')"); PyObject* iterator = PyObject_GetIter(layers_obj);
if (!iterator) {
PyErr_SetString(PyExc_TypeError, "layers must be an iterable of ColorLayer or TileLayer objects");
return -1; return -1;
} }
PyObject* key; auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
PyObject* value; if (!mcrfpy_module) {
Py_ssize_t pos = 0; Py_DECREF(iterator);
int layer_z = -1; // Start at -1 (below entities), decrement for each layer
while (PyDict_Next(layers_obj, &pos, &key, &value)) {
if (!PyUnicode_Check(key)) {
PyErr_SetString(PyExc_TypeError, "Layer names must be strings");
return -1;
}
if (!PyUnicode_Check(value)) {
PyErr_SetString(PyExc_TypeError, "Layer types must be strings ('color' or 'tile')");
return -1; return -1;
} }
const char* layer_name = PyUnicode_AsUTF8(key); auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer");
const char* layer_type = PyUnicode_AsUTF8(value); auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer");
Py_DECREF(mcrfpy_module);
if (!color_layer_type || !tile_layer_type) {
if (color_layer_type) Py_DECREF(color_layer_type);
if (tile_layer_type) Py_DECREF(tile_layer_type);
Py_DECREF(iterator);
return -1;
}
PyObject* item;
while ((item = PyIter_Next(iterator)) != NULL) {
std::shared_ptr<GridLayer> layer;
if (PyObject_IsInstance(item, color_layer_type)) {
PyColorLayerObject* py_layer = (PyColorLayerObject*)item;
if (!py_layer->data) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
}
// Check if already attached to another grid
if (py_layer->grid) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return -1;
}
layer = py_layer->data;
// Check for protected names // Check for protected names
if (UIGrid::isProtectedLayerName(layer_name)) { if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer_name); Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return -1; return -1;
} }
if (strcmp(layer_type, "color") == 0) { // Handle name collision
self->data->addColorLayer(layer_z--, layer_name); if (!layer->name.empty()) {
} else if (strcmp(layer_type, "tile") == 0) { auto existing = self->data->getLayerByName(layer->name);
self->data->addTileLayer(layer_z--, texture_ptr, layer_name); if (existing) {
} else { existing->parent_grid = nullptr;
PyErr_Format(PyExc_ValueError, "Unknown layer type '%s' (expected 'color' or 'tile')", layer_type); self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return -1; return -1;
} }
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
py_layer->grid = self->data;
} else if (PyObject_IsInstance(item, tile_layer_type)) {
PyTileLayerObject* py_layer = (PyTileLayerObject*)item;
if (!py_layer->data) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return -1;
} }
// Check if already attached to another grid
if (py_layer->grid) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return -1;
}
layer = py_layer->data;
// Check for protected names
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return -1;
}
// Handle name collision
if (!layer->name.empty()) {
auto existing = self->data->getLayerByName(layer->name);
if (existing) {
existing->parent_grid = nullptr;
self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return -1;
}
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
py_layer->grid = self->data;
} else {
Py_DECREF(item);
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_TypeError, "layers must contain only ColorLayer or TileLayer objects");
return -1;
}
Py_DECREF(item);
}
Py_DECREF(iterator);
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
if (PyErr_Occurred()) {
return -1;
}
self->data->layers_need_sort = true;
} }
// else: layers_obj is Py_None - explicit empty, no layers created // else: layers_obj is Py_None - explicit empty, no layers created
@ -1456,74 +1590,151 @@ PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* k
// Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root) // Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root)
// #147 - Layer system Python API // #147 - Layer system Python API
PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args, PyObject* kwds) { PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) {
static const char* kwlist[] = {"type", "z_index", "texture", NULL}; PyObject* layer_obj;
const char* type_str = nullptr; if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
int z_index = -1;
PyObject* texture_obj = nullptr;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|iO", const_cast<char**>(kwlist),
&type_str, &z_index, &texture_obj)) {
return NULL; return NULL;
} }
std::string type(type_str);
if (type == "color") {
auto layer = self->data->addColorLayer(z_index);
// Create Python ColorLayer object
auto* color_layer_type = (PyTypeObject*)PyObject_GetAttrString(
PyImport_ImportModule("mcrfpy"), "ColorLayer");
if (!color_layer_type) return NULL;
PyColorLayerObject* py_layer = (PyColorLayerObject*)color_layer_type->tp_alloc(color_layer_type, 0);
Py_DECREF(color_layer_type);
if (!py_layer) return NULL;
py_layer->data = layer;
py_layer->grid = self->data;
return (PyObject*)py_layer;
} else if (type == "tile") {
// Parse texture
std::shared_ptr<PyTexture> texture;
if (texture_obj && texture_obj != Py_None) {
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return NULL; if (!mcrfpy_module) return NULL;
auto* texture_type = PyObject_GetAttrString(mcrfpy_module, "Texture"); auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer");
auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer");
Py_DECREF(mcrfpy_module); Py_DECREF(mcrfpy_module);
if (!texture_type) return NULL;
if (!PyObject_IsInstance(texture_obj, texture_type)) { if (!color_layer_type || !tile_layer_type) {
Py_DECREF(texture_type); if (color_layer_type) Py_DECREF(color_layer_type);
PyErr_SetString(PyExc_TypeError, "texture must be a Texture object"); if (tile_layer_type) Py_DECREF(tile_layer_type);
return NULL; return NULL;
} }
Py_DECREF(texture_type);
texture = ((PyTextureObject*)texture_obj)->data; std::shared_ptr<GridLayer> layer;
PyObject* py_layer_ref = nullptr;
if (PyObject_IsInstance(layer_obj, color_layer_type)) {
PyColorLayerObject* py_layer = (PyColorLayerObject*)layer_obj;
if (!py_layer->data) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
} }
auto layer = self->data->addTileLayer(z_index, texture); // Check if already attached to another grid
if (py_layer->grid && py_layer->grid.get() != self->data.get()) {
// Create Python TileLayer object Py_DECREF(color_layer_type);
auto* tile_layer_type = (PyTypeObject*)PyObject_GetAttrString(
PyImport_ImportModule("mcrfpy"), "TileLayer");
if (!tile_layer_type) return NULL;
PyTileLayerObject* py_layer = (PyTileLayerObject*)tile_layer_type->tp_alloc(tile_layer_type, 0);
Py_DECREF(tile_layer_type); Py_DECREF(tile_layer_type);
if (!py_layer) return NULL; PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return NULL;
}
py_layer->data = layer; layer = py_layer->data;
py_layer_ref = layer_obj;
// Check for protected names
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return NULL;
}
// Handle name collision - unlink existing layer with same name
if (!layer->name.empty()) {
auto existing = self->data->getLayerByName(layer->name);
if (existing && existing.get() != layer.get()) {
existing->parent_grid = nullptr;
self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return NULL;
}
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
self->data->layers_need_sort = true;
py_layer->grid = self->data;
} else if (PyObject_IsInstance(layer_obj, tile_layer_type)) {
PyTileLayerObject* py_layer = (PyTileLayerObject*)layer_obj;
if (!py_layer->data) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
return NULL;
}
// Check if already attached to another grid
if (py_layer->grid && py_layer->grid.get() != self->data.get()) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
return NULL;
}
layer = py_layer->data;
py_layer_ref = layer_obj;
// Check for protected names
if (!layer->name.empty() && UIGrid::isProtectedLayerName(layer->name)) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
return NULL;
}
// Handle name collision - unlink existing layer with same name
if (!layer->name.empty()) {
auto existing = self->data->getLayerByName(layer->name);
if (existing && existing.get() != layer.get()) {
existing->parent_grid = nullptr;
self->data->removeLayer(existing);
}
}
// Lazy allocation: resize if layer is (0,0)
if (layer->grid_x == 0 && layer->grid_y == 0) {
layer->resize(self->data->grid_w, self->data->grid_h);
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_Format(PyExc_ValueError,
"Layer size (%d, %d) does not match Grid size (%d, %d)",
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
return NULL;
}
// Link to grid
layer->parent_grid = self->data.get();
self->data->layers.push_back(layer);
self->data->layers_need_sort = true;
py_layer->grid = self->data; py_layer->grid = self->data;
return (PyObject*)py_layer;
} else { } else {
PyErr_SetString(PyExc_ValueError, "type must be 'color' or 'tile'"); Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer");
return NULL; return NULL;
} }
Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type);
// Return the layer object (incref it since we're returning a reference)
Py_INCREF(py_layer_ref);
return py_layer_ref;
} }
PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) { PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
@ -1532,6 +1743,22 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
return NULL; return NULL;
} }
// Check if it's a string (layer name)
if (PyUnicode_Check(layer_obj)) {
const char* name_str = PyUnicode_AsUTF8(layer_obj);
if (!name_str) return NULL;
auto layer = self->data->getLayerByName(std::string(name_str));
if (!layer) {
PyErr_Format(PyExc_KeyError, "Layer '%s' not found", name_str);
return NULL;
}
layer->parent_grid = nullptr;
self->data->removeLayer(layer);
Py_RETURN_NONE;
}
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return NULL; if (!mcrfpy_module) return NULL;
@ -1541,7 +1768,11 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
Py_DECREF(color_layer_type); Py_DECREF(color_layer_type);
Py_DECREF(mcrfpy_module); Py_DECREF(mcrfpy_module);
auto* py_layer = (PyColorLayerObject*)layer_obj; auto* py_layer = (PyColorLayerObject*)layer_obj;
if (py_layer->data) {
py_layer->data->parent_grid = nullptr;
self->data->removeLayer(py_layer->data); self->data->removeLayer(py_layer->data);
py_layer->grid.reset();
}
Py_RETURN_NONE; Py_RETURN_NONE;
} }
if (color_layer_type) Py_DECREF(color_layer_type); if (color_layer_type) Py_DECREF(color_layer_type);
@ -1552,25 +1783,29 @@ PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) {
Py_DECREF(tile_layer_type); Py_DECREF(tile_layer_type);
Py_DECREF(mcrfpy_module); Py_DECREF(mcrfpy_module);
auto* py_layer = (PyTileLayerObject*)layer_obj; auto* py_layer = (PyTileLayerObject*)layer_obj;
if (py_layer->data) {
py_layer->data->parent_grid = nullptr;
self->data->removeLayer(py_layer->data); self->data->removeLayer(py_layer->data);
py_layer->grid.reset();
}
Py_RETURN_NONE; Py_RETURN_NONE;
} }
if (tile_layer_type) Py_DECREF(tile_layer_type); if (tile_layer_type) Py_DECREF(tile_layer_type);
Py_DECREF(mcrfpy_module); Py_DECREF(mcrfpy_module);
PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer"); PyErr_SetString(PyExc_TypeError, "layer must be a string (layer name), ColorLayer, or TileLayer");
return NULL; return NULL;
} }
PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) { PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) {
self->data->sortLayers(); self->data->sortLayers();
PyObject* list = PyList_New(self->data->layers.size()); PyObject* tuple = PyTuple_New(self->data->layers.size());
if (!list) return NULL; if (!tuple) return NULL;
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) { if (!mcrfpy_module) {
Py_DECREF(list); Py_DECREF(tuple);
return NULL; return NULL;
} }
@ -1581,7 +1816,7 @@ PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) {
if (!color_layer_type || !tile_layer_type) { if (!color_layer_type || !tile_layer_type) {
if (color_layer_type) Py_DECREF(color_layer_type); if (color_layer_type) Py_DECREF(color_layer_type);
if (tile_layer_type) Py_DECREF(tile_layer_type); if (tile_layer_type) Py_DECREF(tile_layer_type);
Py_DECREF(list); Py_DECREF(tuple);
return NULL; return NULL;
} }
@ -1608,26 +1843,29 @@ PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) {
if (!py_layer) { if (!py_layer) {
Py_DECREF(color_layer_type); Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type); Py_DECREF(tile_layer_type);
Py_DECREF(list); Py_DECREF(tuple);
return NULL; return NULL;
} }
PyList_SET_ITEM(list, i, py_layer); // Steals reference PyTuple_SET_ITEM(tuple, i, py_layer); // Steals reference
} }
Py_DECREF(color_layer_type); Py_DECREF(color_layer_type);
Py_DECREF(tile_layer_type); Py_DECREF(tile_layer_type);
return list; return tuple;
} }
PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) { PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
int z_index; const char* name_str;
if (!PyArg_ParseTuple(args, "i", &z_index)) { if (!PyArg_ParseTuple(args, "s", &name_str)) {
return NULL; return NULL;
} }
for (auto& layer : self->data->layers) { auto layer = self->data->getLayerByName(std::string(name_str));
if (layer->z_index == z_index) { if (!layer) {
Py_RETURN_NONE;
}
auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); auto* mcrfpy_module = PyImport_ImportModule("mcrfpy");
if (!mcrfpy_module) return NULL; if (!mcrfpy_module) return NULL;
@ -1656,10 +1894,6 @@ PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) {
obj->grid = self->data; obj->grid = self->data;
return (PyObject*)obj; return (PyObject*)obj;
} }
}
}
Py_RETURN_NONE;
} }
// #115 - Spatial hash query for entities in radius // #115 - Spatial hash query for entities in radius
@ -2061,12 +2295,12 @@ PyMethodDef UIGrid::methods[] = {
"Clear all cached Dijkstra maps.\n\n" "Clear all cached Dijkstra maps.\n\n"
"Call this after modifying grid cell walkability to ensure pathfinding\n" "Call this after modifying grid cell walkability to ensure pathfinding\n"
"uses updated walkability data."}, "uses updated walkability data."},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
"add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer"}, "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer"},
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
"remove_layer(layer: ColorLayer | TileLayer) -> None"}, "remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None"},
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS, {"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
"layer(z_index: int) -> ColorLayer | TileLayer | None"}, "layer(name: str) -> ColorLayer | TileLayer | None"},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS, {"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
"entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n" "entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n"
"Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n"
@ -2166,27 +2400,34 @@ PyMethodDef UIGrid_all_methods[] = {
"Clear all cached Dijkstra maps.\n\n" "Clear all cached Dijkstra maps.\n\n"
"Call this after modifying grid cell walkability to ensure pathfinding\n" "Call this after modifying grid cell walkability to ensure pathfinding\n"
"uses updated walkability data."}, "uses updated walkability data."},
{"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS | METH_KEYWORDS, {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS,
"add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer\n\n" "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer\n\n"
"Add a new layer to the grid.\n\n" "Add a layer to the grid.\n\n"
"Args:\n" "Args:\n"
" type: Layer type ('color' or 'tile')\n" " layer: A ColorLayer or TileLayer object. Layers with size (0, 0) are\n"
" z_index: Render order. Negative = below entities, >= 0 = above entities. Default: -1\n" " automatically resized to match the grid. Named layers replace\n"
" texture: Texture for tile layers. Required for 'tile' type.\n\n" " any existing layer with the same name.\n\n"
"Returns:\n" "Returns:\n"
" The created ColorLayer or TileLayer object."}, " The added layer object.\n\n"
"Raises:\n"
" ValueError: If layer is already attached to another grid, or if\n"
" layer size doesn't match grid (and isn't (0,0)).\n"
" TypeError: If argument is not a ColorLayer or TileLayer."},
{"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS,
"remove_layer(layer: ColorLayer | TileLayer) -> None\n\n" "remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None\n\n"
"Remove a layer from the grid.\n\n" "Remove a layer from the grid.\n\n"
"Args:\n" "Args:\n"
" layer: The layer to remove."}, " name_or_layer: Either a layer name (str) or the layer object itself.\n\n"
"Raises:\n"
" KeyError: If name is provided but no layer with that name exists.\n"
" TypeError: If argument is not a string, ColorLayer, or TileLayer."},
{"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS, {"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS,
"layer(z_index: int) -> ColorLayer | TileLayer | None\n\n" "layer(name: str) -> ColorLayer | TileLayer | None\n\n"
"Get a layer by its z_index.\n\n" "Get a layer by its name.\n\n"
"Args:\n" "Args:\n"
" z_index: The z_index of the layer to find.\n\n" " name: The name of the layer to find.\n\n"
"Returns:\n" "Returns:\n"
" The layer with the specified z_index, or None if not found."}, " The layer with the specified name, or None if not found."},
{"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS, {"entities_in_radius", (PyCFunction)UIGrid::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
"entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n" "entities_in_radius(pos: tuple|Vector, radius: float) -> list[Entity]\n\n"
"Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n"

View file

@ -243,7 +243,7 @@ public:
static PyObject* get_hovered_cell(PyUIGridObject* self, void* closure); static PyObject* get_hovered_cell(PyUIGridObject* self, void* closure);
// #147 - Layer system Python API // #147 - Layer system Python API
static PyObject* py_add_layer(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_add_layer(PyUIGridObject* self, PyObject* args);
static PyObject* py_remove_layer(PyUIGridObject* self, PyObject* args); static PyObject* py_remove_layer(PyUIGridObject* self, PyObject* args);
static PyObject* get_layers(PyUIGridObject* self, void* closure); static PyObject* get_layers(PyUIGridObject* self, void* closure);
static PyObject* py_layer(PyUIGridObject* self, PyObject* args); static PyObject* py_layer(PyUIGridObject* self, PyObject* args);

View file

@ -0,0 +1,275 @@
"""
Unit tests for the modernized Grid Layer API.
Tests:
1. Layer name in constructor
2. Lazy allocation (size (0,0) auto-resizes)
3. layer.grid property (getter and setter)
4. grid.layer(name) lookup
5. grid.layers returns tuple
6. add_layer accepts layer objects
7. remove_layer by name and by layer object
8. Name collision handling
"""
import mcrfpy
import sys
def test_layer_name_in_constructor():
"""Test that layers can be created with names."""
print("Test 1: Layer name in constructor...")
color_layer = mcrfpy.ColorLayer(name='fog', z_index=-1)
assert color_layer.name == 'fog', f"Expected 'fog', got {color_layer.name}"
tile_layer = mcrfpy.TileLayer(name='terrain', z_index=-2)
assert tile_layer.name == 'terrain', f"Expected 'terrain', got {tile_layer.name}"
# Name should appear in repr
assert 'fog' in repr(color_layer), f"Name not in repr: {repr(color_layer)}"
assert 'terrain' in repr(tile_layer), f"Name not in repr: {repr(tile_layer)}"
# Layers without name should work too
unnamed = mcrfpy.ColorLayer(z_index=0)
assert unnamed.name == '', f"Expected empty string, got {unnamed.name}"
print(" PASS")
def test_lazy_allocation():
"""Test that layers with size (0,0) auto-resize when attached to grid."""
print("Test 2: Lazy allocation...")
layer = mcrfpy.ColorLayer(name='test')
assert layer.grid_size == (0, 0), f"Expected (0,0), got {layer.grid_size}"
grid = mcrfpy.Grid(grid_size=(10, 8), layers=[layer])
assert layer.grid_size == (10, 8), f"Expected (10,8), got {layer.grid_size}"
print(" PASS")
def test_layer_grid_property():
"""Test that layer.grid property works for getting and setting."""
print("Test 3: layer.grid property...")
layer = mcrfpy.ColorLayer(name='overlay', z_index=0)
assert layer.grid is None, f"Expected None, got {layer.grid}"
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[])
layer.grid = grid
assert layer.grid is not None, "layer.grid should not be None after assignment"
assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}"
assert layer.grid_size == (5, 5), f"Expected (5,5), got {layer.grid_size}"
# Unlink by setting to None
layer.grid = None
assert layer.grid is None, "layer.grid should be None after unsetting"
assert len(grid.layers) == 0, f"Expected 0 layers, got {len(grid.layers)}"
print(" PASS")
def test_grid_layer_name_lookup():
"""Test that grid.layer(name) finds layers by name."""
print("Test 4: grid.layer(name) lookup...")
fog = mcrfpy.ColorLayer(name='fog', z_index=-1)
terrain = mcrfpy.TileLayer(name='terrain', z_index=-2)
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog, terrain])
retrieved_fog = grid.layer('fog')
assert retrieved_fog is not None, "Should find 'fog' layer"
assert retrieved_fog.name == 'fog', f"Expected 'fog', got {retrieved_fog.name}"
retrieved_terrain = grid.layer('terrain')
assert retrieved_terrain is not None, "Should find 'terrain' layer"
assert retrieved_terrain.name == 'terrain', f"Expected 'terrain', got {retrieved_terrain.name}"
# Non-existent name should return None
result = grid.layer('nonexistent')
assert result is None, f"Expected None for nonexistent, got {result}"
print(" PASS")
def test_layers_returns_tuple():
"""Test that grid.layers returns a tuple (immutable)."""
print("Test 5: grid.layers returns tuple...")
fog = mcrfpy.ColorLayer(name='fog')
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog])
layers = grid.layers
assert isinstance(layers, tuple), f"Expected tuple, got {type(layers)}"
print(" PASS")
def test_add_layer_accepts_objects():
"""Test that add_layer accepts ColorLayer and TileLayer objects."""
print("Test 6: add_layer accepts objects...")
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[])
assert len(grid.layers) == 0, f"Expected 0 layers, got {len(grid.layers)}"
new_layer = mcrfpy.TileLayer(name='overlay', z_index=1)
returned = grid.add_layer(new_layer)
assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}"
assert new_layer.grid is not None, "Layer should be attached to grid"
assert returned is new_layer, "Should return the same layer object"
# Add another layer
color = mcrfpy.ColorLayer(name='highlights', z_index=2)
grid.add_layer(color)
assert len(grid.layers) == 2, f"Expected 2 layers, got {len(grid.layers)}"
print(" PASS")
def test_remove_layer_by_name():
"""Test that remove_layer accepts layer name as string."""
print("Test 7a: remove_layer by name...")
fog = mcrfpy.ColorLayer(name='fog')
terrain = mcrfpy.TileLayer(name='terrain')
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog, terrain])
assert len(grid.layers) == 2
grid.remove_layer('fog')
assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}"
assert grid.layer('fog') is None, "fog should be removed"
assert grid.layer('terrain') is not None, "terrain should remain"
# Removing non-existent should raise KeyError
try:
grid.remove_layer('nonexistent')
assert False, "Should raise KeyError for nonexistent layer"
except KeyError:
pass # Expected
print(" PASS")
def test_remove_layer_by_object():
"""Test that remove_layer accepts layer object."""
print("Test 7b: remove_layer by object...")
fog = mcrfpy.ColorLayer(name='fog')
terrain = mcrfpy.TileLayer(name='terrain')
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[fog, terrain])
grid.remove_layer(terrain)
assert len(grid.layers) == 1, f"Expected 1 layer, got {len(grid.layers)}"
assert terrain.grid is None, "Removed layer should have grid=None"
print(" PASS")
def test_name_collision_replaces():
"""Test that adding a layer with existing name replaces the old layer."""
print("Test 8: Name collision replaces old layer...")
old = mcrfpy.ColorLayer(name='fog')
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[old])
assert len(grid.layers) == 1
assert old.grid is not None
# Add new layer with same name
new = mcrfpy.ColorLayer(name='fog')
grid.add_layer(new)
assert len(grid.layers) == 1, f"Expected 1 layer after replacement, got {len(grid.layers)}"
assert old.grid is None, "Old layer should be unlinked"
assert new.grid is not None, "New layer should be linked"
# Verify it's the new one
retrieved = grid.layer('fog')
# Both have same name, but we can check the new one is attached
assert retrieved is not None
print(" PASS")
def test_size_validation():
"""Test that mismatched sizes raise errors."""
print("Test 9: Size validation...")
# Layer with specific size that doesn't match grid
layer = mcrfpy.ColorLayer(name='test', grid_size=(10, 10))
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[])
try:
grid.add_layer(layer)
assert False, "Should raise ValueError for size mismatch"
except ValueError as e:
assert "size" in str(e).lower(), f"Error should mention size: {e}"
print(" PASS")
def test_protected_names():
"""Test that protected names (walkable, transparent) are rejected."""
print("Test 10: Protected names...")
layer = mcrfpy.ColorLayer(name='walkable')
grid = mcrfpy.Grid(grid_size=(5, 5), layers=[])
try:
grid.add_layer(layer)
assert False, "Should raise ValueError for protected name 'walkable'"
except ValueError as e:
assert "reserved" in str(e).lower() or "walkable" in str(e).lower(), f"Error: {e}"
layer2 = mcrfpy.ColorLayer(name='transparent')
try:
grid.add_layer(layer2)
assert False, "Should raise ValueError for protected name 'transparent'"
except ValueError as e:
assert "reserved" in str(e).lower() or "transparent" in str(e).lower(), f"Error: {e}"
print(" PASS")
def test_already_attached_error():
"""Test that attaching a layer to two grids raises error."""
print("Test 11: Already attached error...")
layer = mcrfpy.ColorLayer(name='shared')
grid1 = mcrfpy.Grid(grid_size=(5, 5), layers=[layer])
grid2 = mcrfpy.Grid(grid_size=(5, 5), layers=[])
try:
grid2.add_layer(layer)
assert False, "Should raise ValueError for already attached layer"
except ValueError as e:
assert "attached" in str(e).lower() or "another" in str(e).lower(), f"Error: {e}"
print(" PASS")
def run_all_tests():
"""Run all tests."""
print("Running Grid Layer API tests...\n")
test_layer_name_in_constructor()
test_lazy_allocation()
test_layer_grid_property()
test_grid_layer_name_lookup()
test_layers_returns_tuple()
test_add_layer_accepts_objects()
test_remove_layer_by_name()
test_remove_layer_by_object()
test_name_collision_replaces()
test_size_validation()
test_protected_names()
test_already_attached_error()
print("\n" + "="*50)
print("All tests PASSED!")
print("="*50)
if __name__ == "__main__":
try:
run_all_tests()
sys.exit(0)
except AssertionError as e:
print(f"\nFAIL: {e}")
sys.exit(1)
except Exception as e:
print(f"\nERROR: {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
sys.exit(1)