Source code for simvx.core.nodes_2d.trail

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