feat: Add entity.visible_entities() and improve entity.updateVisibility() (closes #113)

Phase 3 of Agent POV Integration:

Entity.updateVisibility() improvements:
- Now uses grid.fov_algorithm and grid.fov_radius instead of hardcoded values
- Updates any ColorLayers bound to this entity via apply_perspective()
- Properly triggers layer FOV recomputation when entity moves

New Entity.visible_entities(fov=None, radius=None) method:
- Returns list of other entities visible from this entity's position
- Optional fov parameter to override grid's FOV algorithm
- Optional radius parameter to override grid's fov_radius
- Useful for AI decision-making and line-of-sight checks

Test coverage in test_perspective_binding.py:
- Tests entity movement with bound layers
- Tests visible_entities with wall occlusion
- Tests radius override limiting visibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-12-01 15:55:18 -05:00
commit c5b4200dea
3 changed files with 411 additions and 12 deletions

View file

@ -5,6 +5,7 @@
#include "PyObjectUtils.h"
#include "PyVector.h"
#include "PythonObjectCache.h"
#include "PyFOV.h"
// UIDrawable methods now in UIBase.h
#include "UIEntityPyMethods.h"
@ -28,7 +29,7 @@ UIEntity::~UIEntity() {
void UIEntity::updateVisibility()
{
if (!grid) return;
// Lazy initialize gridstate if needed
if (gridstate.size() == 0) {
gridstate.resize(grid->grid_x * grid->grid_y);
@ -38,19 +39,19 @@ void UIEntity::updateVisibility()
state.discovered = false;
}
}
// First, mark all cells as not visible
for (auto& state : gridstate) {
state.visible = false;
}
// Compute FOV from entity's position
// Compute FOV from entity's position using grid's FOV settings (#114)
int x = static_cast<int>(position.x);
int y = static_cast<int>(position.y);
// Use default FOV radius of 10 (can be made configurable later)
grid->computeFOV(x, y, 10);
// Use grid's configured FOV algorithm and radius
grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm);
// Update visible cells based on FOV computation
for (int gy = 0; gy < grid->grid_y; gy++) {
for (int gx = 0; gx < grid->grid_x; gx++) {
@ -61,6 +62,32 @@ void UIEntity::updateVisibility()
}
}
}
// #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* o) {
@ -588,11 +615,101 @@ PyObject* UIEntity::update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSE
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 position
int x = static_cast<int>(self->data->position.x);
int y = static_cast<int>(self->data->position.y);
// Compute FOV from this entity's 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
PyTypeObject* entity_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
if (!entity_type) {
Py_DECREF(result);
return NULL;
}
// 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
int ex = static_cast<int>(entity->position.x);
int ey = static_cast<int>(entity->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);
Py_DECREF(entity_type);
return PyErr_NoMemory();
}
pyEntity->data = entity;
pyEntity->weakreflist = NULL;
if (PyList_Append(result, (PyObject*)pyEntity) < 0) {
Py_DECREF(pyEntity);
Py_DECREF(result);
Py_DECREF(entity_type);
return NULL;
}
Py_DECREF(pyEntity); // List now owns the reference
}
}
}
Py_DECREF(entity_type);
return result;
}
PyMethodDef UIEntity::methods[] = {
{"at", (PyCFunction)UIEntity::at, METH_O},
{"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"},
{"die", (PyCFunction)UIEntity::die, METH_NOARGS, "Remove this entity from its grid"},
{"path_to", (PyCFunction)UIEntity::path_to, METH_VARARGS | METH_KEYWORDS,
{"path_to", (PyCFunction)UIEntity::path_to, METH_VARARGS | METH_KEYWORDS,
"path_to(x: int, y: int) -> bool\n\n"
"Find and follow path to target position using A* pathfinding.\n\n"
"Args:\n"
@ -602,12 +719,22 @@ PyMethodDef UIEntity::methods[] = {
" True if a path was found and the entity started moving, False otherwise\n\n"
"The entity will automatically move along the path over multiple frames.\n"
"Call this again to change the target or repath."},
{"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS,
{"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS,
"update_visibility() -> None\n\n"
"Update entity's visibility state based on current FOV.\n\n"
"Recomputes which cells are visible from the entity's position and updates\n"
"the entity's gridstate to track explored areas. This is called automatically\n"
"when the entity moves if it has a grid with perspective set."},
{"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS,
"visible_entities(fov=None, radius=None) -> list[Entity]\n\n"
"Get list of other entities visible from this entity's position.\n\n"
"Args:\n"
" fov (FOV, optional): FOV algorithm to use. Default: grid.fov\n"
" radius (int, optional): FOV radius. Default: grid.fov_radius\n\n"
"Returns:\n"
" List of Entity objects that are within field of view.\n\n"
"Computes FOV from this entity's position and returns all other entities\n"
"whose positions fall within the visible area."},
{NULL, NULL, 0, NULL}
};
@ -620,7 +747,7 @@ PyMethodDef UIEntity_all_methods[] = {
{"at", (PyCFunction)UIEntity::at, METH_O},
{"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"},
{"die", (PyCFunction)UIEntity::die, METH_NOARGS, "Remove this entity from its grid"},
{"path_to", (PyCFunction)UIEntity::path_to, METH_VARARGS | METH_KEYWORDS,
{"path_to", (PyCFunction)UIEntity::path_to, METH_VARARGS | METH_KEYWORDS,
"path_to(x: int, y: int) -> bool\n\n"
"Find and follow path to target position using A* pathfinding.\n\n"
"Args:\n"
@ -630,12 +757,22 @@ PyMethodDef UIEntity_all_methods[] = {
" True if a path was found and the entity started moving, False otherwise\n\n"
"The entity will automatically move along the path over multiple frames.\n"
"Call this again to change the target or repath."},
{"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS,
{"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS,
"update_visibility() -> None\n\n"
"Update entity's visibility state based on current FOV.\n\n"
"Recomputes which cells are visible from the entity's position and updates\n"
"the entity's gridstate to track explored areas. This is called automatically\n"
"when the entity moves if it has a grid with perspective set."},
{"visible_entities", (PyCFunction)UIEntity::visible_entities, METH_VARARGS | METH_KEYWORDS,
"visible_entities(fov=None, radius=None) -> list[Entity]\n\n"
"Get list of other entities visible from this entity's position.\n\n"
"Args:\n"
" fov (FOV, optional): FOV algorithm to use. Default: grid.fov\n"
" radius (int, optional): FOV radius. Default: grid.fov_radius\n\n"
"Returns:\n"
" List of Entity objects that are within field of view.\n\n"
"Computes FOV from this entity's position and returns all other entities\n"
"whose positions fall within the visible area."},
{NULL} // Sentinel
};