nodes/enemy.pyΒΆ

Part of Tower Defence.

  1"""Waypoint-following enemy.
  2
  3The port walks the waypoint list by hand rather than routing through
  4`simvx.core.navigation.NavGrid2D`, because:
  5
  6  - the path is hand-authored, not derived, so there is nothing to solve,
  7  - a full A* search per spawn would be wasted work for a fixed route,
  8  - enemies align with the painted road exactly, with no grid quantisation.
  9
 10The result is a short read: walk towards the next waypoint at ``speed``, take
 11the next one when you reach it, and signal when the road runs out.
 12"""
 13
 14from __future__ import annotations
 15
 16import math
 17from pathlib import Path
 18
 19from simvx.core import Node2D, Property, Signal, Sprite2D, Vec2
 20
 21from .td_data import ENEMY_COLOURS, ENEMY_DATA, KILL_REWARD, TILE_SIZE
 22
 23ENEMY_IMAGES = {
 24    "weak": "enemy_1.png",
 25    "medium": "enemy_2.png",
 26    "strong": "enemy_3.png",
 27    "elite": "enemy_4.png",
 28}
 29
 30_ENEMY_DIR = Path(__file__).parent.parent / "assets" / "images" / "enemies"
 31
 32
 33class Enemy(Node2D):
 34    """An enemy unit walking the waypoint polyline."""
 35
 36    speed = Property(120.0, range=(20, 1000), hint="Walking speed in px/sec")
 37    max_health = Property(10, range=(1, 1000))
 38
 39    died = Signal(int)  # kill reward
 40    escaped = Signal()  # reached the path end alive
 41    damaged = Signal(int)  # damage taken
 42
 43    def __init__(self, enemy_type: str, waypoints: list[tuple[float, float]], **kwargs):
 44        super().__init__(**kwargs)
 45        self.add_to_group("enemies")
 46
 47        data = ENEMY_DATA[enemy_type]
 48        self.enemy_type = enemy_type
 49        self.max_health = data["health"]
 50        self.speed = data["speed"]
 51        self.health = data["health"]
 52        self.waypoints = waypoints
 53        self.target_index = 1
 54        self.position = Vec2(*waypoints[0])
 55        self.reached_end = False
 56
 57        # Slow debuff state (set by Slow turrets)
 58        self.slow_factor: float = 1.0
 59        self.slow_timer: float = 0.0
 60
 61        # Sprite child -- 96x96, scaled to ~TILE_SIZE.
 62        self.sprite = self.add_child(
 63            Sprite2D(
 64                texture=str(_ENEMY_DIR / ENEMY_IMAGES[enemy_type]),
 65                width=TILE_SIZE,
 66                height=TILE_SIZE,
 67                colour=ENEMY_COLOURS[enemy_type],
 68            )
 69        )
 70
 71    # ------------------------------------------------------------------
 72    # Damage helpers
 73    # ------------------------------------------------------------------
 74
 75    def take_damage(self, amount: int, *, slow_factor: float | None = None, slow_seconds: float | None = None) -> None:
 76        if self.reached_end or self.health <= 0:
 77            return
 78        self.health -= amount
 79        self.damaged(amount)
 80        if slow_factor is not None and slow_seconds is not None:
 81            # Apply only if stronger than current slow
 82            if slow_factor < self.slow_factor or self.slow_timer <= 0:
 83                self.slow_factor = slow_factor
 84                self.slow_timer = slow_seconds
 85        if self.health <= 0:
 86            self.died(KILL_REWARD)
 87            self.destroy()
 88
 89    # ------------------------------------------------------------------
 90    # Tick
 91    # ------------------------------------------------------------------
 92
 93    def on_update(self, dt: float) -> None:
 94        if self.reached_end:
 95            return
 96
 97        # Apply the world's fast-forward multiplier (the F key / "FAST x2"
 98        # button) by reading its ``game_speed`` Property.
 99        speed_mult = getattr(self.parent, "game_speed", 1) or 1
100        dt = dt * speed_mult
101
102        # Slow debuff decay
103        if self.slow_timer > 0:
104            self.slow_timer -= dt
105            if self.slow_timer <= 0:
106                self.slow_factor = 1.0
107
108        if self.target_index >= len(self.waypoints):
109            self.reached_end = True
110            self.escaped()
111            self.destroy()
112            return
113
114        target = self.waypoints[self.target_index]
115        dx = target[0] - self.position.x
116        dy = target[1] - self.position.y
117        dist = math.hypot(dx, dy)
118
119        step = self.speed * self.slow_factor * dt
120        if dist <= step:
121            self.position = Vec2(target[0], target[1])
122            self.target_index += 1
123        else:
124            self.position = Vec2(
125                self.position.x + dx / dist * step,
126                self.position.y + dy / dist * step,
127            )
128            # Rotate sprite to face direction of travel.
129            self.sprite.rotation = math.atan2(dy, dx)