2D Navigation¶
Demonstrates: Node2D, process(), draw(), Input mouse position, pathfinding. Click anywhere to move the character along the shortest path. Run: uv run python packages/graphics/examples/2d_navigation.py
Source Code¶
1#!/usr/bin/env python3
2"""Navigation demo -- AStar2D click-to-move character on a grid.
3
4Demonstrates: Node2D, process(), draw(), Input mouse position, pathfinding.
5Click anywhere to move the character along the shortest path.
6Run: uv run python packages/graphics/examples/2d_navigation.py
7"""
8
9from simvx.core import Input, InputMap, MouseButton, Node2D, Vec2
10from simvx.graphics import App
11
12WIDTH, HEIGHT = 800, 600
13CELL = 40
14COLS, ROWS = WIDTH // CELL, HEIGHT // CELL
15
16
17class AStar2D:
18 """Minimal A* pathfinder on a 2D grid."""
19
20 def __init__(self, cols: int, rows: int):
21 self.cols, self.rows = cols, rows
22 self.blocked: set[tuple[int, int]] = set()
23
24 def neighbors(self, x: int, y: int):
25 for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)):
26 nx, ny = x + dx, y + dy
27 if 0 <= nx < self.cols and 0 <= ny < self.rows and (nx, ny) not in self.blocked:
28 yield nx, ny
29
30 def find_path(self, start: tuple[int, int], end: tuple[int, int]) -> list[tuple[int, int]]:
31 if start == end or end in self.blocked:
32 return []
33 import heapq
34
35 open_set = [(0, start)]
36 came_from: dict[tuple[int, int], tuple[int, int]] = {}
37 g_score = {start: 0}
38
39 while open_set:
40 _, current = heapq.heappop(open_set)
41 if current == end:
42 path = [current]
43 while current in came_from:
44 current = came_from[current]
45 path.append(current)
46 path.reverse()
47 return path
48
49 for neighbor in self.neighbors(*current):
50 new_g = g_score[current] + 1
51 if new_g < g_score.get(neighbor, float("inf")):
52 came_from[neighbor] = current
53 g_score[neighbor] = new_g
54 h = abs(neighbor[0] - end[0]) + abs(neighbor[1] - end[1])
55 heapq.heappush(open_set, (new_g + h, neighbor))
56 return []
57
58
59class NavigationDemo(Node2D):
60 def ready(self):
61 InputMap.add_action("click", [MouseButton.LEFT])
62
63 self.astar = AStar2D(COLS, ROWS)
64 self.player_cell = (1, 1)
65 self.player_pos = Vec2(1 * CELL + CELL / 2, 1 * CELL + CELL / 2)
66 self._nav_path: list[tuple[int, int]] = []
67 self.move_speed = 200.0
68 self.target_pos: Vec2 | None = None
69
70 # Add some walls
71 for x in range(5, 15):
72 self.astar.blocked.add((x, 5))
73 for y in range(2, 10):
74 self.astar.blocked.add((8, y))
75 for x in range(3, 8):
76 self.astar.blocked.add((x, 10))
77
78 def process(self, dt: float):
79 # Click to set destination
80 if Input.is_action_just_pressed("click"):
81 mx, my = Input.mouse_position
82 cell = (int(mx // CELL), int(my // CELL))
83 if 0 <= cell[0] < COLS and 0 <= cell[1] < ROWS:
84 start = self.player_cell
85 self._nav_path = self.astar.find_path(start, cell)
86 if self._nav_path:
87 self._nav_path.pop(0) # Remove current cell
88 self._next_waypoint()
89
90 # Move toward current waypoint
91 if self.target_pos is not None:
92 diff = self.target_pos - self.player_pos
93 dist = diff.length()
94 if dist < 2.0:
95 self.player_pos = Vec2(self.target_pos.x, self.target_pos.y)
96 self._next_waypoint()
97 else:
98 direction = diff * (1.0 / dist)
99 self.player_pos += direction * self.move_speed * dt
100
101 def _next_waypoint(self):
102 if self._nav_path:
103 cell = self._nav_path.pop(0)
104 self.player_cell = cell
105 self.target_pos = Vec2(cell[0] * CELL + CELL / 2, cell[1] * CELL + CELL / 2)
106 else:
107 self.target_pos = None
108
109 def draw(self, renderer):
110 # Grid
111 for x in range(COLS):
112 for y in range(ROWS):
113 blocked = (x, y) in self.astar.blocked
114 colour = (0.75, 0.22, 0.22) if blocked else (0.12, 0.12, 0.16)
115 renderer.draw_rect((x * CELL, y * CELL), (CELL - 1, CELL - 1), colour=colour, filled=True)
116
117 # Path
118 for cell in self._nav_path:
119 renderer.draw_rect(
120 (cell[0] * CELL + 4, cell[1] * CELL + 4),
121 (CELL - 9, CELL - 9),
122 colour=(0.24, 0.68, 0.28),
123 filled=True,
124 )
125
126 # Player
127 px, py = self.player_pos.x, self.player_pos.y
128 renderer.draw_circle((px, py), CELL // 3, colour=(0.24, 0.71, 1.0), filled=True)
129
130 # HUD
131 renderer.draw_text("Navigation Demo -- Click to move", (10, 10), colour=(1.0, 1.0, 1.0), scale=2)
132
133
134if __name__ == "__main__":
135 App(title="Navigation Demo", width=WIDTH, height=HEIGHT).run(NavigationDemo())