Add collision label support for pathfinding (closes #302)

Add `collide` kwarg to Grid.find_path() and Grid.get_dijkstra_map() that
treats entities bearing a given label as impassable obstacles via
mark-and-restore on the TCOD walkability map. Dijkstra cache key now
includes collide label for separate caching. Add Entity.find_path()
convenience method that delegates to the grid.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-04-02 01:34:19 -04:00
commit c1a9523ac2
7 changed files with 546 additions and 32 deletions

View file

@ -84,6 +84,7 @@ void GridData::syncTCODMap()
} }
} }
fov_dirty = true; fov_dirty = true;
transparency_generation++;
} }
void GridData::syncTCODMapCell(int x, int y) void GridData::syncTCODMapCell(int x, int y)
@ -92,6 +93,7 @@ void GridData::syncTCODMapCell(int x, int y)
const UIGridPoint& point = at(x, y); const UIGridPoint& point = at(x, y);
tcod_map->setProperties(x, y, point.transparent, point.walkable); tcod_map->setProperties(x, y, point.transparent, point.walkable);
fov_dirty = true; fov_dirty = true;
transparency_generation++;
} }
void GridData::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_algorithm_t algo) void GridData::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_algorithm_t algo)

View file

@ -13,6 +13,7 @@
#include <optional> #include <optional>
#include <map> #include <map>
#include <memory> #include <memory>
#include <tuple>
#include <vector> #include <vector>
#include "PyCallable.h" #include "PyCallable.h"
@ -78,10 +79,15 @@ public:
bool fov_last_light_walls = true; bool fov_last_light_walls = true;
TCOD_fov_algorithm_t fov_last_algo = FOV_BASIC; TCOD_fov_algorithm_t fov_last_algo = FOV_BASIC;
// #303 - Transparency generation counter for per-entity FOV caching
// Bumped whenever any cell's transparent/walkable property changes
uint32_t transparency_generation = 0;
// ========================================================================= // =========================================================================
// Pathfinding caches // Pathfinding caches
// ========================================================================= // =========================================================================
std::map<std::pair<int,int>, std::shared_ptr<DijkstraMap>> dijkstra_maps; // Cache key: (root_x, root_y, collide_label) where collide_label="" means no collision filtering
std::map<std::tuple<int,int,std::string>, std::shared_ptr<DijkstraMap>> dijkstra_maps;
// ========================================================================= // =========================================================================
// Layer system (#147, #150) // Layer system (#147, #150)

View file

@ -1,5 +1,6 @@
#include "UIEntity.h" #include "UIEntity.h"
#include "UIGrid.h" #include "UIGrid.h"
#include "UIGridPathfinding.h"
#include "McRFPy_API.h" #include "McRFPy_API.h"
#include <algorithm> #include <algorithm>
#include <cstring> #include <cstring>
@ -883,6 +884,77 @@ PyObject* UIEntity::path_to(PyUIEntityObject* self, PyObject* args, PyObject* kw
return path_list; 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
int target_x, target_y;
if (!UIGridPathfinding::ExtractPosition(target_obj, &target_x, &target_y,
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
auto grid_type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid");
if (!grid_type) return NULL;
auto pyGrid = (PyUIGridObject*)grid_type->tp_alloc(grid_type, 0);
Py_DECREF(grid_type);
if (!pyGrid) return NULL;
new (&pyGrid->data) std::shared_ptr<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)) PyObject* UIEntity::update_visibility(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored))
{ {
self->data->updateVisibility(); self->data->updateVisibility();
@ -1002,6 +1074,18 @@ PyMethodDef UIEntity::methods[] = {
" path = entity.path_to(10, 5)\n" " path = entity.path_to(10, 5)\n"
" path = entity.path_to((10, 5))\n" " path = entity.path_to((10, 5))\n"
" path = entity.path_to(pos=(10, 5))"}, " path = entity.path_to(pos=(10, 5))"},
{"find_path", (PyCFunction)UIEntity::find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(target, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n"
"Find a path from this entity to the target position.\n\n"
"Args:\n"
" target: Target as Vector, Entity, or (x, y) tuple.\n"
" diagonal_cost: Cost of diagonal movement (default 1.41).\n"
" collide: Label string. Entities with this label block pathfinding.\n\n"
"Returns:\n"
" AStarPath object, or None if no path exists.\n\n"
"Example:\n"
" path = entity.find_path((10, 5))\n"
" path = entity.find_path(player, collide='enemy')"},
{"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS, {"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS,
"update_visibility() -> None\n\n" "update_visibility() -> None\n\n"
"Update entity's visibility state based on current FOV.\n\n" "Update entity's visibility state based on current FOV.\n\n"
@ -1362,6 +1446,18 @@ PyMethodDef UIEntity_all_methods[] = {
" path = entity.path_to(10, 5)\n" " path = entity.path_to(10, 5)\n"
" path = entity.path_to((10, 5))\n" " path = entity.path_to((10, 5))\n"
" path = entity.path_to(pos=(10, 5))"}, " path = entity.path_to(pos=(10, 5))"},
{"find_path", (PyCFunction)UIEntity::find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(target, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n"
"Find a path from this entity to the target position.\n\n"
"Args:\n"
" target: Target as Vector, Entity, or (x, y) tuple.\n"
" diagonal_cost: Cost of diagonal movement (default 1.41).\n"
" collide: Label string. Entities with this label block pathfinding.\n\n"
"Returns:\n"
" AStarPath object, or None if no path exists.\n\n"
"Example:\n"
" path = entity.find_path((10, 5))\n"
" path = entity.find_path(player, collide='enemy')"},
{"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS, {"update_visibility", (PyCFunction)UIEntity::update_visibility, METH_NOARGS,
"update_visibility() -> None\n\n" "update_visibility() -> None\n\n"
"Update entity's visibility state based on current FOV.\n\n" "Update entity's visibility state based on current FOV.\n\n"

View file

@ -79,6 +79,27 @@ public:
float move_speed = 0.15f; // #300: animation duration for movement (0 = instant) float move_speed = 0.15f; // #300: animation duration for movement (0 = instant)
std::string target_label; // #300: label to search for with TARGET trigger std::string target_label; // #300: label to search for with TARGET trigger
int sight_radius = 10; // #300: FOV radius for TARGET trigger int sight_radius = 10; // #300: FOV radius for TARGET trigger
// #303 - Per-entity FOV result cache for TARGET trigger optimization
// Caches the visibility bitmap from the last FOV computation so that
// entities that haven't moved skip recomputation entirely.
struct TargetFOVCache {
sf::Vector2i origin{-1, -1};
int radius = -1;
uint32_t transparency_gen = 0;
std::vector<bool> visibility; // (2*radius+1)^2 bitmap
int vis_side = 0; // 2*radius+1
bool isValid(sf::Vector2i pos, int r, uint32_t gen) const {
return vis_side > 0 && pos == origin && r == radius && gen == transparency_gen;
}
bool isVisible(int x, int y) const {
int dx = x - origin.x + radius;
int dy = y - origin.y + radius;
if (dx < 0 || dx >= vis_side || dy < 0 || dy >= vis_side) return false;
return visibility[dy * vis_side + dx];
}
} target_fov_cache;
//void render(sf::Vector2f); //override final; //void render(sf::Vector2f); //override final;
UIEntity(); UIEntity();
@ -120,6 +141,7 @@ public:
static PyObject* index(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored)); static PyObject* index(PyUIEntityObject* self, PyObject* Py_UNUSED(ignored));
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* find_path(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 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);

View file

@ -2153,26 +2153,28 @@ PyMethodDef UIGrid::methods[] = {
" True if the cell is visible, False otherwise\n\n" " True if the cell is visible, False otherwise\n\n"
"Must call compute_fov() first to calculate visibility."}, "Must call compute_fov() first to calculate visibility."},
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n" "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n"
"Compute A* path between two points.\n\n" "Compute A* path between two points.\n\n"
"Args:\n" "Args:\n"
" start: Starting position as Vector, Entity, or (x, y) tuple\n" " start: Starting position as Vector, Entity, or (x, y) tuple\n"
" end: Target position as Vector, Entity, or (x, y) tuple\n" " end: Target position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n"
" collide: Label string. Entities with this label block pathfinding.\n\n"
"Returns:\n" "Returns:\n"
" AStarPath object if path exists, None otherwise.\n\n" " AStarPath object if path exists, None otherwise.\n\n"
"The returned AStarPath can be iterated or walked step-by-step."}, "The returned AStarPath can be iterated or walked step-by-step."},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n" "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n"
"Get or create a Dijkstra distance map for a root position.\n\n" "Get or create a Dijkstra distance map for a root position.\n\n"
"Args:\n" "Args:\n"
" root: Root position as Vector, Entity, or (x, y) tuple\n" " root: Root position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n"
" collide: Label string. Entities with this label block pathfinding.\n\n"
"Returns:\n" "Returns:\n"
" DijkstraMap object for querying distances and paths.\n\n" " DijkstraMap object for querying distances and paths.\n\n"
"Grid caches DijkstraMaps by root position. Multiple requests for the\n" "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n"
"same root return the same cached map. Call clear_dijkstra_maps() after\n" "the same root and collide label return the same cached map. Call\n"
"changing grid walkability to invalidate the cache."}, "clear_dijkstra_maps() after changing grid walkability to invalidate."},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
"clear_dijkstra_maps() -> None\n\n" "clear_dijkstra_maps() -> None\n\n"
"Clear all cached Dijkstra maps.\n\n" "Clear all cached Dijkstra maps.\n\n"
@ -2329,27 +2331,64 @@ PyObject* UIGrid::py_step(PyUIGridObject* self, PyObject* args, PyObject* kwds)
if (entity->behavior.type == BehaviorType::IDLE) continue; if (entity->behavior.type == BehaviorType::IDLE) continue;
// Check TARGET trigger (if target_label set) // Check TARGET trigger (if target_label set)
// #303: Tiered optimization:
// Tier 1: O(1) label check (target_label.empty())
// Tier 2: O(bucket) spatial hash pre-filter
// Tier 3: O(radius^2) bounded FOV (TCOD respects radius)
// Tier 4: Per-entity FOV cache — skip recomputation when
// entity hasn't moved and map transparency unchanged
if (!entity->target_label.empty()) { if (!entity->target_label.empty()) {
// Quick check: are there any entities with target_label nearby? // Tier 2: Spatial hash proximity pre-filter
auto nearby = grid->spatial_hash.queryRadius( auto nearby = grid->spatial_hash.queryRadius(
static_cast<float>(entity->cell_position.x), static_cast<float>(entity->cell_position.x),
static_cast<float>(entity->cell_position.y), static_cast<float>(entity->cell_position.y),
static_cast<float>(entity->sight_radius)); static_cast<float>(entity->sight_radius));
for (auto& target : nearby) { // Collect matching targets before touching FOV
if (target.get() == entity.get()) continue; std::vector<std::shared_ptr<UIEntity>> matching_targets;
if (target->labels.count(entity->target_label)) { for (auto& candidate : nearby) {
// Compute FOV to verify line of sight 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;
// Tier 4: Check per-entity FOV cache
if (!cache.isValid(entity->cell_position, entity->sight_radius,
grid->transparency_generation)) {
// Cache miss — compute FOV and snapshot the visibility bitmap
grid->computeFOV(entity->cell_position.x, entity->cell_position.y, grid->computeFOV(entity->cell_position.x, entity->cell_position.y,
entity->sight_radius, true, grid->fov_algorithm); entity->sight_radius, true, grid->fov_algorithm);
if (grid->isInFOV(target->cell_position.x, target->cell_position.y)) {
// Fire TARGET trigger 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);
}
}
}
// Check targets against cached visibility
for (auto& target : matching_targets) {
if (cache.isVisible(target->cell_position.x,
target->cell_position.y)) {
PyObject* target_pyobj = Py_None; PyObject* target_pyobj = Py_None;
if (target->pyobject) { if (target->pyobject) {
target_pyobj = target->pyobject; target_pyobj = target->pyobject;
} }
fireStepCallback(entity, 2 /* TARGET */, target_pyobj); fireStepCallback(entity, 2 /* TARGET */, target_pyobj);
goto next_entity; // Skip behavior execution after TARGET goto next_entity;
} }
} }
} }
@ -2433,26 +2472,28 @@ PyMethodDef UIGrid_all_methods[] = {
" True if the cell is visible, False otherwise\n\n" " True if the cell is visible, False otherwise\n\n"
"Must call compute_fov() first to calculate visibility."}, "Must call compute_fov() first to calculate visibility."},
{"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS, {"find_path", (PyCFunction)UIGridPathfinding::Grid_find_path, METH_VARARGS | METH_KEYWORDS,
"find_path(start, end, diagonal_cost: float = 1.41) -> AStarPath | None\n\n" "find_path(start, end, diagonal_cost=1.41, collide=None) -> AStarPath | None\n\n"
"Compute A* path between two points.\n\n" "Compute A* path between two points.\n\n"
"Args:\n" "Args:\n"
" start: Starting position as Vector, Entity, or (x, y) tuple\n" " start: Starting position as Vector, Entity, or (x, y) tuple\n"
" end: Target position as Vector, Entity, or (x, y) tuple\n" " end: Target position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n"
" collide: Label string. Entities with this label block pathfinding.\n\n"
"Returns:\n" "Returns:\n"
" AStarPath object if path exists, None otherwise.\n\n" " AStarPath object if path exists, None otherwise.\n\n"
"The returned AStarPath can be iterated or walked step-by-step."}, "The returned AStarPath can be iterated or walked step-by-step."},
{"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS, {"get_dijkstra_map", (PyCFunction)UIGridPathfinding::Grid_get_dijkstra_map, METH_VARARGS | METH_KEYWORDS,
"get_dijkstra_map(root, diagonal_cost: float = 1.41) -> DijkstraMap\n\n" "get_dijkstra_map(root, diagonal_cost=1.41, collide=None) -> DijkstraMap\n\n"
"Get or create a Dijkstra distance map for a root position.\n\n" "Get or create a Dijkstra distance map for a root position.\n\n"
"Args:\n" "Args:\n"
" root: Root position as Vector, Entity, or (x, y) tuple\n" " root: Root position as Vector, Entity, or (x, y) tuple\n"
" diagonal_cost: Cost of diagonal movement (default: 1.41)\n\n" " diagonal_cost: Cost of diagonal movement (default: 1.41)\n"
" collide: Label string. Entities with this label block pathfinding.\n\n"
"Returns:\n" "Returns:\n"
" DijkstraMap object for querying distances and paths.\n\n" " DijkstraMap object for querying distances and paths.\n\n"
"Grid caches DijkstraMaps by root position. Multiple requests for the\n" "Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n"
"same root return the same cached map. Call clear_dijkstra_maps() after\n" "the same root and collide label return the same cached map. Call\n"
"changing grid walkability to invalidate the cache."}, "clear_dijkstra_maps() after changing grid walkability to invalidate."},
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS, {"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
"clear_dijkstra_maps() -> None\n\n" "clear_dijkstra_maps() -> None\n\n"
"Clear all cached Dijkstra maps.\n\n" "Clear all cached Dijkstra maps.\n\n"

View file

@ -458,14 +458,55 @@ PyObject* UIGridPathfinding::DijkstraMap_to_heightmap(PyDijkstraMapObject* self,
// Grid Factory Methods // Grid Factory Methods
//============================================================================= //=============================================================================
// Helper: collect cells occupied by entities with a given label, mark non-walkable.
// Returns the list of (x, y, was_walkable) for restoration.
static std::vector<std::tuple<int,int,bool>> markCollisionLabel(
GridData* grid, const std::string& collide_label)
{
std::vector<std::tuple<int,int,bool>> restore_list;
if (!grid->entities || collide_label.empty()) return restore_list;
TCODMap* tcod_map = grid->getTCODMap();
if (!tcod_map) return restore_list;
for (auto& entity : *grid->entities) {
if (!entity) continue;
if (entity->labels.count(collide_label)) {
int ex = entity->cell_position.x;
int ey = entity->cell_position.y;
if (ex >= 0 && ex < grid->grid_w && ey >= 0 && ey < grid->grid_h) {
bool was_walkable = tcod_map->isWalkable(ex, ey);
if (was_walkable) {
tcod_map->setProperties(ex, ey, tcod_map->isTransparent(ex, ey), false);
restore_list.emplace_back(ex, ey, true);
}
}
}
}
return restore_list;
}
// Helper: restore walkability after pathfinding.
static void restoreCollisionLabel(GridData* grid,
const std::vector<std::tuple<int,int,bool>>& restore_list)
{
TCODMap* tcod_map = grid->getTCODMap();
if (!tcod_map) return;
for (auto& [x, y, was_walkable] : restore_list) {
tcod_map->setProperties(x, y, tcod_map->isTransparent(x, y), was_walkable);
}
}
PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) { PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL}; static const char* kwlist[] = {"start", "end", "diagonal_cost", "collide", NULL};
PyObject* start_obj = NULL; PyObject* start_obj = NULL;
PyObject* end_obj = NULL; PyObject* end_obj = NULL;
float diagonal_cost = 1.41f; float diagonal_cost = 1.41f;
const char* collide_label = NULL;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast<char**>(kwlist), if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|fz", const_cast<char**>(kwlist),
&start_obj, &end_obj, &diagonal_cost)) { &start_obj, &end_obj, &diagonal_cost, &collide_label)) {
return NULL; return NULL;
} }
@ -489,9 +530,18 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args
return NULL; return NULL;
} }
// Mark-and-restore: temporarily block cells occupied by entities with collide label
std::string label_str = collide_label ? collide_label : "";
auto restore_list = markCollisionLabel(self->data.get(), label_str);
// Compute path using temporary TCODPath // Compute path using temporary TCODPath
TCODPath tcod_path(self->data->getTCODMap(), diagonal_cost); TCODPath tcod_path(self->data->getTCODMap(), diagonal_cost);
if (!tcod_path.compute(x1, y1, x2, y2)) { bool found = tcod_path.compute(x1, y1, x2, y2);
// Restore walkability before returning
restoreCollisionLabel(self->data.get(), restore_list);
if (!found) {
Py_RETURN_NONE; // No path exists Py_RETURN_NONE; // No path exists
} }
@ -518,12 +568,13 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args
} }
PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) { PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {"root", "diagonal_cost", NULL}; static const char* kwlist[] = {"root", "diagonal_cost", "collide", NULL};
PyObject* root_obj = NULL; PyObject* root_obj = NULL;
float diagonal_cost = 1.41f; float diagonal_cost = 1.41f;
const char* collide_label = NULL;
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast<char**>(kwlist), if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|fz", const_cast<char**>(kwlist),
&root_obj, &diagonal_cost)) { &root_obj, &diagonal_cost, &collide_label)) {
return NULL; return NULL;
} }
@ -543,12 +594,13 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec
return NULL; return NULL;
} }
auto key = std::make_pair(root_x, root_y); std::string label_str = collide_label ? collide_label : "";
auto key = std::make_tuple(root_x, root_y, label_str);
// Check cache // Check cache
auto it = self->data->dijkstra_maps.find(key); auto it = self->data->dijkstra_maps.find(key);
if (it != self->data->dijkstra_maps.end()) { if (it != self->data->dijkstra_maps.end()) {
// Check diagonal cost matches (or we could ignore this) // Check diagonal cost matches
if (std::abs(it->second->getDiagonalCost() - diagonal_cost) < 0.001f) { if (std::abs(it->second->getDiagonalCost() - diagonal_cost) < 0.001f) {
// Return existing // Return existing
PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc( PyDijkstraMapObject* result = (PyDijkstraMapObject*)mcrfpydef::PyDijkstraMapType.tp_alloc(
@ -561,10 +613,16 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec
self->data->dijkstra_maps.erase(it); self->data->dijkstra_maps.erase(it);
} }
// Mark-and-restore: temporarily block cells with collide label
auto restore_list = markCollisionLabel(self->data.get(), label_str);
// Create new DijkstraMap // Create new DijkstraMap
auto dijkstra = std::make_shared<DijkstraMap>( auto dijkstra = std::make_shared<DijkstraMap>(
self->data->getTCODMap(), root_x, root_y, diagonal_cost); self->data->getTCODMap(), root_x, root_y, diagonal_cost);
// Restore walkability
restoreCollisionLabel(self->data.get(), restore_list);
// Cache it // Cache it
self->data->dijkstra_maps[key] = dijkstra; self->data->dijkstra_maps[key] = dijkstra;

View file

@ -0,0 +1,289 @@
"""Tests for pathfinding with collision labels (#302)
Tests Grid.find_path(collide=), Grid.get_dijkstra_map(collide=),
and Entity.find_path() convenience method.
"""
import mcrfpy
import sys
PASS = 0
FAIL = 0
def test(name, condition):
global PASS, FAIL
if condition:
PASS += 1
else:
FAIL += 1
print(f"FAIL: {name}")
def make_grid():
"""Create a 10x10 grid with all cells walkable."""
tex = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16)
g = mcrfpy.Grid(grid_size=(10, 10), texture=tex,
pos=(0, 0), size=(160, 160))
# Make all cells walkable
for y in range(10):
for x in range(10):
pt = g.at(x, y)
pt.walkable = True
pt.transparent = True
return g
def test_find_path_no_collide():
"""find_path without collide should ignore entity labels."""
g = make_grid()
scene = mcrfpy.Scene("test_fp_nocollide")
scene.children.append(g)
# Place a blocking entity at (3, 0) with label "enemy"
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("enemy")
# Without collide, path should go through (3, 0)
path = g.find_path((0, 0), (5, 0))
test("find_path without collide returns path", path is not None)
if path:
steps = [s for s in path]
coords = [(int(s.x), int(s.y)) for s in steps]
test("find_path without collide goes through entity cell",
(3, 0) in coords)
def test_find_path_with_collide():
"""find_path with collide should avoid entities with that label."""
g = make_grid()
scene = mcrfpy.Scene("test_fp_collide")
scene.children.append(g)
# Place blocking entities in a line at y=0, x=3
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("enemy")
# With collide="enemy", path must avoid (3, 0)
path = g.find_path((0, 0), (5, 0), collide="enemy")
test("find_path with collide returns path", path is not None)
if path:
steps = [s for s in path]
coords = [(int(s.x), int(s.y)) for s in steps]
test("find_path with collide avoids entity cell",
(3, 0) not in coords)
def test_find_path_collide_different_label():
"""find_path with collide should only block matching labels."""
g = make_grid()
scene = mcrfpy.Scene("test_fp_difflabel")
scene.children.append(g)
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("friend")
# Collide with "enemy" should not block "friend" entities
path = g.find_path((0, 0), (5, 0), collide="enemy")
test("find_path collide ignores non-matching labels", path is not None)
if path:
steps = [s for s in path]
coords = [(int(s.x), int(s.y)) for s in steps]
test("find_path goes through non-matching label entity",
(3, 0) in coords)
def test_find_path_collide_restores_walkability():
"""After find_path with collide, cell walkability is restored."""
g = make_grid()
scene = mcrfpy.Scene("test_fp_restore")
scene.children.append(g)
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("enemy")
# Check walkability before
test("cell walkable before find_path", g.at(3, 0).walkable)
path = g.find_path((0, 0), (5, 0), collide="enemy")
# Cell should be walkable again after
test("cell walkable after find_path with collide", g.at(3, 0).walkable)
def test_find_path_collide_blocks_path():
"""If colliding entities block the only path, find_path returns None."""
g = make_grid()
scene = mcrfpy.Scene("test_fp_blocked")
scene.children.append(g)
# Create a wall of enemies across the middle
for x in range(10):
e = mcrfpy.Entity((x, 5), grid=g)
e.add_label("wall")
path = g.find_path((0, 0), (0, 9), collide="wall")
test("find_path returns None when collide blocks all paths", path is None)
def test_dijkstra_no_collide():
"""get_dijkstra_map without collide ignores labels."""
g = make_grid()
scene = mcrfpy.Scene("test_dij_nocollide")
scene.children.append(g)
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("enemy")
dmap = g.get_dijkstra_map((0, 0))
dist = dmap.distance((3, 0))
test("dijkstra without collide reaches entity cell", dist is not None)
test("dijkstra distance to (3,0) is 3", abs(dist - 3.0) < 0.01)
def test_dijkstra_with_collide():
"""get_dijkstra_map with collide blocks labeled entities."""
g = make_grid()
scene = mcrfpy.Scene("test_dij_collide")
scene.children.append(g)
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("enemy")
dmap = g.get_dijkstra_map((0, 0), collide="enemy")
dist = dmap.distance((3, 0))
# (3,0) is blocked, so distance should be None (unreachable as walkable)
# Actually, the cell is marked non-walkable for computation, but libtcod
# may still report a distance. Let's check that path avoids it.
path = dmap.path_from((5, 0))
test("dijkstra with collide returns path", path is not None)
if path:
steps = [s for s in path]
coords = [(int(s.x), int(s.y)) for s in steps]
test("dijkstra path avoids collide entity cell",
(3, 0) not in coords)
def test_dijkstra_cache_separate_keys():
"""Dijkstra maps with different collide labels are cached separately."""
g = make_grid()
scene = mcrfpy.Scene("test_dij_cache")
scene.children.append(g)
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("enemy")
dmap_none = g.get_dijkstra_map((0, 0))
dmap_enemy = g.get_dijkstra_map((0, 0), collide="enemy")
# Same root, different collide label = different maps
dist_none = dmap_none.distance((3, 0))
dist_enemy = dmap_enemy.distance((3, 0))
test("dijkstra no-collide reaches (3,0)", dist_none is not None and dist_none >= 0)
# With collide, (3,0) is non-walkable, distance should be different
# (either None or a longer path distance)
test("dijkstra cache separates collide labels",
dist_none != dist_enemy or dist_enemy is None)
def test_dijkstra_collide_restores_walkability():
"""After get_dijkstra_map with collide, walkability is restored."""
g = make_grid()
scene = mcrfpy.Scene("test_dij_restore")
scene.children.append(g)
e = mcrfpy.Entity((3, 0), grid=g)
e.add_label("enemy")
test("cell walkable before dijkstra", g.at(3, 0).walkable)
dmap = g.get_dijkstra_map((0, 0), collide="enemy")
test("cell walkable after dijkstra with collide", g.at(3, 0).walkable)
def test_entity_find_path():
"""Entity.find_path() convenience method."""
g = make_grid()
scene = mcrfpy.Scene("test_efp")
scene.children.append(g)
player = mcrfpy.Entity((0, 0), grid=g)
target = mcrfpy.Entity((5, 5), grid=g)
path = player.find_path((5, 5))
test("entity.find_path returns AStarPath", path is not None)
test("entity.find_path type is AStarPath",
type(path).__name__ == "AStarPath")
if path:
test("entity.find_path origin is entity pos",
int(path.origin.x) == 0 and int(path.origin.y) == 0)
test("entity.find_path destination is target",
int(path.destination.x) == 5 and int(path.destination.y) == 5)
def test_entity_find_path_with_collide():
"""Entity.find_path() with collide kwarg."""
g = make_grid()
scene = mcrfpy.Scene("test_efp_collide")
scene.children.append(g)
player = mcrfpy.Entity((0, 0), grid=g)
enemy = mcrfpy.Entity((3, 0), grid=g)
enemy.add_label("enemy")
path = player.find_path((5, 0), collide="enemy")
test("entity.find_path with collide returns path", path is not None)
if path:
steps = [s for s in path]
coords = [(int(s.x), int(s.y)) for s in steps]
test("entity.find_path with collide avoids enemy",
(3, 0) not in coords)
def test_entity_find_path_to_entity():
"""Entity.find_path() accepts an Entity as target."""
g = make_grid()
scene = mcrfpy.Scene("test_efp_entity")
scene.children.append(g)
player = mcrfpy.Entity((0, 0), grid=g)
goal = mcrfpy.Entity((5, 5), grid=g)
path = player.find_path(goal)
test("entity.find_path(entity) returns path", path is not None)
if path:
test("entity.find_path(entity) destination correct",
int(path.destination.x) == 5 and int(path.destination.y) == 5)
def test_entity_find_path_no_grid():
"""Entity.find_path() raises if entity has no grid."""
e = mcrfpy.Entity((0, 0))
try:
path = e.find_path((5, 5))
test("entity.find_path without grid raises", False)
except ValueError:
test("entity.find_path without grid raises", True)
if __name__ == "__main__":
test_find_path_no_collide()
test_find_path_with_collide()
test_find_path_collide_different_label()
test_find_path_collide_restores_walkability()
test_find_path_collide_blocks_path()
test_dijkstra_no_collide()
test_dijkstra_with_collide()
test_dijkstra_cache_separate_keys()
test_dijkstra_collide_restores_walkability()
test_entity_find_path()
test_entity_find_path_with_collide()
test_entity_find_path_to_entity()
test_entity_find_path_no_grid()
total = PASS + FAIL
print(f"\n{PASS}/{total} tests passed")
if FAIL:
print(f"{FAIL} FAILED")
sys.exit(1)
else:
print("PASS")
sys.exit(0)