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

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