diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index 805dcf3..d681ebe 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -10,7 +10,7 @@ #include "PyFOV.h" #include "PyPositionHelper.h" // For standardized position argument parsing #include "PyVector.h" // #179, #181 - For Vector return types -#include "PyHeightMap.h" // #199 - HeightMap application methods +// PyHeightMap.h moved to UIGridPyMethods.cpp #include "PyShader.h" // #106: Shader support #include "PyUniformCollection.h" // #106: Uniform collection support #include "PyMouseButton.h" // For MouseButton enum @@ -1029,1758 +1029,8 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { return 0; // Success } -// #179 - Return grid_size as Vector -PyObject* UIGrid::get_grid_size(PyUIGridObject* self, void* closure) { - return PyVector(sf::Vector2f(static_cast(self->data->grid_w), - static_cast(self->data->grid_h))).pyObject(); -} - -PyObject* UIGrid::get_grid_w(PyUIGridObject* self, void* closure) { - return PyLong_FromLong(self->data->grid_w); -} - -PyObject* UIGrid::get_grid_h(PyUIGridObject* self, void* closure) { - return PyLong_FromLong(self->data->grid_h); -} - -// #181 - Return size as Vector -PyObject* UIGrid::get_size(PyUIGridObject* self, void* closure) { - auto& box = self->data->box; - return PyVector(box.getSize()).pyObject(); -} - -int UIGrid::set_size(PyUIGridObject* self, PyObject* value, void* closure) { - float w, h; - // Accept Vector or tuple - PyVectorObject* vec = PyVector::from_arg(value); - if (vec) { - w = vec->data.x; - h = vec->data.y; - Py_DECREF(vec); - } else { - PyErr_Clear(); - if (!PyArg_ParseTuple(value, "ff", &w, &h)) { - PyErr_SetString(PyExc_TypeError, "size must be a Vector or tuple (w, h)"); - return -1; - } - } - self->data->box.setSize(sf::Vector2f(w, h)); - - // Recreate renderTexture with new size to avoid rendering issues - // Add some padding to handle zoom and ensure we don't cut off content - unsigned int tex_width = static_cast(w * 1.5f); - unsigned int tex_height = static_cast(h * 1.5f); - - // Clamp to reasonable maximum to avoid GPU memory issues - tex_width = std::min(tex_width, 4096u); - tex_height = std::min(tex_height, 4096u); - - self->data->renderTexture.create(tex_width, tex_height); - self->data->markDirty(); // #291: size change - - return 0; -} - -// #181 - Return center as Vector -PyObject* UIGrid::get_center(PyUIGridObject* self, void* closure) { - return PyVector(sf::Vector2f(self->data->center_x, self->data->center_y)).pyObject(); -} - -int UIGrid::set_center(PyUIGridObject* self, PyObject* value, void* closure) { - float x, y; - if (!PyArg_ParseTuple(value, "ff", &x, &y)) { - PyErr_SetString(PyExc_ValueError, "Size must be a tuple of two floats"); - return -1; - } - self->data->center_x = x; - self->data->center_y = y; - self->data->markDirty(); // #291: camera position change - return 0; -} - -PyObject* UIGrid::get_float_member(PyUIGridObject* self, void* closure) -{ - auto member_ptr = reinterpret_cast(closure); - if (member_ptr == 0) // x - return PyFloat_FromDouble(self->data->box.getPosition().x); - else if (member_ptr == 1) // y - return PyFloat_FromDouble(self->data->box.getPosition().y); - else if (member_ptr == 2) // w - return PyFloat_FromDouble(self->data->box.getSize().x); - else if (member_ptr == 3) // h - return PyFloat_FromDouble(self->data->box.getSize().y); - else if (member_ptr == 4) // center_x - return PyFloat_FromDouble(self->data->center_x); - else if (member_ptr == 5) // center_y - return PyFloat_FromDouble(self->data->center_y); - else if (member_ptr == 6) // zoom - return PyFloat_FromDouble(self->data->zoom); - else if (member_ptr == 7) // camera_rotation - return PyFloat_FromDouble(self->data->camera_rotation); - else - { - PyErr_SetString(PyExc_AttributeError, "Invalid attribute"); - return nullptr; - } -} - -int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closure) -{ - float val; - auto member_ptr = reinterpret_cast(closure); - if (PyFloat_Check(value)) - { - val = PyFloat_AsDouble(value); - } - else if (PyLong_Check(value)) - { - val = PyLong_AsLong(value); - } - else - { - PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)"); - return -1; - } - if (member_ptr == 0) // x - self->data->box.setPosition(val, self->data->box.getPosition().y); - else if (member_ptr == 1) // y - self->data->box.setPosition(self->data->box.getPosition().x, val); - else if (member_ptr == 2) // w - { - self->data->box.setSize(sf::Vector2f(val, self->data->box.getSize().y)); - // Recreate renderTexture when width changes - unsigned int tex_width = static_cast(val * 1.5f); - unsigned int tex_height = static_cast(self->data->box.getSize().y * 1.5f); - tex_width = std::min(tex_width, 4096u); - tex_height = std::min(tex_height, 4096u); - self->data->renderTexture.create(tex_width, tex_height); - } - else if (member_ptr == 3) // h - { - self->data->box.setSize(sf::Vector2f(self->data->box.getSize().x, val)); - // Recreate renderTexture when height changes - unsigned int tex_width = static_cast(self->data->box.getSize().x * 1.5f); - unsigned int tex_height = static_cast(val * 1.5f); - tex_width = std::min(tex_width, 4096u); - tex_height = std::min(tex_height, 4096u); - self->data->renderTexture.create(tex_width, tex_height); - } - else if (member_ptr == 4) // center_x - self->data->center_x = val; - else if (member_ptr == 5) // center_y - self->data->center_y = val; - else if (member_ptr == 6) // zoom - self->data->zoom = val; - else if (member_ptr == 7) // camera_rotation - self->data->camera_rotation = val; - - // #252 shim: sync rendering state to GridView - if (self->view) { - if (member_ptr == 0) // x - self->view->box.setPosition(val, self->view->box.getPosition().y); - else if (member_ptr == 1) // y - self->view->box.setPosition(self->view->box.getPosition().x, val); - else if (member_ptr == 2) // w - self->view->box.setSize(sf::Vector2f(val, self->view->box.getSize().y)); - else if (member_ptr == 3) // h - self->view->box.setSize(sf::Vector2f(self->view->box.getSize().x, val)); - else if (member_ptr == 4) self->view->center_x = val; - else if (member_ptr == 5) self->view->center_y = val; - else if (member_ptr == 6) self->view->zoom = val; - else if (member_ptr == 7) self->view->camera_rotation = val; - self->view->position = self->view->box.getPosition(); - } - - // #291: Dirty flag propagation for visual property changes - if (member_ptr == 0 || member_ptr == 1) { - self->data->markCompositeDirty(); // position change - } else { - self->data->markDirty(); // content/size change - } - - return 0; -} -// TODO (7DRL Day 2, item 5.) return Texture object -/* -PyObject* UIGrid::get_texture(PyUIGridObject* self, void* closure) { - Py_INCREF(self->texture); - return self->texture; -} -*/ - -PyObject* UIGrid::get_texture(PyUIGridObject* self, void* closure) { - //return self->data->getTexture()->pyObject(); - // PyObject_GetAttrString(McRFPy_API::mcrf_module, "GridPointState") - //PyTextureObject* obj = (PyTextureObject*)((&PyTextureType)->tp_alloc(&PyTextureType, 0)); - - // Return None if no texture - auto texture = self->data->getTexture(); - if (!texture) { - Py_RETURN_NONE; - } - - auto type = &mcrfpydef::PyTextureType; - auto obj = (PyTextureObject*)type->tp_alloc(type, 0); - obj->data = texture; - return (PyObject*)obj; -} - -PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds) -{ - int x, y; - - // Use the flexible position parsing helper - accepts: - // at(x, y), at((x, y)), at([x, y]), at(Vector(x, y)), at(pos=(x, y)), etc. - if (!PyPosition_ParseInt(args, kwds, &x, &y)) { - return NULL; // Error already set by PyPosition_ParseInt - } - - // Range validation - if (x < 0 || x >= self->data->grid_w) { - PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w); - return NULL; - } - if (y < 0 || y >= self->data->grid_h) { - PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h); - return NULL; - } - - // Use the type directly since GridPoint is internal-only (not exported to module) - auto type = &mcrfpydef::PyUIGridPointType; - auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); - obj->grid = self->data; - obj->x = x; - obj->y = y; - return (PyObject*)obj; -} - -// Grid subscript access: grid[x, y] -> GridPoint -// Enables Pythonic cell access syntax -PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key) -{ - // We expect a tuple of (x, y) - if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) { - PyErr_SetString(PyExc_TypeError, "Grid indices must be a tuple of (x, y)"); - return NULL; - } - - PyObject* x_obj = PyTuple_GetItem(key, 0); - PyObject* y_obj = PyTuple_GetItem(key, 1); - - if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { - PyErr_SetString(PyExc_TypeError, "Grid indices must be integers"); - return NULL; - } - - int x = PyLong_AsLong(x_obj); - int y = PyLong_AsLong(y_obj); - - // Range validation - if (x < 0 || x >= self->data->grid_w) { - PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w); - return NULL; - } - if (y < 0 || y >= self->data->grid_h) { - PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h); - return NULL; - } - - // Create GridPoint object (same as py_at) - auto type = &mcrfpydef::PyUIGridPointType; - auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); - if (!obj) return NULL; - - obj->grid = self->data; - obj->x = x; - obj->y = y; - return (PyObject*)obj; -} - -// Mapping methods for grid[x, y] subscript access -PyMappingMethods UIGrid::mpmethods = { - .mp_length = NULL, // No len() for grid via mapping (use grid_w * grid_h) - .mp_subscript = (binaryfunc)UIGrid::subscript, - .mp_ass_subscript = NULL // No assignment via subscript (use grid[x,y].property = value) -}; - -PyObject* UIGrid::get_fill_color(PyUIGridObject* self, void* closure) -{ - auto& color = self->data->fill_color; - auto type = &mcrfpydef::PyColorType; - PyObject* args = Py_BuildValue("(iiii)", color.r, color.g, color.b, color.a); - PyObject* obj = PyObject_CallObject((PyObject*)type, args); - Py_DECREF(args); - return obj; -} - -int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure) -{ - if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyColorType)) { - PyErr_SetString(PyExc_TypeError, "fill_color must be a Color object"); - return -1; - } - - PyColorObject* color = (PyColorObject*)value; - self->data->fill_color = color->data; - self->data->markDirty(); // #291: color change - return 0; -} - -PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure) -{ - auto locked = self->data->perspective_entity.lock(); - if (locked) { - // Check cache first to preserve derived class - if (locked->serial_number != 0) { - PyObject* cached = PythonObjectCache::getInstance().lookup(locked->serial_number); - if (cached) { - return cached; // Already INCREF'd by lookup - } - } - - // Create a new base Entity object - auto type = &mcrfpydef::PyUIEntityType; - auto o = (PyUIEntityObject*)type->tp_alloc(type, 0); - if (o) { - o->data = locked; - o->weakreflist = NULL; - return (PyObject*)o; - } - } - Py_RETURN_NONE; -} - -int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure) -{ - if (value == Py_None) { - // Clear perspective but keep perspective_enabled unchanged - self->data->perspective_entity.reset(); - self->data->markDirty(); // #291: FOV rendering change - return 0; - } - - // Extract UIEntity from PyObject - if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIEntityType)) { - PyErr_SetString(PyExc_TypeError, "perspective must be a UIEntity or None"); - return -1; - } - - PyUIEntityObject* entity_obj = (PyUIEntityObject*)value; - self->data->perspective_entity = entity_obj->data; - self->data->perspective_enabled = true; // Enable perspective when entity assigned - self->data->markDirty(); // #291: FOV rendering change - return 0; -} - -PyObject* UIGrid::get_perspective_enabled(PyUIGridObject* self, void* closure) -{ - return PyBool_FromLong(self->data->perspective_enabled); -} - -int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure) -{ - int enabled = PyObject_IsTrue(value); - if (enabled == -1) { - return -1; // Error occurred - } - self->data->perspective_enabled = enabled; - self->data->markDirty(); // #291: FOV rendering toggle - return 0; -} - -// #114 - FOV algorithm property -PyObject* UIGrid::get_fov(PyUIGridObject* self, void* closure) -{ - // Return the FOV enum member for the current algorithm - if (PyFOV::fov_enum_class) { - // Get the enum member by value - PyObject* value = PyLong_FromLong(self->data->fov_algorithm); - if (!value) return NULL; - - // Call FOV(value) to get the enum member - PyObject* args = PyTuple_Pack(1, value); - Py_DECREF(value); - if (!args) return NULL; - - PyObject* result = PyObject_Call(PyFOV::fov_enum_class, args, NULL); - Py_DECREF(args); - return result; - } - // Fallback to integer - return PyLong_FromLong(self->data->fov_algorithm); -} - -int UIGrid::set_fov(PyUIGridObject* self, PyObject* value, void* closure) -{ - TCOD_fov_algorithm_t algo; - if (!PyFOV::from_arg(value, &algo, nullptr)) { - return -1; - } - self->data->fov_algorithm = algo; - self->data->markDirty(); // #291: FOV algorithm change - return 0; -} - -// #114 - FOV radius property -PyObject* UIGrid::get_fov_radius(PyUIGridObject* self, void* closure) -{ - return PyLong_FromLong(self->data->fov_radius); -} - -int UIGrid::set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure) -{ - if (!PyLong_Check(value)) { - PyErr_SetString(PyExc_TypeError, "fov_radius must be an integer"); - return -1; - } - long radius = PyLong_AsLong(value); - if (radius == -1 && PyErr_Occurred()) { - return -1; - } - if (radius < 0) { - PyErr_SetString(PyExc_ValueError, "fov_radius must be non-negative"); - return -1; - } - self->data->fov_radius = (int)radius; - self->data->markDirty(); // #291: FOV radius change - return 0; -} - -// Python API implementations for TCOD functionality -PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds) -{ - static const char* kwlist[] = {"pos", "radius", "light_walls", "algorithm", NULL}; - PyObject* pos_obj = NULL; - int radius = 0; - int light_walls = 1; - int algorithm = FOV_BASIC; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|ipi", const_cast(kwlist), - &pos_obj, &radius, &light_walls, &algorithm)) { - return NULL; - } - - int x, y; - if (!PyPosition_FromObjectInt(pos_obj, &x, &y)) { - return NULL; - } - - // Compute FOV - self->data->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm); - - // Return None - use is_in_fov() to query visibility - // See issue #146: returning a list had O(grid_size) performance - Py_RETURN_NONE; -} - -PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds) -{ - int x, y; - if (!PyPosition_ParseInt(args, kwds, &x, &y)) { - return NULL; - } - - bool in_fov = self->data->isInFOV(x, y); - return PyBool_FromLong(in_fov); -} - -// Old pathfinding Python methods removed - see UIGridPathfinding.cpp for new implementation -// Grid.find_path() now returns AStarPath objects -// Grid.get_dijkstra_map() returns DijkstraMap objects (cached by root) - -// #147 - Layer system Python API -PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) { - PyObject* layer_obj; - if (!PyArg_ParseTuple(args, "O", &layer_obj)) { - return NULL; - } - - auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); - if (!mcrfpy_module) return NULL; - - auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); - 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); - return NULL; - } - - std::shared_ptr 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; - } - - // 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; - - } 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; - - // Inherit grid texture if TileLayer has none (#254) - auto tile_layer = std::static_pointer_cast(layer); - if (!tile_layer->texture) { - tile_layer->texture = self->data->getTexture(); - } - - } else { - Py_DECREF(color_layer_type); - Py_DECREF(tile_layer_type); - PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer"); - 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* layer_obj; - if (!PyArg_ParseTuple(args, "O", &layer_obj)) { - 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"); - if (!mcrfpy_module) return NULL; - - // Check if ColorLayer - auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); - if (color_layer_type && PyObject_IsInstance(layer_obj, color_layer_type)) { - Py_DECREF(color_layer_type); - Py_DECREF(mcrfpy_module); - auto* py_layer = (PyColorLayerObject*)layer_obj; - if (py_layer->data) { - py_layer->data->parent_grid = nullptr; - self->data->removeLayer(py_layer->data); - py_layer->grid.reset(); - } - Py_RETURN_NONE; - } - if (color_layer_type) Py_DECREF(color_layer_type); - - // Check if TileLayer - auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer"); - if (tile_layer_type && PyObject_IsInstance(layer_obj, tile_layer_type)) { - Py_DECREF(tile_layer_type); - Py_DECREF(mcrfpy_module); - auto* py_layer = (PyTileLayerObject*)layer_obj; - if (py_layer->data) { - py_layer->data->parent_grid = nullptr; - self->data->removeLayer(py_layer->data); - py_layer->grid.reset(); - } - Py_RETURN_NONE; - } - if (tile_layer_type) Py_DECREF(tile_layer_type); - - Py_DECREF(mcrfpy_module); - PyErr_SetString(PyExc_TypeError, "layer must be a string (layer name), ColorLayer, or TileLayer"); - return NULL; -} - -PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) { - self->data->sortLayers(); - - PyObject* tuple = PyTuple_New(self->data->layers.size()); - if (!tuple) return NULL; - - auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); - if (!mcrfpy_module) { - Py_DECREF(tuple); - return NULL; - } - - auto* color_layer_type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); - auto* tile_layer_type = (PyTypeObject*)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(tuple); - return NULL; - } - - for (size_t i = 0; i < self->data->layers.size(); ++i) { - auto& layer = self->data->layers[i]; - PyObject* py_layer = nullptr; - - if (layer->type == GridLayerType::Color) { - PyColorLayerObject* obj = (PyColorLayerObject*)color_layer_type->tp_alloc(color_layer_type, 0); - if (obj) { - obj->data = std::static_pointer_cast(layer); - obj->grid = self->data; - py_layer = (PyObject*)obj; - } - } else { - PyTileLayerObject* obj = (PyTileLayerObject*)tile_layer_type->tp_alloc(tile_layer_type, 0); - if (obj) { - obj->data = std::static_pointer_cast(layer); - obj->grid = self->data; - py_layer = (PyObject*)obj; - } - } - - if (!py_layer) { - Py_DECREF(color_layer_type); - Py_DECREF(tile_layer_type); - Py_DECREF(tuple); - return NULL; - } - - PyTuple_SET_ITEM(tuple, i, py_layer); // Steals reference - } - - Py_DECREF(color_layer_type); - Py_DECREF(tile_layer_type); - return tuple; -} - -PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) { - const char* name_str; - if (!PyArg_ParseTuple(args, "s", &name_str)) { - return NULL; - } - - auto layer = self->data->getLayerByName(std::string(name_str)); - if (!layer) { - Py_RETURN_NONE; - } - - auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); - if (!mcrfpy_module) return NULL; - - if (layer->type == GridLayerType::Color) { - auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); - Py_DECREF(mcrfpy_module); - if (!type) return NULL; - - PyColorLayerObject* obj = (PyColorLayerObject*)type->tp_alloc(type, 0); - Py_DECREF(type); - if (!obj) return NULL; - - obj->data = std::static_pointer_cast(layer); - obj->grid = self->data; - return (PyObject*)obj; - } else { - auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "TileLayer"); - Py_DECREF(mcrfpy_module); - if (!type) return NULL; - - PyTileLayerObject* obj = (PyTileLayerObject*)type->tp_alloc(type, 0); - Py_DECREF(type); - if (!obj) return NULL; - - obj->data = std::static_pointer_cast(layer); - obj->grid = self->data; - return (PyObject*)obj; - } -} - -// #115 - Spatial hash query for entities in radius -// #216 - Updated to use position tuple/Vector instead of x, y -PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds) -{ - static const char* kwlist[] = {"pos", "radius", NULL}; - PyObject* pos_obj; - float radius; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "Of", const_cast(kwlist), - &pos_obj, &radius)) { - return NULL; - } - - // Parse position from tuple, Vector, or other 2-element sequence - float x, y; - if (!PyPosition_FromObject(pos_obj, &x, &y)) { - return NULL; // Error already set by helper - } - - if (radius < 0) { - PyErr_SetString(PyExc_ValueError, "radius must be non-negative"); - return NULL; - } - - // Query spatial hash for entities in radius - auto entities = self->data->spatial_hash.queryRadius(x, y, radius); - - // Create result list - PyObject* result = PyList_New(entities.size()); - if (!result) return PyErr_NoMemory(); - - PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType; - - for (size_t i = 0; i < entities.size(); i++) { - auto& entity = entities[i]; - - // Check cache first to preserve derived class identity - PyObject* py_entity = nullptr; - if (entity->serial_number != 0) { - py_entity = PythonObjectCache::getInstance().lookup(entity->serial_number); - } - if (!py_entity) { - // Create new Python Entity wrapper - auto pyEntity = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); - if (!pyEntity) { - Py_DECREF(result); - return PyErr_NoMemory(); - } - pyEntity->data = entity; - pyEntity->weakreflist = NULL; - py_entity = (PyObject*)pyEntity; - } - PyList_SET_ITEM(result, i, py_entity); - } - - return result; -} - -// #169 - center_camera implementations -void UIGrid::center_camera() { - // Center on grid's middle tile - int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; - int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; - center_x = (grid_w / 2.0f) * cell_width; - center_y = (grid_h / 2.0f) * cell_height; - markDirty(); // #144 - View change affects content -} - -void UIGrid::center_camera(float tile_x, float tile_y) { - // Position specified tile at top-left of widget - int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; - int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; - // To put tile (tx, ty) at top-left: center = tile_pos + half_viewport - float half_viewport_x = box.getSize().x / zoom / 2.0f; - float half_viewport_y = box.getSize().y / zoom / 2.0f; - center_x = tile_x * cell_width + half_viewport_x; - center_y = tile_y * cell_height + half_viewport_y; - markDirty(); // #144 - View change affects content -} - -PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) { - PyObject* pos_arg = nullptr; - - // Parse optional positional argument (tuple of tile coordinates) - if (!PyArg_ParseTuple(args, "|O", &pos_arg)) { - return nullptr; - } - - if (pos_arg == nullptr || pos_arg == Py_None) { - // No args: center on grid's middle tile - self->data->center_camera(); - } else if (PyTuple_Check(pos_arg) && PyTuple_Size(pos_arg) == 2) { - // Tuple provided: center on (tile_x, tile_y) - PyObject* x_obj = PyTuple_GetItem(pos_arg, 0); - PyObject* y_obj = PyTuple_GetItem(pos_arg, 1); - - float tile_x, tile_y; - if (PyFloat_Check(x_obj)) { - tile_x = PyFloat_AsDouble(x_obj); - } else if (PyLong_Check(x_obj)) { - tile_x = (float)PyLong_AsLong(x_obj); - } else { - PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric"); - return nullptr; - } - - if (PyFloat_Check(y_obj)) { - tile_y = PyFloat_AsDouble(y_obj); - } else if (PyLong_Check(y_obj)) { - tile_y = (float)PyLong_AsLong(y_obj); - } else { - PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric"); - return nullptr; - } - - self->data->center_camera(tile_x, tile_y); - } else { - PyErr_SetString(PyExc_TypeError, "center_camera() takes an optional tuple (tile_x, tile_y)"); - return nullptr; - } - - Py_RETURN_NONE; -} - -// #199 - HeightMap application methods - -PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* keywords[] = {"source", "range", "walkable", "transparent", nullptr}; - PyObject* source_obj = nullptr; - PyObject* range_obj = nullptr; - PyObject* walkable_obj = Py_None; - PyObject* transparent_obj = Py_None; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|OO", const_cast(keywords), - &source_obj, &range_obj, &walkable_obj, &transparent_obj)) { - return nullptr; - } - - // Validate source is a HeightMap - if (!PyObject_IsInstance(source_obj, (PyObject*)&mcrfpydef::PyHeightMapType)) { - PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); - return nullptr; - } - PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj; - - if (!hmap->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - // Parse range tuple - if (!PyTuple_Check(range_obj) || PyTuple_Size(range_obj) != 2) { - PyErr_SetString(PyExc_TypeError, "range must be a tuple of (min, max)"); - return nullptr; - } - - float range_min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 0)); - float range_max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 1)); - - if (PyErr_Occurred()) { - return nullptr; - } - - // Check size match - if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) { - PyErr_Format(PyExc_ValueError, - "HeightMap size (%d, %d) does not match Grid size (%d, %d)", - hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h); - return nullptr; - } - - // Parse optional walkable/transparent booleans - bool set_walkable = (walkable_obj != Py_None); - bool set_transparent = (transparent_obj != Py_None); - bool walkable_value = false; - bool transparent_value = false; - - if (set_walkable) { - walkable_value = PyObject_IsTrue(walkable_obj); - } - if (set_transparent) { - transparent_value = PyObject_IsTrue(transparent_obj); - } - - // Apply threshold - for (int y = 0; y < self->data->grid_h; y++) { - for (int x = 0; x < self->data->grid_w; x++) { - float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); - if (value >= range_min && value <= range_max) { - UIGridPoint& point = self->data->at(x, y); - if (set_walkable) { - point.walkable = walkable_value; - } - if (set_transparent) { - point.transparent = transparent_value; - } - } - } - } - - // Sync TCOD map if it exists - if (self->data->getTCODMap()) { - self->data->syncTCODMap(); - } - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} - -PyObject* UIGrid::py_apply_ranges(PyUIGridObject* self, PyObject* args) { - PyObject* source_obj = nullptr; - PyObject* ranges_obj = nullptr; - - if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) { - return nullptr; - } - - // Validate source is a HeightMap - if (!PyObject_IsInstance(source_obj, (PyObject*)&mcrfpydef::PyHeightMapType)) { - PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); - return nullptr; - } - PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj; - - if (!hmap->heightmap) { - PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); - return nullptr; - } - - // Validate ranges is a list - if (!PyList_Check(ranges_obj)) { - PyErr_SetString(PyExc_TypeError, "ranges must be a list"); - return nullptr; - } - - // Check size match - if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) { - PyErr_Format(PyExc_ValueError, - "HeightMap size (%d, %d) does not match Grid size (%d, %d)", - hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h); - return nullptr; - } - - // Parse all ranges first to catch errors early - struct RangeEntry { - float min, max; - bool set_walkable, set_transparent; - bool walkable_value, transparent_value; - }; - std::vector entries; - - Py_ssize_t num_ranges = PyList_Size(ranges_obj); - for (Py_ssize_t i = 0; i < num_ranges; i++) { - PyObject* entry = PyList_GetItem(ranges_obj, i); - - if (!PyTuple_Check(entry) || PyTuple_Size(entry) != 2) { - PyErr_Format(PyExc_TypeError, - "ranges[%zd] must be a tuple of (range, properties_dict)", i); - return nullptr; - } - - PyObject* range_tuple = PyTuple_GetItem(entry, 0); - PyObject* props_dict = PyTuple_GetItem(entry, 1); - - if (!PyTuple_Check(range_tuple) || PyTuple_Size(range_tuple) != 2) { - PyErr_Format(PyExc_TypeError, - "ranges[%zd] range must be a tuple of (min, max)", i); - return nullptr; - } - - if (!PyDict_Check(props_dict)) { - PyErr_Format(PyExc_TypeError, - "ranges[%zd] properties must be a dict", i); - return nullptr; - } - - RangeEntry re; - re.min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 0)); - re.max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 1)); - - if (PyErr_Occurred()) { - return nullptr; - } - - // Parse walkable from dict - PyObject* walkable_val = PyDict_GetItemString(props_dict, "walkable"); - re.set_walkable = (walkable_val != nullptr); - if (re.set_walkable) { - re.walkable_value = PyObject_IsTrue(walkable_val); - } - - // Parse transparent from dict - PyObject* transparent_val = PyDict_GetItemString(props_dict, "transparent"); - re.set_transparent = (transparent_val != nullptr); - if (re.set_transparent) { - re.transparent_value = PyObject_IsTrue(transparent_val); - } - - entries.push_back(re); - } - - // Apply all ranges in a single pass - for (int y = 0; y < self->data->grid_h; y++) { - for (int x = 0; x < self->data->grid_w; x++) { - float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); - UIGridPoint& point = self->data->at(x, y); - - // Check each range (first match wins) - for (const auto& re : entries) { - if (value >= re.min && value <= re.max) { - if (re.set_walkable) { - point.walkable = re.walkable_value; - } - if (re.set_transparent) { - point.transparent = re.transparent_value; - } - break; // First matching range wins - } - } - } - } - - // Sync TCOD map if it exists - if (self->data->getTCODMap()) { - self->data->syncTCODMap(); - } - - // Return self for chaining - Py_INCREF(self); - return (PyObject*)self; -} - -PyMethodDef UIGrid::methods[] = { - {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, - {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, - "compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n" - "Compute field of view from a position.\n\n" - "Args:\n" - " pos: Position as (x, y) tuple, list, or Vector\n" - " radius: Maximum view distance (0 = unlimited)\n" - " light_walls: Whether walls are lit when visible\n" - " algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n" - "Updates the internal FOV state. Use is_in_fov(pos) to query visibility."}, - {"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS, - "is_in_fov(pos) -> bool\n\n" - "Check if a cell is in the field of view.\n\n" - "Args:\n" - " pos: Position as (x, y) tuple, list, or Vector\n\n" - "Returns:\n" - " True if the cell is visible, False otherwise\n\n" - "Must call compute_fov() first to calculate visibility."}, - {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, - "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" - "Compute A* path between two points.\n\n" - "Args:\n" - " start: Starting position as Vector, Entity, or (x, y) tuple\n" - " end: Target position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" - " collide: Label string. Entities with this label block pathfinding.\n\n" - "Returns:\n" - " AStarPath object if path exists, None otherwise.\n\n" - "The returned AStarPath can be iterated or walked step-by-step."}, - {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, - "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n" - "Get or create a Dijkstra distance map for a root position.\n\n" - "Args:\n" - " root: Root position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" - " collide: Label string. Entities with this label block pathfinding.\n\n" - "Returns:\n" - " DijkstraMap object for querying distances and paths.\n\n" - "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n" - "the same root and collide label return the same cached map. Call\n" - "clear_dijkstra_maps() after changing grid walkability to invalidate."}, - {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, - "clear_dijkstra_maps() -> None\n\n" - "Clear all cached Dijkstra maps.\n\n" - "Call this after modifying grid cell walkability to ensure pathfinding\n" - "uses updated walkability data."}, - {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS, - "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer"}, - {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, - "remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None"}, - {"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS, - "layer(name: str) -> ColorLayer | TileLayer | None"}, - {"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" - "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" - "Args:\n" - " pos: Center position as (x, y) tuple, Vector, or other 2-element sequence\n" - " radius: Search radius\n\n" - "Returns:\n" - " List of Entity objects within the radius."}, - {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS, - "center_camera(pos: tuple = None) -> None\n\n" - "Center the camera on a tile coordinate.\n\n" - "Args:\n" - " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n" - "Example:\n" - " grid.center_camera() # Center on middle of grid\n" - " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" - " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, - // #199 - HeightMap application methods - {"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS, - "apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n" - "Apply walkable/transparent properties where heightmap values are in range.\n\n" - "Args:\n" - " source: HeightMap with values to check. Must match grid size.\n" - " range: Tuple of (min, max) - cells with values in this range are affected.\n" - " walkable: If not None, set walkable to this value for cells in range.\n" - " transparent: If not None, set transparent to this value for cells in range.\n\n" - "Returns:\n" - " Grid: self, for method chaining.\n\n" - "Raises:\n" - " ValueError: If HeightMap size doesn't match grid size."}, - {"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS, - "apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n" - "Apply multiple thresholds in a single pass.\n\n" - "Args:\n" - " source: HeightMap with values to check. Must match grid size.\n" - " ranges: List of (range_tuple, properties_dict) tuples.\n" - " range_tuple: (min, max) value range\n" - " properties_dict: {'walkable': bool, 'transparent': bool}\n\n" - "Returns:\n" - " Grid: self, for method chaining.\n\n" - "Example:\n" - " grid.apply_ranges(terrain, [\n" - " ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n" - " ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n" - " ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n" - " ])"}, - {NULL, NULL, 0, NULL} -}; - -// #301 - grid.step() turn manager -#include "EntityBehavior.h" -#include "PyTrigger.h" - -// Helper: fire step callback on entity -static void fireStepCallback(std::shared_ptr& entity, int trigger_int, PyObject* data) { - PyObject* callback = entity->step_callback; - - // If no explicit callback, check for subclass method override - if (!callback && entity->pyobject) { - // Check if the Python object's type has a 'step' method that isn't the C property - PyObject* step_attr = PyObject_GetAttrString(entity->pyobject, "on_step"); - if (step_attr && PyCallable_Check(step_attr)) { - callback = step_attr; - } else { - PyErr_Clear(); - Py_XDECREF(step_attr); - return; - } - // Call and decref the looked-up method - PyObject* trigger_obj = nullptr; - if (PyTrigger::trigger_enum_class) { - trigger_obj = PyObject_CallFunction(PyTrigger::trigger_enum_class, "i", trigger_int); - } - if (!trigger_obj) { - PyErr_Clear(); - trigger_obj = PyLong_FromLong(trigger_int); - } - if (!data) data = Py_None; - PyObject* result = PyObject_CallFunction(callback, "OO", trigger_obj, data); - Py_XDECREF(result); - if (PyErr_Occurred()) PyErr_Print(); - Py_DECREF(trigger_obj); - Py_DECREF(step_attr); - return; - } - - if (!callback) return; - - // Build trigger enum value - PyObject* trigger_obj = nullptr; - if (PyTrigger::trigger_enum_class) { - trigger_obj = PyObject_CallFunction(PyTrigger::trigger_enum_class, "i", trigger_int); - } - if (!trigger_obj) { - PyErr_Clear(); - trigger_obj = PyLong_FromLong(trigger_int); - } - - if (!data) data = Py_None; - PyObject* result = PyObject_CallFunction(callback, "OO", trigger_obj, data); - Py_XDECREF(result); - if (PyErr_Occurred()) PyErr_Print(); - Py_DECREF(trigger_obj); -} - -PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds) { - static const char* kwlist[] = {"n", "turn_order", nullptr}; - int n = 1; - PyObject* turn_order_filter = nullptr; - - if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iO", const_cast(kwlist), - &n, &turn_order_filter)) { - return NULL; - } - - int filter_turn_order = -1; // -1 = no filter - if (turn_order_filter && turn_order_filter != Py_None) { - filter_turn_order = PyLong_AsLong(turn_order_filter); - if (filter_turn_order == -1 && PyErr_Occurred()) return NULL; - } - - auto& grid = self->data; - if (!grid->entities) Py_RETURN_NONE; - - for (int round = 0; round < n; round++) { - // Snapshot entity list to avoid iterator invalidation from callbacks - std::vector> snapshot; - for (auto& entity : *grid->entities) { - if (entity->turn_order == 0) continue; // Skip turn_order=0 - if (filter_turn_order >= 0 && entity->turn_order != filter_turn_order) continue; - snapshot.push_back(entity); - } - - // Sort by turn_order (ascending) - std::sort(snapshot.begin(), snapshot.end(), - [](const auto& a, const auto& b) { return a->turn_order < b->turn_order; }); - - for (auto& entity : snapshot) { - // Skip if entity was removed from grid during this round - if (!entity->grid) continue; - - // Skip IDLE - if (entity->behavior.type == BehaviorType::IDLE) continue; - - // Check TARGET trigger (if target_label set) - // #303: Tiered optimization: - // Tier 1: O(1) label check (target_label.empty()) - // Tier 2: O(bucket) spatial hash pre-filter - // Tier 3: O(radius^2) bounded FOV (TCOD respects radius) - // Tier 4: Per-entity FOV cache — skip recomputation when - // entity hasn't moved and map transparency unchanged - if (!entity->target_label.empty()) { - // Tier 2: Spatial hash proximity pre-filter - auto nearby = grid->spatial_hash.queryRadius( - static_cast(entity->cell_position.x), - static_cast(entity->cell_position.y), - static_cast(entity->sight_radius)); - - // Collect matching targets before touching FOV - std::vector> matching_targets; - for (auto& candidate : nearby) { - if (candidate.get() != entity.get() && - candidate->labels.count(entity->target_label)) { - matching_targets.push_back(candidate); - } - } - - if (!matching_targets.empty()) { - auto& cache = entity->target_fov_cache; - - // Tier 4: Check per-entity FOV cache - if (!cache.isValid(entity->cell_position, entity->sight_radius, - grid->transparency_generation)) { - // Cache miss — compute FOV and snapshot the visibility bitmap - grid->computeFOV(entity->cell_position.x, entity->cell_position.y, - entity->sight_radius, true, grid->fov_algorithm); - - int r = entity->sight_radius; - int side = 2 * r + 1; - cache.origin = entity->cell_position; - cache.radius = r; - cache.transparency_gen = grid->transparency_generation; - cache.vis_side = side; - cache.visibility.resize(side * side); - for (int dy = -r; dy <= r; dy++) { - for (int dx = -r; dx <= r; dx++) { - cache.visibility[(dy + r) * side + (dx + r)] = - grid->isInFOV(entity->cell_position.x + dx, - entity->cell_position.y + dy); - } - } - } - - // Check targets against cached visibility - for (auto& target : matching_targets) { - if (cache.isVisible(target->cell_position.x, - target->cell_position.y)) { - PyObject* target_pyobj = Py_None; - if (target->pyobject) { - target_pyobj = target->pyobject; - } - fireStepCallback(entity, 2 /* TARGET */, target_pyobj); - goto next_entity; - } - } - } - } - - { - // Execute behavior - BehaviorOutput output = executeBehavior(*entity, *grid); - - switch (output.result) { - case BehaviorResult::MOVED: { - int old_x = entity->cell_position.x; - int old_y = entity->cell_position.y; - entity->cell_position = output.target_cell; - grid->spatial_hash.updateCell(entity, old_x, old_y); - - // Queue movement animation - if (entity->move_speed > 0) { - // Animate draw_pos from old to new - entity->position = sf::Vector2f( - static_cast(output.target_cell.x), - static_cast(output.target_cell.y)); - } else { - // Instant: snap draw_pos - entity->position = sf::Vector2f( - static_cast(output.target_cell.x), - static_cast(output.target_cell.y)); - } - break; - } - case BehaviorResult::DONE: { - fireStepCallback(entity, 0 /* DONE */, Py_None); - // Revert to default behavior - entity->behavior.type = static_cast(entity->default_behavior); - break; - } - case BehaviorResult::BLOCKED: { - // Try to find what's blocking - PyObject* blocker = Py_None; - auto blockers = grid->spatial_hash.queryCell( - output.target_cell.x, output.target_cell.y); - if (!blockers.empty() && blockers[0]->pyobject) { - blocker = blockers[0]->pyobject; - } - fireStepCallback(entity, 1 /* BLOCKED */, blocker); - break; - } - case BehaviorResult::NO_ACTION: - break; - } - } - next_entity:; - } - } - - Py_RETURN_NONE; -} - -// Define the PyObjectType alias for the macros -typedef PyUIGridObject PyObjectType; - -// Combined methods array -PyMethodDef UIGrid_all_methods[] = { - UIDRAWABLE_METHODS, - {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, - {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, - "compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n" - "Compute field of view from a position.\n\n" - "Args:\n" - " pos: Position as (x, y) tuple, list, or Vector\n" - " radius: Maximum view distance (0 = unlimited)\n" - " light_walls: Whether walls are lit when visible\n" - " algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n" - "Updates the internal FOV state. Use is_in_fov(pos) to query visibility."}, - {"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS, - "is_in_fov(pos) -> bool\n\n" - "Check if a cell is in the field of view.\n\n" - "Args:\n" - " pos: Position as (x, y) tuple, list, or Vector\n\n" - "Returns:\n" - " True if the cell is visible, False otherwise\n\n" - "Must call compute_fov() first to calculate visibility."}, - {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, - "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" - "Compute A* path between two points.\n\n" - "Args:\n" - " start: Starting position as Vector, Entity, or (x, y) tuple\n" - " end: Target position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" - " collide: Label string. Entities with this label block pathfinding.\n\n" - "Returns:\n" - " AStarPath object if path exists, None otherwise.\n\n" - "The returned AStarPath can be iterated or walked step-by-step."}, - {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, - "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n" - "Get or create a Dijkstra distance map for a root position.\n\n" - "Args:\n" - " root: Root position as Vector, Entity, or (x, y) tuple\n" - " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" - " collide: Label string. Entities with this label block pathfinding.\n\n" - "Returns:\n" - " DijkstraMap object for querying distances and paths.\n\n" - "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n" - "the same root and collide label return the same cached map. Call\n" - "clear_dijkstra_maps() after changing grid walkability to invalidate."}, - {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, - "clear_dijkstra_maps() -> None\n\n" - "Clear all cached Dijkstra maps.\n\n" - "Call this after modifying grid cell walkability to ensure pathfinding\n" - "uses updated walkability data."}, - {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS, - "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer\n\n" - "Add a layer to the grid.\n\n" - "Args:\n" - " layer: A ColorLayer or TileLayer object. Layers with size (0, 0) are\n" - " automatically resized to match the grid. Named layers replace\n" - " any existing layer with the same name.\n\n" - "Returns:\n" - " 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(name_or_layer: str | ColorLayer | TileLayer) -> None\n\n" - "Remove a layer from the grid.\n\n" - "Args:\n" - " 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(name: str) -> ColorLayer | TileLayer | None\n\n" - "Get a layer by its name.\n\n" - "Args:\n" - " name: The name of the layer to find.\n\n" - "Returns:\n" - " 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(pos: tuple|Vector, radius: float) -> list[Entity]\n\n" - "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" - "Args:\n" - " pos: Center position as (x, y) tuple, Vector, or other 2-element sequence\n" - " radius: Search radius\n\n" - "Returns:\n" - " List of Entity objects within the radius."}, - {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS, - "center_camera(pos: tuple = None) -> None\n\n" - "Center the camera on a tile coordinate.\n\n" - "Args:\n" - " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n" - "Example:\n" - " grid.center_camera() # Center on middle of grid\n" - " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" - " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, - // #199 - HeightMap application methods - {"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS, - "apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n" - "Apply walkable/transparent properties where heightmap values are in range.\n\n" - "Args:\n" - " source: HeightMap with values to check. Must match grid size.\n" - " range: Tuple of (min, max) - cells with values in this range are affected.\n" - " walkable: If not None, set walkable to this value for cells in range.\n" - " transparent: If not None, set transparent to this value for cells in range.\n\n" - "Returns:\n" - " Grid: self, for method chaining.\n\n" - "Raises:\n" - " ValueError: If HeightMap size doesn't match grid size."}, - {"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS, - "apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n" - "Apply multiple thresholds in a single pass.\n\n" - "Args:\n" - " source: HeightMap with values to check. Must match grid size.\n" - " ranges: List of (range_tuple, properties_dict) tuples.\n" - " range_tuple: (min, max) value range\n" - " properties_dict: {'walkable': bool, 'transparent': bool}\n\n" - "Returns:\n" - " Grid: self, for method chaining.\n\n" - "Example:\n" - " grid.apply_ranges(terrain, [\n" - " ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n" - " ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n" - " ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n" - " ])"}, - // #301 - Turn management - {"step", (PyCFunction)UIGrid::py_step, METH_VARARGS | METH_KEYWORDS, - "step(n=1, turn_order=None) -> None\n\n" - "Execute n rounds of turn-based entity behavior.\n\n" - "Args:\n" - " n (int): Number of rounds to execute. Default: 1\n" - " turn_order (int, optional): Only process entities with this turn_order value\n\n" - "Each round: entities grouped by turn_order (ascending), behaviors executed,\n" - "triggers fired (TARGET, DONE, BLOCKED), movement animated."}, - {NULL} // Sentinel -}; - -PyGetSetDef UIGrid::getsetters[] = { - - // TODO - refactor into get_vector_member with field identifier values `(void*)n` - {"grid_size", (getter)UIGrid::get_grid_size, NULL, "Grid dimensions (grid_w, grid_h)", NULL}, - {"grid_w", (getter)UIGrid::get_grid_w, NULL, "Grid width in cells", NULL}, - {"grid_h", (getter)UIGrid::get_grid_h, NULL, "Grid height in cells", NULL}, - {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position of the grid as Vector", (void*)PyObjectsEnum::UIGRID}, - {"grid_pos", (getter)UIDrawable::get_grid_pos, (setter)UIDrawable::set_grid_pos, "Position in parent grid's tile coordinates (only when parent is Grid)", (void*)PyObjectsEnum::UIGRID}, - {"size", (getter)UIGrid::get_size, (setter)UIGrid::set_size, "Size of the grid as Vector (width, height)", NULL}, - {"center", (getter)UIGrid::get_center, (setter)UIGrid::set_center, "Grid coordinate at the center of the Grid's view (pan)", NULL}, - - {"entities", (getter)UIGrid::get_entities, NULL, "EntityCollection of entities on this grid", NULL}, - {"children", (getter)UIGrid::get_children, NULL, "UICollection of UIDrawable children (speech bubbles, effects, overlays)", NULL}, - {"layers", (getter)UIGrid::get_layers, NULL, "List of grid layers (ColorLayer, TileLayer) sorted by z_index", NULL}, - - {"x", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "top-left corner X-coordinate", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 0)}, - {"y", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "top-left corner Y-coordinate", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 1)}, - {"w", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "visible widget width", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 2)}, - {"h", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "visible widget height", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 3)}, - {"center_x", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view X-coordinate", (void*)4}, - {"center_y", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view Y-coordinate", (void*)5}, - {"zoom", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "zoom factor for displaying the Grid", (void*)6}, - {"camera_rotation", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "Rotation of grid contents around camera center (degrees). The grid widget stays axis-aligned; only the view into the world rotates.", (void*)7}, - - {"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click, - MCRF_PROPERTY(on_click, - "Callable executed when object is clicked. " - "Function receives (pos: Vector, button: str, action: str)." - ), (void*)PyObjectsEnum::UIGRID}, - - {"texture", (getter)UIGrid::get_texture, NULL, "Texture of the grid", NULL}, //TODO 7DRL-day2-item5 - {"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color, - "Background fill color of the grid. Returns a copy; modifying components requires reassignment. " - "For animation, use 'fill_color.r', 'fill_color.g', etc.", NULL}, - {"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective, - "Entity whose perspective to use for FOV rendering (None for omniscient view). " - "Setting an entity automatically enables perspective mode.", NULL}, - {"perspective_enabled", (getter)UIGrid::get_perspective_enabled, (setter)UIGrid::set_perspective_enabled, - "Whether to use perspective-based FOV rendering. When True with no valid entity, " - "all cells appear undiscovered.", NULL}, - {"fov", (getter)UIGrid::get_fov, (setter)UIGrid::set_fov, - "FOV algorithm for this grid (mcrfpy.FOV enum). " - "Used by entity.updateVisibility() and layer methods when fov=None.", NULL}, - {"fov_radius", (getter)UIGrid::get_fov_radius, (setter)UIGrid::set_fov_radius, - "Default FOV radius for this grid. Used when radius not specified.", NULL}, - {"z_index", (getter)UIDrawable::get_int, (setter)UIDrawable::set_int, - MCRF_PROPERTY(z_index, - "Z-order for rendering (lower values rendered first). " - "Automatically triggers scene resort when changed." - ), (void*)PyObjectsEnum::UIGRID}, - {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID}, - UIDRAWABLE_GETSETTERS, - UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID), - UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRID), - UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIGRID), - // #142 - Grid cell mouse events - {"on_cell_enter", (getter)UIGrid::get_on_cell_enter, (setter)UIGrid::set_on_cell_enter, - "Callback when mouse enters a grid cell. Called with (cell_pos: Vector).", NULL}, - {"on_cell_exit", (getter)UIGrid::get_on_cell_exit, (setter)UIGrid::set_on_cell_exit, - "Callback when mouse exits a grid cell. Called with (cell_pos: Vector).", NULL}, - {"on_cell_click", (getter)UIGrid::get_on_cell_click, (setter)UIGrid::set_on_cell_click, - "Callback when a grid cell is clicked. Called with (cell_pos: Vector).", NULL}, - {"hovered_cell", (getter)UIGrid::get_hovered_cell, NULL, - "Currently hovered cell as (x, y) tuple, or None if not hovering.", NULL}, - UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRID), - // #252 - GridView shim - {"view", (getter)UIGrid::get_view, NULL, - "Auto-created GridView for rendering (read-only). " - "When Grid is appended to a scene, this view is what actually renders.", NULL}, - {NULL} /* Sentinel */ -}; - -PyObject* UIGrid::get_entities(PyUIGridObject* self, void* closure) -{ - // Returns EntityCollection for entity management - // Use the type directly from namespace (type not exported to module) - PyTypeObject* type = &mcrfpydef::PyUIEntityCollectionType; - auto o = (PyUIEntityCollectionObject*)type->tp_alloc(type, 0); - if (o) { - o->data = self->data->entities; - o->grid = self->data; - } - return (PyObject*)o; -} - -PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure) -{ - // Returns UICollection for UIDrawable children (speech bubbles, effects, overlays) - // Use the type directly from namespace (#189 - type not exported to module) - PyTypeObject* type = &mcrfpydef::PyUICollectionType; - auto o = (PyUICollectionObject*)type->tp_alloc(type, 0); - if (o) { - o->data = self->data->children; - o->owner = self->data; // #122: Set owner for parent tracking - } - return (PyObject*)o; -} - -// #252 - get_view returns the auto-created GridView -PyObject* UIGrid::get_view(PyUIGridObject* self, void* closure) -{ - if (!self->view) Py_RETURN_NONE; - auto type = &mcrfpydef::PyUIGridViewType; - auto obj = (PyUIGridViewObject*)type->tp_alloc(type, 0); - if (!obj) return PyErr_NoMemory(); - obj->data = self->view; - obj->weakreflist = NULL; - return (PyObject*)obj; -} - -PyObject* UIGrid::repr(PyUIGridObject* self) -{ - std::ostringstream ss; - if (!self->data) ss << ""; - else { - auto grid = self->data; - auto box = grid->box; - ss << "center_x << ", " << grid->center_y << "), zoom=" << grid->zoom << - ")>"; - } - std::string repr_str = ss.str(); - return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); -} - -/* // TODO standard pointer would need deleted, but I opted for a shared pointer. tp_dealloc currently not even defined in the PyTypeObject -void PyUIGrid_dealloc(PyUIGridObject* self) { - delete self->data; // Clean up the allocated UIGrid object - Py_TYPE(self)->tp_free((PyObject*)self); -} -*/ - -// #142 - Grid cell mouse event getters/setters -PyObject* UIGrid::get_on_cell_enter(PyUIGridObject* self, void* closure) { - if (self->data->on_cell_enter_callable) { - PyObject* cb = self->data->on_cell_enter_callable->borrow(); - Py_INCREF(cb); // Return new reference, not borrowed - return cb; - } - Py_RETURN_NONE; -} - -// #230 - Cell hover callbacks now use PyCellHoverCallable (position-only) -int UIGrid::set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure) { - if (value == Py_None) { - self->data->on_cell_enter_callable.reset(); - } else { - self->data->on_cell_enter_callable = std::make_unique(value); - } - return 0; -} - -PyObject* UIGrid::get_on_cell_exit(PyUIGridObject* self, void* closure) { - if (self->data->on_cell_exit_callable) { - PyObject* cb = self->data->on_cell_exit_callable->borrow(); - Py_INCREF(cb); // Return new reference, not borrowed - return cb; - } - Py_RETURN_NONE; -} - -// #230 - Cell hover callbacks now use PyCellHoverCallable (position-only) -int UIGrid::set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure) { - if (value == Py_None) { - self->data->on_cell_exit_callable.reset(); - } else { - self->data->on_cell_exit_callable = std::make_unique(value); - } - return 0; -} - -PyObject* UIGrid::get_on_cell_click(PyUIGridObject* self, void* closure) { - if (self->data->on_cell_click_callable) { - PyObject* cb = self->data->on_cell_click_callable->borrow(); - Py_INCREF(cb); // Return new reference, not borrowed - return cb; - } - Py_RETURN_NONE; -} - -int UIGrid::set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure) { - if (value == Py_None) { - self->data->on_cell_click_callable.reset(); - } else { - self->data->on_cell_click_callable = std::make_unique(value); - } - return 0; -} - -PyObject* UIGrid::get_hovered_cell(PyUIGridObject* self, void* closure) { - if (self->data->hovered_cell.has_value()) { - return Py_BuildValue("(ii)", self->data->hovered_cell->x, self->data->hovered_cell->y); - } - Py_RETURN_NONE; -} +// Python property getters/setters moved to UIGridPyProperties.cpp +// Python method implementations moved to UIGridPyMethods.cpp // #142 - Convert screen coordinates to cell coordinates std::optional UIGrid::screenToCell(sf::Vector2f screen_pos) const { diff --git a/src/UIGridPyMethods.cpp b/src/UIGridPyMethods.cpp new file mode 100644 index 0000000..2c248fa --- /dev/null +++ b/src/UIGridPyMethods.cpp @@ -0,0 +1,1104 @@ +// UIGridPyMethods.cpp — Python method implementations for UIGrid +// Extracted from UIGrid.cpp (#149) for maintainability. +// Contains: FOV, layer management, spatial queries, camera, heightmap ops, +// step/behavior system, py_at/subscript, and method table arrays. +#include "UIGrid.h" +#include "UIGridView.h" +#include "UIGridPathfinding.h" +#include "McRFPy_API.h" +#include "PythonObjectCache.h" +#include "UIEntity.h" +#include "PyPositionHelper.h" +#include "PyVector.h" +#include "PyHeightMap.h" +#include "EntityBehavior.h" +#include "PyTrigger.h" +#include "UIBase.h" + +// ========================================================================= +// Cell access: py_at, subscript, mpmethods +// ========================================================================= + +PyObject* UIGrid::py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + int x, y; + + if (!PyPosition_ParseInt(args, kwds, &x, &y)) { + return NULL; + } + + if (x < 0 || x >= self->data->grid_w) { + PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w); + return NULL; + } + if (y < 0 || y >= self->data->grid_h) { + PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h); + return NULL; + } + + auto type = &mcrfpydef::PyUIGridPointType; + auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); + obj->grid = self->data; + obj->x = x; + obj->y = y; + return (PyObject*)obj; +} + +PyObject* UIGrid::subscript(PyUIGridObject* self, PyObject* key) +{ + if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) { + PyErr_SetString(PyExc_TypeError, "Grid indices must be a tuple of (x, y)"); + return NULL; + } + + PyObject* x_obj = PyTuple_GetItem(key, 0); + PyObject* y_obj = PyTuple_GetItem(key, 1); + + if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) { + PyErr_SetString(PyExc_TypeError, "Grid indices must be integers"); + return NULL; + } + + int x = PyLong_AsLong(x_obj); + int y = PyLong_AsLong(y_obj); + + if (x < 0 || x >= self->data->grid_w) { + PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w); + return NULL; + } + if (y < 0 || y >= self->data->grid_h) { + PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h); + return NULL; + } + + auto type = &mcrfpydef::PyUIGridPointType; + auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); + if (!obj) return NULL; + + obj->grid = self->data; + obj->x = x; + obj->y = y; + return (PyObject*)obj; +} + +PyMappingMethods UIGrid::mpmethods = { + .mp_length = NULL, + .mp_subscript = (binaryfunc)UIGrid::subscript, + .mp_ass_subscript = NULL +}; + +// ========================================================================= +// FOV methods +// ========================================================================= + +PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"pos", "radius", "light_walls", "algorithm", NULL}; + PyObject* pos_obj = NULL; + int radius = 0; + int light_walls = 1; + int algorithm = FOV_BASIC; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|ipi", const_cast(kwlist), + &pos_obj, &radius, &light_walls, &algorithm)) { + return NULL; + } + + int x, y; + if (!PyPosition_FromObjectInt(pos_obj, &x, &y)) { + return NULL; + } + + self->data->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm); + + Py_RETURN_NONE; +} + +PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + int x, y; + if (!PyPosition_ParseInt(args, kwds, &x, &y)) { + return NULL; + } + + bool in_fov = self->data->isInFOV(x, y); + return PyBool_FromLong(in_fov); +} + +// ========================================================================= +// Layer management +// ========================================================================= + +PyObject* UIGrid::py_add_layer(PyUIGridObject* self, PyObject* args) { + PyObject* layer_obj; + if (!PyArg_ParseTuple(args, "O", &layer_obj)) { + return NULL; + } + + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return NULL; + + auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); + 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); + return NULL; + } + + std::shared_ptr 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; + } + + 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; + + 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; + } + + 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); + } + } + + 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; + } + + 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; + } + + 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; + + 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; + } + + 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); + } + } + + 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; + } + + layer->parent_grid = self->data.get(); + self->data->layers.push_back(layer); + self->data->layers_need_sort = true; + py_layer->grid = self->data; + + auto tile_layer = std::static_pointer_cast(layer); + if (!tile_layer->texture) { + tile_layer->texture = self->data->getTexture(); + } + + } else { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer"); + return NULL; + } + + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + + Py_INCREF(py_layer_ref); + return py_layer_ref; +} + +PyObject* UIGrid::py_remove_layer(PyUIGridObject* self, PyObject* args) { + PyObject* layer_obj; + if (!PyArg_ParseTuple(args, "O", &layer_obj)) { + return NULL; + } + + 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"); + if (!mcrfpy_module) return NULL; + + auto* color_layer_type = PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); + if (color_layer_type && PyObject_IsInstance(layer_obj, color_layer_type)) { + Py_DECREF(color_layer_type); + Py_DECREF(mcrfpy_module); + auto* py_layer = (PyColorLayerObject*)layer_obj; + if (py_layer->data) { + py_layer->data->parent_grid = nullptr; + self->data->removeLayer(py_layer->data); + py_layer->grid.reset(); + } + Py_RETURN_NONE; + } + if (color_layer_type) Py_DECREF(color_layer_type); + + auto* tile_layer_type = PyObject_GetAttrString(mcrfpy_module, "TileLayer"); + if (tile_layer_type && PyObject_IsInstance(layer_obj, tile_layer_type)) { + Py_DECREF(tile_layer_type); + Py_DECREF(mcrfpy_module); + auto* py_layer = (PyTileLayerObject*)layer_obj; + if (py_layer->data) { + py_layer->data->parent_grid = nullptr; + self->data->removeLayer(py_layer->data); + py_layer->grid.reset(); + } + Py_RETURN_NONE; + } + if (tile_layer_type) Py_DECREF(tile_layer_type); + + Py_DECREF(mcrfpy_module); + PyErr_SetString(PyExc_TypeError, "layer must be a string (layer name), ColorLayer, or TileLayer"); + return NULL; +} + +PyObject* UIGrid::py_layer(PyUIGridObject* self, PyObject* args) { + const char* name_str; + if (!PyArg_ParseTuple(args, "s", &name_str)) { + return NULL; + } + + auto layer = self->data->getLayerByName(std::string(name_str)); + if (!layer) { + Py_RETURN_NONE; + } + + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) return NULL; + + if (layer->type == GridLayerType::Color) { + auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); + Py_DECREF(mcrfpy_module); + if (!type) return NULL; + + PyColorLayerObject* obj = (PyColorLayerObject*)type->tp_alloc(type, 0); + Py_DECREF(type); + if (!obj) return NULL; + + obj->data = std::static_pointer_cast(layer); + obj->grid = self->data; + return (PyObject*)obj; + } else { + auto* type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "TileLayer"); + Py_DECREF(mcrfpy_module); + if (!type) return NULL; + + PyTileLayerObject* obj = (PyTileLayerObject*)type->tp_alloc(type, 0); + Py_DECREF(type); + if (!obj) return NULL; + + obj->data = std::static_pointer_cast(layer); + obj->grid = self->data; + return (PyObject*)obj; + } +} + +// ========================================================================= +// Spatial queries +// ========================================================================= + +PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds) +{ + static const char* kwlist[] = {"pos", "radius", NULL}; + PyObject* pos_obj; + float radius; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "Of", const_cast(kwlist), + &pos_obj, &radius)) { + return NULL; + } + + float x, y; + if (!PyPosition_FromObject(pos_obj, &x, &y)) { + return NULL; + } + + if (radius < 0) { + PyErr_SetString(PyExc_ValueError, "radius must be non-negative"); + return NULL; + } + + auto entities = self->data->spatial_hash.queryRadius(x, y, radius); + + PyObject* result = PyList_New(entities.size()); + if (!result) return PyErr_NoMemory(); + + PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType; + + for (size_t i = 0; i < entities.size(); i++) { + auto& entity = entities[i]; + + PyObject* py_entity = nullptr; + if (entity->serial_number != 0) { + py_entity = PythonObjectCache::getInstance().lookup(entity->serial_number); + } + if (!py_entity) { + auto pyEntity = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0); + if (!pyEntity) { + Py_DECREF(result); + return PyErr_NoMemory(); + } + pyEntity->data = entity; + pyEntity->weakreflist = NULL; + py_entity = (PyObject*)pyEntity; + } + PyList_SET_ITEM(result, i, py_entity); + } + + return result; +} + +// ========================================================================= +// Camera positioning +// ========================================================================= + +void UIGrid::center_camera() { + int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + center_x = (grid_w / 2.0f) * cell_width; + center_y = (grid_h / 2.0f) * cell_height; + markDirty(); +} + +void UIGrid::center_camera(float tile_x, float tile_y) { + int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + float half_viewport_x = box.getSize().x / zoom / 2.0f; + float half_viewport_y = box.getSize().y / zoom / 2.0f; + center_x = tile_x * cell_width + half_viewport_x; + center_y = tile_y * cell_height + half_viewport_y; + markDirty(); +} + +PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) { + PyObject* pos_arg = nullptr; + + if (!PyArg_ParseTuple(args, "|O", &pos_arg)) { + return nullptr; + } + + if (pos_arg == nullptr || pos_arg == Py_None) { + self->data->center_camera(); + } else if (PyTuple_Check(pos_arg) && PyTuple_Size(pos_arg) == 2) { + PyObject* x_obj = PyTuple_GetItem(pos_arg, 0); + PyObject* y_obj = PyTuple_GetItem(pos_arg, 1); + + float tile_x, tile_y; + if (PyFloat_Check(x_obj)) { + tile_x = PyFloat_AsDouble(x_obj); + } else if (PyLong_Check(x_obj)) { + tile_x = (float)PyLong_AsLong(x_obj); + } else { + PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric"); + return nullptr; + } + + if (PyFloat_Check(y_obj)) { + tile_y = PyFloat_AsDouble(y_obj); + } else if (PyLong_Check(y_obj)) { + tile_y = (float)PyLong_AsLong(y_obj); + } else { + PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric"); + return nullptr; + } + + self->data->center_camera(tile_x, tile_y); + } else { + PyErr_SetString(PyExc_TypeError, "center_camera() takes an optional tuple (tile_x, tile_y)"); + return nullptr; + } + + Py_RETURN_NONE; +} + +// ========================================================================= +// HeightMap application methods +// ========================================================================= + +PyObject* UIGrid::py_apply_threshold(PyUIGridObject* self, PyObject* args, PyObject* kwds) { + static const char* keywords[] = {"source", "range", "walkable", "transparent", nullptr}; + PyObject* source_obj = nullptr; + PyObject* range_obj = nullptr; + PyObject* walkable_obj = Py_None; + PyObject* transparent_obj = Py_None; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|OO", const_cast(keywords), + &source_obj, &range_obj, &walkable_obj, &transparent_obj)) { + return nullptr; + } + + if (!PyObject_IsInstance(source_obj, (PyObject*)&mcrfpydef::PyHeightMapType)) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return nullptr; + } + PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj; + + if (!hmap->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + if (!PyTuple_Check(range_obj) || PyTuple_Size(range_obj) != 2) { + PyErr_SetString(PyExc_TypeError, "range must be a tuple of (min, max)"); + return nullptr; + } + + float range_min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 0)); + float range_max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) { + PyErr_Format(PyExc_ValueError, + "HeightMap size (%d, %d) does not match Grid size (%d, %d)", + hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h); + return nullptr; + } + + bool set_walkable = (walkable_obj != Py_None); + bool set_transparent = (transparent_obj != Py_None); + bool walkable_value = false; + bool transparent_value = false; + + if (set_walkable) { + walkable_value = PyObject_IsTrue(walkable_obj); + } + if (set_transparent) { + transparent_value = PyObject_IsTrue(transparent_obj); + } + + for (int y = 0; y < self->data->grid_h; y++) { + for (int x = 0; x < self->data->grid_w; x++) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + if (value >= range_min && value <= range_max) { + UIGridPoint& point = self->data->at(x, y); + if (set_walkable) { + point.walkable = walkable_value; + } + if (set_transparent) { + point.transparent = transparent_value; + } + } + } + } + + if (self->data->getTCODMap()) { + self->data->syncTCODMap(); + } + + Py_INCREF(self); + return (PyObject*)self; +} + +PyObject* UIGrid::py_apply_ranges(PyUIGridObject* self, PyObject* args) { + PyObject* source_obj = nullptr; + PyObject* ranges_obj = nullptr; + + if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) { + return nullptr; + } + + if (!PyObject_IsInstance(source_obj, (PyObject*)&mcrfpydef::PyHeightMapType)) { + PyErr_SetString(PyExc_TypeError, "source must be a HeightMap"); + return nullptr; + } + PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj; + + if (!hmap->heightmap) { + PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized"); + return nullptr; + } + + if (!PyList_Check(ranges_obj)) { + PyErr_SetString(PyExc_TypeError, "ranges must be a list"); + return nullptr; + } + + if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) { + PyErr_Format(PyExc_ValueError, + "HeightMap size (%d, %d) does not match Grid size (%d, %d)", + hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h); + return nullptr; + } + + struct RangeEntry { + float min, max; + bool set_walkable, set_transparent; + bool walkable_value, transparent_value; + }; + std::vector entries; + + Py_ssize_t num_ranges = PyList_Size(ranges_obj); + for (Py_ssize_t i = 0; i < num_ranges; i++) { + PyObject* entry = PyList_GetItem(ranges_obj, i); + + if (!PyTuple_Check(entry) || PyTuple_Size(entry) != 2) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] must be a tuple of (range, properties_dict)", i); + return nullptr; + } + + PyObject* range_tuple = PyTuple_GetItem(entry, 0); + PyObject* props_dict = PyTuple_GetItem(entry, 1); + + if (!PyTuple_Check(range_tuple) || PyTuple_Size(range_tuple) != 2) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] range must be a tuple of (min, max)", i); + return nullptr; + } + + if (!PyDict_Check(props_dict)) { + PyErr_Format(PyExc_TypeError, + "ranges[%zd] properties must be a dict", i); + return nullptr; + } + + RangeEntry re; + re.min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 0)); + re.max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 1)); + + if (PyErr_Occurred()) { + return nullptr; + } + + PyObject* walkable_val = PyDict_GetItemString(props_dict, "walkable"); + re.set_walkable = (walkable_val != nullptr); + if (re.set_walkable) { + re.walkable_value = PyObject_IsTrue(walkable_val); + } + + PyObject* transparent_val = PyDict_GetItemString(props_dict, "transparent"); + re.set_transparent = (transparent_val != nullptr); + if (re.set_transparent) { + re.transparent_value = PyObject_IsTrue(transparent_val); + } + + entries.push_back(re); + } + + for (int y = 0; y < self->data->grid_h; y++) { + for (int x = 0; x < self->data->grid_w; x++) { + float value = TCOD_heightmap_get_value(hmap->heightmap, x, y); + UIGridPoint& point = self->data->at(x, y); + + for (const auto& re : entries) { + if (value >= re.min && value <= re.max) { + if (re.set_walkable) { + point.walkable = re.walkable_value; + } + if (re.set_transparent) { + point.transparent = re.transparent_value; + } + break; + } + } + } + } + + if (self->data->getTCODMap()) { + self->data->syncTCODMap(); + } + + Py_INCREF(self); + return (PyObject*)self; +} + +// ========================================================================= +// Step / turn-based behavior system +// ========================================================================= + +static void fireStepCallback(std::shared_ptr& entity, int trigger_int, PyObject* data) { + PyObject* callback = entity->step_callback; + + if (!callback && entity->pyobject) { + PyObject* step_attr = PyObject_GetAttrString(entity->pyobject, "on_step"); + if (step_attr && PyCallable_Check(step_attr)) { + callback = step_attr; + } else { + PyErr_Clear(); + Py_XDECREF(step_attr); + return; + } + PyObject* trigger_obj = nullptr; + if (PyTrigger::trigger_enum_class) { + trigger_obj = PyObject_CallFunction(PyTrigger::trigger_enum_class, "i", trigger_int); + } + if (!trigger_obj) { + PyErr_Clear(); + trigger_obj = PyLong_FromLong(trigger_int); + } + if (!data) data = Py_None; + PyObject* result = PyObject_CallFunction(callback, "OO", trigger_obj, data); + Py_XDECREF(result); + if (PyErr_Occurred()) PyErr_Print(); + Py_DECREF(trigger_obj); + Py_DECREF(step_attr); + return; + } + + if (!callback) return; + + PyObject* trigger_obj = nullptr; + if (PyTrigger::trigger_enum_class) { + trigger_obj = PyObject_CallFunction(PyTrigger::trigger_enum_class, "i", trigger_int); + } + if (!trigger_obj) { + PyErr_Clear(); + trigger_obj = PyLong_FromLong(trigger_int); + } + + if (!data) data = Py_None; + PyObject* result = PyObject_CallFunction(callback, "OO", trigger_obj, data); + Py_XDECREF(result); + if (PyErr_Occurred()) PyErr_Print(); + Py_DECREF(trigger_obj); +} + +PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds) { + static const char* kwlist[] = {"n", "turn_order", nullptr}; + int n = 1; + PyObject* turn_order_filter = nullptr; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iO", const_cast(kwlist), + &n, &turn_order_filter)) { + return NULL; + } + + int filter_turn_order = -1; + if (turn_order_filter && turn_order_filter != Py_None) { + filter_turn_order = PyLong_AsLong(turn_order_filter); + if (filter_turn_order == -1 && PyErr_Occurred()) return NULL; + } + + auto& grid = self->data; + if (!grid->entities) Py_RETURN_NONE; + + for (int round = 0; round < n; round++) { + std::vector> snapshot; + for (auto& entity : *grid->entities) { + if (entity->turn_order == 0) continue; + if (filter_turn_order >= 0 && entity->turn_order != filter_turn_order) continue; + snapshot.push_back(entity); + } + + std::sort(snapshot.begin(), snapshot.end(), + [](const auto& a, const auto& b) { return a->turn_order < b->turn_order; }); + + for (auto& entity : snapshot) { + if (!entity->grid) continue; + if (entity->behavior.type == BehaviorType::IDLE) continue; + + if (!entity->target_label.empty()) { + auto nearby = grid->spatial_hash.queryRadius( + static_cast(entity->cell_position.x), + static_cast(entity->cell_position.y), + static_cast(entity->sight_radius)); + + std::vector> matching_targets; + for (auto& candidate : nearby) { + if (candidate.get() != entity.get() && + candidate->labels.count(entity->target_label)) { + matching_targets.push_back(candidate); + } + } + + if (!matching_targets.empty()) { + auto& cache = entity->target_fov_cache; + + if (!cache.isValid(entity->cell_position, entity->sight_radius, + grid->transparency_generation)) { + grid->computeFOV(entity->cell_position.x, entity->cell_position.y, + entity->sight_radius, true, grid->fov_algorithm); + + int r = entity->sight_radius; + int side = 2 * r + 1; + cache.origin = entity->cell_position; + cache.radius = r; + cache.transparency_gen = grid->transparency_generation; + cache.vis_side = side; + cache.visibility.resize(side * side); + for (int dy = -r; dy <= r; dy++) { + for (int dx = -r; dx <= r; dx++) { + cache.visibility[(dy + r) * side + (dx + r)] = + grid->isInFOV(entity->cell_position.x + dx, + entity->cell_position.y + dy); + } + } + } + + for (auto& target : matching_targets) { + if (cache.isVisible(target->cell_position.x, + target->cell_position.y)) { + PyObject* target_pyobj = Py_None; + if (target->pyobject) { + target_pyobj = target->pyobject; + } + fireStepCallback(entity, 2 /* TARGET */, target_pyobj); + goto next_entity; + } + } + } + } + + { + BehaviorOutput output = executeBehavior(*entity, *grid); + + switch (output.result) { + case BehaviorResult::MOVED: { + int old_x = entity->cell_position.x; + int old_y = entity->cell_position.y; + entity->cell_position = output.target_cell; + grid->spatial_hash.updateCell(entity, old_x, old_y); + + if (entity->move_speed > 0) { + entity->position = sf::Vector2f( + static_cast(output.target_cell.x), + static_cast(output.target_cell.y)); + } else { + entity->position = sf::Vector2f( + static_cast(output.target_cell.x), + static_cast(output.target_cell.y)); + } + break; + } + case BehaviorResult::DONE: { + fireStepCallback(entity, 0 /* DONE */, Py_None); + entity->behavior.type = static_cast(entity->default_behavior); + break; + } + case BehaviorResult::BLOCKED: { + PyObject* blocker = Py_None; + auto blockers = grid->spatial_hash.queryCell( + output.target_cell.x, output.target_cell.y); + if (!blockers.empty() && blockers[0]->pyobject) { + blocker = blockers[0]->pyobject; + } + fireStepCallback(entity, 1 /* BLOCKED */, blocker); + break; + } + case BehaviorResult::NO_ACTION: + break; + } + } + next_entity:; + } + } + + Py_RETURN_NONE; +} + +// ========================================================================= +// Method tables +// ========================================================================= + +PyMethodDef UIGrid::methods[] = { + {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, + {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, + "compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n" + "Compute field of view from a position.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n" + " radius: Maximum view distance (0 = unlimited)\n" + " light_walls: Whether walls are lit when visible\n" + " algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n" + "Updates the internal FOV state. Use is_in_fov(pos) to query visibility."}, + {"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS, + "is_in_fov(pos) -> bool\n\n" + "Check if a cell is in the field of view.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n\n" + "Returns:\n" + " True if the cell is visible, False otherwise\n\n" + "Must call compute_fov() first to calculate visibility."}, + {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, + "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" + "Compute A* path between two points.\n\n" + "Args:\n" + " start: Starting position as Vector, Entity, or (x, y) tuple\n" + " end: Target position as Vector, Entity, or (x, y) tuple\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" + "Returns:\n" + " AStarPath object if path exists, None otherwise.\n\n" + "The returned AStarPath can be iterated or walked step-by-step."}, + {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, + "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n" + "Get or create a Dijkstra distance map for a root position.\n\n" + "Args:\n" + " root: Root position as Vector, Entity, or (x, y) tuple\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" + "Returns:\n" + " DijkstraMap object for querying distances and paths.\n\n" + "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n" + "the same root and collide label return the same cached map. Call\n" + "clear_dijkstra_maps() after changing grid walkability to invalidate."}, + {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, + "clear_dijkstra_maps() -> None\n\n" + "Clear all cached Dijkstra maps.\n\n" + "Call this after modifying grid cell walkability to ensure pathfinding\n" + "uses updated walkability data."}, + {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS, + "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer"}, + {"remove_layer", (PyCFunction)UIGrid::py_remove_layer, METH_VARARGS, + "remove_layer(name_or_layer: str | ColorLayer | TileLayer) -> None"}, + {"layer", (PyCFunction)UIGrid::py_layer, METH_VARARGS, + "layer(name: str) -> ColorLayer | TileLayer | None"}, + {"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" + "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" + "Args:\n" + " pos: Center position as (x, y) tuple, Vector, or other 2-element sequence\n" + " radius: Search radius\n\n" + "Returns:\n" + " List of Entity objects within the radius."}, + {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS, + "center_camera(pos: tuple = None) -> None\n\n" + "Center the camera on a tile coordinate.\n\n" + "Args:\n" + " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n" + "Example:\n" + " grid.center_camera() # Center on middle of grid\n" + " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" + " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, + {"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS, + "apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n" + "Apply walkable/transparent properties where heightmap values are in range.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " range: Tuple of (min, max) - cells with values in this range are affected.\n" + " walkable: If not None, set walkable to this value for cells in range.\n" + " transparent: If not None, set transparent to this value for cells in range.\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Raises:\n" + " ValueError: If HeightMap size doesn't match grid size."}, + {"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS, + "apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n" + "Apply multiple thresholds in a single pass.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " ranges: List of (range_tuple, properties_dict) tuples.\n" + " range_tuple: (min, max) value range\n" + " properties_dict: {'walkable': bool, 'transparent': bool}\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Example:\n" + " grid.apply_ranges(terrain, [\n" + " ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n" + " ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n" + " ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n" + " ])"}, + {NULL, NULL, 0, NULL} +}; + +typedef PyUIGridObject PyObjectType; + +PyMethodDef UIGrid_all_methods[] = { + UIDRAWABLE_METHODS, + {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, + {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, + "compute_fov(pos, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n" + "Compute field of view from a position.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n" + " radius: Maximum view distance (0 = unlimited)\n" + " light_walls: Whether walls are lit when visible\n" + " algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n" + "Updates the internal FOV state. Use is_in_fov(pos) to query visibility."}, + {"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS | METH_KEYWORDS, + "is_in_fov(pos) -> bool\n\n" + "Check if a cell is in the field of view.\n\n" + "Args:\n" + " pos: Position as (x, y) tuple, list, or Vector\n\n" + "Returns:\n" + " True if the cell is visible, False otherwise\n\n" + "Must call compute_fov() first to calculate visibility."}, + {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, + "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n" + "Compute A* path between two points.\n\n" + "Args:\n" + " start: Starting position as Vector, Entity, or (x, y) tuple\n" + " end: Target position as Vector, Entity, or (x, y) tuple\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" + "Returns:\n" + " AStarPath object if path exists, None otherwise.\n\n" + "The returned AStarPath can be iterated or walked step-by-step."}, + {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, + "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n" + "Get or create a Dijkstra distance map for a root position.\n\n" + "Args:\n" + " root: Root position as Vector, Entity, or (x, y) tuple\n" + " diagonal_cost: Cost of diagonal movement (default: 1.41)\n" + " collide: Label string. Entities with this label block pathfinding.\n\n" + "Returns:\n" + " DijkstraMap object for querying distances and paths.\n\n" + "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n" + "the same root and collide label return the same cached map. Call\n" + "clear_dijkstra_maps() after changing grid walkability to invalidate."}, + {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, + "clear_dijkstra_maps() -> None\n\n" + "Clear all cached Dijkstra maps.\n\n" + "Call this after modifying grid cell walkability to ensure pathfinding\n" + "uses updated walkability data."}, + {"add_layer", (PyCFunction)UIGrid::py_add_layer, METH_VARARGS, + "add_layer(layer: ColorLayer | TileLayer) -> ColorLayer | TileLayer\n\n" + "Add a layer to the grid.\n\n" + "Args:\n" + " layer: A ColorLayer or TileLayer object. Layers with size (0, 0) are\n" + " automatically resized to match the grid. Named layers replace\n" + " any existing layer with the same name.\n\n" + "Returns:\n" + " 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(name_or_layer: str | ColorLayer | TileLayer) -> None\n\n" + "Remove a layer from the grid.\n\n" + "Args:\n" + " 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(name: str) -> ColorLayer | TileLayer | None\n\n" + "Get a layer by its name.\n\n" + "Args:\n" + " name: The name of the layer to find.\n\n" + "Returns:\n" + " 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(pos: tuple|Vector, radius: float) -> list[Entity]\n\n" + "Query entities within radius using spatial hash (O(k) where k = nearby entities).\n\n" + "Args:\n" + " pos: Center position as (x, y) tuple, Vector, or other 2-element sequence\n" + " radius: Search radius\n\n" + "Returns:\n" + " List of Entity objects within the radius."}, + {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS, + "center_camera(pos: tuple = None) -> None\n\n" + "Center the camera on a tile coordinate.\n\n" + "Args:\n" + " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n" + "Example:\n" + " grid.center_camera() # Center on middle of grid\n" + " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" + " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, + {"apply_threshold", (PyCFunction)UIGrid::py_apply_threshold, METH_VARARGS | METH_KEYWORDS, + "apply_threshold(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None) -> Grid\n\n" + "Apply walkable/transparent properties where heightmap values are in range.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " range: Tuple of (min, max) - cells with values in this range are affected.\n" + " walkable: If not None, set walkable to this value for cells in range.\n" + " transparent: If not None, set transparent to this value for cells in range.\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Raises:\n" + " ValueError: If HeightMap size doesn't match grid size."}, + {"apply_ranges", (PyCFunction)UIGrid::py_apply_ranges, METH_VARARGS, + "apply_ranges(source: HeightMap, ranges: list) -> Grid\n\n" + "Apply multiple thresholds in a single pass.\n\n" + "Args:\n" + " source: HeightMap with values to check. Must match grid size.\n" + " ranges: List of (range_tuple, properties_dict) tuples.\n" + " range_tuple: (min, max) value range\n" + " properties_dict: {'walkable': bool, 'transparent': bool}\n\n" + "Returns:\n" + " Grid: self, for method chaining.\n\n" + "Example:\n" + " grid.apply_ranges(terrain, [\n" + " ((0.0, 0.3), {'walkable': False, 'transparent': True}), # Water\n" + " ((0.3, 0.8), {'walkable': True, 'transparent': True}), # Land\n" + " ((0.8, 1.0), {'walkable': False, 'transparent': False}), # Mountains\n" + " ])"}, + {"step", (PyCFunction)UIGrid::py_step, METH_VARARGS | METH_KEYWORDS, + "step(n=1, turn_order=None) -> None\n\n" + "Execute n rounds of turn-based entity behavior.\n\n" + "Args:\n" + " n (int): Number of rounds to execute. Default: 1\n" + " turn_order (int, optional): Only process entities with this turn_order value\n\n" + "Each round: entities grouped by turn_order (ascending), behaviors executed,\n" + "triggers fired (TARGET, DONE, BLOCKED), movement animated."}, + {NULL} +}; diff --git a/src/UIGridPyProperties.cpp b/src/UIGridPyProperties.cpp new file mode 100644 index 0000000..3a60c26 --- /dev/null +++ b/src/UIGridPyProperties.cpp @@ -0,0 +1,597 @@ +// UIGridPyProperties.cpp — Python getter/setter implementations for UIGrid +// Extracted from UIGrid.cpp (#149) for maintainability. +// Contains: all PyGetSetDef getters/setters and the getsetters[] array. +#include "UIGrid.h" +#include "UIGridView.h" +#include "McRFPy_API.h" +#include "PythonObjectCache.h" +#include "PyColor.h" +#include "PyVector.h" +#include "PyFOV.h" +#include "UIBase.h" +#include "UICollection.h" +#include "McRFPy_Doc.h" + +// ========================================================================= +// Grid dimension properties +// ========================================================================= + +PyObject* UIGrid::get_grid_size(PyUIGridObject* self, void* closure) { + return PyVector(sf::Vector2f(static_cast(self->data->grid_w), + static_cast(self->data->grid_h))).pyObject(); +} + +PyObject* UIGrid::get_grid_w(PyUIGridObject* self, void* closure) { + return PyLong_FromLong(self->data->grid_w); +} + +PyObject* UIGrid::get_grid_h(PyUIGridObject* self, void* closure) { + return PyLong_FromLong(self->data->grid_h); +} + +PyObject* UIGrid::get_size(PyUIGridObject* self, void* closure) { + auto& box = self->data->box; + return PyVector(box.getSize()).pyObject(); +} + +int UIGrid::set_size(PyUIGridObject* self, PyObject* value, void* closure) { + float w, h; + PyVectorObject* vec = PyVector::from_arg(value); + if (vec) { + w = vec->data.x; + h = vec->data.y; + Py_DECREF(vec); + } else { + PyErr_Clear(); + if (!PyArg_ParseTuple(value, "ff", &w, &h)) { + PyErr_SetString(PyExc_TypeError, "size must be a Vector or tuple (w, h)"); + return -1; + } + } + self->data->box.setSize(sf::Vector2f(w, h)); + + unsigned int tex_width = static_cast(w * 1.5f); + unsigned int tex_height = static_cast(h * 1.5f); + + tex_width = std::min(tex_width, 4096u); + tex_height = std::min(tex_height, 4096u); + + self->data->renderTexture.create(tex_width, tex_height); + self->data->markDirty(); + + return 0; +} + +// ========================================================================= +// Camera/view properties +// ========================================================================= + +PyObject* UIGrid::get_center(PyUIGridObject* self, void* closure) { + return PyVector(sf::Vector2f(self->data->center_x, self->data->center_y)).pyObject(); +} + +int UIGrid::set_center(PyUIGridObject* self, PyObject* value, void* closure) { + float x, y; + if (!PyArg_ParseTuple(value, "ff", &x, &y)) { + PyErr_SetString(PyExc_ValueError, "Size must be a tuple of two floats"); + return -1; + } + self->data->center_x = x; + self->data->center_y = y; + self->data->markDirty(); + return 0; +} + +PyObject* UIGrid::get_float_member(PyUIGridObject* self, void* closure) +{ + auto member_ptr = reinterpret_cast(closure); + if (member_ptr == 0) + return PyFloat_FromDouble(self->data->box.getPosition().x); + else if (member_ptr == 1) + return PyFloat_FromDouble(self->data->box.getPosition().y); + else if (member_ptr == 2) + return PyFloat_FromDouble(self->data->box.getSize().x); + else if (member_ptr == 3) + return PyFloat_FromDouble(self->data->box.getSize().y); + else if (member_ptr == 4) + return PyFloat_FromDouble(self->data->center_x); + else if (member_ptr == 5) + return PyFloat_FromDouble(self->data->center_y); + else if (member_ptr == 6) + return PyFloat_FromDouble(self->data->zoom); + else if (member_ptr == 7) + return PyFloat_FromDouble(self->data->camera_rotation); + else + { + PyErr_SetString(PyExc_AttributeError, "Invalid attribute"); + return nullptr; + } +} + +int UIGrid::set_float_member(PyUIGridObject* self, PyObject* value, void* closure) +{ + float val; + auto member_ptr = reinterpret_cast(closure); + if (PyFloat_Check(value)) + { + val = PyFloat_AsDouble(value); + } + else if (PyLong_Check(value)) + { + val = PyLong_AsLong(value); + } + else + { + PyErr_SetString(PyExc_TypeError, "Value must be a number (int or float)"); + return -1; + } + if (member_ptr == 0) + self->data->box.setPosition(val, self->data->box.getPosition().y); + else if (member_ptr == 1) + self->data->box.setPosition(self->data->box.getPosition().x, val); + else if (member_ptr == 2) + { + self->data->box.setSize(sf::Vector2f(val, self->data->box.getSize().y)); + unsigned int tex_width = static_cast(val * 1.5f); + unsigned int tex_height = static_cast(self->data->box.getSize().y * 1.5f); + tex_width = std::min(tex_width, 4096u); + tex_height = std::min(tex_height, 4096u); + self->data->renderTexture.create(tex_width, tex_height); + } + else if (member_ptr == 3) + { + self->data->box.setSize(sf::Vector2f(self->data->box.getSize().x, val)); + unsigned int tex_width = static_cast(self->data->box.getSize().x * 1.5f); + unsigned int tex_height = static_cast(val * 1.5f); + tex_width = std::min(tex_width, 4096u); + tex_height = std::min(tex_height, 4096u); + self->data->renderTexture.create(tex_width, tex_height); + } + else if (member_ptr == 4) + self->data->center_x = val; + else if (member_ptr == 5) + self->data->center_y = val; + else if (member_ptr == 6) + self->data->zoom = val; + else if (member_ptr == 7) + self->data->camera_rotation = val; + + if (self->view) { + if (member_ptr == 0) + self->view->box.setPosition(val, self->view->box.getPosition().y); + else if (member_ptr == 1) + self->view->box.setPosition(self->view->box.getPosition().x, val); + else if (member_ptr == 2) + self->view->box.setSize(sf::Vector2f(val, self->view->box.getSize().y)); + else if (member_ptr == 3) + self->view->box.setSize(sf::Vector2f(self->view->box.getSize().x, val)); + else if (member_ptr == 4) self->view->center_x = val; + else if (member_ptr == 5) self->view->center_y = val; + else if (member_ptr == 6) self->view->zoom = val; + else if (member_ptr == 7) self->view->camera_rotation = val; + self->view->position = self->view->box.getPosition(); + } + + if (member_ptr == 0 || member_ptr == 1) { + self->data->markCompositeDirty(); + } else { + self->data->markDirty(); + } + + return 0; +} + +// ========================================================================= +// Texture property +// ========================================================================= + +PyObject* UIGrid::get_texture(PyUIGridObject* self, void* closure) { + auto texture = self->data->getTexture(); + if (!texture) { + Py_RETURN_NONE; + } + + auto type = &mcrfpydef::PyTextureType; + auto obj = (PyTextureObject*)type->tp_alloc(type, 0); + obj->data = texture; + return (PyObject*)obj; +} + +// ========================================================================= +// Fill color +// ========================================================================= + +PyObject* UIGrid::get_fill_color(PyUIGridObject* self, void* closure) +{ + auto& color = self->data->fill_color; + auto type = &mcrfpydef::PyColorType; + PyObject* args = Py_BuildValue("(iiii)", color.r, color.g, color.b, color.a); + PyObject* obj = PyObject_CallObject((PyObject*)type, args); + Py_DECREF(args); + return obj; +} + +int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure) +{ + if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyColorType)) { + PyErr_SetString(PyExc_TypeError, "fill_color must be a Color object"); + return -1; + } + + PyColorObject* color = (PyColorObject*)value; + self->data->fill_color = color->data; + self->data->markDirty(); + return 0; +} + +// ========================================================================= +// Perspective properties +// ========================================================================= + +PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure) +{ + auto locked = self->data->perspective_entity.lock(); + if (locked) { + if (locked->serial_number != 0) { + PyObject* cached = PythonObjectCache::getInstance().lookup(locked->serial_number); + if (cached) { + return cached; + } + } + + auto type = &mcrfpydef::PyUIEntityType; + auto o = (PyUIEntityObject*)type->tp_alloc(type, 0); + if (o) { + o->data = locked; + o->weakreflist = NULL; + return (PyObject*)o; + } + } + Py_RETURN_NONE; +} + +int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure) +{ + if (value == Py_None) { + self->data->perspective_entity.reset(); + self->data->markDirty(); + return 0; + } + + if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIEntityType)) { + PyErr_SetString(PyExc_TypeError, "perspective must be a UIEntity or None"); + return -1; + } + + PyUIEntityObject* entity_obj = (PyUIEntityObject*)value; + self->data->perspective_entity = entity_obj->data; + self->data->perspective_enabled = true; + self->data->markDirty(); + return 0; +} + +PyObject* UIGrid::get_perspective_enabled(PyUIGridObject* self, void* closure) +{ + return PyBool_FromLong(self->data->perspective_enabled); +} + +int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure) +{ + int enabled = PyObject_IsTrue(value); + if (enabled == -1) { + return -1; + } + self->data->perspective_enabled = enabled; + self->data->markDirty(); + return 0; +} + +// ========================================================================= +// FOV properties +// ========================================================================= + +PyObject* UIGrid::get_fov(PyUIGridObject* self, void* closure) +{ + if (PyFOV::fov_enum_class) { + PyObject* value = PyLong_FromLong(self->data->fov_algorithm); + if (!value) return NULL; + + PyObject* args = PyTuple_Pack(1, value); + Py_DECREF(value); + if (!args) return NULL; + + PyObject* result = PyObject_Call(PyFOV::fov_enum_class, args, NULL); + Py_DECREF(args); + return result; + } + return PyLong_FromLong(self->data->fov_algorithm); +} + +int UIGrid::set_fov(PyUIGridObject* self, PyObject* value, void* closure) +{ + TCOD_fov_algorithm_t algo; + if (!PyFOV::from_arg(value, &algo, nullptr)) { + return -1; + } + self->data->fov_algorithm = algo; + self->data->markDirty(); + return 0; +} + +PyObject* UIGrid::get_fov_radius(PyUIGridObject* self, void* closure) +{ + return PyLong_FromLong(self->data->fov_radius); +} + +int UIGrid::set_fov_radius(PyUIGridObject* self, PyObject* value, void* closure) +{ + if (!PyLong_Check(value)) { + PyErr_SetString(PyExc_TypeError, "fov_radius must be an integer"); + return -1; + } + long radius = PyLong_AsLong(value); + if (radius == -1 && PyErr_Occurred()) { + return -1; + } + if (radius < 0) { + PyErr_SetString(PyExc_ValueError, "fov_radius must be non-negative"); + return -1; + } + self->data->fov_radius = (int)radius; + self->data->markDirty(); + return 0; +} + +// ========================================================================= +// Collection getters +// ========================================================================= + +PyObject* UIGrid::get_entities(PyUIGridObject* self, void* closure) +{ + PyTypeObject* type = &mcrfpydef::PyUIEntityCollectionType; + auto o = (PyUIEntityCollectionObject*)type->tp_alloc(type, 0); + if (o) { + o->data = self->data->entities; + o->grid = self->data; + } + return (PyObject*)o; +} + +PyObject* UIGrid::get_children(PyUIGridObject* self, void* closure) +{ + PyTypeObject* type = &mcrfpydef::PyUICollectionType; + auto o = (PyUICollectionObject*)type->tp_alloc(type, 0); + if (o) { + o->data = self->data->children; + o->owner = self->data; + } + return (PyObject*)o; +} + +PyObject* UIGrid::get_view(PyUIGridObject* self, void* closure) +{ + if (!self->view) Py_RETURN_NONE; + auto type = &mcrfpydef::PyUIGridViewType; + auto obj = (PyUIGridViewObject*)type->tp_alloc(type, 0); + if (!obj) return PyErr_NoMemory(); + obj->data = self->view; + obj->weakreflist = NULL; + return (PyObject*)obj; +} + +PyObject* UIGrid::get_layers(PyUIGridObject* self, void* closure) { + self->data->sortLayers(); + + PyObject* tuple = PyTuple_New(self->data->layers.size()); + if (!tuple) return NULL; + + auto* mcrfpy_module = PyImport_ImportModule("mcrfpy"); + if (!mcrfpy_module) { + Py_DECREF(tuple); + return NULL; + } + + auto* color_layer_type = (PyTypeObject*)PyObject_GetAttrString(mcrfpy_module, "ColorLayer"); + auto* tile_layer_type = (PyTypeObject*)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(tuple); + return NULL; + } + + for (size_t i = 0; i < self->data->layers.size(); ++i) { + auto& layer = self->data->layers[i]; + PyObject* py_layer = nullptr; + + if (layer->type == GridLayerType::Color) { + PyColorLayerObject* obj = (PyColorLayerObject*)color_layer_type->tp_alloc(color_layer_type, 0); + if (obj) { + obj->data = std::static_pointer_cast(layer); + obj->grid = self->data; + py_layer = (PyObject*)obj; + } + } else { + PyTileLayerObject* obj = (PyTileLayerObject*)tile_layer_type->tp_alloc(tile_layer_type, 0); + if (obj) { + obj->data = std::static_pointer_cast(layer); + obj->grid = self->data; + py_layer = (PyObject*)obj; + } + } + + if (!py_layer) { + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + Py_DECREF(tuple); + return NULL; + } + + PyTuple_SET_ITEM(tuple, i, py_layer); + } + + Py_DECREF(color_layer_type); + Py_DECREF(tile_layer_type); + return tuple; +} + +// ========================================================================= +// repr +// ========================================================================= + +PyObject* UIGrid::repr(PyUIGridObject* self) +{ + std::ostringstream ss; + if (!self->data) ss << ""; + else { + auto grid = self->data; + auto box = grid->box; + ss << "center_x << ", " << grid->center_y << "), zoom=" << grid->zoom << + ")>"; + } + std::string repr_str = ss.str(); + return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); +} + +// ========================================================================= +// Cell callback properties +// ========================================================================= + +PyObject* UIGrid::get_on_cell_enter(PyUIGridObject* self, void* closure) { + if (self->data->on_cell_enter_callable) { + PyObject* cb = self->data->on_cell_enter_callable->borrow(); + Py_INCREF(cb); + return cb; + } + Py_RETURN_NONE; +} + +int UIGrid::set_on_cell_enter(PyUIGridObject* self, PyObject* value, void* closure) { + if (value == Py_None) { + self->data->on_cell_enter_callable.reset(); + } else { + self->data->on_cell_enter_callable = std::make_unique(value); + } + return 0; +} + +PyObject* UIGrid::get_on_cell_exit(PyUIGridObject* self, void* closure) { + if (self->data->on_cell_exit_callable) { + PyObject* cb = self->data->on_cell_exit_callable->borrow(); + Py_INCREF(cb); + return cb; + } + Py_RETURN_NONE; +} + +int UIGrid::set_on_cell_exit(PyUIGridObject* self, PyObject* value, void* closure) { + if (value == Py_None) { + self->data->on_cell_exit_callable.reset(); + } else { + self->data->on_cell_exit_callable = std::make_unique(value); + } + return 0; +} + +PyObject* UIGrid::get_on_cell_click(PyUIGridObject* self, void* closure) { + if (self->data->on_cell_click_callable) { + PyObject* cb = self->data->on_cell_click_callable->borrow(); + Py_INCREF(cb); + return cb; + } + Py_RETURN_NONE; +} + +int UIGrid::set_on_cell_click(PyUIGridObject* self, PyObject* value, void* closure) { + if (value == Py_None) { + self->data->on_cell_click_callable.reset(); + } else { + self->data->on_cell_click_callable = std::make_unique(value); + } + return 0; +} + +PyObject* UIGrid::get_hovered_cell(PyUIGridObject* self, void* closure) { + if (self->data->hovered_cell.has_value()) { + return Py_BuildValue("(ii)", self->data->hovered_cell->x, self->data->hovered_cell->y); + } + Py_RETURN_NONE; +} + +// ========================================================================= +// getsetters[] table +// ========================================================================= + +typedef PyUIGridObject PyObjectType; + +PyGetSetDef UIGrid::getsetters[] = { + + {"grid_size", (getter)UIGrid::get_grid_size, NULL, "Grid dimensions (grid_w, grid_h)", NULL}, + {"grid_w", (getter)UIGrid::get_grid_w, NULL, "Grid width in cells", NULL}, + {"grid_h", (getter)UIGrid::get_grid_h, NULL, "Grid height in cells", NULL}, + {"pos", (getter)UIDrawable::get_pos, (setter)UIDrawable::set_pos, "Position of the grid as Vector", (void*)PyObjectsEnum::UIGRID}, + {"grid_pos", (getter)UIDrawable::get_grid_pos, (setter)UIDrawable::set_grid_pos, "Position in parent grid's tile coordinates (only when parent is Grid)", (void*)PyObjectsEnum::UIGRID}, + {"size", (getter)UIGrid::get_size, (setter)UIGrid::set_size, "Size of the grid as Vector (width, height)", NULL}, + {"center", (getter)UIGrid::get_center, (setter)UIGrid::set_center, "Grid coordinate at the center of the Grid's view (pan)", NULL}, + + {"entities", (getter)UIGrid::get_entities, NULL, "EntityCollection of entities on this grid", NULL}, + {"children", (getter)UIGrid::get_children, NULL, "UICollection of UIDrawable children (speech bubbles, effects, overlays)", NULL}, + {"layers", (getter)UIGrid::get_layers, NULL, "List of grid layers (ColorLayer, TileLayer) sorted by z_index", NULL}, + + {"x", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "top-left corner X-coordinate", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 0)}, + {"y", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "top-left corner Y-coordinate", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 1)}, + {"w", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "visible widget width", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 2)}, + {"h", (getter)UIDrawable::get_float_member, (setter)UIDrawable::set_float_member, "visible widget height", (void*)((intptr_t)PyObjectsEnum::UIGRID << 8 | 3)}, + {"center_x", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view X-coordinate", (void*)4}, + {"center_y", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "center of the view Y-coordinate", (void*)5}, + {"zoom", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "zoom factor for displaying the Grid", (void*)6}, + {"camera_rotation", (getter)UIGrid::get_float_member, (setter)UIGrid::set_float_member, "Rotation of grid contents around camera center (degrees). The grid widget stays axis-aligned; only the view into the world rotates.", (void*)7}, + + {"on_click", (getter)UIDrawable::get_click, (setter)UIDrawable::set_click, + MCRF_PROPERTY(on_click, + "Callable executed when object is clicked. " + "Function receives (pos: Vector, button: str, action: str)." + ), (void*)PyObjectsEnum::UIGRID}, + + {"texture", (getter)UIGrid::get_texture, NULL, "Texture of the grid", NULL}, + {"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color, + "Background fill color of the grid. Returns a copy; modifying components requires reassignment. " + "For animation, use 'fill_color.r', 'fill_color.g', etc.", NULL}, + {"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective, + "Entity whose perspective to use for FOV rendering (None for omniscient view). " + "Setting an entity automatically enables perspective mode.", NULL}, + {"perspective_enabled", (getter)UIGrid::get_perspective_enabled, (setter)UIGrid::set_perspective_enabled, + "Whether to use perspective-based FOV rendering. When True with no valid entity, " + "all cells appear undiscovered.", NULL}, + {"fov", (getter)UIGrid::get_fov, (setter)UIGrid::set_fov, + "FOV algorithm for this grid (mcrfpy.FOV enum). " + "Used by entity.updateVisibility() and layer methods when fov=None.", NULL}, + {"fov_radius", (getter)UIGrid::get_fov_radius, (setter)UIGrid::set_fov_radius, + "Default FOV radius for this grid. Used when radius not specified.", NULL}, + {"z_index", (getter)UIDrawable::get_int, (setter)UIDrawable::set_int, + MCRF_PROPERTY(z_index, + "Z-order for rendering (lower values rendered first). " + "Automatically triggers scene resort when changed." + ), (void*)PyObjectsEnum::UIGRID}, + {"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID}, + UIDRAWABLE_GETSETTERS, + UIDRAWABLE_PARENT_GETSETTERS(PyObjectsEnum::UIGRID), + UIDRAWABLE_ALIGNMENT_GETSETTERS(PyObjectsEnum::UIGRID), + UIDRAWABLE_ROTATION_GETSETTERS(PyObjectsEnum::UIGRID), + {"on_cell_enter", (getter)UIGrid::get_on_cell_enter, (setter)UIGrid::set_on_cell_enter, + "Callback when mouse enters a grid cell. Called with (cell_pos: Vector).", NULL}, + {"on_cell_exit", (getter)UIGrid::get_on_cell_exit, (setter)UIGrid::set_on_cell_exit, + "Callback when mouse exits a grid cell. Called with (cell_pos: Vector).", NULL}, + {"on_cell_click", (getter)UIGrid::get_on_cell_click, (setter)UIGrid::set_on_cell_click, + "Callback when a grid cell is clicked. Called with (cell_pos: Vector).", NULL}, + {"hovered_cell", (getter)UIGrid::get_hovered_cell, NULL, + "Currently hovered cell as (x, y) tuple, or None if not hovering.", NULL}, + UIDRAWABLE_SHADER_GETSETTERS(PyObjectsEnum::UIGRID), + {"view", (getter)UIGrid::get_view, NULL, + "Auto-created GridView for rendering (read-only). " + "When Grid is appended to a scene, this view is what actually renders.", NULL}, + {NULL} +}; diff --git a/tools/build_debug_libs.sh b/tools/build_debug_libs.sh new file mode 100755 index 0000000..f4eae8e --- /dev/null +++ b/tools/build_debug_libs.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# +# Build libtcod-headless with sanitizer instrumentation. +# +# Usage: +# tools/build_debug_libs.sh # Build with debug symbols only +# tools/build_debug_libs.sh --asan # Build with AddressSanitizer +# tools/build_debug_libs.sh --tsan # Build with ThreadSanitizer +# tools/build_debug_libs.sh --clean # Remove build artifacts first +# +# Output: __lib_debug/libtcod.so (instrumented) +# +# Why: The pre-built libtcod in __lib/ is uninstrumented. ASan/TSan can +# detect bugs in our code that corrupt libtcod's memory, but cannot detect +# bugs originating inside libtcod (FOV, pathfinding) that touch our data. +# Building libtcod with the same sanitizer flags closes this gap. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +LIBTCOD_DIR="$PROJECT_ROOT/modules/libtcod-headless" +OUTPUT_DIR="$PROJECT_ROOT/__lib_debug" +JOBS="$(nproc 2>/dev/null || echo 4)" + +MODE="debug" +CLEAN=0 +for arg in "$@"; do + case "$arg" in + --asan) MODE="asan" ;; + --tsan) MODE="tsan" ;; + --clean) CLEAN=1 ;; + --help|-h) + echo "Usage: $0 [--asan|--tsan] [--clean]" + echo "" + echo "Flags:" + echo " (default) Debug symbols only (-g -O1)" + echo " --asan AddressSanitizer + UBSan instrumentation" + echo " --tsan ThreadSanitizer instrumentation" + echo " --clean Remove build artifacts before building" + exit 0 + ;; + *) + echo "Unknown argument: $arg" + echo "Run '$0 --help' for usage." + exit 1 + ;; + esac +done + +BUILD_DIR="$LIBTCOD_DIR/build-debug-$MODE" + +case "$MODE" in + debug) + SANITIZER_FLAGS="" + echo "=== Building libtcod-headless with debug symbols ===" + ;; + asan) + SANITIZER_FLAGS="-fsanitize=address,undefined -fno-omit-frame-pointer" + echo "=== Building libtcod-headless with ASan + UBSan ===" + ;; + tsan) + SANITIZER_FLAGS="-fsanitize=thread" + echo "=== Building libtcod-headless with TSan ===" + ;; +esac + +if [ "$CLEAN" -eq 1 ]; then + echo "Cleaning $BUILD_DIR..." + rm -rf "$BUILD_DIR" +fi + +if [ ! -f "$LIBTCOD_DIR/CMakeLists.txt" ]; then + echo "ERROR: libtcod-headless not found at $LIBTCOD_DIR/CMakeLists.txt" + echo "Make sure the submodule is initialized:" + echo " git submodule update --init modules/libtcod-headless" + exit 1 +fi + +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +CMAKE_EXTRA_FLAGS="" +if [ -n "$SANITIZER_FLAGS" ]; then + CMAKE_EXTRA_FLAGS="-DCMAKE_C_FLAGS=$SANITIZER_FLAGS -DCMAKE_CXX_FLAGS=$SANITIZER_FLAGS" +fi + +if [ ! -f Makefile ]; then + echo "Configuring libtcod-headless ($MODE)..." + # shellcheck disable=SC2086 + cmake "$LIBTCOD_DIR" \ + -DCMAKE_BUILD_TYPE=Debug \ + -DBUILD_SHARED_LIBS=ON \ + $CMAKE_EXTRA_FLAGS + echo "Configuration complete." +else + echo "Makefile exists, skipping configure (use --clean to reconfigure)." +fi + +echo "Building libtcod-headless (-j$JOBS)..." +make -j"$JOBS" + +BUILT_LIB="" +for candidate in "$BUILD_DIR/libtcod.so" "$BUILD_DIR/libtcod"*.so*; do + if [ -f "$candidate" ] && [ ! -L "$candidate" ]; then + BUILT_LIB="$candidate" + break + fi +done + +if [ -z "$BUILT_LIB" ]; then + echo "ERROR: Could not find built libtcod.so in $BUILD_DIR" + echo "Contents:" + ls -la "$BUILD_DIR"/libtcod* 2>/dev/null || echo " (no libtcod files found)" + exit 1 +fi + +mkdir -p "$OUTPUT_DIR" + +BASENAME="$(basename "$BUILT_LIB")" +echo "Copying $BUILT_LIB -> $OUTPUT_DIR/$BASENAME" +cp "$BUILT_LIB" "$OUTPUT_DIR/$BASENAME" + +cd "$OUTPUT_DIR" +for link_name in libtcod.so libtcod.so.2 libtcod.so.2.2; do + if [ "$link_name" != "$BASENAME" ]; then + ln -sf "$BASENAME" "$link_name" + fi +done + +echo "" +echo "=== Instrumented libtcod build complete ===" +echo " Mode: $MODE" +echo " Library: $OUTPUT_DIR/$BASENAME" +echo " Size: $(du -h "$OUTPUT_DIR/$BASENAME" | cut -f1)" +echo "" +echo "The existing 'make asan' / 'make tsan' targets link __lib_debug/ first," +echo "so the instrumented libtcod will be picked up automatically."