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