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)