#include "UIEntity.h" #include "UIGrid.h" #include "UIGridView.h" // #252: Entity.grid accepts GridView #include "UIGridPathfinding.h" #include "PathProvider.h" #include "McRFPy_API.h" #include #include #include #include "PyVector.h" #include "PythonObjectCache.h" #include "PyFOV.h" #include "PyDiscreteMap.h" // #294: perspective_map wrapper #include "PyPerspective.h" // #294: VISIBLE constant #include "Animation.h" #include "PyAnimation.h" #include "PyEasing.h" #include "PyPositionHelper.h" #include "PyShader.h" // #106: Shader support #include "PyUniformCollection.h" // #106: Uniform collection support // UIDrawable methods now in UIBase.h #include "UIEntityPyMethods.h" #include "McRFPy_Doc.h" #include // #313: UIEntity::grid holds the GridData base, but some Python wrappers // (PyUIGridObject, PyUIGridPointObject) and pathfinding helpers still take the // full UIGrid. GridData is never independently heap-allocated -- it is always // a UIGrid base subobject (see GridData.h) -- so this aliasing downcast is // valid and shares the original control block (never mints a new one, which // would double-free and break the #251 use_count dealloc gate). // TODO(#252): remove once those wrappers accept pure GridData. static std::shared_ptr grid_as_uigrid(const std::shared_ptr& grid) { if (!grid) return nullptr; assert(dynamic_cast(grid.get()) != nullptr); return std::shared_ptr(grid, static_cast(grid.get())); } UIEntity::UIEntity() : grid(nullptr), position(0.0f, 0.0f), sprite_offset(0.0f, 0.0f) { // perspective_map starts null; lazily allocated on first access or // updateVisibility() call once a grid is set (#294). } UIEntity::~UIEntity() { releasePyIdentity(); if (serial_number != 0) { PythonObjectCache::getInstance().remove(serial_number); } } // Removed UIEntity(UIGrid&) constructor - using lazy initialization instead void UIEntity::updateVisibility() { if (!grid) return; // Lazy-allocate or resize perspective_map if grid dimensions changed. // Dimension mismatch wipes prior state -- the entity's memory has no // identity with a differently-sized grid, so starting fresh is correct. size_t expected = static_cast(grid->grid_w) * grid->grid_h; if (!perspective_map || perspective_map->size() != expected) { perspective_map = std::make_shared(grid->grid_w, grid->grid_h, 0); } // Demote visible (2) -> discovered (1) from prior tick. The invariant // `visible subset of discovered` is structural in the 3-state model: cells // currently visible will be re-promoted to 2 below, and cells that // just left FOV fall to discovered. perspective_map->demoteVisible(); // Compute FOV from entity's cell position (#114, #295) int x = cell_position.x; int y = cell_position.y; // Use grid's configured FOV algorithm and radius grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm); // Promote visible cells to 2 (VISIBLE). Cells going 0 -> 2 are // freshly discovered; cells going 1 -> 2 were already discovered. uint8_t* buf = perspective_map->data(); for (int gy = 0; gy < grid->grid_h; gy++) { for (int gx = 0; gx < grid->grid_w; gx++) { if (grid->isInFOV(gx, gy)) { buf[gy * grid->grid_w + gx] = PyPerspective::VISIBLE; } } } // #113 - Update any ColorLayers bound to this entity via perspective // Get shared_ptr to self for comparison std::shared_ptr self_ptr = nullptr; if (grid->entities) { for (auto& entity : *grid->entities) { if (entity.get() == this) { self_ptr = entity; break; } } } if (self_ptr) { for (auto& layer : grid->layers) { if (layer->type == GridLayerType::Color) { auto color_layer = std::static_pointer_cast(layer); if (color_layer->has_perspective) { auto bound_entity = color_layer->perspective_entity.lock(); if (bound_entity && bound_entity.get() == this) { color_layer->updatePerspective(); } } } } } } PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { // #294: at(x, y) returns grid.at(x, y) when the cell is currently VISIBLE // to this entity, None otherwise. Equivalent to: // self.grid.at(x, y) if self.perspective_map[x, y] == Perspective.VISIBLE else None int x, y; if (!PyPosition_ParseInt(args, kwds, &x, &y)) { return NULL; // Error already set by PyPosition_ParseInt } auto& entity = self->data; if (!entity->grid) { PyErr_SetString(PyExc_ValueError, "Entity cannot access surroundings because it is not associated with a grid"); return NULL; } // Bounds check if (x < 0 || x >= entity->grid->grid_w || y < 0 || y >= entity->grid->grid_h) { PyErr_Format(PyExc_IndexError, "Grid coordinates (%d, %d) out of bounds", x, y); return NULL; } // No perspective yet or cell not visible -> None if (!entity->perspective_map) Py_RETURN_NONE; uint8_t state = entity->perspective_map->data()[y * entity->grid->grid_w + x]; if (state != PyPerspective::VISIBLE) Py_RETURN_NONE; // Construct a GridPoint wrapper (same pattern as grid.at(x, y)). auto type = &mcrfpydef::PyUIGridPointType; auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0); if (!obj) return NULL; obj->grid = grid_as_uigrid(entity->grid); // #313: wrapper still holds UIGrid obj->x = x; obj->y = y; return (PyObject*)obj; } PyObject* UIEntity::index(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)) { // Check if entity has an associated grid if (!self->data || !self->data->grid) { PyErr_SetString(PyExc_RuntimeError, "Entity is not associated with a grid"); return NULL; } // Get the grid's entity collection auto entities = self->data->grid->entities; if (!entities) { PyErr_SetString(PyExc_RuntimeError, "Grid has no entity collection"); return NULL; } // Find this entity in the collection int index = 0; for (auto it = entities->begin(); it != entities->end(); ++it, ++index) { if (it->get() == self->data.get()) { return PyLong_FromLong(index); } } // Entity not found in its grid's collection PyErr_SetString(PyExc_ValueError, "Entity not found in its grid's entity collection"); return NULL; } int UIEntity::init(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { // Define all parameters with defaults PyObject* grid_pos_obj = nullptr; PyObject* texture = nullptr; int sprite_index = 0; PyObject* grid_obj = nullptr; int visible = 1; float opacity = 1.0f; const char* name = nullptr; float x = 0.0f, y = 0.0f; PyObject* sprite_offset_obj = nullptr; PyObject* labels_obj = nullptr; // Keywords list matches the new spec: positional args first, then all keyword args static const char* kwlist[] = { "grid_pos", "texture", "sprite_index", // Positional args (as per spec) // Keyword-only args "grid", "visible", "opacity", "name", "x", "y", "sprite_offset", "labels", nullptr }; // Parse arguments with | for optional positional args if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOiOifzffOO", const_cast(kwlist), &grid_pos_obj, &texture, &sprite_index, // Positional &grid_obj, &visible, &opacity, &name, &x, &y, &sprite_offset_obj, &labels_obj)) { return -1; } // Handle grid position argument (can be tuple or use x/y keywords) if (grid_pos_obj) { if (PyTuple_Check(grid_pos_obj) && PyTuple_Size(grid_pos_obj) == 2) { PyObject* x_val = PyTuple_GetItem(grid_pos_obj, 0); PyObject* y_val = PyTuple_GetItem(grid_pos_obj, 1); if ((PyFloat_Check(x_val) || PyLong_Check(x_val)) && (PyFloat_Check(y_val) || PyLong_Check(y_val))) { x = PyFloat_Check(x_val) ? PyFloat_AsDouble(x_val) : PyLong_AsLong(x_val); y = PyFloat_Check(y_val) ? PyFloat_AsDouble(y_val) : PyLong_AsLong(y_val); } else { PyErr_SetString(PyExc_TypeError, "grid_pos tuple must contain numbers"); return -1; } } else { PyErr_SetString(PyExc_TypeError, "grid_pos must be a tuple (x, y)"); return -1; } } // Handle texture argument std::shared_ptr texture_ptr = nullptr; if (texture && texture != Py_None) { if (!PyObject_IsInstance(texture, (PyObject*)&mcrfpydef::PyTextureType)) { PyErr_SetString(PyExc_TypeError, "texture must be a mcrfpy.Texture instance or None"); return -1; } auto pytexture = (PyTextureObject*)texture; texture_ptr = pytexture->data; } else { // Use default texture when None or not provided texture_ptr = McRFPy_API::default_texture; } // Handle grid argument - accept both internal _GridData and GridView (unified Grid) if (grid_obj && !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridType) && !PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) { PyErr_SetString(PyExc_TypeError, "grid must be a mcrfpy.Grid instance"); return -1; } // Create the entity self->data = std::make_shared(); // Initialize weak reference list self->weakreflist = NULL; // Register in Python object cache if (self->data->serial_number == 0) { self->data->serial_number = PythonObjectCache::getInstance().assignSerial(); PyObject* weakref = PyWeakref_NewRef((PyObject*)self, NULL); if (weakref) { PythonObjectCache::getInstance().registerObject(self->data->serial_number, weakref); Py_DECREF(weakref); // Cache owns the reference now } } // Hold a strong reference to preserve Python subclass identity. // Without this, the Python wrapper can be GC'd while the C++ entity // lives on in a grid, and later access returns a base Entity wrapper // that lacks subclass methods. Cleared in die() and set_grid(None). self->data->pyobject = (PyObject*)self; Py_INCREF(self); // Set texture and sprite index if (texture_ptr) { self->data->sprite = UISprite(texture_ptr, sprite_index, sf::Vector2f(0,0), 1.0); } else { // Create an empty sprite for testing self->data->sprite = UISprite(); } // Set position using grid coordinates self->data->position = sf::Vector2f(x, y); // #295: Initialize cell_position from grid coordinates self->data->cell_position = sf::Vector2i(static_cast(x), static_cast(y)); // Handle sprite_offset argument (optional tuple, default (0,0)) if (sprite_offset_obj && sprite_offset_obj != Py_None) { sf::Vector2f offset = PyObject_to_sfVector2f(sprite_offset_obj); if (PyErr_Occurred()) { return -1; } self->data->sprite_offset = offset; } // Set other properties (delegate to sprite) self->data->sprite.visible = visible; self->data->sprite.opacity = opacity; if (name) { self->data->sprite.name = std::string(name); } // #296 - Parse labels kwarg if (labels_obj && labels_obj != Py_None) { PyObject* iter = PyObject_GetIter(labels_obj); if (!iter) { PyErr_SetString(PyExc_TypeError, "labels must be iterable"); return -1; } PyObject* item; while ((item = PyIter_Next(iter)) != NULL) { if (!PyUnicode_Check(item)) { Py_DECREF(item); Py_DECREF(iter); PyErr_SetString(PyExc_TypeError, "labels must contain only strings"); return -1; } self->data->labels.insert(PyUnicode_AsUTF8(item)); Py_DECREF(item); } Py_DECREF(iter); if (PyErr_Occurred()) return -1; } // Handle grid attachment if (grid_obj) { std::shared_ptr grid_ptr; if (PyObject_IsInstance(grid_obj, (PyObject*)&mcrfpydef::PyUIGridViewType)) { // #252: GridView (unified Grid) - share its grid data directly (#313) PyUIGridViewObject* pyview = (PyUIGridViewObject*)grid_obj; grid_ptr = pyview->data->grid_data; } else { // Internal _GridData type PyUIGridObject* pygrid = (PyUIGridObject*)grid_obj; grid_ptr = pygrid->data; } if (grid_ptr) { self->data->grid = grid_ptr; grid_ptr->entities->push_back(self->data); grid_ptr->spatial_hash.insert(self->data); } } return 0; } PyObject* UIEntity::get_spritenumber(PyUIEntityObject* self, void* closure) { return PyLong_FromDouble(self->data->sprite.getSpriteIndex()); } PyObject* sfVector2f_to_PyObject(sf::Vector2f vec) { auto type = &mcrfpydef::PyVectorType; auto obj = (PyVectorObject*)type->tp_alloc(type, 0); if (obj) { obj->data = vec; } return (PyObject*)obj; } PyObject* sfVector2i_to_PyObject(sf::Vector2i vec) { auto type = &mcrfpydef::PyVectorType; auto obj = (PyVectorObject*)type->tp_alloc(type, 0); if (obj) { obj->data = sf::Vector2f(static_cast(vec.x), static_cast(vec.y)); } return (PyObject*)obj; } sf::Vector2f PyObject_to_sfVector2f(PyObject* obj) { PyVectorObject* vec = PyVector::from_arg(obj); if (!vec) { // PyVector::from_arg already set the error return sf::Vector2f(0, 0); } return vec->data; } sf::Vector2i PyObject_to_sfVector2i(PyObject* obj) { PyVectorObject* vec = PyVector::from_arg(obj); if (!vec) { // PyVector::from_arg already set the error return sf::Vector2i(0, 0); } return sf::Vector2i(static_cast(vec->data.x), static_cast(vec->data.y)); } PyObject* UIEntity::get_position(PyUIEntityObject* self, void* closure) { if (reinterpret_cast(closure) == 0) { return sfVector2f_to_PyObject(self->data->position); } else { // Return integer-cast position for grid coordinates sf::Vector2i int_pos(static_cast(self->data->position.x), static_cast(self->data->position.y)); return sfVector2i_to_PyObject(int_pos); } } int UIEntity::set_position(PyUIEntityObject* self, PyObject* value, void* closure) { // Save old position for spatial hash update (#115) float old_x = self->data->position.x; float old_y = self->data->position.y; if (reinterpret_cast(closure) == 0) { sf::Vector2f vec = PyObject_to_sfVector2f(value); if (PyErr_Occurred()) { return -1; // Error already set by PyObject_to_sfVector2f } self->data->position = vec; } else { // For integer position, convert to float and set position sf::Vector2i vec = PyObject_to_sfVector2i(value); if (PyErr_Occurred()) { return -1; // Error already set by PyObject_to_sfVector2i } self->data->position = sf::Vector2f(static_cast(vec.x), static_cast(vec.y)); } // Update spatial hash if grid exists (#115) if (self->data->grid) { self->data->grid->spatial_hash.update(self->data, old_x, old_y); } return 0; } // #294: perspective_map property. Returns a live DiscreteMap reference // (not a snapshot); lazy-allocates on first access when a grid is set. PyObject* UIEntity::get_perspective_map(PyUIEntityObject* self, void* closure) { auto& entity = self->data; if (!entity->grid) Py_RETURN_NONE; if (!entity->perspective_map) { entity->perspective_map = std::make_shared( entity->grid->grid_w, entity->grid->grid_h, 0); } // Wrap in PyDiscreteMapObject sharing the same shared_ptr. auto type = &mcrfpydef::PyDiscreteMapType; auto obj = (PyDiscreteMapObject*)type->tp_alloc(type, 0); if (!obj) return NULL; new (&obj->data) std::shared_ptr(entity->perspective_map); obj->values = obj->data->data(); obj->w = obj->data->width(); obj->h = obj->data->height(); obj->enum_type = PyPerspective::perspective_enum_class; if (obj->enum_type) Py_INCREF(obj->enum_type); return (PyObject*)obj; } // #294: Assign a DiscreteMap as the entity's perspective. The incoming map // must match the grid's current dimensions; otherwise ValueError is raised. // Assigning None clears the perspective (it will be lazy-reallocated on next // access or updateVisibility()). int UIEntity::set_perspective_map(PyUIEntityObject* self, PyObject* value, void* closure) { auto& entity = self->data; if (value == NULL || value == Py_None) { entity->perspective_map.reset(); return 0; } if (!PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyDiscreteMapType)) { PyErr_SetString(PyExc_TypeError, "perspective_map must be a DiscreteMap or None"); return -1; } if (!entity->grid) { PyErr_SetString(PyExc_ValueError, "Cannot assign perspective_map: entity has no grid"); return -1; } auto* incoming = (PyDiscreteMapObject*)value; if (!incoming->data) { PyErr_SetString(PyExc_ValueError, "perspective_map DiscreteMap is not initialized"); return -1; } if (incoming->data->width() != entity->grid->grid_w || incoming->data->height() != entity->grid->grid_h) { PyErr_Format(PyExc_ValueError, "DiscreteMap size (%d, %d) does not match grid size (%d, %d)", incoming->data->width(), incoming->data->height(), entity->grid->grid_w, entity->grid->grid_h); return -1; } entity->perspective_map = incoming->data; // share ownership return 0; } int UIEntity::set_spritenumber(PyUIEntityObject* self, PyObject* value, void* closure) { int val; if (PyLong_Check(value)) val = PyLong_AsLong(value); else { PyErr_SetString(PyExc_TypeError, "sprite_index must be an integer"); return -1; } //self->data->sprite.sprite_index = val; self->data->sprite.setSpriteIndex(val); // todone - I don't like ".sprite.sprite" in this stack of UIEntity.UISprite.sf::Sprite return 0; } // #313 - texture property: thin wrapper over the entity's own UISprite. // Entities render from their own texture (falling back to default_texture at // construction); the grid's texture is only used for cell-size math. PyObject* UIEntity::get_texture(PyUIEntityObject* self, void* closure) { if (!self->data) { // Entity.__new__ without __init__ leaves data null PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); return NULL; } auto tex = self->data->sprite.getTexture(); if (!tex) { // Only reachable if default_texture was null at construction Py_RETURN_NONE; } return tex->pyObject(); } int UIEntity::set_texture(PyUIEntityObject* self, PyObject* value, void* closure) { if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); return -1; } if (!value) { PyErr_SetString(PyExc_TypeError, "Cannot delete texture attribute"); return -1; } int is_texture = PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyTextureType); if (is_texture == -1) return -1; // isinstance itself raised if (!is_texture) { PyErr_SetString(PyExc_TypeError, "texture must be a mcrfpy.Texture instance"); return -1; } auto pytexture = (PyTextureObject*)value; if (!pytexture->data) { // Texture.__new__ without __init__ leaves data null (same guard as UISprite) PyErr_SetString(PyExc_ValueError, "Invalid texture object"); return -1; } // Preserves sprite_index (not re-validated against the new atlas) self->data->sprite.setTexture(pytexture->data); if (self->data->grid) self->data->grid->markDirty(); return 0; } PyObject* UIEntity::get_float_member(PyUIEntityObject* self, void* closure) { auto member_ptr = reinterpret_cast(closure); if (member_ptr == 0) // x return PyFloat_FromDouble(self->data->position.x); else if (member_ptr == 1) // y return PyFloat_FromDouble(self->data->position.y); else { PyErr_SetString(PyExc_AttributeError, "Invalid attribute"); return nullptr; } } int UIEntity::set_float_member(PyUIEntityObject* 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, "Position must be a number (int or float)"); return -1; } // Save old position for spatial hash update (#115) float old_x = self->data->position.x; float old_y = self->data->position.y; if (member_ptr == 0) // x { self->data->position.x = val; } else if (member_ptr == 1) // y { self->data->position.y = val; } // Update spatial hash if grid exists (#115) if (self->data->grid) { self->data->grid->spatial_hash.update(self->data, old_x, old_y); } return 0; } // #176 - Helper to get cell dimensions from grid static void get_cell_dimensions(UIEntity* entity, float& cell_width, float& cell_height) { // Default cell dimensions when no grid is attached constexpr float DEFAULT_CELL_WIDTH = 16.0f; constexpr float DEFAULT_CELL_HEIGHT = 16.0f; if (entity->grid) { // #313: cell size lives on the data layer (mirrored from the grid's // texture at construction) -- entities no longer reach into rendering. cell_width = static_cast(entity->grid->cell_width()); cell_height = static_cast(entity->grid->cell_height()); } else { cell_width = DEFAULT_CELL_WIDTH; cell_height = DEFAULT_CELL_HEIGHT; } } // #176 - Pixel position: pos = draw_pos * tile_size PyObject* UIEntity::get_pixel_pos(PyUIEntityObject* self, void* closure) { if (!self->data->grid) { PyErr_SetString(PyExc_RuntimeError, "entity is not attached to a Grid"); return NULL; } float cell_width, cell_height; get_cell_dimensions(self->data.get(), cell_width, cell_height); sf::Vector2f pixel_pos( self->data->position.x * cell_width, self->data->position.y * cell_height ); return sfVector2f_to_PyObject(pixel_pos); } int UIEntity::set_pixel_pos(PyUIEntityObject* self, PyObject* value, void* closure) { if (!self->data->grid) { PyErr_SetString(PyExc_RuntimeError, "entity is not attached to a Grid"); return -1; } sf::Vector2f pixel_vec = PyObject_to_sfVector2f(value); if (PyErr_Occurred()) { return -1; } float cell_width, cell_height; get_cell_dimensions(self->data.get(), cell_width, cell_height); // Save old position for spatial hash update float old_x = self->data->position.x; float old_y = self->data->position.y; // Convert pixels to tile coordinates self->data->position.x = pixel_vec.x / cell_width; self->data->position.y = pixel_vec.y / cell_height; // Update spatial hash self->data->grid->spatial_hash.update(self->data, old_x, old_y); return 0; } // #176 - Individual pixel coordinates (x, y) PyObject* UIEntity::get_pixel_member(PyUIEntityObject* self, void* closure) { if (!self->data->grid) { PyErr_SetString(PyExc_RuntimeError, "entity is not attached to a Grid"); return NULL; } float cell_width, cell_height; get_cell_dimensions(self->data.get(), cell_width, cell_height); auto member_ptr = reinterpret_cast(closure); if (member_ptr == 0) // x return PyFloat_FromDouble(self->data->position.x * cell_width); else // y return PyFloat_FromDouble(self->data->position.y * cell_height); } int UIEntity::set_pixel_member(PyUIEntityObject* self, PyObject* value, void* closure) { if (!self->data->grid) { PyErr_SetString(PyExc_RuntimeError, "entity is not attached to a Grid"); return -1; } float val; if (PyFloat_Check(value)) { val = PyFloat_AsDouble(value); } else if (PyLong_Check(value)) { val = PyLong_AsLong(value); } else { PyErr_SetString(PyExc_TypeError, "Position must be a number (int or float)"); return -1; } float cell_width, cell_height; get_cell_dimensions(self->data.get(), cell_width, cell_height); // Save old position for spatial hash update float old_x = self->data->position.x; float old_y = self->data->position.y; auto member_ptr = reinterpret_cast(closure); if (member_ptr == 0) // x self->data->position.x = val / cell_width; else // y self->data->position.y = val / cell_height; // Update spatial hash self->data->grid->spatial_hash.update(self->data, old_x, old_y); return 0; } // #176 - Integer grid position (grid_x, grid_y) PyObject* UIEntity::get_grid_int_member(PyUIEntityObject* self, void* closure) { auto member_ptr = reinterpret_cast(closure); if (member_ptr == 0) // grid_x return PyLong_FromLong(static_cast(self->data->position.x)); else // grid_y return PyLong_FromLong(static_cast(self->data->position.y)); } int UIEntity::set_grid_int_member(PyUIEntityObject* self, PyObject* value, void* closure) { int val; if (PyLong_Check(value)) { val = PyLong_AsLong(value); } else if (PyFloat_Check(value)) { val = static_cast(PyFloat_AsDouble(value)); } else { PyErr_SetString(PyExc_TypeError, "Grid position must be an integer"); return -1; } // Save old position for spatial hash update float old_x = self->data->position.x; float old_y = self->data->position.y; auto member_ptr = reinterpret_cast(closure); if (member_ptr == 0) // grid_x self->data->position.x = static_cast(val); else // grid_y self->data->position.y = static_cast(val); // Update spatial hash if grid exists if (self->data->grid) { self->data->grid->spatial_hash.update(self->data, old_x, old_y); } return 0; } PyObject* UIEntity::get_grid(PyUIEntityObject* self, void* closure) { if (!self->data || !self->data->grid) { Py_RETURN_NONE; } auto& grid = self->data->grid; // #252: If the grid has an owning GridView, return that instead. // This preserves the unified Grid API where entity.grid returns the same // object the user created via mcrfpy.Grid(...). auto owning_view = grid->owning_view.lock(); if (owning_view) { // Check cache for the GridView if (owning_view->serial_number != 0) { PyObject* cached = PythonObjectCache::getInstance().lookup(owning_view->serial_number); if (cached) return cached; } auto view_type = &mcrfpydef::PyUIGridViewType; auto pyView = (PyUIGridViewObject*)view_type->tp_alloc(view_type, 0); if (pyView) { pyView->data = owning_view; pyView->weakreflist = NULL; if (owning_view->serial_number == 0) { owning_view->serial_number = PythonObjectCache::getInstance().assignSerial(); } PyObject* weakref = PyWeakref_NewRef((PyObject*)pyView, NULL); if (weakref) { PythonObjectCache::getInstance().registerObject(owning_view->serial_number, weakref); Py_DECREF(weakref); } } return (PyObject*)pyView; } // Fallback: return internal _GridData wrapper (no owning view) // #313: serial_number lives on the UIDrawable side; recover the full // UIGrid via the aliasing helper (same control block, no new ownership). auto uigrid = grid_as_uigrid(grid); if (uigrid->serial_number != 0) { PyObject* cached = PythonObjectCache::getInstance().lookup(uigrid->serial_number); if (cached) { return cached; } } auto grid_type = &mcrfpydef::PyUIGridType; auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0); if (pyGrid) { pyGrid->data = uigrid; pyGrid->weakreflist = NULL; if (uigrid->serial_number == 0) { uigrid->serial_number = PythonObjectCache::getInstance().assignSerial(); } PyObject* weakref = PyWeakref_NewRef((PyObject*)pyGrid, NULL); if (weakref) { PythonObjectCache::getInstance().registerObject(uigrid->serial_number, weakref); Py_DECREF(weakref); } } return (PyObject*)pyGrid; } int UIEntity::set_grid(PyUIEntityObject* self, PyObject* value, void* closure) { if (!self->data) { PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object"); return -1; } // Handle None - remove from current grid if (value == Py_None) { if (self->data->grid) { // Remove from spatial hash before removing from entity list self->data->grid->spatial_hash.remove(self->data); // Remove from current grid's entity list auto& entities = self->data->grid->entities; auto it = std::find_if(entities->begin(), entities->end(), [self](const std::shared_ptr& e) { return e.get() == self->data.get(); }); if (it != entities->end()) { entities->erase(it); } self->data->grid.reset(); // Release identity strong ref -- entity left grid self->data->releasePyIdentity(); } return 0; } // #252: Accept both internal _GridData and GridView (unified Grid) std::shared_ptr new_grid; if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridViewType)) { PyUIGridViewObject* pyview = (PyUIGridViewObject*)value; if (pyview->data->grid_data) { new_grid = pyview->data->grid_data; // #313: share data directly } else { PyErr_SetString(PyExc_ValueError, "Grid has no data"); return -1; } } else if (PyObject_IsInstance(value, (PyObject*)&mcrfpydef::PyUIGridType)) { new_grid = ((PyUIGridObject*)value)->data; } else { PyErr_SetString(PyExc_TypeError, "grid must be a Grid or None"); return -1; } // Remove from old grid first (if any) if (self->data->grid && self->data->grid != new_grid) { self->data->grid->spatial_hash.remove(self->data); auto& old_entities = self->data->grid->entities; auto it = std::find_if(old_entities->begin(), old_entities->end(), [self](const std::shared_ptr& e) { return e.get() == self->data.get(); }); if (it != old_entities->end()) { old_entities->erase(it); } } // Add to new grid if (self->data->grid != new_grid) { new_grid->entities->push_back(self->data); self->data->grid = new_grid; new_grid->spatial_hash.insert(self->data); // #274 // #294: perspective_map is lazy -- the next updateVisibility() call // (or first `entity.perspective_map` access) allocates sized to the // new grid. We deliberately do NOT preserve or clear the old map: // game code that wants per-grid memory should save/restore via // to_bytes/from_bytes and assign before calling updateVisibility. } return 0; } // sprite_offset property - Vector (tuple) PyObject* UIEntity::get_sprite_offset(PyUIEntityObject* self, void* closure) { return sfVector2f_to_PyObject(self->data->sprite_offset); } int UIEntity::set_sprite_offset(PyUIEntityObject* self, PyObject* value, void* closure) { sf::Vector2f vec = PyObject_to_sfVector2f(value); if (PyErr_Occurred()) return -1; self->data->sprite_offset = vec; if (self->data->grid) self->data->grid->markDirty(); return 0; } // sprite_offset_x / sprite_offset_y individual components PyObject* UIEntity::get_sprite_offset_member(PyUIEntityObject* self, void* closure) { auto member_ptr = reinterpret_cast(closure); if (member_ptr == 0) return PyFloat_FromDouble(self->data->sprite_offset.x); else return PyFloat_FromDouble(self->data->sprite_offset.y); } int UIEntity::set_sprite_offset_member(PyUIEntityObject* self, PyObject* value, void* closure) { float val; if (PyFloat_Check(value)) val = PyFloat_AsDouble(value); else if (PyLong_Check(value)) val = PyLong_AsLong(value); else { PyErr_SetString(PyExc_TypeError, "sprite_offset component must be a number"); return -1; } auto member_ptr = reinterpret_cast(closure); if (member_ptr == 0) self->data->sprite_offset.x = val; else self->data->sprite_offset.y = val; if (self->data->grid) self->data->grid->markDirty(); return 0; } // #236 - Multi-tile entity size PyObject* UIEntity::get_tile_size(PyUIEntityObject* self, void* closure) { return sfVector2f_to_PyObject(sf::Vector2f( static_cast(self->data->tile_width), static_cast(self->data->tile_height))); } int UIEntity::set_tile_size(PyUIEntityObject* self, PyObject* value, void* closure) { sf::Vector2f vec = PyObject_to_sfVector2f(value); if (PyErr_Occurred()) return -1; int tw = static_cast(vec.x); int th = static_cast(vec.y); if (tw < 1 || th < 1) { PyErr_SetString(PyExc_ValueError, "tile_size components must be >= 1"); return -1; } self->data->tile_width = tw; self->data->tile_height = th; if (self->data->grid) self->data->grid->markDirty(); return 0; } PyObject* UIEntity::get_tile_width(PyUIEntityObject* self, void* closure) { return PyLong_FromLong(self->data->tile_width); } int UIEntity::set_tile_width(PyUIEntityObject* self, PyObject* value, void* closure) { int val = PyLong_AsLong(value); if (val == -1 && PyErr_Occurred()) return -1; if (val < 1) { PyErr_SetString(PyExc_ValueError, "tile_width must be >= 1"); return -1; } self->data->tile_width = val; if (self->data->grid) self->data->grid->markDirty(); return 0; } PyObject* UIEntity::get_tile_height(PyUIEntityObject* self, void* closure) { return PyLong_FromLong(self->data->tile_height); } int UIEntity::set_tile_height(PyUIEntityObject* self, PyObject* value, void* closure) { int val = PyLong_AsLong(value); if (val == -1 && PyErr_Occurred()) return -1; if (val < 1) { PyErr_SetString(PyExc_ValueError, "tile_height must be >= 1"); return -1; } self->data->tile_height = val; if (self->data->grid) self->data->grid->markDirty(); return 0; } // #237 - Composite sprite grid PyObject* UIEntity::get_sprite_grid(PyUIEntityObject* self, void* closure) { auto& sg = self->data->sprite_grid; if (sg.empty()) { Py_RETURN_NONE; } int tw = self->data->tile_width; int th = self->data->tile_height; PyObject* rows = PyList_New(th); if (!rows) return NULL; for (int y = 0; y < th; y++) { PyObject* row = PyList_New(tw); if (!row) { Py_DECREF(rows); return NULL; } for (int x = 0; x < tw; x++) { int idx = sg[y * tw + x]; PyList_SET_ITEM(row, x, PyLong_FromLong(idx)); } PyList_SET_ITEM(rows, y, row); } return rows; } int UIEntity::set_sprite_grid(PyUIEntityObject* self, PyObject* value, void* closure) { if (value == Py_None) { self->data->sprite_grid.clear(); if (self->data->grid) self->data->grid->markDirty(); return 0; } int tw = self->data->tile_width; int th = self->data->tile_height; // Accept flat list or nested list if (!PyList_Check(value) && !PyTuple_Check(value)) { PyErr_SetString(PyExc_TypeError, "sprite_grid must be a list of lists, a flat list, or None"); return -1; } Py_ssize_t outer_len = PySequence_Size(value); if (outer_len < 0) return -1; std::vector new_grid; // Check if it's nested (first element is a sequence) PyObject* first = (outer_len > 0) ? PySequence_GetItem(value, 0) : nullptr; bool nested = first && (PyList_Check(first) || PyTuple_Check(first)); Py_XDECREF(first); if (nested) { if (outer_len != th) { PyErr_Format(PyExc_ValueError, "sprite_grid has %zd rows, expected %d (tile_height)", outer_len, th); return -1; } new_grid.reserve(tw * th); for (int y = 0; y < th; y++) { PyObject* row = PySequence_GetItem(value, y); if (!row) return -1; Py_ssize_t row_len = PySequence_Size(row); if (row_len != tw) { Py_DECREF(row); PyErr_Format(PyExc_ValueError, "sprite_grid row %d has %zd items, expected %d (tile_width)", y, row_len, tw); return -1; } for (int x = 0; x < tw; x++) { PyObject* item = PySequence_GetItem(row, x); if (!item) { Py_DECREF(row); return -1; } long idx = PyLong_AsLong(item); Py_DECREF(item); if (idx == -1 && PyErr_Occurred()) { Py_DECREF(row); return -1; } new_grid.push_back(static_cast(idx)); } Py_DECREF(row); } } else { // Flat list if (outer_len != tw * th) { PyErr_Format(PyExc_ValueError, "sprite_grid has %zd items, expected %d (tile_width * tile_height)", outer_len, tw * th); return -1; } new_grid.reserve(tw * th); for (Py_ssize_t i = 0; i < outer_len; i++) { PyObject* item = PySequence_GetItem(value, i); if (!item) return -1; long idx = PyLong_AsLong(item); Py_DECREF(item); if (idx == -1 && PyErr_Occurred()) return -1; new_grid.push_back(static_cast(idx)); } } self->data->sprite_grid = std::move(new_grid); if (self->data->grid) self->data->grid->markDirty(); return 0; } PyObject* UIEntity::die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)) { // Check if entity has a grid if (!self->data || !self->data->grid) { Py_RETURN_NONE; // Entity not on a grid, nothing to do } // Remove entity from grid's entity list auto grid = self->data->grid; auto& entities = grid->entities; // Find and remove this entity from the list auto it = std::find_if(entities->begin(), entities->end(), [self](const std::shared_ptr& e) { return e.get() == self->data.get(); }); if (it != entities->end()) { // Remove from spatial hash before erasing (#115) grid->spatial_hash.remove(self->data); entities->erase(it); // Clear the grid reference self->data->grid.reset(); // Release identity strong ref -- entity is no longer in a grid self->data->releasePyIdentity(); } Py_RETURN_NONE; } PyObject* UIEntity::path_to(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { int target_x, target_y; // Parse position using flexible position helper // Supports: path_to(x, y), path_to((x, y)), path_to(pos=(x, y)), path_to(Vector(x, y)) if (!PyPosition_ParseInt(args, kwds, &target_x, &target_y)) { return NULL; // Error already set by PyPosition_ParseInt } // Check if entity has a grid if (!self->data || !self->data->grid) { PyErr_SetString(PyExc_ValueError, "Entity must be associated with a grid to compute paths"); return NULL; } // Get current position int current_x = static_cast(self->data->position.x); int current_y = static_cast(self->data->position.y); // Validate target position auto grid = self->data->grid; if (target_x < 0 || target_x >= grid->grid_w || target_y < 0 || target_y >= grid->grid_h) { PyErr_Format(PyExc_ValueError, "Target position (%d, %d) is out of grid bounds (0-%d, 0-%d)", target_x, target_y, grid->grid_w - 1, grid->grid_h - 1); return NULL; } // Use A* pathfinding via temporary TCODPath TCODPath tcod_path(grid->getTCODMap(), 1.41f); if (!tcod_path.compute(current_x, current_y, target_x, target_y)) { // No path found - return empty list return PyList_New(0); } // Convert path to Python list of tuples PyObject* path_list = PyList_New(tcod_path.size()); if (!path_list) return PyErr_NoMemory(); for (int i = 0; i < tcod_path.size(); ++i) { int px, py; tcod_path.get(i, &px, &py); PyObject* coord_tuple = PyTuple_New(2); if (!coord_tuple) { Py_DECREF(path_list); return PyErr_NoMemory(); } PyTuple_SetItem(coord_tuple, 0, PyLong_FromLong(px)); PyTuple_SetItem(coord_tuple, 1, PyLong_FromLong(py)); PyList_SetItem(path_list, i, coord_tuple); } return path_list; } PyObject* UIEntity::find_path(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { static const char* kwlist[] = {"target", "diagonal_cost", "collide", NULL}; PyObject* target_obj = NULL; float diagonal_cost = 1.41f; const char* collide_label = NULL; if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|fz", const_cast(kwlist), &target_obj, &diagonal_cost, &collide_label)) { return NULL; } if (!self->data || !self->data->grid) { PyErr_SetString(PyExc_ValueError, "Entity must be associated with a grid to compute paths"); return NULL; } auto grid = self->data->grid; // Extract target position // #313: ExtractPosition still takes UIGrid*; the downcast is valid because // GridData is always a UIGrid base subobject (see grid_as_uigrid). int target_x, target_y; if (!UIGridPathfinding::ExtractPosition(target_obj, &target_x, &target_y, static_cast(grid.get()), "target")) { return NULL; } int start_x = self->data->cell_position.x; int start_y = self->data->cell_position.y; // Bounds check if (start_x < 0 || start_x >= grid->grid_w || start_y < 0 || start_y >= grid->grid_h || target_x < 0 || target_x >= grid->grid_w || target_y < 0 || target_y >= grid->grid_h) { PyErr_SetString(PyExc_ValueError, "Position out of grid bounds"); return NULL; } // Build args to delegate to Grid.find_path // Create a temporary PyUIGridObject wrapper for the grid (internal _GridData type) // #313: wrapper holds shared_ptr; alias-cast from the data ptr. auto* grid_type = &mcrfpydef::PyUIGridType; auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0); if (!pyGrid) return NULL; new (&pyGrid->data) std::shared_ptr(grid_as_uigrid(grid)); // Build keyword args for Grid.find_path PyObject* start_tuple = Py_BuildValue("(ii)", start_x, start_y); PyObject* target_tuple = Py_BuildValue("(ii)", target_x, target_y); PyObject* fwd_args = PyTuple_Pack(2, start_tuple, target_tuple); Py_DECREF(start_tuple); Py_DECREF(target_tuple); PyObject* fwd_kwds = PyDict_New(); PyObject* py_diag = PyFloat_FromDouble(diagonal_cost); PyDict_SetItemString(fwd_kwds, "diagonal_cost", py_diag); Py_DECREF(py_diag); if (collide_label) { PyObject* py_collide = PyUnicode_FromString(collide_label); PyDict_SetItemString(fwd_kwds, "collide", py_collide); Py_DECREF(py_collide); } PyObject* result = UIGridPathfinding::Grid_find_path(pyGrid, fwd_args, fwd_kwds); Py_DECREF(fwd_args); Py_DECREF(fwd_kwds); Py_DECREF(pyGrid); return result; } PyObject* UIEntity::update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)) { self->data->updateVisibility(); Py_RETURN_NONE; } PyObject* UIEntity::visible_entities(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { static const char* keywords[] = {"fov", "radius", nullptr}; PyObject* fov_arg = nullptr; int radius = -1; // -1 means use grid default if (!PyArg_ParseTupleAndKeywords(args, kwds, "|Oi", const_cast(keywords), &fov_arg, &radius)) { return NULL; } // Check if entity has a grid if (!self->data || !self->data->grid) { PyErr_SetString(PyExc_ValueError, "Entity must be associated with a grid to find visible entities"); return NULL; } auto grid = self->data->grid; // Parse FOV algorithm - use grid default if not specified TCOD_fov_algorithm_t algorithm = grid->fov_algorithm; bool fov_was_none = false; if (fov_arg && fov_arg != Py_None) { if (PyFOV::from_arg(fov_arg, &algorithm, &fov_was_none) < 0) { return NULL; // Error already set } } // Use grid radius if not specified if (radius < 0) { radius = grid->fov_radius; } // Get current cell position (#295) int x = self->data->cell_position.x; int y = self->data->cell_position.y; // Compute FOV from this entity's cell position grid->computeFOV(x, y, radius, true, algorithm); // Create result list PyObject* result = PyList_New(0); if (!result) return PyErr_NoMemory(); // Get Entity type for creating Python objects auto entity_type = &mcrfpydef::PyUIEntityType; // Iterate through all entities in the grid if (grid->entities) { for (auto& entity : *grid->entities) { // Skip self if (entity.get() == self->data.get()) { continue; } // Check if entity is in FOV (#295: use cell_position) int ex = entity->cell_position.x; int ey = entity->cell_position.y; if (grid->isInFOV(ex, ey)) { // Create Python Entity object for this 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; if (PyList_Append(result, (PyObject*)pyEntity) < 0) { Py_DECREF(pyEntity); Py_DECREF(result); return NULL; } Py_DECREF(pyEntity); // List now owns the reference } } } return result; } PyMethodDef UIEntity::methods[] = { {"at", (PyCFunction)UIEntity::at, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, at, MCRF_SIG("(x: int, y: int)", "GridPoint | None"), MCRF_DESC("Return the GridPoint at (x, y) if currently VISIBLE to this entity's perspective_map, otherwise None."), MCRF_ARGS_START MCRF_ARG("x", "Grid X coordinate (also accepts a tuple/Vector as first positional arg)") MCRF_ARG("y", "Grid Y coordinate (omit when passing a tuple or Vector)") MCRF_RETURNS("GridPoint if visible, None if undiscovered or not currently in FOV") MCRF_NOTE("To inspect discovered-but-not-visible cells, read entity.perspective_map[x, y] directly.") )}, {"index", (PyCFunction)UIEntity::index, METH_NOARGS, MCRF_METHOD(Entity, index, MCRF_SIG("()", "int"), MCRF_DESC("Return the index of this entity in its grid's entity collection."), MCRF_RETURNS("Zero-based index of this entity in grid.entities") MCRF_RAISES("RuntimeError", "If entity is not associated with a grid") )}, {"die", (PyCFunction)UIEntity::die, METH_NOARGS, MCRF_METHOD(Entity, die, MCRF_SIG("()", "None"), MCRF_DESC("Remove this entity from its grid."), MCRF_RETURNS("None") MCRF_NOTE("Do not call during iteration over grid.entities; modifying the collection during iteration raises RuntimeError.") )}, {"path_to", (PyCFunction)UIEntity::path_to, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, path_to, MCRF_SIG("(x: int, y: int)", "list"), MCRF_DESC("Find a path to the target position using A* pathfinding."), MCRF_ARGS_START MCRF_ARG("x", "Target X coordinate (also accepts a tuple/Vector as first positional arg)") MCRF_ARG("y", "Target Y coordinate (omit when passing a tuple or Vector)") MCRF_RETURNS("List of (x, y) tuples representing the path from current position to target") MCRF_RAISES("ValueError", "If entity has no grid or target is out of bounds") )}, {"find_path", (PyCFunction)UIEntity::find_path, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, find_path, MCRF_SIG("(target, diagonal_cost: float = 1.41, collide: str = None)", "AStarPath | None"), MCRF_DESC("Find a path from this entity to the target position."), MCRF_ARGS_START MCRF_ARG("target", "Target as Vector, Entity, or (x, y) tuple") MCRF_ARG("diagonal_cost", "Cost of diagonal movement (default 1.41)") MCRF_ARG("collide", "Label string; entities with this label block pathfinding") MCRF_RETURNS("AStarPath object, or None if no path exists") MCRF_RAISES("ValueError", "If entity has no grid or positions are out of bounds") )}, {"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS, MCRF_METHOD(Entity, update_visibility, MCRF_SIG("()", "None"), MCRF_DESC("Recompute which cells are visible from this entity's position and update perspective_map."), MCRF_RETURNS("None") MCRF_NOTE("Called automatically when the entity moves if the grid has FOV configured.") )}, {"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, visible_entities, MCRF_SIG("(fov=None, radius: int = None)", "list[Entity]"), MCRF_DESC("Get list of other entities visible from this entity's position."), MCRF_ARGS_START MCRF_ARG("fov", "FOV algorithm to use (FOV enum or None to use grid.fov)") MCRF_ARG("radius", "FOV radius (int or None to use grid.fov_radius)") MCRF_RETURNS("List of Entity objects within field of view, excluding self") MCRF_RAISES("ValueError", "If entity is not associated with a grid") )}, {NULL, NULL, 0, NULL} }; // Define the PyObjectType alias for the macros typedef PyUIEntityObject PyObjectType; // Combine base methods with entity-specific methods // Note: Use UIDRAWABLE_METHODS_BASE (not UIDRAWABLE_METHODS) because UIEntity is NOT a UIDrawable // and the template-based animate helper won't work. Entity has its own animate() method. // #296 - Label system implementations PyObject* UIEntity::get_labels(PyUIEntityObject* self, void* closure) { PyObject* frozen = PyFrozenSet_New(NULL); if (!frozen) return NULL; for (const auto& label : self->data->labels) { PyObject* str = PyUnicode_FromString(label.c_str()); if (!str) { Py_DECREF(frozen); return NULL; } if (PySet_Add(frozen, str) < 0) { Py_DECREF(str); Py_DECREF(frozen); return NULL; } Py_DECREF(str); } return frozen; } int UIEntity::set_labels(PyUIEntityObject* self, PyObject* value, void* closure) { PyObject* iter = PyObject_GetIter(value); if (!iter) { PyErr_SetString(PyExc_TypeError, "labels must be iterable"); return -1; } std::unordered_set new_labels; PyObject* item; while ((item = PyIter_Next(iter)) != NULL) { if (!PyUnicode_Check(item)) { Py_DECREF(item); Py_DECREF(iter); PyErr_SetString(PyExc_TypeError, "labels must contain only strings"); return -1; } new_labels.insert(PyUnicode_AsUTF8(item)); Py_DECREF(item); } Py_DECREF(iter); if (PyErr_Occurred()) return -1; self->data->labels = std::move(new_labels); return 0; } PyObject* UIEntity::py_add_label(PyUIEntityObject* self, PyObject* arg) { if (!PyUnicode_Check(arg)) { PyErr_SetString(PyExc_TypeError, "label must be a string"); return NULL; } self->data->labels.insert(PyUnicode_AsUTF8(arg)); Py_RETURN_NONE; } PyObject* UIEntity::py_remove_label(PyUIEntityObject* self, PyObject* arg) { if (!PyUnicode_Check(arg)) { PyErr_SetString(PyExc_TypeError, "label must be a string"); return NULL; } self->data->labels.erase(PyUnicode_AsUTF8(arg)); Py_RETURN_NONE; } PyObject* UIEntity::py_has_label(PyUIEntityObject* self, PyObject* arg) { if (!PyUnicode_Check(arg)) { PyErr_SetString(PyExc_TypeError, "label must be a string"); return NULL; } if (self->data->labels.count(PyUnicode_AsUTF8(arg))) { Py_RETURN_TRUE; } Py_RETURN_FALSE; } // #299 - Step callback and default_behavior implementations PyObject* UIEntity::get_step(PyUIEntityObject* self, void* closure) { if (self->data->step_callback) { Py_INCREF(self->data->step_callback); return self->data->step_callback; } Py_RETURN_NONE; } int UIEntity::set_step(PyUIEntityObject* self, PyObject* value, void* closure) { if (value == Py_None) { Py_XDECREF(self->data->step_callback); self->data->step_callback = nullptr; return 0; } if (!PyCallable_Check(value)) { PyErr_SetString(PyExc_TypeError, "step must be callable or None"); return -1; } Py_XDECREF(self->data->step_callback); Py_INCREF(value); self->data->step_callback = value; return 0; } PyObject* UIEntity::get_default_behavior(PyUIEntityObject* self, void* closure) { return PyLong_FromLong(self->data->default_behavior); } int UIEntity::set_default_behavior(PyUIEntityObject* self, PyObject* value, void* closure) { long val = PyLong_AsLong(value); if (val == -1 && PyErr_Occurred()) return -1; self->data->default_behavior = static_cast(val); return 0; } // #300 - Behavior system property implementations PyObject* UIEntity::get_behavior_type(PyUIEntityObject* self, void* closure) { return PyLong_FromLong(static_cast(self->data->behavior.type)); } PyObject* UIEntity::get_turn_order(PyUIEntityObject* self, void* closure) { return PyLong_FromLong(self->data->turn_order); } int UIEntity::set_turn_order(PyUIEntityObject* self, PyObject* value, void* closure) { long val = PyLong_AsLong(value); if (val == -1 && PyErr_Occurred()) return -1; self->data->turn_order = static_cast(val); return 0; } PyObject* UIEntity::get_move_speed(PyUIEntityObject* self, void* closure) { return PyFloat_FromDouble(self->data->move_speed); } int UIEntity::set_move_speed(PyUIEntityObject* self, PyObject* value, void* closure) { double val = PyFloat_AsDouble(value); if (val == -1.0 && PyErr_Occurred()) return -1; self->data->move_speed = static_cast(val); return 0; } PyObject* UIEntity::get_target_label(PyUIEntityObject* self, void* closure) { if (self->data->target_label.empty()) Py_RETURN_NONE; return PyUnicode_FromString(self->data->target_label.c_str()); } int UIEntity::set_target_label(PyUIEntityObject* self, PyObject* value, void* closure) { if (value == Py_None) { self->data->target_label.clear(); return 0; } if (!PyUnicode_Check(value)) { PyErr_SetString(PyExc_TypeError, "target_label must be a string or None"); return -1; } self->data->target_label = PyUnicode_AsUTF8(value); return 0; } PyObject* UIEntity::get_sight_radius(PyUIEntityObject* self, void* closure) { return PyLong_FromLong(self->data->sight_radius); } int UIEntity::set_sight_radius(PyUIEntityObject* self, PyObject* value, void* closure) { long val = PyLong_AsLong(value); if (val == -1 && PyErr_Occurred()) return -1; self->data->sight_radius = static_cast(val); return 0; } PyObject* UIEntity::py_set_behavior(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { static const char* kwlist[] = {"type", "waypoints", "turns", "path", "pathfinder", nullptr}; int type_val = 0; PyObject* waypoints_obj = nullptr; int turns = 0; PyObject* path_obj = nullptr; PyObject* pathfinder_obj = nullptr; if (!PyArg_ParseTupleAndKeywords(args, kwds, "i|OiOO", const_cast(kwlist), &type_val, &waypoints_obj, &turns, &path_obj, &pathfinder_obj)) { return NULL; } auto& behavior = self->data->behavior; behavior.reset(); behavior.type = static_cast(type_val); // Parse waypoints if (waypoints_obj && waypoints_obj != Py_None) { PyObject* iter = PyObject_GetIter(waypoints_obj); if (!iter) { PyErr_SetString(PyExc_TypeError, "waypoints must be iterable"); return NULL; } PyObject* item; while ((item = PyIter_Next(iter)) != NULL) { if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { Py_DECREF(item); Py_DECREF(iter); PyErr_SetString(PyExc_TypeError, "Each waypoint must be a (x, y) tuple"); return NULL; } int wx = PyLong_AsLong(PyTuple_GetItem(item, 0)); int wy = PyLong_AsLong(PyTuple_GetItem(item, 1)); Py_DECREF(item); if (PyErr_Occurred()) { Py_DECREF(iter); return NULL; } behavior.waypoints.push_back({wx, wy}); } Py_DECREF(iter); if (PyErr_Occurred()) return NULL; } // Parse path if (path_obj && path_obj != Py_None) { PyObject* iter = PyObject_GetIter(path_obj); if (!iter) { PyErr_SetString(PyExc_TypeError, "path must be iterable"); return NULL; } PyObject* item; while ((item = PyIter_Next(iter)) != NULL) { if (!PyTuple_Check(item) || PyTuple_Size(item) != 2) { Py_DECREF(item); Py_DECREF(iter); PyErr_SetString(PyExc_TypeError, "Each path step must be a (x, y) tuple"); return NULL; } int px = PyLong_AsLong(PyTuple_GetItem(item, 0)); int py_val = PyLong_AsLong(PyTuple_GetItem(item, 1)); Py_DECREF(item); if (PyErr_Occurred()) { Py_DECREF(iter); return NULL; } behavior.current_path.push_back({px, py_val}); } Py_DECREF(iter); if (PyErr_Occurred()) return NULL; } // Set sleep turns if (turns > 0) { behavior.sleep_turns_remaining = turns; } // Parse pathfinder (#315): DijkstraMap, AStarPath, or (x, y) target tuple. if (pathfinder_obj && pathfinder_obj != Py_None) { if (PyObject_IsInstance(pathfinder_obj, (PyObject*)&mcrfpydef::PyDijkstraMapType)) { auto* dmap = (PyDijkstraMapObject*)pathfinder_obj; if (!dmap->data) { PyErr_SetString(PyExc_RuntimeError, "pathfinder: DijkstraMap is invalid"); return NULL; } behavior.path_provider = std::make_unique(dmap->data); } else if (PyObject_IsInstance(pathfinder_obj, (PyObject*)&mcrfpydef::PyAStarPathType)) { auto* apath = (PyAStarPathObject*)pathfinder_obj; // Copy remaining steps - the provider owns its own iteration state. std::vector steps( apath->path.begin() + apath->current_index, apath->path.end()); behavior.path_provider = std::make_unique(std::move(steps)); } else if (PyTuple_Check(pathfinder_obj) && PyTuple_Size(pathfinder_obj) == 2) { long tx = PyLong_AsLong(PyTuple_GetItem(pathfinder_obj, 0)); long ty = PyLong_AsLong(PyTuple_GetItem(pathfinder_obj, 1)); if (PyErr_Occurred()) return NULL; behavior.path_provider = std::make_unique( sf::Vector2i(static_cast(tx), static_cast(ty))); } else { PyErr_SetString(PyExc_TypeError, "pathfinder must be a DijkstraMap, AStarPath, or (x, y) tuple"); return NULL; } } Py_RETURN_NONE; } // #295 - cell_pos property implementations PyObject* UIEntity::get_cell_pos(PyUIEntityObject* self, void* closure) { return sfVector2i_to_PyObject(self->data->cell_position); } int UIEntity::set_cell_pos(PyUIEntityObject* self, PyObject* value, void* closure) { int old_x = self->data->cell_position.x; int old_y = self->data->cell_position.y; sf::Vector2f vec = PyObject_to_sfVector2f(value); if (PyErr_Occurred()) return -1; self->data->cell_position.x = static_cast(vec.x); self->data->cell_position.y = static_cast(vec.y); // Update spatial hash if (self->data->grid) { self->data->grid->spatial_hash.updateCell(self->data, old_x, old_y); } return 0; } PyObject* UIEntity::get_cell_member(PyUIEntityObject* self, void* closure) { if (reinterpret_cast(closure) == 0) { return PyLong_FromLong(self->data->cell_position.x); } else { return PyLong_FromLong(self->data->cell_position.y); } } int UIEntity::set_cell_member(PyUIEntityObject* self, PyObject* value, void* closure) { long val = PyLong_AsLong(value); if (val == -1 && PyErr_Occurred()) return -1; int old_x = self->data->cell_position.x; int old_y = self->data->cell_position.y; if (reinterpret_cast(closure) == 0) { self->data->cell_position.x = static_cast(val); } else { self->data->cell_position.y = static_cast(val); } if (self->data->grid) { self->data->grid->spatial_hash.updateCell(self->data, old_x, old_y); } return 0; } PyMethodDef UIEntity_all_methods[] = { UIDRAWABLE_METHODS_BASE, {"animate", (PyCFunction)UIEntity::animate, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, animate, MCRF_SIG("(property: str, target: Any, duration: float, easing=None, delta=False, loop=False, callback=None, conflict_mode='replace')", "Animation"), MCRF_DESC("Create and start an animation on this entity's property."), MCRF_ARGS_START MCRF_ARG("property", "Name of the property to animate: 'draw_x', 'draw_y' (tile coords), 'sprite_scale', 'sprite_index'") MCRF_ARG("target", "Target value - float, int, or list of int (for sprite frame sequences)") MCRF_ARG("duration", "Animation duration in seconds") MCRF_ARG("easing", "Easing function: Easing enum value, string name, or None for linear") MCRF_ARG("delta", "If True, target is relative to current value; if False, target is absolute") MCRF_ARG("loop", "If True, animation repeats from start when it reaches the end (default False)") MCRF_ARG("callback", "Optional callable invoked when animation completes (not called for looping animations)") MCRF_ARG("conflict_mode", "'replace' (default), 'queue', or 'error' if property already animating") MCRF_RETURNS("Animation object for monitoring progress") MCRF_RAISES("ValueError", "If property name is not valid for Entity (draw_x, draw_y, sprite_scale, sprite_index)") MCRF_NOTE("Use 'draw_x'/'draw_y' to animate tile coordinates for smooth movement between grid cells. " "Use list target with loop=True for repeating sprite frame animations.") )}, {"at", (PyCFunction)UIEntity::at, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, at, MCRF_SIG("(x: int, y: int)", "GridPoint | None"), MCRF_DESC("Return the GridPoint at (x, y) if currently VISIBLE to this entity's perspective_map, otherwise None."), MCRF_ARGS_START MCRF_ARG("x", "Grid X coordinate (also accepts a tuple/Vector as first positional arg)") MCRF_ARG("y", "Grid Y coordinate (omit when passing a tuple or Vector)") MCRF_RETURNS("GridPoint if visible, None if undiscovered or not currently in FOV") MCRF_NOTE("To inspect discovered-but-not-visible cells, read entity.perspective_map[x, y] directly.") )}, {"index", (PyCFunction)UIEntity::index, METH_NOARGS, MCRF_METHOD(Entity, index, MCRF_SIG("()", "int"), MCRF_DESC("Return the index of this entity in its grid's entity collection."), MCRF_RETURNS("Zero-based index of this entity in grid.entities") MCRF_RAISES("RuntimeError", "If entity is not associated with a grid") )}, {"die", (PyCFunction)UIEntity::die, METH_NOARGS, MCRF_METHOD(Entity, die, MCRF_SIG("()", "None"), MCRF_DESC("Remove this entity from its grid."), MCRF_RETURNS("None") MCRF_NOTE("Do not call during iteration over grid.entities; modifying the collection during iteration raises RuntimeError.") )}, {"path_to", (PyCFunction)UIEntity::path_to, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, path_to, MCRF_SIG("(x: int, y: int)", "list"), MCRF_DESC("Find a path to the target position using A* pathfinding."), MCRF_ARGS_START MCRF_ARG("x", "Target X coordinate (also accepts a tuple/Vector as first positional arg)") MCRF_ARG("y", "Target Y coordinate (omit when passing a tuple or Vector)") MCRF_RETURNS("List of (x, y) tuples representing the path from current position to target") MCRF_RAISES("ValueError", "If entity has no grid or target is out of bounds") )}, {"find_path", (PyCFunction)UIEntity::find_path, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, find_path, MCRF_SIG("(target, diagonal_cost: float = 1.41, collide: str = None)", "AStarPath | None"), MCRF_DESC("Find a path from this entity to the target position."), MCRF_ARGS_START MCRF_ARG("target", "Target as Vector, Entity, or (x, y) tuple") MCRF_ARG("diagonal_cost", "Cost of diagonal movement (default 1.41)") MCRF_ARG("collide", "Label string; entities with this label block pathfinding") MCRF_RETURNS("AStarPath object, or None if no path exists") MCRF_RAISES("ValueError", "If entity has no grid or positions are out of bounds") )}, {"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS, MCRF_METHOD(Entity, update_visibility, MCRF_SIG("()", "None"), MCRF_DESC("Recompute which cells are visible from this entity's position and update perspective_map."), MCRF_RETURNS("None") MCRF_NOTE("Called automatically when the entity moves if the grid has FOV configured.") )}, {"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, visible_entities, MCRF_SIG("(fov=None, radius: int = None)", "list[Entity]"), MCRF_DESC("Get list of other entities visible from this entity's position."), MCRF_ARGS_START MCRF_ARG("fov", "FOV algorithm to use (FOV enum or None to use grid.fov)") MCRF_ARG("radius", "FOV radius (int or None to use grid.fov_radius)") MCRF_RETURNS("List of Entity objects within field of view, excluding self") MCRF_RAISES("ValueError", "If entity is not associated with a grid") )}, // #296 - Label methods {"add_label", (PyCFunction)UIEntity::py_add_label, METH_O, MCRF_METHOD(Entity, add_label, MCRF_SIG("(label: str)", "None"), MCRF_DESC("Add a label to this entity. Idempotent; adding the same label twice is safe."), MCRF_ARGS_START MCRF_ARG("label", "String label to add") MCRF_RETURNS("None") )}, {"remove_label", (PyCFunction)UIEntity::py_remove_label, METH_O, MCRF_METHOD(Entity, remove_label, MCRF_SIG("(label: str)", "None"), MCRF_DESC("Remove a label from this entity. No-op if label is not present."), MCRF_ARGS_START MCRF_ARG("label", "String label to remove") MCRF_RETURNS("None") )}, {"has_label", (PyCFunction)UIEntity::py_has_label, METH_O, MCRF_METHOD(Entity, has_label, MCRF_SIG("(label: str)", "bool"), MCRF_DESC("Check if this entity has the given label."), MCRF_ARGS_START MCRF_ARG("label", "String label to check") MCRF_RETURNS("True if the entity has the label, False otherwise") )}, // #300 - Behavior system {"set_behavior", (PyCFunction)UIEntity::py_set_behavior, METH_VARARGS | METH_KEYWORDS, MCRF_METHOD(Entity, set_behavior, MCRF_SIG("(type, waypoints=None, turns: int = 0, path=None, pathfinder=None)", "None"), MCRF_DESC("Configure this entity's behavior for grid.step() turn management."), MCRF_ARGS_START MCRF_ARG("type", "Behavior type (int or Behavior enum, e.g., Behavior.PATROL)") MCRF_ARG("waypoints", "List of (x, y) tuples for WAYPOINT/PATROL/LOOP behaviors") MCRF_ARG("turns", "Number of turns for SLEEP behavior") MCRF_ARG("path", "Pre-computed path as list of (x, y) tuples for PATH behavior") MCRF_ARG("pathfinder", "DijkstraMap, AStarPath, or (x, y) target tuple for SEEK behavior") MCRF_RETURNS("None") )}, {NULL} // Sentinel }; PyGetSetDef UIEntity::getsetters[] = { // #176 - Pixel coordinates (relative to grid, like UIDrawable.pos) {"pos", (getter)UIEntity::get_pixel_pos, (setter)UIEntity::set_pixel_pos, MCRF_PROPERTY(pos, "Pixel position relative to grid (Vector). Computed as draw_pos * tile_size. Requires entity to be attached to a grid."), NULL}, {"x", (getter)UIEntity::get_pixel_member, (setter)UIEntity::set_pixel_member, MCRF_PROPERTY(x, "Pixel X position relative to grid (float). Requires entity to be attached to a grid."), (void*)0}, {"y", (getter)UIEntity::get_pixel_member, (setter)UIEntity::set_pixel_member, MCRF_PROPERTY(y, "Pixel Y position relative to grid (float). Requires entity to be attached to a grid."), (void*)1}, // #295 - Integer cell position (decoupled from float draw_pos) // #314 F3: grid_pos is the CANONICAL name (matches the grid_pos= constructor // argument); cell_pos/cell_x/cell_y are documented aliases. Both share the // same getter/setter and remain fully interchangeable. {"grid_pos", (getter)UIEntity::get_cell_pos, (setter)UIEntity::set_cell_pos, MCRF_PROPERTY(grid_pos, "Integer logical cell position (Vector). Canonical cell-position property matching the 'grid_pos' constructor argument. Decoupled from draw_pos. Determines which cell this entity logically occupies for collision and pathfinding."), NULL}, {"grid_x", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member, MCRF_PROPERTY(grid_x, "Integer X cell coordinate (int). Canonical; matches grid_pos."), (void*)0}, {"grid_y", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member, MCRF_PROPERTY(grid_y, "Integer Y cell coordinate (int). Canonical; matches grid_pos."), (void*)1}, {"cell_pos", (getter)UIEntity::get_cell_pos, (setter)UIEntity::set_cell_pos, MCRF_PROPERTY(cell_pos, "Integer logical cell position (Vector). Alias for grid_pos (the canonical name)."), NULL}, {"cell_x", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member, MCRF_PROPERTY(cell_x, "Integer X cell coordinate (int). Alias for grid_x."), (void*)0}, {"cell_y", (getter)UIEntity::get_cell_member, (setter)UIEntity::set_cell_member, MCRF_PROPERTY(cell_y, "Integer Y cell coordinate (int). Alias for grid_y."), (void*)1}, // Float tile coordinates (for smooth animation between tiles) {"draw_pos", (getter)UIEntity::get_position, (setter)UIEntity::set_position, MCRF_PROPERTY(draw_pos, "Fractional tile position for rendering (Vector). Use for smooth animation between grid cells."), (void*)0}, {"perspective_map", (getter)UIEntity::get_perspective_map, (setter)UIEntity::set_perspective_map, MCRF_PROPERTY(perspective_map, "Per-entity FOV memory (DiscreteMap). 3-state values per cell: 0=unknown, 1=discovered, 2=visible. Lazy-allocated on first access once entity has a grid; returns None otherwise. The returned DiscreteMap is a live reference. Assigning a DiscreteMap replaces the entity's memory; size must match the grid or ValueError is raised. Assign None to clear."), NULL}, {"grid", (getter)UIEntity::get_grid, (setter)UIEntity::set_grid, MCRF_PROPERTY(grid, "Grid this entity belongs to (Grid or None). Assign a Grid to attach the entity, or None to remove it from its current grid."), NULL}, {"sprite_index", (getter)UIEntity::get_spritenumber, (setter)UIEntity::set_spritenumber, MCRF_PROPERTY(sprite_index, "Sprite index into the entity's texture atlas (int)."), NULL}, // #313 - entities render from their OWN texture, not the grid's {"texture", (getter)UIEntity::get_texture, (setter)UIEntity::set_texture, MCRF_PROPERTY(texture, "Sprite texture atlas (Texture). Defaults to mcrfpy.default_texture at construction. Setting preserves sprite_index (not re-validated against the new atlas)."), NULL}, {"visible", (getter)UIEntity_get_visible, (setter)UIEntity_set_visible, MCRF_PROPERTY(visible, "Visibility flag (bool). When False, the entity is not rendered."), NULL}, {"opacity", (getter)UIEntity_get_opacity, (setter)UIEntity_set_opacity, MCRF_PROPERTY(opacity, "Render opacity (float). 0.0 = fully transparent, 1.0 = fully opaque."), NULL}, {"name", (getter)UIEntity_get_name, (setter)UIEntity_set_name, MCRF_PROPERTY(name, "Entity name for lookup (str)."), NULL}, {"shader", (getter)UIEntity_get_shader, (setter)UIEntity_set_shader, MCRF_PROPERTY(shader, "GPU shader for visual effects (Shader or None). Set to None to disable shader rendering."), NULL}, {"uniforms", (getter)UIEntity_get_uniforms, NULL, MCRF_PROPERTY(uniforms, "Collection of shader uniforms (UniformCollection, read-only). Set values via dict-like syntax: entity.uniforms['name'] = value."), NULL}, {"sprite_offset", (getter)UIEntity::get_sprite_offset, (setter)UIEntity::set_sprite_offset, MCRF_PROPERTY(sprite_offset, "Pixel offset for oversized sprites (Vector). Applied pre-zoom during grid rendering."), NULL}, {"sprite_offset_x", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member, MCRF_PROPERTY(sprite_offset_x, "X component of sprite pixel offset (float)."), (void*)0}, {"sprite_offset_y", (getter)UIEntity::get_sprite_offset_member, (setter)UIEntity::set_sprite_offset_member, MCRF_PROPERTY(sprite_offset_y, "Y component of sprite pixel offset (float)."), (void*)1}, // #236 - Multi-tile entity size {"tile_size", (getter)UIEntity::get_tile_size, (setter)UIEntity::set_tile_size, MCRF_PROPERTY(tile_size, "Entity size in tiles as (width, height) (Vector). Default (1, 1)."), NULL}, {"tile_width", (getter)UIEntity::get_tile_width, (setter)UIEntity::set_tile_width, MCRF_PROPERTY(tile_width, "Entity width in tiles (int). Must be >= 1. Default 1."), NULL}, {"tile_height", (getter)UIEntity::get_tile_height, (setter)UIEntity::set_tile_height, MCRF_PROPERTY(tile_height, "Entity height in tiles (int). Must be >= 1. Default 1."), NULL}, // #237 - Composite sprite grid {"sprite_grid", (getter)UIEntity::get_sprite_grid, (setter)UIEntity::set_sprite_grid, MCRF_PROPERTY(sprite_grid, "Per-tile sprite indices for composite multi-tile entities (list of lists or None). Row-major, dimensions must match tile_width x tile_height. Use -1 for empty tiles."), NULL}, // #296 - Label system {"labels", (getter)UIEntity::get_labels, (setter)UIEntity::set_labels, MCRF_PROPERTY(labels, "String labels for collision and targeting (frozenset). Assign any iterable of strings to replace all labels."), NULL}, // #299 - Step callback and default behavior {"step", (getter)UIEntity::get_step, (setter)UIEntity::set_step, MCRF_PROPERTY(step, "Step callback for grid.step() turn management (Callable or None). Called with (trigger, data) when behavior triggers fire."), NULL}, {"default_behavior", (getter)UIEntity::get_default_behavior, (setter)UIEntity::set_default_behavior, MCRF_PROPERTY(default_behavior, "Default behavior type (int, maps to Behavior enum). Entity reverts to this after DONE trigger. Default: 0 (IDLE)."), NULL}, // #300 - Behavior system {"behavior_type", (getter)UIEntity::get_behavior_type, NULL, MCRF_PROPERTY(behavior_type, "Current behavior type (int, read-only). Use set_behavior() to change."), NULL}, {"turn_order", (getter)UIEntity::get_turn_order, (setter)UIEntity::set_turn_order, MCRF_PROPERTY(turn_order, "Turn order for grid.step() (int). 0 = skip, higher values go later. Default: 1."), NULL}, {"move_speed", (getter)UIEntity::get_move_speed, (setter)UIEntity::set_move_speed, MCRF_PROPERTY(move_speed, "Animation duration for behavior movement in seconds (float). 0 = instant. Default: 0.15."), NULL}, {"target_label", (getter)UIEntity::get_target_label, (setter)UIEntity::set_target_label, MCRF_PROPERTY(target_label, "Label to search for with TARGET trigger (str or None). Default: None."), NULL}, {"sight_radius", (getter)UIEntity::get_sight_radius, (setter)UIEntity::set_sight_radius, MCRF_PROPERTY(sight_radius, "FOV radius for TARGET trigger (int). Default: 10."), NULL}, {NULL} /* Sentinel */ }; PyObject* UIEntity::repr(PyUIEntityObject* self) { std::ostringstream ss; if (!self->data) ss << ""; else { // #217 - Show actual float position (draw_pos) to avoid confusion // Position is stored in tile coordinates; use draw_pos for float values ss << "data->position.x << ", " << self->data->position.y << ")" << ", sprite_index=" << self->data->sprite.getSpriteIndex() << ")>"; } std::string repr_str = ss.str(); return PyUnicode_DecodeUTF8(repr_str.c_str(), repr_str.size(), "replace"); } // Property system implementation for animations // #176 - Animation properties use tile coordinates (draw_x, draw_y) // "x" and "y" are kept as aliases for backwards compatibility bool UIEntity::setProperty(const std::string& name, float value) { if (name == "draw_x" || name == "x") { // #176 - draw_x is preferred, x is alias float old_x = position.x; float old_y = position.y; position.x = value; if (grid) { grid->markCompositeDirty(); grid->spatial_hash.update(shared_from_this(), old_x, old_y); // #256 } return true; } else if (name == "draw_y" || name == "y") { // #176 - draw_y is preferred, y is alias float old_x = position.x; float old_y = position.y; position.y = value; if (grid) { grid->markCompositeDirty(); grid->spatial_hash.update(shared_from_this(), old_x, old_y); // #256 } return true; } else if (name == "sprite_scale") { sprite.setScale(sf::Vector2f(value, value)); if (grid) grid->markCompositeDirty(); // #144 - Content change return true; } else if (name == "sprite_offset_x") { sprite_offset.x = value; if (grid) grid->markCompositeDirty(); return true; } else if (name == "sprite_offset_y") { sprite_offset.y = value; if (grid) grid->markCompositeDirty(); return true; } // #106: Shader uniform properties - delegate to sprite if (sprite.setShaderProperty(name, value)) { return true; } return false; } bool UIEntity::setProperty(const std::string& name, int value) { if (name == "sprite_index") { sprite.setSpriteIndex(value); if (grid) grid->markDirty(); // #144 - Content change return true; } return false; } bool UIEntity::getProperty(const std::string& name, float& value) const { if (name == "draw_x" || name == "x") { // #176 value = position.x; return true; } else if (name == "draw_y" || name == "y") { // #176 value = position.y; return true; } else if (name == "sprite_scale") { value = sprite.getScale().x; // Assuming uniform scale return true; } else if (name == "sprite_offset_x") { value = sprite_offset.x; return true; } else if (name == "sprite_offset_y") { value = sprite_offset.y; return true; } // #106: Shader uniform properties - delegate to sprite if (sprite.getShaderProperty(name, value)) { return true; } return false; } bool UIEntity::hasProperty(const std::string& name) const { // #176 - Float properties (draw_x/draw_y preferred, x/y are aliases) if (name == "draw_x" || name == "draw_y" || name == "x" || name == "y" || name == "sprite_scale" || name == "sprite_offset_x" || name == "sprite_offset_y") { return true; } // Int properties if (name == "sprite_index") { return true; } // #106: Shader uniform properties - delegate to sprite if (sprite.hasShaderProperty(name)) { return true; } return false; } // Animation shorthand for Entity - creates and starts an animation PyObject* UIEntity::animate(PyUIEntityObject* self, PyObject* args, PyObject* kwds) { static const char* keywords[] = {"property", "target", "duration", "easing", "delta", "loop", "callback", "conflict_mode", nullptr}; const char* property_name; PyObject* target_value; float duration; PyObject* easing_arg = Py_None; int delta = 0; int loop_val = 0; PyObject* callback = nullptr; const char* conflict_mode_str = nullptr; if (!PyArg_ParseTupleAndKeywords(args, kwds, "sOf|OppOs", const_cast(keywords), &property_name, &target_value, &duration, &easing_arg, &delta, &loop_val, &callback, &conflict_mode_str)) { return NULL; } // Validate property exists on this entity if (!self->data->hasProperty(property_name)) { PyErr_Format(PyExc_ValueError, "Property '%s' is not valid for animation on Entity. " "Valid properties: draw_x, draw_y (tile coords), sprite_scale, sprite_index", property_name); return NULL; } // Validate callback is callable if provided if (callback && callback != Py_None && !PyCallable_Check(callback)) { PyErr_SetString(PyExc_TypeError, "callback must be callable"); return NULL; } // Convert None to nullptr for C++ if (callback == Py_None) { callback = nullptr; } // Convert Python target value to AnimationValue // Entity supports float, int, and list of int (for sprite frame animation) AnimationValue animValue; if (PyFloat_Check(target_value)) { animValue = static_cast(PyFloat_AsDouble(target_value)); } else if (PyLong_Check(target_value)) { animValue = static_cast(PyLong_AsLong(target_value)); } else if (PyList_Check(target_value)) { // List of integers for sprite animation std::vector indices; Py_ssize_t size = PyList_Size(target_value); for (Py_ssize_t i = 0; i < size; i++) { PyObject* item = PyList_GetItem(target_value, i); if (PyLong_Check(item)) { indices.push_back(PyLong_AsLong(item)); } else { PyErr_SetString(PyExc_TypeError, "Sprite animation list must contain only integers"); return NULL; } } animValue = indices; } else { PyErr_SetString(PyExc_TypeError, "Entity animations support float, int, or list of int target values"); return NULL; } // Get easing function from argument EasingFunction easingFunc; if (!PyEasing::from_arg(easing_arg, &easingFunc, nullptr)) { return NULL; // Error already set by from_arg } // Parse conflict mode AnimationConflictMode conflict_mode = AnimationConflictMode::REPLACE; if (conflict_mode_str) { if (strcmp(conflict_mode_str, "replace") == 0) { conflict_mode = AnimationConflictMode::REPLACE; } else if (strcmp(conflict_mode_str, "queue") == 0) { conflict_mode = AnimationConflictMode::QUEUE; } else if (strcmp(conflict_mode_str, "error") == 0) { conflict_mode = AnimationConflictMode::RAISE_ERROR; } else { PyErr_Format(PyExc_ValueError, "Invalid conflict_mode '%s'. Must be 'replace', 'queue', or 'error'.", conflict_mode_str); return NULL; } } // Create the Animation auto animation = std::make_shared(property_name, animValue, duration, easingFunc, delta != 0, loop_val != 0, callback); // Start on this entity (uses startEntity, not start) animation->startEntity(self->data); // Add to AnimationManager AnimationManager::getInstance().addAnimation(animation, conflict_mode); // Check if ERROR mode raised an exception if (PyErr_Occurred()) { return NULL; } // Create and return a PyAnimation wrapper auto animType = &mcrfpydef::PyAnimationType; PyAnimationObject* pyAnim = (PyAnimationObject*)animType->tp_alloc(animType, 0); if (!pyAnim) { return NULL; } pyAnim->data = animation; return (PyObject*)pyAnim; }