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