nodes/enemies.pyΒΆ

Part of SNKRX.

  1"""Enemy types: chase the snake head, deal contact damage, drop XP on death.
  2
  3Two flavours: Seeker (steady chaser) and Rusher (fast, lower hp). Bosses are
  4fat and slow but hit hard. All enemies are Node2D + small simulation logic;
  5the arena queries them by ``alive`` flag.
  6"""
  7
  8from __future__ import annotations
  9
 10import random
 11
 12from simvx.core import Node2D, Vec2
 13
 14from .colours import ORANGE, PURPLE, RED
 15
 16
 17class Enemy(Node2D):
 18    def __init__(self, *, kind: str, position: Vec2, level: int = 1, **kwargs):
 19        super().__init__(name=f"Enemy_{kind}", position=Vec2(position), **kwargs)
 20        self.kind = kind
 21        cfg = ENEMY_TYPES[kind]
 22        scale = 1.0 + 0.10 * (level - 1)
 23        self.max_hp = cfg["hp"] * scale
 24        self.hp = self.max_hp
 25        self.contact_dmg = cfg["dmg"] * scale
 26        self.speed = cfg["speed"]
 27        self.radius = cfg["radius"]
 28        self.colour = cfg["colour"]
 29        self.xp_value = cfg["xp"]
 30        self.alive = True
 31        self._hit_flash = 0.0
 32        # Random wander offset to avoid stacking
 33        self._wander = Vec2(random.uniform(-1, 1), random.uniform(-1, 1))
 34
 35    def take_damage(self, amount: float) -> bool:
 36        if not self.alive:
 37            return False
 38        self.hp -= amount
 39        self._hit_flash = 0.1
 40        if self.hp <= 0:
 41            self.alive = False
 42            return True
 43        return False
 44
 45    def chase(self, target: Vec2, dt: float):
 46        if not self.alive or dt <= 0:
 47            return
 48        d = target - self.position
 49        dist = d.length()
 50        if dist > 1e-3:
 51            dir_ = d * (1.0 / dist)
 52            wander = self._wander * 30.0
 53            self.position = self.position + (dir_ * self.speed + wander) * dt
 54        if self._hit_flash > 0:
 55            self._hit_flash -= dt
 56
 57    def on_draw(self, renderer):
 58        if not self.alive:
 59            return
 60        c = self.colour
 61        if self._hit_flash > 0:
 62            c = (1.0, 1.0, 1.0, 1.0)
 63        renderer.circle((0, 0), self.radius + 1.5, fill=(0.0, 0.0, 0.0, 0.5), segments=14)
 64        renderer.circle((0, 0), self.radius, fill=c, segments=14)
 65        # HP pip: only when damaged
 66        if self.hp < self.max_hp:
 67            ratio = max(0.0, self.hp / self.max_hp)
 68            x = -self.radius
 69            y = -self.radius - 4
 70            renderer.rect((x, y), (self.radius * 2, 2), fill=(0.15, 0.15, 0.15, 0.85))
 71            renderer.rect((x, y), (self.radius * 2 * ratio, 2), fill=(0.95, 0.35, 0.35, 1.0))
 72
 73
 74ENEMY_TYPES = {
 75    "seeker": {"hp": 18, "dmg": 8.0, "speed": 90.0, "radius": 7.0, "colour": ORANGE, "xp": 1},
 76    "rusher": {"hp": 10, "dmg": 6.0, "speed": 150.0, "radius": 5.0, "colour": RED, "xp": 1},
 77    "boss": {"hp": 220, "dmg": 18.0, "speed": 60.0, "radius": 14.0, "colour": PURPLE, "xp": 6},
 78}
 79
 80
 81def edge_spawn(arena_w: float, arena_h: float, *, margin: float = 36.0) -> Vec2:
 82    """Pick a random spawn just inside the arena bounds."""
 83    side = random.randint(0, 3)
 84    if side == 0:
 85        return Vec2(random.uniform(margin, arena_w - margin), margin)
 86    if side == 1:
 87        return Vec2(arena_w - margin, random.uniform(margin, arena_h - margin))
 88    if side == 2:
 89        return Vec2(random.uniform(margin, arena_w - margin), arena_h - margin)
 90    return Vec2(margin, random.uniform(margin, arena_h - margin))
 91
 92
 93def wave_for_level(level: int) -> list[tuple[str, int]]:
 94    """Compose an enemy list (kind, level) for arena round *level* (1-based)."""
 95    out: list[tuple[str, int]] = []
 96    seekers = 5 + level * 2
 97    rushers = max(0, (level - 1) * 3)
 98    for _ in range(seekers):
 99        out.append(("seeker", level))
100    for _ in range(rushers):
101        out.append(("rusher", level))
102    if level >= 5:
103        out.append(("boss", level))
104    return out
105
106
107def wave_duration(level: int) -> float:
108    """Soft cap on a wave so it doesn't drag if the player kites."""
109    return 25.0 + level * 4.0