From c5b4200dea0e03d7e9871b10cd046858f6c800cc Mon Sep 17 00:00:00 2001 From: John McCardle Date: Mon, 1 Dec 2025 15:55:18 -0500 Subject: [PATCH] feat: Add entity.visible_entities() and improve entity.updateVisibility() (closes #113) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/UIEntity.cpp | 161 +++++++++++++-- src/UIEntity.h | 1 + tests/unit/test_perspective_binding.py | 261 +++++++++++++++++++++++++ 3 files changed, 411 insertions(+), 12 deletions(-) create mode 100644 tests/unit/test_perspective_binding.py diff --git a/src/UIEntity.cpp b/src/UIEntity.cpp index bc6eb88..2547819 100644 --- a/src/UIEntity.cpp +++ b/src/UIEntity.cpp @@ -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(position.x); int y = static_cast(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 self_ptr = nullptr; + if (grid->entities) { + for (auto& entity : *grid->entities) { + if (entity.get() == this) { + self_ptr = entity; + break; + } + } + } + + if (self_ptr) { + for (auto& layer : grid->layers) { + if (layer->type == GridLayerType::Color) { + auto color_layer = std::static_pointer_cast(layer); + if (color_layer->has_perspective) { + auto bound_entity = color_layer->perspective_entity.lock(); + if (bound_entity && bound_entity.get() == this) { + color_layer->updatePerspective(); + } + } + } + } + } } PyObject* UIEntity::at(PyUIEntityObject* self, PyObject* 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(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(self->data->position.x); + int y = static_cast(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(entity->position.x); + int ey = static_cast(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 }; diff --git a/src/UIEntity.h b/src/UIEntity.h index 6af7511..3bdc9fc 100644 --- a/src/UIEntity.h +++ b/src/UIEntity.h @@ -89,6 +89,7 @@ public: static PyObject* die(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* path_to(PyUIEntityObject* self, PyObject* args, PyObject* kwds); 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 PyObject* get_position(PyUIEntityObject* self, void* closure); diff --git a/tests/unit/test_perspective_binding.py b/tests/unit/test_perspective_binding.py new file mode 100644 index 0000000..771be74 --- /dev/null +++ b/tests/unit/test_perspective_binding.py @@ -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)