Source code for simvx.core.navigation.path_follower_2d

"""PathFollower2D: constant-speed waypoint traversal node.

Distinct from :class:`NavigationAgent2D`, which steers toward a single target
position and replans with A*. ``PathFollower2D`` consumes a pre-computed
sequence of :class:`Vec2` waypoints and lerps along the polyline at a fixed
linear speed. Useful for scripted enemy patrols, conveyor belts, on-rails
camera tracks, projectile splines, and Tower Defence-style creep routes.

Usage::

    follower = PathFollower2D(path=[Vec2(0, 0), Vec2(100, 0), Vec2(100, 100)], speed=120)
    parent.add_child(follower)
    follower.path_finished.connect(lambda: print("arrived"))

Override :attr:`loop` to make the follower wrap back to the start on
completion. Override :attr:`auto_rotate` to align the node's ``rotation`` with
the current segment direction.
"""

from __future__ import annotations

import math

from ..descriptors import Property
from ..math.types import Vec2
from ..nodes_2d.node2d import Node2D
from ..signals import Signal

__all__ = ["PathFollower2D"]


[docs] class PathFollower2D(Node2D): """Follows a polyline of :class:`Vec2` waypoints at constant linear speed. Args: path: Sequence of waypoints in world space. Empty or single-point paths leave the follower idle. speed: Linear units per second along the polyline. loop: When ``True`` the follower wraps from the last waypoint back to the first; otherwise it stops and emits ``path_finished``. auto_rotate: When ``True`` the node's ``rotation`` tracks the current segment heading (radians, atan2(dy, dx)). """ speed = Property(100.0, range=(0, 10000), hint="Linear units/sec along the path", group="Path") loop = Property(False, hint="Wrap to the start on completion", group="Path") auto_rotate = Property(False, hint="Rotate to face the current segment", group="Path") def __init__( self, path: list[Vec2] | None = None, speed: float = 100.0, *, loop: bool = False, auto_rotate: bool = False, **kwargs, ): super().__init__(**kwargs) self.speed = speed self.loop = loop self.auto_rotate = auto_rotate self.path_finished = Signal() # Internal canonical path: list[(x, y)] floats + cumulative arc lengths. self._waypoints: list[tuple[float, float]] = [] self._segment_lengths: list[float] = [] self._total_length: float = 0.0 self._distance: float = 0.0 self._finished: bool = False self.path = path or [] # -- Public API ----------------------------------------------------------- @property def path(self) -> list[Vec2]: """Return a copy of the path as Vec2 waypoints.""" return [Vec2(x, y) for x, y in self._waypoints]
[docs] @path.setter def path(self, waypoints) -> None: self.set_path(waypoints)
[docs] def set_path(self, waypoints) -> None: """Replace the polyline and snap the follower to the first waypoint.""" pts: list[tuple[float, float]] = [] for wp in waypoints or (): if hasattr(wp, "x"): pts.append((float(wp.x), float(wp.y))) else: pts.append((float(wp[0]), float(wp[1]))) self._waypoints = pts self._segment_lengths = [ math.hypot(b[0] - a[0], b[1] - a[1]) for a, b in zip(pts, pts[1:]) ] self._total_length = sum(self._segment_lengths) self._distance = 0.0 self._finished = False if pts: self.position = Vec2(pts[0][0], pts[0][1])
[docs] @property def progress(self) -> float: """Fractional progress along the path in ``[0.0, 1.0]``.""" if self._total_length <= 0.0: return 1.0 if self._finished else 0.0 return min(1.0, self._distance / self._total_length)
[docs] @property def is_finished(self) -> bool: """``True`` when a non-looping path has reached its final waypoint.""" return self._finished
[docs] def reset(self) -> None: """Restart from the first waypoint without rebuilding the polyline.""" self._distance = 0.0 self._finished = False if self._waypoints: self.position = Vec2(self._waypoints[0][0], self._waypoints[0][1])
# -- Frame update ---------------------------------------------------------
[docs] def on_process(self, dt: float): # noqa: D401: Node hook if self._finished or len(self._waypoints) < 2 or self.speed <= 0.0 or dt <= 0.0: return self._distance += self.speed * dt if self._distance >= self._total_length: if self.loop and self._total_length > 0.0: # Wrap residual distance into the next lap. self._distance %= self._total_length else: self._distance = self._total_length self._finished = True self._apply_pose(self._total_length) self.path_finished() return self._apply_pose(self._distance)
# -- Helpers -------------------------------------------------------------- def _apply_pose(self, distance: float) -> None: """Place the node at ``distance`` along the polyline, optionally rotated.""" remaining = distance for i, seg_len in enumerate(self._segment_lengths): if remaining <= seg_len or i == len(self._segment_lengths) - 1: ax, ay = self._waypoints[i] bx, by = self._waypoints[i + 1] t = (remaining / seg_len) if seg_len > 0.0 else 1.0 t = max(0.0, min(1.0, t)) self.position = Vec2(ax + (bx - ax) * t, ay + (by - ay) * t) if self.auto_rotate and seg_len > 0.0: self.rotation = math.atan2(by - ay, bx - ax) return remaining -= seg_len