nodes/turret.py¶
Part of Tower Defence.
1"""Tower / turret node.
2
3One ``Turret`` covers all three types (basic / slow / sniper) through the
4``TURRET_TYPES`` data table: only the stats and the artwork differ. The basic
5turret plays a sprite-sheet fire cycle; slow and sniper use a body texture
6generated at load time in their tier colour, so no extra art is needed.
7"""
8
9from __future__ import annotations
10
11import math
12from pathlib import Path
13
14import numpy as np
15
16from simvx.core import (
17 AnimatedSprite2D,
18 AudioClip,
19 AudioPlayer,
20 Node2D,
21 Property,
22 Signal,
23 Sprite2D,
24 Vec2,
25)
26
27from .audio import make_shot
28from .td_data import (
29 ANIMATION_FPS,
30 ANIMATION_STEPS,
31 TILE_SIZE,
32 TURRET_LEVELS,
33 TURRET_TYPES,
34)
35
36_SHOT_STREAM: AudioClip | None = None
37
38
39def _shot_stream() -> AudioClip:
40 """Bake the shot once; every turret's player shares the stream."""
41 global _SHOT_STREAM
42 if _SHOT_STREAM is None:
43 _SHOT_STREAM = make_shot()
44 return _SHOT_STREAM
45
46
47# Cache procedural turret-body + range-ring textures so we don't rebuild
48# ndarrays per turret / range tier.
49_BODY_CACHE: dict[tuple, np.ndarray] = {}
50_RANGE_CACHE: dict[float, np.ndarray] = {}
51
52
53def _make_range_texture(radius: float) -> np.ndarray:
54 """A soft white ring of the given radius, on transparent background.
55
56 Shows a turret's targeting range while it is selected. Baking the ring into a
57 texture gives it an anti-aliased edge and a soft interior fill that a polygon
58 circle cannot, and makes the range indicator a plain child sprite: it is shown
59 and hidden with ``visible`` and costs nothing the rest of the time. One
60 texture is cached per radius and shared by every turret on that tier.
61 """
62 cached = _RANGE_CACHE.get(radius)
63 if cached is not None:
64 return cached
65 size = int(radius * 2 + 4)
66 img = np.zeros((size, size, 4), dtype=np.uint8)
67 cy, cx = size / 2, size / 2
68 yy, xx = np.mgrid[0:size, 0:size]
69 dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
70 inner_fill = np.clip(radius - dist, 0.0, 1.0)
71 edge = np.exp(-((dist - radius) ** 2) / 2.5)
72 fill_alpha = inner_fill * 0.18
73 edge_alpha = edge * 0.6
74 img[..., 0] = 255
75 img[..., 1] = 255
76 img[..., 2] = 255
77 img[..., 3] = np.clip(np.maximum(fill_alpha, edge_alpha) * 255, 0, 255).astype(np.uint8)
78 _RANGE_CACHE[radius] = img
79 return img
80
81
82def _make_body_texture(colour: tuple[float, float, float, float], size: int = 96) -> np.ndarray:
83 """Filled circle on transparent background, with a dark rim and barrel stub.
84
85 Returned as an RGBA uint8 ndarray suitable for ``Sprite2D(texture=arr)``.
86 The barrel points up (-y) to match the basic turret's idle orientation;
87 Turret.on_update syncs ``sprite.rotation`` so the barrel tracks targets.
88 """
89 key = (colour, size)
90 cached = _BODY_CACHE.get(key)
91 if cached is not None:
92 return cached
93
94 img = np.zeros((size, size, 4), dtype=np.uint8)
95 cy, cx = size / 2, size / 2
96 yy, xx = np.mgrid[0:size, 0:size]
97 dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
98 body_r = size * 0.35
99 rim_r = size * 0.36
100 barrel_half = size * 0.06
101 barrel_len = size * 0.55 # extends from centre upward
102
103 # Body fill (anti-aliased edge over 1.5 px)
104 body_alpha = np.clip(body_r - dist + 0.5, 0.0, 1.0)
105 rim_alpha = np.clip(rim_r - dist + 0.5, 0.0, 1.0) - body_alpha
106 rim_alpha = np.clip(rim_alpha, 0.0, 1.0)
107
108 # Barrel: vertical stripe centred at column cx, from cy upward.
109 in_barrel_x = np.abs(xx - cx) <= barrel_half
110 in_barrel_y = (yy >= cy - barrel_len) & (yy <= cy)
111 barrel_mask = (in_barrel_x & in_barrel_y).astype(np.float32)
112
113 r = body_alpha * (colour[0] * 255) + rim_alpha * 30 + barrel_mask * 30
114 g = body_alpha * (colour[1] * 255) + rim_alpha * 30 + barrel_mask * 30
115 b = body_alpha * (colour[2] * 255) + rim_alpha * 35 + barrel_mask * 35
116 a = np.clip(np.maximum(np.maximum(body_alpha, rim_alpha), barrel_mask) * 255, 0, 255)
117
118 img[..., 0] = np.clip(r, 0, 255).astype(np.uint8)
119 img[..., 1] = np.clip(g, 0, 255).astype(np.uint8)
120 img[..., 2] = np.clip(b, 0, 255).astype(np.uint8)
121 img[..., 3] = a.astype(np.uint8)
122
123 _BODY_CACHE[key] = img
124 return img
125
126
127_TURRET_DIR = Path(__file__).parent.parent / "assets" / "images" / "turrets"
128
129
130class Turret(Node2D):
131 """A tile-aligned tower that shoots the closest enemy in range.
132
133 Acquires a target each ``cooldown`` seconds, deals damage instantly (the
134 tutorial's projectile is purely visual), and emits ``target_acquired``
135 so the parent scene can spawn a tracer / muzzle flash.
136 """
137
138 upgrade_level = Property(1, range=(1, TURRET_LEVELS))
139 range_radius = Property(90.0, range=(0, 600))
140 damage = Property(5, range=(0, 200))
141 cooldown = Property(1.5, range=(0.05, 5.0))
142
143 target_acquired = Signal() # Vec2 muzzle, Vec2 target (for tracer FX)
144
145 def __init__(self, turret_type: str, tile_x: int, tile_y: int, **kwargs):
146 super().__init__(**kwargs)
147 self.add_to_group("turrets")
148 self.turret_type = turret_type
149 self.tile_x = tile_x
150 self.tile_y = tile_y
151
152 self.position = Vec2(
153 (tile_x + 0.5) * TILE_SIZE,
154 (tile_y + 0.5) * TILE_SIZE,
155 )
156
157 self._cooldown_timer = 0.0
158 self._anim_t = 0.0
159 self._anim_frame = 0
160 self._firing = False
161 self.angle = -math.pi / 2 # facing up
162 self._selected = False
163
164 self._tiers = TURRET_TYPES[turret_type]
165 self._apply_tier(0)
166
167 # The basic turret uses the sprite-sheet; slow and sniper get a baked
168 # circular body in their tier colour so the three types read apart.
169 if turret_type == "basic":
170 self.sprite: AnimatedSprite2D | None = self.add_child(
171 AnimatedSprite2D(
172 texture=str(_TURRET_DIR / f"turret_{self.upgrade_level}.png"),
173 frames_h=ANIMATION_STEPS,
174 frames_v=1,
175 frame_width=96,
176 frame_height=96,
177 width=96,
178 height=96,
179 )
180 )
181 # Register an idle animation but pause so we can drive the frame
182 # index manually -- this initialises ``current_animation`` so
183 # ``frame_uv`` returns the per-frame slice (otherwise it falls
184 # back to "show the whole strip").
185 self.sprite.add_animation("fire", list(range(ANIMATION_STEPS)), fps=ANIMATION_FPS, loop=True)
186 self.sprite.play("fire")
187 self.sprite.pause()
188 self.sprite.frame = 0
189 else:
190 # Slow / sniper -- a procedural circular body baked into an ndarray,
191 # so the two port-added types read distinctly without extra art.
192 tex = _make_body_texture(self._tier_colour)
193 self.sprite = self.add_child(Sprite2D(texture=tex, width=72, height=72))
194
195 # Range indicator -- hidden until the turret is selected (``selected``).
196 range_tex = _make_range_texture(self.range_radius)
197 self._range_sprite = self.add_child(
198 Sprite2D(
199 texture=range_tex,
200 width=int(self.range_radius * 2 + 4),
201 height=int(self.range_radius * 2 + 4),
202 )
203 )
204 self._range_sprite.visible = False
205
206 # Single shared shot SFX -- cheap to reuse the same player because the
207 # shot is short. (One per turret keeps simultaneous fires clean.)
208 # The stream is baked once and shared (see _shot_stream / nodes/audio.py).
209 self.shot_player = self.add_child(AudioPlayer(stream=_shot_stream(), volume_db=-12.0))
210
211 # ------------------------------------------------------------------
212 # Stat helpers
213 # ------------------------------------------------------------------
214
215 def _apply_tier(self, tier_index: int) -> None:
216 tier = self._tiers[tier_index]
217 self.range_radius = tier["range"]
218 self.cooldown = tier["cooldown"]
219 self.damage = tier["damage"]
220 self._slow_factor = tier.get("slow_factor")
221 self._slow_seconds = tier.get("slow_seconds")
222 self._tier_colour = tier.get("colour", (1.0, 1.0, 1.0, 1.0))
223
224 @property
225 def can_upgrade(self) -> bool:
226 return self.upgrade_level < len(self._tiers)
227
228 def upgrade(self) -> None:
229 if not self.can_upgrade:
230 return
231 self.upgrade_level += 1
232 self._apply_tier(self.upgrade_level - 1)
233 # Re-skin the range ring for the new radius. ``texture``, ``width`` and
234 # ``height`` are Properties whose on_change hooks invalidate the cached
235 # texture id and draw size, so assigning them is all it takes.
236 if self._range_sprite is not None:
237 size = int(self.range_radius * 2 + 4)
238 self._range_sprite.texture = _make_range_texture(self.range_radius)
239 self._range_sprite.width = size
240 self._range_sprite.height = size
241 # Swap the basic sprite-sheet for the new tier.
242 if isinstance(self.sprite, AnimatedSprite2D):
243 self.sprite.texture = str(_TURRET_DIR / f"turret_{self.upgrade_level}.png")
244 elif isinstance(self.sprite, Sprite2D):
245 # Slow/sniper: rebuild body to match new tier colour (currently
246 # tier colour doesn't change, but keep this consistent).
247 self.sprite.texture = _make_body_texture(self._tier_colour)
248
249 # ------------------------------------------------------------------
250 # Targeting
251 # ------------------------------------------------------------------
252
253 def _pick_target(self):
254 if not self.tree:
255 return None
256 best = None
257 best_dist = self.range_radius
258 for enemy in self.tree.group("enemies"):
259 if enemy.health <= 0 or enemy.reached_end:
260 continue
261 dx = enemy.position.x - self.position.x
262 dy = enemy.position.y - self.position.y
263 d = math.hypot(dx, dy)
264 if d <= best_dist:
265 best_dist = d
266 best = (enemy, dx, dy)
267 return best
268
269 # ------------------------------------------------------------------
270 # Tick
271 # ------------------------------------------------------------------
272
273 def on_update(self, dt: float) -> None:
274 # Match the enemy fast-forward multiplier so cooldowns scale together.
275 speed_mult = getattr(self.parent, "game_speed", 1) or 1
276 dt = dt * speed_mult
277
278 # Animation: while firing the basic turret cycles through 8 frames at
279 # ANIMATION_FPS. Slow/sniper just hold a brief muzzle line.
280 if self._firing:
281 self._anim_t += dt
282 self._anim_frame = int(self._anim_t * ANIMATION_FPS)
283 if self._anim_frame >= ANIMATION_STEPS:
284 self._anim_frame = 0
285 self._anim_t = 0.0
286 self._firing = False
287 self._cooldown_timer = self.cooldown
288
289 if not self._firing:
290 self._cooldown_timer -= dt
291 if self._cooldown_timer <= 0:
292 shot = self._pick_target()
293 if shot:
294 enemy, dx, dy = shot
295 self.angle = math.atan2(dy, dx)
296 enemy.take_damage(
297 self.damage,
298 slow_factor=self._slow_factor,
299 slow_seconds=self._slow_seconds,
300 )
301 self.shot_player.play()
302 self.target_acquired(self.position, enemy.position)
303 self._firing = True
304 self._cooldown_timer = self.cooldown
305 self._anim_t = 0.0
306 self._anim_frame = 0
307 else:
308 # No target -- recheck shortly
309 self._cooldown_timer = 0.05
310
311 # Sync sprite rotation -- texture barrel points up (-y) so add 90°.
312 if self.sprite is not None:
313 self.sprite.rotation = self.angle + math.pi / 2
314 if isinstance(self.sprite, AnimatedSprite2D) and self.sprite.frame != self._anim_frame:
315 # ``frame`` is a plain attribute, not a Property, so the retained
316 # 2D layer is not dirtied by writing it: say so explicitly, and
317 # only on the frames the fire cycle actually advances.
318 self.sprite.frame = self._anim_frame
319 self.sprite.queue_redraw()
320
321 # ------------------------------------------------------------------
322 # Selection
323 # ------------------------------------------------------------------
324
325 @property
326 def selected(self) -> bool:
327 return self._selected
328
329 @selected.setter
330 def selected(self, value: bool) -> None:
331 self._selected = bool(value)
332 if self._range_sprite is not None:
333 self._range_sprite.visible = self._selected