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:
parent
6a0040d630
commit
c1a9523ac2
7 changed files with 546 additions and 32 deletions
|
|
@ -84,6 +84,7 @@ void GridData::syncTCODMap()
|
|||
}
|
||||
}
|
||||
fov_dirty = true;
|
||||
transparency_generation++;
|
||||
}
|
||||
|
||||
void GridData::syncTCODMapCell(int x, int y)
|
||||
|
|
@ -92,6 +93,7 @@ void GridData::syncTCODMapCell(int x, int y)
|
|||
const UIGridPoint& point = at(x, y);
|
||||
tcod_map->setProperties(x, y, point.transparent, point.walkable);
|
||||
fov_dirty = true;
|
||||
transparency_generation++;
|
||||
}
|
||||
|
||||
void GridData::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_algorithm_t algo)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
#include <optional>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <tuple>
|
||||
#include <vector>
|
||||
|
||||
#include "PyCallable.h"
|
||||
|
|
@ -78,10 +79,15 @@ public:
|
|||
bool fov_last_light_walls = true;
|
||||
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
|
||||
// =========================================================================
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
#include "UIEntity.h"
|
||||
#include "UIGrid.h"
|
||||
#include "UIGridPathfinding.h"
|
||||
#include "McRFPy_API.h"
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
|
@ -883,6 +884,77 @@ PyObject* UIEntity::path_to(PyUIEntityObject* self, PyObject* args, PyObject* kw
|
|||
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))
|
||||
{
|
||||
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(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() -> None\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(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() -> None\n\n"
|
||||
"Update entity's visibility state based on current FOV.\n\n"
|
||||
|
|
|
|||
|
|
@ -79,6 +79,27 @@ public:
|
|||
float move_speed = 0.15f; // #300: animation duration for movement (0 = instant)
|
||||
std::string target_label; // #300: label to search for with 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;
|
||||
|
||||
UIEntity();
|
||||
|
|
@ -120,6 +141,7 @@ public:
|
|||
static PyObject* index(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* find_path(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);
|
||||
|
|
|
|||
|
|
@ -2153,26 +2153,28 @@ PyMethodDef UIGrid::methods[] = {
|
|||
" True if the cell is visible, False otherwise\n\n"
|
||||
"Must call compute_fov() first to calculate visibility."},
|
||||
{"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"
|
||||
"Args:\n"
|
||||
" start: Starting 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"
|
||||
" AStarPath object if path exists, None otherwise.\n\n"
|
||||
"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(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"
|
||||
"Args:\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"
|
||||
" DijkstraMap object for querying distances and paths.\n\n"
|
||||
"Grid caches DijkstraMaps by root position. Multiple requests for the\n"
|
||||
"same root return the same cached map. Call clear_dijkstra_maps() after\n"
|
||||
"changing grid walkability to invalidate the cache."},
|
||||
"Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n"
|
||||
"the same root and collide label return the same cached map. Call\n"
|
||||
"clear_dijkstra_maps() after changing grid walkability to invalidate."},
|
||||
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
|
||||
"clear_dijkstra_maps() -> None\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;
|
||||
|
||||
// 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()) {
|
||||
// Quick check: are there any entities with target_label nearby?
|
||||
// Tier 2: Spatial hash proximity pre-filter
|
||||
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));
|
||||
|
||||
for (auto& target : nearby) {
|
||||
if (target.get() == entity.get()) continue;
|
||||
if (target->labels.count(entity->target_label)) {
|
||||
// Compute FOV to verify line of sight
|
||||
// Collect matching targets before touching FOV
|
||||
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;
|
||||
|
||||
// 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,
|
||||
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;
|
||||
if (target->pyobject) {
|
||||
target_pyobj = target->pyobject;
|
||||
}
|
||||
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"
|
||||
"Must call compute_fov() first to calculate visibility."},
|
||||
{"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"
|
||||
"Args:\n"
|
||||
" start: Starting 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"
|
||||
" AStarPath object if path exists, None otherwise.\n\n"
|
||||
"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(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"
|
||||
"Args:\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"
|
||||
" DijkstraMap object for querying distances and paths.\n\n"
|
||||
"Grid caches DijkstraMaps by root position. Multiple requests for the\n"
|
||||
"same root return the same cached map. Call clear_dijkstra_maps() after\n"
|
||||
"changing grid walkability to invalidate the cache."},
|
||||
"Grid caches DijkstraMaps by (root, collide) key. Multiple requests for\n"
|
||||
"the same root and collide label return the same cached map. Call\n"
|
||||
"clear_dijkstra_maps() after changing grid walkability to invalidate."},
|
||||
{"clear_dijkstra_maps", (PyCFunction)UIGridPathfinding::Grid_clear_dijkstra_maps, METH_NOARGS,
|
||||
"clear_dijkstra_maps() -> None\n\n"
|
||||
"Clear all cached Dijkstra maps.\n\n"
|
||||
|
|
|
|||
|
|
@ -458,14 +458,55 @@ PyObject* UIGridPathfinding::DijkstraMap_to_heightmap(PyDijkstraMapObject* self,
|
|||
// 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) {
|
||||
static const char* kwlist[] = {"start", "end", "diagonal_cost", NULL};
|
||||
static const char* kwlist[] = {"start", "end", "diagonal_cost", "collide", NULL};
|
||||
PyObject* start_obj = NULL;
|
||||
PyObject* end_obj = NULL;
|
||||
float diagonal_cost = 1.41f;
|
||||
const char* collide_label = NULL;
|
||||
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|f", const_cast<char**>(kwlist),
|
||||
&start_obj, &end_obj, &diagonal_cost)) {
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "OO|fz", const_cast<char**>(kwlist),
|
||||
&start_obj, &end_obj, &diagonal_cost, &collide_label)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
|
@ -489,9 +530,18 @@ PyObject* UIGridPathfinding::Grid_find_path(PyUIGridObject* self, PyObject* args
|
|||
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
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -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) {
|
||||
static const char* kwlist[] = {"root", "diagonal_cost", NULL};
|
||||
static const char* kwlist[] = {"root", "diagonal_cost", "collide", NULL};
|
||||
PyObject* root_obj = NULL;
|
||||
float diagonal_cost = 1.41f;
|
||||
const char* collide_label = NULL;
|
||||
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|f", const_cast<char**>(kwlist),
|
||||
&root_obj, &diagonal_cost)) {
|
||||
if (!PyArg_ParseTupleAndKeywords(args, kwds, "O|fz", const_cast<char**>(kwlist),
|
||||
&root_obj, &diagonal_cost, &collide_label)) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
|
|
@ -543,12 +594,13 @@ PyObject* UIGridPathfinding::Grid_get_dijkstra_map(PyUIGridObject* self, PyObjec
|
|||
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
|
||||
auto it = self->data->dijkstra_maps.find(key);
|
||||
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) {
|
||||
// Return existing
|
||||
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);
|
||||
}
|
||||
|
||||
// Mark-and-restore: temporarily block cells with collide label
|
||||
auto restore_list = markCollisionLabel(self->data.get(), label_str);
|
||||
|
||||
// Create new DijkstraMap
|
||||
auto dijkstra = std::make_shared<DijkstraMap>(
|
||||
self->data->getTCODMap(), root_x, root_y, diagonal_cost);
|
||||
|
||||
// Restore walkability
|
||||
restoreCollisionLabel(self->data.get(), restore_list);
|
||||
|
||||
// Cache it
|
||||
self->data->dijkstra_maps[key] = dijkstra;
|
||||
|
||||
|
|
|
|||
289
tests/unit/pathfinding_collide_test.py
Normal file
289
tests/unit/pathfinding_collide_test.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue