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;
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)

View file

@ -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)

View file

@ -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"

View file

@ -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);

View file

@ -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"

View file

@ -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;

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)