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