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
parent 018e73590f
commit c5b4200dea
3 changed files with 411 additions and 12 deletions

View File

@ -5,6 +5,7 @@
#include "PyObjectUtils.h" #include "PyObjectUtils.h"
#include "PyVector.h" #include "PyVector.h"
#include "PythonObjectCache.h" #include "PythonObjectCache.h"
#include "PyFOV.h"
// UIDrawable methods now in UIBase.h // UIDrawable methods now in UIBase.h
#include "UIEntityPyMethods.h" #include "UIEntityPyMethods.h"
@ -44,12 +45,12 @@ void UIEntity::updateVisibility()
state.visible = false; 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 x = static_cast<int>(position.x);
int y = static_cast<int>(position.y); int y = static_cast<int>(position.y);
// Use default FOV radius of 10 (can be made configurable later) // Use grid's configured FOV algorithm and radius
grid->computeFOV(x, y, 10); grid->computeFOV(x, y, grid->fov_radius, true, grid->fov_algorithm);
// Update visible cells based on FOV computation // Update visible cells based on FOV computation
for (int gy = 0; gy < grid->grid_y; gy++) { for (int gy = 0; gy < grid->grid_y; gy++) {
@ -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) { PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* o) {
@ -588,6 +615,96 @@ PyObject* UIEntity::update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSE
Py_RETURN_NONE; 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[] = { PyMethodDef UIEntity::methods[] = {
{"at", (PyCFunction)UIEntity::at, METH_O}, {"at", (PyCFunction)UIEntity::at, METH_O},
{"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"}, {"index", (PyCFunction)UIEntity::index, METH_NOARGS, "Return the index of this entity in its grid's entity collection"},
@ -608,6 +725,16 @@ PyMethodDef UIEntity::methods[] = {
"Recomputes which cells are visible from the entity's position and updates\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" "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."}, "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} {NULL, NULL, 0, NULL}
}; };
@ -636,6 +763,16 @@ PyMethodDef UIEntity_all_methods[] = {
"Recomputes which cells are visible from the entity's position and updates\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" "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."}, "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 {NULL} // Sentinel
}; };

View File

@ -89,6 +89,7 @@ public:
static PyObject* die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored));
static PyObject* path_to(PyUIEntityObject* self, PyObject* args, PyObject* kwds); static PyObject* path_to(PyUIEntityObject* self, PyObject* args, PyObject* kwds);
static PyObject* update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored));
static PyObject* visible_entities(PyUIEntityObject* self, PyObject* args, PyObject* kwds);
static int init(PyUIEntityObject* self, PyObject* args, PyObject* kwds); static int init(PyUIEntityObject* self, PyObject* args, PyObject* kwds);
static PyObject* get_position(PyUIEntityObject* self, void* closure); static PyObject* get_position(PyUIEntityObject* self, void* closure);

View File

@ -0,0 +1,261 @@
#!/usr/bin/env python3
"""
Test Perspective Binding System
===============================
Tests the integration between:
1. ColorLayer.apply_perspective() - binding a layer to an entity
2. entity.updateVisibility() - automatically updating bound layers
3. ColorLayer.clear_perspective() - removing the binding
This implements issue #113 requirements for "Agent POV Integration".
"""
import mcrfpy
import sys
def run_tests():
"""Run perspective binding tests"""
print("=== Perspective Binding Tests ===\n")
# Test 1: Create grid with entity and color layer
print("Test 1: Setup")
grid = mcrfpy.Grid(pos=(0, 0), size=(640, 400), grid_size=(40, 25))
# Set up walls
for y in range(25):
for x in range(40):
point = grid.at(x, y)
# Border walls
if x == 0 or x == 39 or y == 0 or y == 24:
point.walkable = False
point.transparent = False
# Central wall
elif x == 20 and y != 12: # Wall with door at y=12
point.walkable = False
point.transparent = False
else:
point.walkable = True
point.transparent = True
# Create player entity
player = mcrfpy.Entity((5, 12))
grid.entities.append(player)
print(f" Player at ({player.x}, {player.y})")
print(" Grid setup complete")
print()
# Test 2: Apply perspective binding
print("Test 2: Perspective Binding")
fov_layer = grid.add_layer('color', z_index=-1)
fov_layer.fill((0, 0, 0, 255)) # Start with black (unknown)
fov_layer.apply_perspective(
entity=player,
visible=(255, 255, 200, 64),
discovered=(100, 100, 100, 128),
unknown=(0, 0, 0, 255)
)
print(" Applied perspective to layer")
# Check layer is bound
# (We can't directly check internal state, but we can verify behavior)
print()
# Test 3: updateVisibility should update the bound layer
print("Test 3: Entity updateVisibility")
player.update_visibility()
# Check that the player's position is now visible
visible_cell = fov_layer.at(int(player.x), int(player.y))
assert visible_cell.r == 255, f"Player position should be visible (got r={visible_cell.r})"
print(" Player position has visible color after updateVisibility()")
# Check that cells behind wall are unknown
behind_wall = fov_layer.at(21, 5)
assert behind_wall.r == 0, f"Behind wall should be unknown (got r={behind_wall.r})"
print(" Cell behind wall is unknown")
print()
# Test 4: Moving entity and calling updateVisibility
print("Test 4: Entity Movement with Perspective")
# Move player through the door
player.x = 21
player.y = 12
player.update_visibility()
# Now the player should see both sides of the wall
# Check a cell that was previously hidden
now_visible = fov_layer.at(25, 12) # To the right of where player moved
# This should now be visible (or discovered if was visible)
assert now_visible.r in [255, 100], f"Cell should be visible or discovered (got r={now_visible.r})"
print(f" After moving to door, cell (25,12) has r={now_visible.r}")
# Player's new position should be visible
new_pos_color = fov_layer.at(int(player.x), int(player.y))
assert new_pos_color.r == 255, f"New player position should be visible (got r={new_pos_color.r})"
print(f" Player's new position ({player.x}, {player.y}) is visible")
print()
# Test 5: Check discovered cells remain discovered
print("Test 5: Discovered State Persistence")
# Move player away from original position
player.x = 35
player.y = 12
player.update_visibility()
# Original position (5, 12) should now be discovered (not visible, but was seen)
original_pos = fov_layer.at(5, 12)
# It could be visible if in line of sight, or discovered if not
print(f" Original position (5,12) color: r={original_pos.r}")
print()
# Test 6: Clear perspective
print("Test 6: Clear Perspective")
fov_layer.clear_perspective()
# After clearing, updateVisibility should not affect this layer
fov_layer.fill((128, 0, 128, 255)) # Fill with purple
player.update_visibility()
# Layer should still be purple (not modified by updateVisibility)
check_cell = fov_layer.at(int(player.x), int(player.y))
assert check_cell.r == 128, f"Layer should be unchanged after clear_perspective (got r={check_cell.r})"
assert check_cell.g == 0, f"Layer should be unchanged (got g={check_cell.g})"
assert check_cell.b == 128, f"Layer should be unchanged (got b={check_cell.b})"
print(" Layer unchanged after clear_perspective()")
print()
# Test 7: Grid FOV settings
print("Test 7: Grid FOV Settings Integration")
# Create a new grid and layer to test FOV radius without discovered interference
grid2 = mcrfpy.Grid(pos=(0, 0), size=(640, 400), grid_size=(40, 25))
# Set all cells walkable/transparent
for y in range(25):
for x in range(40):
point = grid2.at(x, y)
point.walkable = True
point.transparent = True
# Create player entity
player2 = mcrfpy.Entity((20, 12))
grid2.entities.append(player2)
# Set grid FOV settings
grid2.fov = mcrfpy.FOV.SHADOW
grid2.fov_radius = 5 # Smaller radius
# Create layer and bind perspective
fov_layer2 = grid2.add_layer('color', z_index=-1)
fov_layer2.fill((0, 0, 0, 255)) # Start with black (unknown)
fov_layer2.apply_perspective(
entity=player2,
visible=(255, 0, 0, 64), # Red for visible
discovered=(100, 100, 100, 128),
unknown=(0, 0, 0, 255)
)
# Update visibility - this should only illuminate cells within radius 5
player2.update_visibility()
# With radius 5, cells far from player should be unknown (never discovered)
far_cell = fov_layer2.at(30, 12) # 10 cells away from player
assert far_cell.r == 0, f"Far cell should be unknown with radius 5 (got r={far_cell.r})"
print(f" Far cell (30,12) is unknown with radius=5")
# Near cell should be visible
near_cell = fov_layer2.at(22, 12) # 2 cells away
assert near_cell.r == 255, f"Near cell should be visible (got r={near_cell.r})"
print(f" Near cell (22,12) is visible")
print()
# Test 8: visible_entities method
print("Test 8: Entity.visible_entities()")
# Create a grid with multiple entities
grid3 = mcrfpy.Grid(pos=(0, 0), size=(640, 400), grid_size=(40, 25))
# Set all cells transparent
for y in range(25):
for x in range(40):
point = grid3.at(x, y)
point.walkable = True
point.transparent = True
# Add a wall to block visibility
for y in range(25):
if y != 12: # Door at y=12
point = grid3.at(20, y)
point.walkable = False
point.transparent = False
# Create entities
player3 = mcrfpy.Entity((5, 12)) # Left side
ally = mcrfpy.Entity((8, 12)) # Near player
enemy1 = mcrfpy.Entity((35, 12)) # Behind wall
enemy2 = mcrfpy.Entity((25, 12)) # Through door (should be visible)
grid3.entities.append(player3)
grid3.entities.append(ally)
grid3.entities.append(enemy1)
grid3.entities.append(enemy2)
# Set grid FOV settings
grid3.fov = mcrfpy.FOV.SHADOW
grid3.fov_radius = 20
# Get visible entities from player
visible = player3.visible_entities()
visible_positions = [(int(e.x), int(e.y)) for e in visible]
print(f" Player at (5, 12)")
print(f" Visible entities: {visible_positions}")
# Ally should be visible
assert (8, 12) in visible_positions, "Ally at (8,12) should be visible"
print(" Ally at (8, 12) is visible")
# Enemy1 behind wall should NOT be visible
assert (35, 12) not in visible_positions, "Enemy1 at (35,12) should NOT be visible (behind wall)"
print(" Enemy1 at (35, 12) is NOT visible (behind wall)")
# Enemy2 through door should be visible
assert (25, 12) in visible_positions, "Enemy2 at (25,12) should be visible through door"
print(" Enemy2 at (25, 12) is visible (through door)")
print()
# Test 9: visible_entities with radius override
print("Test 9: visible_entities with radius override")
# With small radius, only ally should be visible
visible_small = player3.visible_entities(radius=4)
visible_small_positions = [(int(e.x), int(e.y)) for e in visible_small]
print(f" With radius=4: {visible_small_positions}")
assert (8, 12) in visible_small_positions, "Ally should be visible with radius=4"
assert (25, 12) not in visible_small_positions, "Enemy2 should NOT be visible with radius=4"
print(" Correctly limited visibility to nearby entities")
print()
print("=== All Perspective Binding Tests Passed! ===")
return True
# Main execution
if __name__ == "__main__":
try:
if run_tests():
print("\nPASS")
sys.exit(0)
else:
print("\nFAIL")
sys.exit(1)
except Exception as e:
print(f"\nFAIL: {e}")
import traceback
traceback.print_exc()
sys.exit(1)