McRogueFace/tests/demo/screens/pathfinding_demo.py
John McCardle 3030ac488b Add interactive pathfinding demo for #315; closes #315
tests/demo/screens/pathfinding_demo.py runs three panels side-by-side:

  Panel 1 - A* with selectable heuristic. Keys 1-5 cycle EUCLIDEAN, MANHATTAN,
            CHEBYSHEV, DIAGONAL, ZERO. Q/W bump the weight by 0.25 to show
            weighted A* behaviour.
  Panel 2 - Dijkstra flood from a cursor-controlled root. Arrow keys move the
            cursor; the distance field re-renders as a blue gradient.
  Panel 3 - Multi-root FLEE: three guard entities flee from a shared set of
            threats using an inverted multi-root DijkstraMap, animated one
            step per timer tick. T adds a new threat; R resets.

Exercises the new surface: mcrfpy.Heuristic, Grid.find_path(heuristic=,
weight=), Grid.get_dijkstra_map(roots=...), DijkstraMap.invert(), and
DijkstraMap.descent_step().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 09:19:17 -04:00

290 lines
9.7 KiB
Python

"""pathfinding_demo.py - Visual demo of the #315 pathfinding primitives.
Three panels side-by-side on one scene:
[Panel 1] A* with selectable heuristic. Keys 1-5 cycle EUCLIDEAN, MANHATTAN,
CHEBYSHEV, DIAGONAL, ZERO. Q/W bump the weight by 0.25.
[Panel 2] Dijkstra flood from a cursor-controlled root. Arrow keys move the
cursor; the distance field re-renders as a blue gradient.
[Panel 3] Multi-root FLEE: three "guard" entities flee from a shared set of
threats, using an inverted multi-root Dijkstra map. Animated one
step per frame tick. Press T to drop a new threat on the panel.
Also exercises: DijkstraMap.invert(), DijkstraMap.descent_step(),
mcrfpy.Heuristic, Grid.find_path(heuristic=, weight=), Grid.get_dijkstra_map(
roots=...).
"""
import mcrfpy
import sys
GRID_W, GRID_H = 20, 20
CELL_PX = 14
PANEL_W_PX = GRID_W * CELL_PX
GAP = 20
SCREEN_W = 3 * PANEL_W_PX + 4 * GAP
SCREEN_H = GRID_H * CELL_PX + 140
scene = mcrfpy.Scene("pathfinding_demo")
bg = mcrfpy.Frame(pos=(0, 0), size=(SCREEN_W, SCREEN_H),
fill_color=mcrfpy.Color(18, 18, 26))
scene.children.append(bg)
title = mcrfpy.Caption(text="Pathfinding Next-Gen Demo (#315)", pos=(GAP, 10))
title.fill_color = mcrfpy.Color(240, 240, 255)
scene.children.append(title)
def make_open_grid(x_off):
g = mcrfpy.Grid(grid_size=(GRID_W, GRID_H),
pos=(x_off, 50),
size=(PANEL_W_PX, GRID_H * CELL_PX))
for y in range(GRID_H):
for x in range(GRID_W):
c = g.at(x, y)
wall = (x in (0, GRID_W - 1)) or (y in (0, GRID_H - 1))
c.walkable = not wall
c.transparent = not wall
# A small wedge of walls in the middle for visual interest.
for y in range(4, 12):
g.at(GRID_W // 2, y).walkable = False
g.at(GRID_W // 2, y).transparent = False
scene.children.append(g)
return g
# =============================================================================
# Panel 1: A* with heuristic switching
# =============================================================================
g1 = make_open_grid(GAP)
astar_layer = mcrfpy.ColorLayer(z_index=1, name="astar_path")
g1.add_layer(astar_layer)
HEURISTICS = [
(mcrfpy.Heuristic.EUCLIDEAN, "EUCLIDEAN"),
(mcrfpy.Heuristic.MANHATTAN, "MANHATTAN"),
(mcrfpy.Heuristic.CHEBYSHEV, "CHEBYSHEV"),
(mcrfpy.Heuristic.DIAGONAL, "DIAGONAL"),
(mcrfpy.Heuristic.ZERO, "ZERO"),
]
state_astar = {"hidx": 0, "weight": 1.0,
"start": (2, 10), "end": (GRID_W - 3, 10)}
cap_astar = mcrfpy.Caption(text="A* heuristic: EUCLIDEAN weight=1.00",
pos=(GAP, 50 + GRID_H * CELL_PX + 6))
cap_astar.fill_color = mcrfpy.Color(180, 220, 255)
scene.children.append(cap_astar)
def redraw_astar():
h, hname = HEURISTICS[state_astar["hidx"]]
astar_layer.fill(mcrfpy.Color(0, 0, 0, 0))
p = g1.find_path(state_astar["start"], state_astar["end"],
heuristic=h, weight=state_astar["weight"])
n_steps = 0
if p is not None:
for step in p:
astar_layer.set((int(step.x), int(step.y)),
mcrfpy.Color(255, 220, 80, 220))
n_steps += 1
# Start/end markers.
astar_layer.set(state_astar["start"], mcrfpy.Color(80, 255, 120, 255))
astar_layer.set(state_astar["end"], mcrfpy.Color(255, 90, 90, 255))
cap_astar.text = (f"A* heuristic: {hname} "
f"weight={state_astar['weight']:.2f} steps={n_steps}")
# =============================================================================
# Panel 2: Dijkstra flood
# =============================================================================
g2 = make_open_grid(GAP * 2 + PANEL_W_PX)
dij_layer = mcrfpy.ColorLayer(z_index=1, name="dij_flood")
g2.add_layer(dij_layer)
state_dij = {"root": (GRID_W // 2 - 3, GRID_H // 2)}
cap_dij = mcrfpy.Caption(text="Dijkstra flood (arrows move root)",
pos=(GAP * 2 + PANEL_W_PX, 50 + GRID_H * CELL_PX + 6))
cap_dij.fill_color = mcrfpy.Color(180, 255, 220)
scene.children.append(cap_dij)
def redraw_dijkstra():
dij_layer.fill(mcrfpy.Color(0, 0, 0, 0))
root = state_dij["root"]
if not g2.at(root[0], root[1]).walkable:
cap_dij.text = "Dijkstra root on wall - move with arrows"
return
dmap = g2.get_dijkstra_map(root)
# Sample distances to find the maximum for normalization.
max_dist = 0.0
for y in range(1, GRID_H - 1):
for x in range(1, GRID_W - 1):
d = dmap.distance((x, y))
if d is not None and d > max_dist:
max_dist = d
if max_dist <= 0:
return
for y in range(1, GRID_H - 1):
for x in range(1, GRID_W - 1):
d = dmap.distance((x, y))
if d is None:
continue
t = min(1.0, d / max_dist)
# Cool gradient: near-root = bright, far = dark.
r = int(40 * t + 80 * (1 - t))
gc = int(160 * (1 - t) + 40 * t)
bc = int(240 * (1 - t) + 80 * t)
dij_layer.set((x, y), mcrfpy.Color(r, gc, bc, 180))
dij_layer.set(root, mcrfpy.Color(255, 255, 90, 255))
cap_dij.text = f"Dijkstra flood max={max_dist:.1f} root={root}"
# =============================================================================
# Panel 3: Multi-root FLEE
# =============================================================================
g3 = make_open_grid(GAP * 3 + 2 * PANEL_W_PX)
flee_layer = mcrfpy.ColorLayer(z_index=1, name="flee_layer")
g3.add_layer(flee_layer)
state_flee = {
"threats": [(3, 3), (GRID_W - 4, GRID_H - 4)],
"guards": [(10, 6), (10, 10), (10, 14)],
"safety": None,
"threat_map": None,
}
cap_flee = mcrfpy.Caption(text="Multi-root FLEE (T adds threat, R resets)",
pos=(GAP * 3 + 2 * PANEL_W_PX,
50 + GRID_H * CELL_PX + 6))
cap_flee.fill_color = mcrfpy.Color(255, 190, 190)
scene.children.append(cap_flee)
def recompute_flee():
state_flee["threat_map"] = g3.get_dijkstra_map(roots=state_flee["threats"])
state_flee["safety"] = state_flee["threat_map"].invert()
def redraw_flee():
flee_layer.fill(mcrfpy.Color(0, 0, 0, 0))
# Threats: red.
for t in state_flee["threats"]:
flee_layer.set(t, mcrfpy.Color(255, 60, 60, 255))
# Guards: green.
for gd in state_flee["guards"]:
flee_layer.set(gd, mcrfpy.Color(80, 255, 120, 255))
cap_flee.text = (f"FLEE: {len(state_flee['threats'])} threats "
f"{len(state_flee['guards'])} guards")
def step_guards():
if state_flee["safety"] is None:
return
new_guards = []
for gd in state_flee["guards"]:
nxt = state_flee["safety"].descent_step(gd)
if nxt is None:
new_guards.append(gd)
else:
candidate = (int(nxt.x), int(nxt.y))
# Avoid landing on another guard or a threat.
if candidate in new_guards or candidate in state_flee["threats"]:
new_guards.append(gd)
else:
new_guards.append(candidate)
state_flee["guards"] = new_guards
redraw_flee()
# =============================================================================
# Key handling
# =============================================================================
instructions = [
"Panel 1 (A*): [1-5] heuristic [Q/W] weight -/+",
"Panel 2 (Dij): [Arrow keys] move root",
"Panel 3 (FLEE): [T] add threat [R] reset (guards auto-step)",
"[ESC] quit",
]
for i, text in enumerate(instructions):
c = mcrfpy.Caption(text=text, pos=(GAP, SCREEN_H - 90 + i * 22))
c.fill_color = mcrfpy.Color(180, 180, 200)
scene.children.append(c)
def on_key(key, state):
if state != mcrfpy.InputState.PRESSED:
return
# Heuristic switching
for i, digit in enumerate([mcrfpy.Key.Num1, mcrfpy.Key.Num2, mcrfpy.Key.Num3,
mcrfpy.Key.Num4, mcrfpy.Key.Num5]):
if key == digit:
state_astar["hidx"] = i
redraw_astar()
return
if key == mcrfpy.Key.Q:
state_astar["weight"] = max(0.25, state_astar["weight"] - 0.25)
redraw_astar()
return
if key == mcrfpy.Key.W:
state_astar["weight"] = min(5.0, state_astar["weight"] + 0.25)
redraw_astar()
return
# Dijkstra root movement
rx, ry = state_dij["root"]
moved = False
if key == mcrfpy.Key.LEFT:
rx = max(1, rx - 1); moved = True
elif key == mcrfpy.Key.RIGHT:
rx = min(GRID_W - 2, rx + 1); moved = True
elif key == mcrfpy.Key.UP:
ry = max(1, ry - 1); moved = True
elif key == mcrfpy.Key.DOWN:
ry = min(GRID_H - 2, ry + 1); moved = True
if moved:
state_dij["root"] = (rx, ry)
redraw_dijkstra()
# FLEE panel: drop threat at a random walkable cell.
if key == mcrfpy.Key.T:
import random
for _ in range(20):
p = (random.randint(1, GRID_W - 2), random.randint(1, GRID_H - 2))
if g3.at(p[0], p[1]).walkable and p not in state_flee["threats"]:
state_flee["threats"].append(p)
break
recompute_flee()
redraw_flee()
if key == mcrfpy.Key.R:
state_flee["threats"] = [(3, 3), (GRID_W - 4, GRID_H - 4)]
state_flee["guards"] = [(10, 6), (10, 10), (10, 14)]
recompute_flee()
redraw_flee()
if key == mcrfpy.Key.ESCAPE:
mcrfpy.exit()
scene.on_key = on_key
# Step FLEE guards on a timer (6 ticks/sec is slow enough to watch).
def tick(timer, runtime):
step_guards()
# Initial render
redraw_astar()
redraw_dijkstra()
recompute_flee()
redraw_flee()
mcrfpy.Timer("flee_tick", tick, 160)
mcrfpy.current_scene = scene
print("pathfinding_demo loaded - see on-screen instructions.")