nodes/projectile.pyΒΆ

Part of Dungeon Explorer.

  1"""Projectile node for ranged attacks: travels in a direction, damages on contact."""
  2
  3import math
  4
  5from simvx.core import Node2D, Property, Signal, Vec2
  6
  7from .combat import _target_radius
  8
  9
 10class Projectile(Node2D):
 11    """A moving projectile that damages the first target it hits."""
 12
 13    # Transient effect: never persisted (a save with one in flight must re-apply cleanly).
 14    __save_persist__ = False
 15
 16    damage = Property(5, range=(0, 9999), group="Combat")
 17    speed = Property(250.0, range=(50, 1000), group="Combat")
 18    max_range = Property(300.0, range=(50, 2000), group="Combat")
 19    #: Hit radius for target overlap (world units).
 20    radius = Property(4.0, range=(1, 100), group="Combat")
 21
 22    hit = Signal()  # (target_node)
 23
 24    def __init__(
 25        self,
 26        damage: int = 5,
 27        direction: Vec2 | None = None,
 28        speed: float = 250.0,
 29        max_range: float = 300.0,
 30        colour: tuple = (1.0, 0.8, 0.2, 1.0),
 31        style: str = "arrow",
 32        **kwargs,
 33    ):
 34        super().__init__(**kwargs)
 35        self.damage = damage
 36        self.speed = speed
 37        self.max_range = max_range
 38        self._direction = direction or Vec2(0, 1)
 39        length = math.sqrt(self._direction.x**2 + self._direction.y**2)
 40        if length > 0.01:
 41            self._direction = Vec2(self._direction.x / length, self._direction.y / length)
 42        self._distance_travelled = 0.0
 43        self._hit_targets: set = set()
 44        self._colour = colour
 45        self._style = style  # "arrow" or "bolt"
 46        self._fog = None
 47        self._dungeon_data = None
 48
 49    def on_update(self, dt: float):
 50        # Move
 51        move = self.speed * dt
 52        self.position = Vec2(
 53            self.position.x + self._direction.x * move,
 54            self.position.y + self._direction.y * move,
 55        )
 56        self._distance_travelled += move
 57
 58        if self._distance_travelled >= self.max_range:
 59            self.destroy()
 60            return
 61
 62        # Wall collision: destroy if inside a non-walkable tile
 63        if self._dungeon_data is not None:
 64            from scripts.dungeon_generator import TILE_SIZE
 65
 66            gx = int(self.position.x / TILE_SIZE)
 67            gy = int(self.position.y / TILE_SIZE)
 68            if not self._dungeon_data.is_walkable(gx, gy):
 69                self.destroy()
 70
 71    def check_hits(self, targets: list) -> list:
 72        """Check collision against targets. Returns list of newly hit targets."""
 73        newly_hit = []
 74        pp = self.world_position
 75        for target in targets:
 76            if target in self._hit_targets:
 77                continue
 78            d = target.world_position - pp
 79            reach = self.radius + _target_radius(target)
 80            if float(d.x) ** 2 + float(d.y) ** 2 <= reach * reach:
 81                self._hit_targets.add(target)
 82                newly_hit.append(target)
 83                self.hit(target)
 84                self.destroy()
 85                break  # Projectile destroys on first hit
 86        return newly_hit
 87
 88    def on_draw(self, renderer):
 89        # Hide projectile in non-visible fog areas
 90        if self._fog:
 91            from scripts.dungeon_generator import TILE_SIZE
 92
 93            gx = int(self.position.x / TILE_SIZE)
 94            gy = int(self.position.y / TILE_SIZE)
 95            if self._fog.get_state(gx, gy) < 2:
 96                return
 97        x, y = self.position.x, self.position.y
 98        c = self._colour
 99        dx, dy = self._direction.x, self._direction.y
100        if self._style == "bolt":
101            # Magic bolt: pulsing orb with glow trail
102            renderer.draw_circle((x - dx * 6, y - dy * 6), 4, colour=(c[0], c[1], c[2], 0.2), filled=True)
103            renderer.draw_circle((x, y), 4, colour=(c[0], c[1], c[2], 0.4), filled=True)
104            renderer.draw_circle((x, y), 2, colour=c, filled=True)
105        else:
106            # Arrow: triangle head + shaft line
107            hx, hy = dx * 6, dy * 6
108            renderer.draw_line((x - hx, y - hy), (x + hx * 0.3, y + hy * 0.3), colour=(0.5, 0.35, 0.2, c[3]))
109            renderer.fill_triangle(x + hx, y + hy, x + hy * 0.4, y - hx * 0.4, x - hy * 0.4, y + hx * 0.4, colour=c)