2D Navigation

Play Demo

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