"""Trail2D -- Ribbon trail that follows a moving node."""
from __future__ import annotations
import math
from ..descriptors import Property
from ..math.types import Vec2
from .node2d import Node2D
[docs]
class Trail2D(Node2D):
"""Ribbon trail that follows this node's movement.
Records positions over time and renders a fading ribbon.
Attach as child of a moving node for sword swipes, missile trails,
movement effects, etc.
The core node manages the point buffer and geometry computation.
Actual rendering is handled by the graphics backend reading
``get_trail_geometry()``.
"""
length = Property(20, range=(2, 500), hint="Maximum number of trail points")
width = Property(10.0, range=(0.1, 200.0), hint="Trail width in pixels")
width_curve = Property(None, hint="Width falloff curve (1.0 at head, 0.0 at tail)")
colour = Property((1.0, 1.0, 1.0, 1.0), hint="Trail colour at head")
colour_end = Property((1.0, 1.0, 1.0, 0.0), hint="Trail colour at tail (fades)")
lifetime = Property(0.5, range=(0.01, 10.0), hint="Time in seconds before trail points expire")
emit = Property(True, hint="Whether to emit new trail points")
gizmo_colour = Property((0.2, 0.8, 1.0, 0.7), hint="Editor gizmo colour")
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Each entry: [position_x, position_y, age]
self._points: list[list[float]] = []
[docs]
def process(self, dt: float):
"""Record position, age points, and cull expired ones."""
# Age existing points
for pt in self._points:
pt[2] += dt
# Remove expired points (oldest are at end of list)
lifetime = self.lifetime
self._points = [pt for pt in self._points if pt[2] < lifetime]
# Emit new point at head (index 0)
if self.emit:
gp = self.world_position
self._points.insert(0, [float(gp.x), float(gp.y), 0.0])
# Cap at max length
max_len = self.length
if len(self._points) > max_len:
self._points = self._points[:max_len]
[docs]
def clear(self):
"""Remove all trail points immediately."""
self._points.clear()
[docs]
def get_trail_points(self) -> list[dict]:
"""Return trail point data for rendering.
Each dict has: position (Vec2), normalized_age (0=new, 1=expired),
width (float), colour (tuple).
"""
n = len(self._points)
if n == 0:
return []
lifetime = self.lifetime
base_width = self.width
curve = self.width_curve
c_head = self.colour
c_tail = self.colour_end
result = []
for i, pt in enumerate(self._points):
norm_age = min(pt[2] / lifetime, 1.0) if lifetime > 0 else 0.0
# t: 0 at head (index 0), 1 at tail (last index)
t = i / (n - 1) if n > 1 else 0.0
# Width interpolation
if curve is not None:
w = base_width * curve(t) if callable(curve) else base_width * float(curve)
else:
w = base_width * (1.0 - t)
# Colour interpolation
colour = tuple(c_head[j] + (c_tail[j] - c_head[j]) * t for j in range(4))
result.append({
"position": Vec2(pt[0], pt[1]),
"normalized_age": norm_age,
"width": w,
"colour": colour,
})
return result
[docs]
def get_trail_geometry(self) -> list[dict]:
"""Return vertices for a triangle strip suitable for rendering.
Each vertex dict has: position (Vec2), colour (tuple).
For each trail point, two vertices are generated offset perpendicular
to the trail direction by +/- half the interpolated width.
"""
n = len(self._points)
if n < 2:
return []
trail_pts = self.get_trail_points()
vertices: list[dict] = []
for i, tp in enumerate(trail_pts):
pos = tp["position"]
w = tp["width"]
col = tp["colour"]
# Compute direction to next (or from previous) point
if i < n - 1:
next_pos = trail_pts[i + 1]["position"]
dx = next_pos.x - pos.x
dy = next_pos.y - pos.y
else:
prev_pos = trail_pts[i - 1]["position"]
dx = pos.x - prev_pos.x
dy = pos.y - prev_pos.y
# Perpendicular (rotated 90 degrees)
length_sq = dx * dx + dy * dy
if length_sq > 1e-12:
inv_len = 1.0 / math.sqrt(length_sq)
nx, ny = -dy * inv_len, dx * inv_len
else:
nx, ny = 0.0, 1.0
half_w = w * 0.5
vertices.append({
"position": Vec2(pos.x + nx * half_w, pos.y + ny * half_w),
"colour": col,
})
vertices.append({
"position": Vec2(pos.x - nx * half_w, pos.y - ny * half_w),
"colour": col,
})
return vertices
[docs]
def get_gizmo_lines(self) -> list[tuple[Vec2, Vec2]]:
"""Return the trail centerline as connected line segments for editor gizmos."""
if len(self._points) < 2:
return []
lines: list[tuple[Vec2, Vec2]] = []
prev = Vec2(self._points[0][0], self._points[0][1])
for i in range(1, len(self._points)):
cur = Vec2(self._points[i][0], self._points[i][1])
lines.append((prev, cur))
prev = cur
return lines