Convert 289 raw PyMethodDef/PyGetSetDef docstring slots to MCRF_METHOD / MCRF_PROPERTY across the 20 frozen (non-3D) binding files, bringing the frozen surface to 100% macro compliance (check_frozen_docstrings.sh PASS). Done via a one-agent-per-file workflow gated by validate_file_docstrings.sh and per-wave build/doc-rebuild checks. - Adds #include "McRFPy_Doc.h" where missing; fills the lone genuine doc gap (UIGrid.at, which was MISSING a doc field in two arrays). - McRFPy_Doc.h: comment documenting the MCRF_METHOD_DOC comma rule (the trap that broke the GridLayers conversion mid-run). - Rebaseline api_surface golden: property types now resolve to real types instead of "Any" (e.g. grid_pos: Vector, on_cell_click: Callable | None), and 11 properties correctly flip rw->ro now that their docstrings carry "read-only" (collections, grid_size, hovered_cell, texture, view — all verified against NULL setter slots). - Regenerate docs/stubs/man page from the new docstrings. Module-level functions use MCRF_METHOD(<name>, ...) (expands identically to the intended MCRF_FUNCTION; the audit's compliance set is METHOD/PROPERTY). Experimental 3D/Voxel bindings (src/3d/) remain exempt from the freeze. Pre-existing failures unrelated to this change: test_animation_*, test_constructor_comprehensive (reference the removed mcrfpy.Animation and old constructor arity). Refs #314 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2172 lines
85 KiB
C++
2172 lines
85 KiB
C++
#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 <algorithm>
|
|
#include <cstring>
|
|
#include <libtcod.h>
|
|
#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 <cassert>
|
|
|
|
// #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<UIGrid> grid_as_uigrid(const std::shared_ptr<GridData>& grid)
|
|
{
|
|
if (!grid) return nullptr;
|
|
assert(dynamic_cast<UIGrid*>(grid.get()) != nullptr);
|
|
return std::shared_ptr<UIGrid>(grid, static_cast<UIGrid*>(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<size_t>(grid->grid_w) * grid->grid_h;
|
|
if (!perspective_map || perspective_map->size() != expected) {
|
|
perspective_map = std::make_shared<DiscreteMap>(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<UIEntity> 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<ColorLayer>(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<char**>(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<PyTexture> 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<UIEntity>();
|
|
|
|
// 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<int>(x), static_cast<int>(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<GridData> 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<float>(vec.x), static_cast<float>(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<int>(vec->data.x), static_cast<int>(vec->data.y));
|
|
}
|
|
|
|
PyObject* UIEntity::get_position(PyUIEntityObject* self, void* closure) {
|
|
if (reinterpret_cast<intptr_t>(closure) == 0) {
|
|
return sfVector2f_to_PyObject(self->data->position);
|
|
} else {
|
|
// Return integer-cast position for grid coordinates
|
|
sf::Vector2i int_pos(static_cast<int>(self->data->position.x),
|
|
static_cast<int>(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<intptr_t>(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<float>(vec.x),
|
|
static_cast<float>(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<DiscreteMap>(
|
|
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<DiscreteMap>(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<intptr_t>(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<intptr_t>(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<float>(entity->grid->cell_width());
|
|
cell_height = static_cast<float>(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<intptr_t>(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<intptr_t>(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<intptr_t>(closure);
|
|
if (member_ptr == 0) // grid_x
|
|
return PyLong_FromLong(static_cast<int>(self->data->position.x));
|
|
else // grid_y
|
|
return PyLong_FromLong(static_cast<int>(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<int>(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<intptr_t>(closure);
|
|
if (member_ptr == 0) // grid_x
|
|
self->data->position.x = static_cast<float>(val);
|
|
else // grid_y
|
|
self->data->position.y = static_cast<float>(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<UIEntity>& 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<GridData> 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<UIEntity>& 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<intptr_t>(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<intptr_t>(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<float>(self->data->tile_width),
|
|
static_cast<float>(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<int>(vec.x);
|
|
int th = static_cast<int>(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<int> 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<int>(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<int>(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<UIEntity>& 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<int>(self->data->position.x);
|
|
int current_y = static_cast<int>(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<char**>(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<UIGrid*>(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<UIGrid>; 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<UIGrid>(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<char**>(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<std::string> 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<int>(val);
|
|
return 0;
|
|
}
|
|
|
|
// #300 - Behavior system property implementations
|
|
PyObject* UIEntity::get_behavior_type(PyUIEntityObject* self, void* closure) {
|
|
return PyLong_FromLong(static_cast<int>(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<int>(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<float>(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<int>(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<char**>(kwlist),
|
|
&type_val, &waypoints_obj, &turns, &path_obj,
|
|
&pathfinder_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
auto& behavior = self->data->behavior;
|
|
behavior.reset();
|
|
behavior.type = static_cast<BehaviorType>(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<DijkstraProvider>(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<sf::Vector2i> steps(
|
|
apath->path.begin() + apath->current_index,
|
|
apath->path.end());
|
|
behavior.path_provider = std::make_unique<AStarProvider>(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<TargetProvider>(
|
|
sf::Vector2i(static_cast<int>(tx), static_cast<int>(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<int>(vec.x);
|
|
self->data->cell_position.y = static_cast<int>(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<intptr_t>(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<intptr_t>(closure) == 0) {
|
|
self->data->cell_position.x = static_cast<int>(val);
|
|
} else {
|
|
self->data->cell_position.y = static_cast<int>(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 << "<Entity (invalid internal object)>";
|
|
else {
|
|
// #217 - Show actual float position (draw_pos) to avoid confusion
|
|
// Position is stored in tile coordinates; use draw_pos for float values
|
|
ss << "<Entity (draw_pos=(" << self->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<char**>(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<float>(PyFloat_AsDouble(target_value));
|
|
}
|
|
else if (PyLong_Check(target_value)) {
|
|
animValue = static_cast<int>(PyLong_AsLong(target_value));
|
|
}
|
|
else if (PyList_Check(target_value)) {
|
|
// List of integers for sprite animation
|
|
std::vector<int> 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<Animation>(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;
|
|
}
|