closes #361 closes #370 closes #371 UIGrid inherited both UIDrawable and GridData, so every map carried a second camera and RenderTexture that nothing on screen corresponded to. Appending a _GridData to a scene drew the whole map again through that ghost, frozen at construction values. GridData is now pure data -- no position, no size, no render() -- and UIGridView is the only widget. UIGrid is deleted rather than demoted: shedding UIDrawable left an empty subclass whose only job was to be what an aliasing shared_ptr pointed into. GridData absorbs the texture (it defines the cell size, which every tile<->pixel conversion depends on) and its own PythonObjectCache serial; the binding layer becomes struct PyGridData. PyObjectsEnum::UIGRID and its 14 case arms go with it. * mcrfpy.GridData is public and standalone-constructible. A map with no view at all -- an offscreen level, still steppable and pathable -- was previously undefined behavior: GridData::markDirty did static_cast<UIGrid*>(this). * UIGridView::init constructs the GridData directly instead of building a throwaway Python _GridData, stealing its base subobject, and copying ~15 fields of rendering state out of the discarded wrapper. One init() serves both modes, so a second view is no longer a crippled kwarg subset (Grid(grid=other, z_index=5) used to raise TypeError). * entity.grid returns the GridData. Accepted break: an entity may be on a map with no view or with several, so "the grid an entity is in" is not a camera. Migrate player.grid.center_camera(p) -> view.center_camera(p). * tp_name is mcrfpy.GridView; mcrfpy.Grid is an alias to the same type object. Two latent bugs surfaced and are fixed here: #370 Grid.center_camera() and Grid.size = ... were SILENT NO-OPS -- neither was defined on the view, so both delegated to the ghost camera. On master, center_camera((5,5)) leaves center_x at 50.0. #371 The shader uniform binder strcmp'd tp_name == "mcrfpy.Grid" (the VIEW's name) then cast to PyUIGridObject. UB that only worked because both wrappers had identical layout and both classes put UIDrawable at offset 0. Suite 327/327. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
907 lines
36 KiB
C++
907 lines
36 KiB
C++
// PyGridDataMethods.cpp - Python method implementations for GridData.
|
|
// #361: center_camera() moved to UIGridView (a map has no camera), and the
|
|
// UIDrawable method set is gone (a map is not a widget).
|
|
// Contains: FOV, layer management, spatial queries, camera, heightmap ops,
|
|
// step/behavior system, py_at/subscript, and method table arrays.
|
|
#include "PyGridData.h"
|
|
#include "UIGridView.h"
|
|
#include "UIGridPathfinding.h"
|
|
#include "McRFPy_API.h"
|
|
#include "PythonObjectCache.h"
|
|
#include "UIEntity.h"
|
|
#include "PyPositionHelper.h"
|
|
#include "PyVector.h"
|
|
#include "PyHeightMap.h"
|
|
#include "EntityBehavior.h"
|
|
#include "PyTrigger.h"
|
|
#include "UIBase.h"
|
|
#include "PyFOV.h"
|
|
#include "McRFPy_Doc.h"
|
|
|
|
// =========================================================================
|
|
// Cell access: py_at, subscript, mpmethods
|
|
// =========================================================================
|
|
|
|
PyObject* PyGridData::py_at(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
|
{
|
|
int x, y;
|
|
|
|
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (x < 0 || x >= self->data->grid_w) {
|
|
PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w);
|
|
return NULL;
|
|
}
|
|
if (y < 0 || y >= self->data->grid_h) {
|
|
PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h);
|
|
return NULL;
|
|
}
|
|
|
|
auto type = &mcrfpydef::PyUIGridPointType;
|
|
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
|
|
obj->grid = self->data;
|
|
obj->x = x;
|
|
obj->y = y;
|
|
return (PyObject*)obj;
|
|
}
|
|
|
|
PyObject* PyGridData::subscript(PyGridDataObject* self, PyObject* key)
|
|
{
|
|
if (!PyTuple_Check(key) || PyTuple_Size(key) != 2) {
|
|
PyErr_SetString(PyExc_TypeError, "Grid indices must be a tuple of (x, y)");
|
|
return NULL;
|
|
}
|
|
|
|
PyObject* x_obj = PyTuple_GetItem(key, 0);
|
|
PyObject* y_obj = PyTuple_GetItem(key, 1);
|
|
|
|
if (!PyLong_Check(x_obj) || !PyLong_Check(y_obj)) {
|
|
PyErr_SetString(PyExc_TypeError, "Grid indices must be integers");
|
|
return NULL;
|
|
}
|
|
|
|
int x = PyLong_AsLong(x_obj);
|
|
int y = PyLong_AsLong(y_obj);
|
|
|
|
if (x < 0 || x >= self->data->grid_w) {
|
|
PyErr_Format(PyExc_IndexError, "x index %d is out of range [0, %d)", x, self->data->grid_w);
|
|
return NULL;
|
|
}
|
|
if (y < 0 || y >= self->data->grid_h) {
|
|
PyErr_Format(PyExc_IndexError, "y index %d is out of range [0, %d)", y, self->data->grid_h);
|
|
return NULL;
|
|
}
|
|
|
|
auto type = &mcrfpydef::PyUIGridPointType;
|
|
auto obj = (PyUIGridPointObject*)type->tp_alloc(type, 0);
|
|
if (!obj) return NULL;
|
|
|
|
obj->grid = self->data;
|
|
obj->x = x;
|
|
obj->y = y;
|
|
return (PyObject*)obj;
|
|
}
|
|
|
|
// Setitem on _GridData / Grid: GridPoints are views, not assignable.
|
|
static int UIGrid_subscript_assign(PyGridDataObject* self, PyObject* key, PyObject* value)
|
|
{
|
|
(void)self; (void)key; (void)value;
|
|
PyErr_SetString(PyExc_TypeError,
|
|
"Grid points are not assignable; modify properties on the returned point");
|
|
return -1;
|
|
}
|
|
|
|
PyMappingMethods PyGridData::mpmethods = {
|
|
.mp_length = NULL,
|
|
.mp_subscript = (binaryfunc)PyGridData::subscript,
|
|
.mp_ass_subscript = (objobjargproc)UIGrid_subscript_assign,
|
|
};
|
|
|
|
// =========================================================================
|
|
// FOV methods
|
|
// =========================================================================
|
|
|
|
PyObject* PyGridData::py_compute_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
|
{
|
|
static const char* kwlist[] = {"pos", "radius", "light_walls", "algorithm", NULL};
|
|
PyObject* pos_obj = NULL;
|
|
int radius = 0;
|
|
int light_walls = 1;
|
|
PyObject* algorithm_obj = NULL;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|ipO", const_cast<char**>(kwlist),
|
|
&pos_obj, &radius, &light_walls, &algorithm_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
int x, y;
|
|
if (!PyPosition_FromObjectInt(pos_obj, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
// #310: validate algorithm via PyFOV::from_arg so out-of-range ints become a
|
|
// ValueError at the Python boundary instead of a UBSan-flagged invalid enum
|
|
// load deep in GridData::computeFOV.
|
|
TCOD_fov_algorithm_t algorithm = FOV_BASIC;
|
|
if (algorithm_obj != NULL) {
|
|
if (!PyFOV::from_arg(algorithm_obj, &algorithm)) {
|
|
return NULL;
|
|
}
|
|
}
|
|
|
|
self->data->computeFOV(x, y, radius, light_walls, algorithm);
|
|
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyObject* PyGridData::py_is_in_fov(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
|
{
|
|
int x, y;
|
|
if (!PyPosition_ParseInt(args, kwds, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
bool in_fov = self->data->isInFOV(x, y);
|
|
return PyBool_FromLong(in_fov);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Layer management
|
|
// =========================================================================
|
|
|
|
PyObject* PyGridData::py_add_layer(PyGridDataObject* self, PyObject* args) {
|
|
PyObject* layer_obj;
|
|
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
auto* color_layer_type = (PyObject*)&mcrfpydef::PyColorLayerType;
|
|
auto* tile_layer_type = (PyObject*)&mcrfpydef::PyTileLayerType;
|
|
|
|
std::shared_ptr<GridLayer> layer;
|
|
PyObject* py_layer_ref = nullptr;
|
|
|
|
if (PyObject_IsInstance(layer_obj, color_layer_type)) {
|
|
PyColorLayerObject* py_layer = (PyColorLayerObject*)layer_obj;
|
|
if (!py_layer->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (py_layer->grid && py_layer->grid.get() != self->data.get()) {
|
|
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
|
|
return NULL;
|
|
}
|
|
|
|
layer = py_layer->data;
|
|
py_layer_ref = layer_obj;
|
|
|
|
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
|
|
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
|
|
return NULL;
|
|
}
|
|
|
|
if (!layer->name.empty()) {
|
|
auto existing = self->data->getLayerByName(layer->name);
|
|
if (existing && existing.get() != layer.get()) {
|
|
existing->parent_grid = nullptr;
|
|
self->data->removeLayer(existing);
|
|
}
|
|
}
|
|
|
|
if (layer->grid_x == 0 && layer->grid_y == 0) {
|
|
layer->resize(self->data->grid_w, self->data->grid_h);
|
|
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"Layer size (%d, %d) does not match Grid size (%d, %d)",
|
|
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
|
|
return NULL;
|
|
}
|
|
|
|
layer->parent_grid = self->data.get();
|
|
self->data->layers.push_back(layer);
|
|
self->data->layers_need_sort = true;
|
|
py_layer->grid = self->data;
|
|
|
|
} else if (PyObject_IsInstance(layer_obj, tile_layer_type)) {
|
|
PyTileLayerObject* py_layer = (PyTileLayerObject*)layer_obj;
|
|
if (!py_layer->data) {
|
|
PyErr_SetString(PyExc_RuntimeError, "Layer has no data");
|
|
return NULL;
|
|
}
|
|
|
|
if (py_layer->grid && py_layer->grid.get() != self->data.get()) {
|
|
PyErr_SetString(PyExc_ValueError, "Layer is already attached to another Grid");
|
|
return NULL;
|
|
}
|
|
|
|
layer = py_layer->data;
|
|
py_layer_ref = layer_obj;
|
|
|
|
if (!layer->name.empty() && GridData::isProtectedLayerName(layer->name)) {
|
|
PyErr_Format(PyExc_ValueError, "Layer name '%s' is reserved", layer->name.c_str());
|
|
return NULL;
|
|
}
|
|
|
|
if (!layer->name.empty()) {
|
|
auto existing = self->data->getLayerByName(layer->name);
|
|
if (existing && existing.get() != layer.get()) {
|
|
existing->parent_grid = nullptr;
|
|
self->data->removeLayer(existing);
|
|
}
|
|
}
|
|
|
|
if (layer->grid_x == 0 && layer->grid_y == 0) {
|
|
layer->resize(self->data->grid_w, self->data->grid_h);
|
|
} else if (layer->grid_x != self->data->grid_w || layer->grid_y != self->data->grid_h) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"Layer size (%d, %d) does not match Grid size (%d, %d)",
|
|
layer->grid_x, layer->grid_y, self->data->grid_w, self->data->grid_h);
|
|
return NULL;
|
|
}
|
|
|
|
layer->parent_grid = self->data.get();
|
|
self->data->layers.push_back(layer);
|
|
self->data->layers_need_sort = true;
|
|
py_layer->grid = self->data;
|
|
|
|
auto tile_layer = std::static_pointer_cast<TileLayer>(layer);
|
|
if (!tile_layer->texture) {
|
|
tile_layer->texture = self->data->getTexture();
|
|
}
|
|
|
|
} else {
|
|
PyErr_SetString(PyExc_TypeError, "layer must be a ColorLayer or TileLayer");
|
|
return NULL;
|
|
}
|
|
|
|
Py_INCREF(py_layer_ref);
|
|
return py_layer_ref;
|
|
}
|
|
|
|
PyObject* PyGridData::py_remove_layer(PyGridDataObject* self, PyObject* args) {
|
|
PyObject* layer_obj;
|
|
if (!PyArg_ParseTuple(args, "O", &layer_obj)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (PyUnicode_Check(layer_obj)) {
|
|
const char* name_str = PyUnicode_AsUTF8(layer_obj);
|
|
if (!name_str) return NULL;
|
|
|
|
auto layer = self->data->getLayerByName(std::string(name_str));
|
|
if (!layer) {
|
|
PyErr_Format(PyExc_KeyError, "Layer '%s' not found", name_str);
|
|
return NULL;
|
|
}
|
|
|
|
layer->parent_grid = nullptr;
|
|
self->data->removeLayer(layer);
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
if (PyObject_IsInstance(layer_obj, (PyObject*)&mcrfpydef::PyColorLayerType)) {
|
|
auto* py_layer = (PyColorLayerObject*)layer_obj;
|
|
if (py_layer->data) {
|
|
py_layer->data->parent_grid = nullptr;
|
|
self->data->removeLayer(py_layer->data);
|
|
py_layer->grid.reset();
|
|
}
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
if (PyObject_IsInstance(layer_obj, (PyObject*)&mcrfpydef::PyTileLayerType)) {
|
|
auto* py_layer = (PyTileLayerObject*)layer_obj;
|
|
if (py_layer->data) {
|
|
py_layer->data->parent_grid = nullptr;
|
|
self->data->removeLayer(py_layer->data);
|
|
py_layer->grid.reset();
|
|
}
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
PyErr_SetString(PyExc_TypeError, "layer must be a string (layer name), ColorLayer, or TileLayer");
|
|
return NULL;
|
|
}
|
|
|
|
PyObject* PyGridData::py_layer(PyGridDataObject* self, PyObject* args) {
|
|
const char* name_str;
|
|
if (!PyArg_ParseTuple(args, "s", &name_str)) {
|
|
return NULL;
|
|
}
|
|
|
|
auto layer = self->data->getLayerByName(std::string(name_str));
|
|
if (!layer) {
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
if (layer->type == GridLayerType::Color) {
|
|
auto* type = &mcrfpydef::PyColorLayerType;
|
|
PyColorLayerObject* obj = (PyColorLayerObject*)type->tp_alloc(type, 0);
|
|
if (!obj) return NULL;
|
|
|
|
obj->data = std::static_pointer_cast<ColorLayer>(layer);
|
|
obj->grid = self->data;
|
|
return (PyObject*)obj;
|
|
} else {
|
|
auto* type = &mcrfpydef::PyTileLayerType;
|
|
PyTileLayerObject* obj = (PyTileLayerObject*)type->tp_alloc(type, 0);
|
|
if (!obj) return NULL;
|
|
|
|
obj->data = std::static_pointer_cast<TileLayer>(layer);
|
|
obj->grid = self->data;
|
|
return (PyObject*)obj;
|
|
}
|
|
}
|
|
|
|
// =========================================================================
|
|
// Spatial queries
|
|
// =========================================================================
|
|
|
|
PyObject* PyGridData::py_entities_in_radius(PyGridDataObject* self, PyObject* args, PyObject* kwds)
|
|
{
|
|
static const char* kwlist[] = {"pos", "radius", NULL};
|
|
PyObject* pos_obj;
|
|
float radius;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "Of", const_cast<char**>(kwlist),
|
|
&pos_obj, &radius)) {
|
|
return NULL;
|
|
}
|
|
|
|
float x, y;
|
|
if (!PyPosition_FromObject(pos_obj, &x, &y)) {
|
|
return NULL;
|
|
}
|
|
|
|
if (radius < 0) {
|
|
PyErr_SetString(PyExc_ValueError, "radius must be non-negative");
|
|
return NULL;
|
|
}
|
|
|
|
auto entities = self->data->spatial_hash.queryRadius(x, y, radius);
|
|
|
|
PyObject* result = PyList_New(entities.size());
|
|
if (!result) return PyErr_NoMemory();
|
|
|
|
PyTypeObject* entity_type = &mcrfpydef::PyUIEntityType;
|
|
|
|
for (size_t i = 0; i < entities.size(); i++) {
|
|
auto& entity = entities[i];
|
|
|
|
PyObject* py_entity = nullptr;
|
|
if (entity->serial_number != 0) {
|
|
py_entity = PythonObjectCache::getInstance().lookup(entity->serial_number);
|
|
}
|
|
if (!py_entity) {
|
|
auto pyEntity = (PyUIEntityObject*)entity_type->tp_alloc(entity_type, 0);
|
|
if (!pyEntity) {
|
|
Py_DECREF(result);
|
|
return PyErr_NoMemory();
|
|
}
|
|
pyEntity->data = entity;
|
|
pyEntity->weakreflist = NULL;
|
|
py_entity = (PyObject*)pyEntity;
|
|
}
|
|
PyList_SET_ITEM(result, i, py_entity);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// =========================================================================
|
|
// HeightMap application methods
|
|
// =========================================================================
|
|
|
|
PyObject* PyGridData::py_apply_threshold(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* keywords[] = {"source", "range", "walkable", "transparent", nullptr};
|
|
PyObject* source_obj = nullptr;
|
|
PyObject* range_obj = nullptr;
|
|
PyObject* walkable_obj = Py_None;
|
|
PyObject* transparent_obj = Py_None;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|OO", const_cast<char**>(keywords),
|
|
&source_obj, &range_obj, &walkable_obj, &transparent_obj)) {
|
|
return nullptr;
|
|
}
|
|
|
|
if (!PyObject_IsInstance(source_obj, (PyObject*)&mcrfpydef::PyHeightMapType)) {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
|
|
return nullptr;
|
|
}
|
|
PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj;
|
|
|
|
if (!hmap->heightmap) {
|
|
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
|
return nullptr;
|
|
}
|
|
|
|
if (!PyTuple_Check(range_obj) || PyTuple_Size(range_obj) != 2) {
|
|
PyErr_SetString(PyExc_TypeError, "range must be a tuple of (min, max)");
|
|
return nullptr;
|
|
}
|
|
|
|
float range_min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 0));
|
|
float range_max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_obj, 1));
|
|
|
|
if (PyErr_Occurred()) {
|
|
return nullptr;
|
|
}
|
|
|
|
if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"HeightMap size (%d, %d) does not match Grid size (%d, %d)",
|
|
hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h);
|
|
return nullptr;
|
|
}
|
|
|
|
bool set_walkable = (walkable_obj != Py_None);
|
|
bool set_transparent = (transparent_obj != Py_None);
|
|
bool walkable_value = false;
|
|
bool transparent_value = false;
|
|
|
|
if (set_walkable) {
|
|
walkable_value = PyObject_IsTrue(walkable_obj);
|
|
}
|
|
if (set_transparent) {
|
|
transparent_value = PyObject_IsTrue(transparent_obj);
|
|
}
|
|
|
|
for (int y = 0; y < self->data->grid_h; y++) {
|
|
for (int x = 0; x < self->data->grid_w; x++) {
|
|
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
|
|
if (value >= range_min && value <= range_max) {
|
|
if (set_walkable) {
|
|
self->data->setWalkable(x, y, walkable_value);
|
|
}
|
|
if (set_transparent) {
|
|
self->data->setTransparent(x, y, transparent_value);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (self->data->getTCODMap()) {
|
|
self->data->syncTCODMap();
|
|
}
|
|
|
|
Py_INCREF(self);
|
|
return (PyObject*)self;
|
|
}
|
|
|
|
PyObject* PyGridData::py_apply_ranges(PyGridDataObject* self, PyObject* args) {
|
|
PyObject* source_obj = nullptr;
|
|
PyObject* ranges_obj = nullptr;
|
|
|
|
if (!PyArg_ParseTuple(args, "OO", &source_obj, &ranges_obj)) {
|
|
return nullptr;
|
|
}
|
|
|
|
if (!PyObject_IsInstance(source_obj, (PyObject*)&mcrfpydef::PyHeightMapType)) {
|
|
PyErr_SetString(PyExc_TypeError, "source must be a HeightMap");
|
|
return nullptr;
|
|
}
|
|
PyHeightMapObject* hmap = (PyHeightMapObject*)source_obj;
|
|
|
|
if (!hmap->heightmap) {
|
|
PyErr_SetString(PyExc_RuntimeError, "HeightMap not initialized");
|
|
return nullptr;
|
|
}
|
|
|
|
if (!PyList_Check(ranges_obj)) {
|
|
PyErr_SetString(PyExc_TypeError, "ranges must be a list");
|
|
return nullptr;
|
|
}
|
|
|
|
if (hmap->heightmap->w != self->data->grid_w || hmap->heightmap->h != self->data->grid_h) {
|
|
PyErr_Format(PyExc_ValueError,
|
|
"HeightMap size (%d, %d) does not match Grid size (%d, %d)",
|
|
hmap->heightmap->w, hmap->heightmap->h, self->data->grid_w, self->data->grid_h);
|
|
return nullptr;
|
|
}
|
|
|
|
struct RangeEntry {
|
|
float min, max;
|
|
bool set_walkable, set_transparent;
|
|
bool walkable_value, transparent_value;
|
|
};
|
|
std::vector<RangeEntry> entries;
|
|
|
|
Py_ssize_t num_ranges = PyList_Size(ranges_obj);
|
|
for (Py_ssize_t i = 0; i < num_ranges; i++) {
|
|
PyObject* entry = PyList_GetItem(ranges_obj, i);
|
|
|
|
if (!PyTuple_Check(entry) || PyTuple_Size(entry) != 2) {
|
|
PyErr_Format(PyExc_TypeError,
|
|
"ranges[%zd] must be a tuple of (range, properties_dict)", i);
|
|
return nullptr;
|
|
}
|
|
|
|
PyObject* range_tuple = PyTuple_GetItem(entry, 0);
|
|
PyObject* props_dict = PyTuple_GetItem(entry, 1);
|
|
|
|
if (!PyTuple_Check(range_tuple) || PyTuple_Size(range_tuple) != 2) {
|
|
PyErr_Format(PyExc_TypeError,
|
|
"ranges[%zd] range must be a tuple of (min, max)", i);
|
|
return nullptr;
|
|
}
|
|
|
|
if (!PyDict_Check(props_dict)) {
|
|
PyErr_Format(PyExc_TypeError,
|
|
"ranges[%zd] properties must be a dict", i);
|
|
return nullptr;
|
|
}
|
|
|
|
RangeEntry re;
|
|
re.min = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 0));
|
|
re.max = (float)PyFloat_AsDouble(PyTuple_GetItem(range_tuple, 1));
|
|
|
|
if (PyErr_Occurred()) {
|
|
return nullptr;
|
|
}
|
|
|
|
PyObject* walkable_val = PyDict_GetItemString(props_dict, "walkable");
|
|
re.set_walkable = (walkable_val != nullptr);
|
|
if (re.set_walkable) {
|
|
re.walkable_value = PyObject_IsTrue(walkable_val);
|
|
}
|
|
|
|
PyObject* transparent_val = PyDict_GetItemString(props_dict, "transparent");
|
|
re.set_transparent = (transparent_val != nullptr);
|
|
if (re.set_transparent) {
|
|
re.transparent_value = PyObject_IsTrue(transparent_val);
|
|
}
|
|
|
|
entries.push_back(re);
|
|
}
|
|
|
|
for (int y = 0; y < self->data->grid_h; y++) {
|
|
for (int x = 0; x < self->data->grid_w; x++) {
|
|
float value = TCOD_heightmap_get_value(hmap->heightmap, x, y);
|
|
|
|
for (const auto& re : entries) {
|
|
if (value >= re.min && value <= re.max) {
|
|
if (re.set_walkable) {
|
|
self->data->setWalkable(x, y, re.walkable_value);
|
|
}
|
|
if (re.set_transparent) {
|
|
self->data->setTransparent(x, y, re.transparent_value);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (self->data->getTCODMap()) {
|
|
self->data->syncTCODMap();
|
|
}
|
|
|
|
Py_INCREF(self);
|
|
return (PyObject*)self;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Step / turn-based behavior system
|
|
// =========================================================================
|
|
|
|
static void fireStepCallback(std::shared_ptr<UIEntity>& entity, int trigger_int, PyObject* data) {
|
|
PyObject* callback = entity->step_callback;
|
|
|
|
if (!callback && entity->pyobject) {
|
|
PyObject* step_attr = PyObject_GetAttrString(entity->pyobject, "on_step");
|
|
if (step_attr && PyCallable_Check(step_attr)) {
|
|
callback = step_attr;
|
|
} else {
|
|
PyErr_Clear();
|
|
Py_XDECREF(step_attr);
|
|
return;
|
|
}
|
|
PyObject* trigger_obj = nullptr;
|
|
if (PyTrigger::trigger_enum_class) {
|
|
trigger_obj = PyObject_CallFunction(PyTrigger::trigger_enum_class, "i", trigger_int);
|
|
}
|
|
if (!trigger_obj) {
|
|
PyErr_Clear();
|
|
trigger_obj = PyLong_FromLong(trigger_int);
|
|
}
|
|
if (!data) data = Py_None;
|
|
PyObject* result = PyObject_CallFunction(callback, "OO", trigger_obj, data);
|
|
Py_XDECREF(result);
|
|
if (PyErr_Occurred()) PyErr_Print();
|
|
Py_DECREF(trigger_obj);
|
|
Py_DECREF(step_attr);
|
|
return;
|
|
}
|
|
|
|
if (!callback) return;
|
|
|
|
PyObject* trigger_obj = nullptr;
|
|
if (PyTrigger::trigger_enum_class) {
|
|
trigger_obj = PyObject_CallFunction(PyTrigger::trigger_enum_class, "i", trigger_int);
|
|
}
|
|
if (!trigger_obj) {
|
|
PyErr_Clear();
|
|
trigger_obj = PyLong_FromLong(trigger_int);
|
|
}
|
|
|
|
if (!data) data = Py_None;
|
|
PyObject* result = PyObject_CallFunction(callback, "OO", trigger_obj, data);
|
|
Py_XDECREF(result);
|
|
if (PyErr_Occurred()) PyErr_Print();
|
|
Py_DECREF(trigger_obj);
|
|
}
|
|
|
|
PyObject* PyGridData::py_step(PyGridDataObject* self, PyObject* args, PyObject* kwds) {
|
|
static const char* kwlist[] = {"n", "turn_order", nullptr};
|
|
int n = 1;
|
|
PyObject* turn_order_filter = nullptr;
|
|
|
|
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|iO", const_cast<char**>(kwlist),
|
|
&n, &turn_order_filter)) {
|
|
return NULL;
|
|
}
|
|
|
|
int filter_turn_order = -1;
|
|
if (turn_order_filter && turn_order_filter != Py_None) {
|
|
filter_turn_order = PyLong_AsLong(turn_order_filter);
|
|
if (filter_turn_order == -1 && PyErr_Occurred()) return NULL;
|
|
}
|
|
|
|
auto& grid = self->data;
|
|
if (!grid->entities) Py_RETURN_NONE;
|
|
|
|
// #351 - the turn manager mutates entity positions directly (bypassing the
|
|
// Python setters that invalidate the view). Track whether any entity moved
|
|
// so we can invalidate the view's render cache once after all rounds.
|
|
bool content_changed = false;
|
|
|
|
for (int round = 0; round < n; round++) {
|
|
std::vector<std::shared_ptr<UIEntity>> snapshot;
|
|
for (auto& entity : *grid->entities) {
|
|
if (entity->turn_order == 0) continue;
|
|
if (filter_turn_order >= 0 && entity->turn_order != filter_turn_order) continue;
|
|
snapshot.push_back(entity);
|
|
}
|
|
|
|
std::sort(snapshot.begin(), snapshot.end(),
|
|
[](const auto& a, const auto& b) { return a->turn_order < b->turn_order; });
|
|
|
|
for (auto& entity : snapshot) {
|
|
if (!entity->grid) continue;
|
|
if (entity->behavior.type == BehaviorType::IDLE) continue;
|
|
|
|
if (!entity->target_label.empty()) {
|
|
auto nearby = grid->spatial_hash.queryRadius(
|
|
static_cast<float>(entity->cell_position.x),
|
|
static_cast<float>(entity->cell_position.y),
|
|
static_cast<float>(entity->sight_radius));
|
|
|
|
std::vector<std::shared_ptr<UIEntity>> matching_targets;
|
|
for (auto& candidate : nearby) {
|
|
if (candidate.get() != entity.get() &&
|
|
candidate->labels.count(entity->target_label)) {
|
|
matching_targets.push_back(candidate);
|
|
}
|
|
}
|
|
|
|
if (!matching_targets.empty()) {
|
|
auto& cache = entity->target_fov_cache;
|
|
|
|
if (!cache.isValid(entity->cell_position, entity->sight_radius,
|
|
grid->transparency_generation)) {
|
|
grid->computeFOV(entity->cell_position.x, entity->cell_position.y,
|
|
entity->sight_radius, true, grid->fov_algorithm);
|
|
|
|
int r = entity->sight_radius;
|
|
int side = 2 * r + 1;
|
|
cache.origin = entity->cell_position;
|
|
cache.radius = r;
|
|
cache.transparency_gen = grid->transparency_generation;
|
|
cache.vis_side = side;
|
|
cache.visibility.resize(side * side);
|
|
for (int dy = -r; dy <= r; dy++) {
|
|
for (int dx = -r; dx <= r; dx++) {
|
|
cache.visibility[(dy + r) * side + (dx + r)] =
|
|
grid->isInFOV(entity->cell_position.x + dx,
|
|
entity->cell_position.y + dy);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (auto& target : matching_targets) {
|
|
if (cache.isVisible(target->cell_position.x,
|
|
target->cell_position.y)) {
|
|
PyObject* target_pyobj = Py_None;
|
|
if (target->pyobject) {
|
|
target_pyobj = target->pyobject;
|
|
}
|
|
fireStepCallback(entity, 2 /* TARGET */, target_pyobj);
|
|
goto next_entity;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
{
|
|
BehaviorOutput output = executeBehavior(*entity, *grid);
|
|
|
|
switch (output.result) {
|
|
case BehaviorResult::MOVED: {
|
|
int old_x = entity->cell_position.x;
|
|
int old_y = entity->cell_position.y;
|
|
entity->cell_position = output.target_cell;
|
|
grid->spatial_hash.updateCell(entity, old_x, old_y);
|
|
|
|
entity->position = sf::Vector2f(
|
|
static_cast<float>(output.target_cell.x),
|
|
static_cast<float>(output.target_cell.y));
|
|
content_changed = true; // #351 - view render cache is now stale
|
|
break;
|
|
}
|
|
case BehaviorResult::DONE: {
|
|
fireStepCallback(entity, 0 /* DONE */, Py_None);
|
|
entity->behavior.type = static_cast<BehaviorType>(entity->default_behavior);
|
|
break;
|
|
}
|
|
case BehaviorResult::BLOCKED: {
|
|
PyObject* blocker = Py_None;
|
|
auto blockers = grid->spatial_hash.queryCell(
|
|
output.target_cell.x, output.target_cell.y);
|
|
if (!blockers.empty() && blockers[0]->pyobject) {
|
|
blocker = blockers[0]->pyobject;
|
|
}
|
|
fireStepCallback(entity, 1 /* BLOCKED */, blocker);
|
|
break;
|
|
}
|
|
case BehaviorResult::NO_ACTION:
|
|
break;
|
|
}
|
|
}
|
|
next_entity:;
|
|
}
|
|
}
|
|
|
|
// #351 - invalidate the view's render early-out once if anything moved.
|
|
if (content_changed) grid->markCompositeDirty();
|
|
|
|
Py_RETURN_NONE;
|
|
}
|
|
|
|
// =========================================================================
|
|
// Method tables
|
|
// =========================================================================
|
|
|
|
typedef PyGridDataObject PyObjectType;
|
|
|
|
PyMethodDef PyGridData_all_methods[] = {
|
|
{"at", (PyCFunction)PyGridData::py_at, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, at,
|
|
MCRF_SIG("(x: int, y: int)", "GridPoint"),
|
|
MCRF_DESC("Return the GridPoint cell at grid coordinates (x, y)."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("x", "Column index (0-based)")
|
|
MCRF_ARG("y", "Row index (0-based)")
|
|
MCRF_RETURNS("GridPoint: the cell at the given position")
|
|
MCRF_RAISES("IndexError", "If x or y is out of range")
|
|
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: at((x, y)) or at(vec), or keyword form: at(pos=(x, y)).")
|
|
)},
|
|
{"compute_fov", (PyCFunction)PyGridData::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, compute_fov,
|
|
MCRF_SIG("(pos, radius: int = 0, light_walls: bool = True, algorithm: FOV | int = FOV.BASIC)", "None"),
|
|
MCRF_DESC("Compute field of view from a position. Updates the internal FOV state; use is_in_fov() to query visibility."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("pos", "Position as (x, y) tuple, list, or Vector")
|
|
MCRF_ARG("radius", "Maximum view distance (0 = unlimited)")
|
|
MCRF_ARG("light_walls", "Whether walls are lit when visible")
|
|
MCRF_ARG("algorithm", "FOV algorithm to use (FOV.BASIC, FOV.DIAMOND, FOV.SHADOW, FOV.PERMISSIVE_0-8)")
|
|
MCRF_RETURNS("None")
|
|
)},
|
|
{"is_in_fov", (PyCFunction)PyGridData::py_is_in_fov, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, is_in_fov,
|
|
MCRF_SIG("(x: int, y: int)", "bool"),
|
|
MCRF_DESC("Check if a cell is in the field of view. Must call compute_fov() first to calculate visibility."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("x", "Column index (0-based)")
|
|
MCRF_ARG("y", "Row index (0-based)")
|
|
MCRF_RETURNS("True if the cell is visible, False otherwise")
|
|
MCRF_NOTE("Also accepts a single positional tuple/list/Vector: is_in_fov((x, y)) or is_in_fov(vec), or keyword form: is_in_fov(pos=(x, y)).")
|
|
)},
|
|
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, find_path,
|
|
MCRF_SIG("(start, end, diagonal_cost: float = 1.41, collide: str = None, heuristic = None, weight: float = 1.0)", "AStarPath | None"),
|
|
MCRF_DESC("Compute A* path between two points. The returned AStarPath can be iterated or walked step-by-step."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("start", "Starting position as Vector, Entity, or (x, y) tuple")
|
|
MCRF_ARG("end", "Target position 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_ARG("heuristic", "Heuristic enum member, string name, or int (EUCLIDEAN=0, MANHATTAN=1, CHEBYSHEV=2). None uses default (Euclidean).")
|
|
MCRF_ARG("weight", "Heuristic weight multiplier. Values > 1.0 trade optimality for speed (weighted A*).")
|
|
MCRF_RETURNS("AStarPath object if path exists, None otherwise")
|
|
)},
|
|
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, get_dijkstra_map,
|
|
MCRF_SIG("(root=None, diagonal_cost: float = 1.41, collide: str = None, roots=None)", "DijkstraMap"),
|
|
MCRF_DESC("Get or create a cached Dijkstra distance map for a root position. Call clear_dijkstra_maps() after changing grid walkability to invalidate."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("root", "Root position as Vector, Entity, or (x, y) tuple. Use 'root' for single-source maps (cached by position).")
|
|
MCRF_ARG("diagonal_cost", "Cost of diagonal movement (default: 1.41)")
|
|
MCRF_ARG("collide", "Label string. Entities with this label block pathfinding.")
|
|
MCRF_ARG("roots", "Sequence of root positions or a DiscreteMap mask for multi-source Dijkstra. Pass 'roots' instead of 'root' for multi-source maps (not cached).")
|
|
MCRF_RETURNS("DijkstraMap object for querying distances and paths")
|
|
)},
|
|
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
|
|
MCRF_METHOD(GridData, clear_dijkstra_maps,
|
|
MCRF_SIG("()", "None"),
|
|
MCRF_DESC("Clear all cached Dijkstra maps. Call this after modifying grid cell walkability to ensure pathfinding uses updated walkability data.")
|
|
)},
|
|
{"add_layer", (PyCFunction)PyGridData::py_add_layer, METH_VARARGS,
|
|
MCRF_METHOD(GridData, add_layer,
|
|
MCRF_SIG("(layer: ColorLayer | TileLayer)", "ColorLayer | TileLayer"),
|
|
MCRF_DESC("Add a layer to the grid. Layers with size (0, 0) are automatically resized to match the grid."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("layer", "A ColorLayer or TileLayer object. Named layers replace any existing layer with the same name.")
|
|
MCRF_RETURNS("The added layer object")
|
|
MCRF_RAISES("ValueError", "If layer is already attached to another grid, or if layer size doesn't match grid (and isn't (0,0))")
|
|
MCRF_RAISES("TypeError", "If argument is not a ColorLayer or TileLayer")
|
|
)},
|
|
{"remove_layer", (PyCFunction)PyGridData::py_remove_layer, METH_VARARGS,
|
|
MCRF_METHOD(GridData, remove_layer,
|
|
MCRF_SIG("(name_or_layer: str | ColorLayer | TileLayer)", "None"),
|
|
MCRF_DESC("Remove a layer from the grid by name or by object reference."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("name_or_layer", "Either a layer name (str) or the layer object itself")
|
|
MCRF_RAISES("KeyError", "If name is provided but no layer with that name exists")
|
|
MCRF_RAISES("TypeError", "If argument is not a string, ColorLayer, or TileLayer")
|
|
)},
|
|
{"layer", (PyCFunction)PyGridData::py_layer, METH_VARARGS,
|
|
MCRF_METHOD(GridData, layer,
|
|
MCRF_SIG("(name: str)", "ColorLayer | TileLayer | None"),
|
|
MCRF_DESC("Get a layer by its name."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("name", "The name of the layer to find")
|
|
MCRF_RETURNS("The layer with the specified name, or None if not found")
|
|
)},
|
|
{"entities_in_radius", (PyCFunction)PyGridData::py_entities_in_radius, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, entities_in_radius,
|
|
MCRF_SIG("(pos: tuple | Vector, radius: float)", "list"),
|
|
MCRF_DESC("Query entities within radius using spatial hash (O(k) where k = nearby entities)."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("pos", "Center position as (x, y) tuple, Vector, or other 2-element sequence")
|
|
MCRF_ARG("radius", "Search radius")
|
|
MCRF_RETURNS("List of Entity objects within the radius")
|
|
)},
|
|
{"apply_threshold", (PyCFunction)PyGridData::py_apply_threshold, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, apply_threshold,
|
|
MCRF_SIG("(source: HeightMap, range: tuple, walkable: bool = None, transparent: bool = None)", "GridData"),
|
|
MCRF_DESC("Apply walkable/transparent properties to cells where heightmap values fall within range."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
|
|
MCRF_ARG("range", "Tuple of (min, max) - cells with values in this range are affected")
|
|
MCRF_ARG("walkable", "If not None, set walkable to this value for cells in range")
|
|
MCRF_ARG("transparent", "If not None, set transparent to this value for cells in range")
|
|
MCRF_RETURNS("GridData: self, for method chaining")
|
|
MCRF_RAISES("ValueError", "If HeightMap size doesn't match grid size")
|
|
)},
|
|
{"apply_ranges", (PyCFunction)PyGridData::py_apply_ranges, METH_VARARGS,
|
|
MCRF_METHOD(GridData, apply_ranges,
|
|
MCRF_SIG("(source: HeightMap, ranges: list)", "GridData"),
|
|
MCRF_DESC("Apply multiple walkability thresholds to the grid in a single pass."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("source", "HeightMap with values to check. Must match grid size.")
|
|
MCRF_ARG("ranges", "List of (range_tuple, properties_dict) tuples where range_tuple=(min,max) and properties_dict has 'walkable'/'transparent' keys")
|
|
MCRF_RETURNS("GridData: self, for method chaining")
|
|
)},
|
|
{"step", (PyCFunction)PyGridData::py_step, METH_VARARGS | METH_KEYWORDS,
|
|
MCRF_METHOD(GridData, step,
|
|
MCRF_SIG("(n: int = 1, turn_order: int = None)", "None"),
|
|
MCRF_DESC("Execute n rounds of turn-based entity behavior. Each round: entities grouped by turn_order (ascending), behaviors executed, triggers fired (TARGET, DONE, BLOCKED), movement animated."),
|
|
MCRF_ARGS_START
|
|
MCRF_ARG("n", "Number of rounds to execute (default: 1)")
|
|
MCRF_ARG("turn_order", "If provided, only process entities with this turn_order value")
|
|
)},
|
|
{NULL}
|
|
};
|