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.draw_circle(
 64            (self.position.x, self.position.y),
 65            self.radius + 1.5,
 66            colour=(0.0, 0.0, 0.0, 0.5),
 67            filled=True,
 68            segments=14,
 69        )
 70        renderer.draw_circle(
 71            (self.position.x, self.position.y),
 72            self.radius,
 73            colour=c,
 74            filled=True,
 75            segments=14,
 76        )
 77        # HP pip: only when damaged
 78        if self.hp < self.max_hp:
 79            ratio = max(0.0, self.hp / self.max_hp)
 80            x = self.position.x - self.radius
 81            y = self.position.y - self.radius - 4
 82            renderer.draw_rect(
 83                (x, y),
 84                (self.radius * 2, 2),
 85                colour=(0.15, 0.15, 0.15, 0.85),
 86                filled=True,
 87            )
 88            renderer.draw_rect(
 89                (x, y),
 90                (self.radius * 2 * ratio, 2),
 91                colour=(0.95, 0.35, 0.35, 1.0),
 92                filled=True,
 93            )
 94
 95
 96ENEMY_TYPES = {
 97    "seeker": {"hp": 18, "dmg": 8.0, "speed": 90.0, "radius": 7.0, "colour": ORANGE, "xp": 1},
 98    "rusher": {"hp": 10, "dmg": 6.0, "speed": 150.0, "radius": 5.0, "colour": RED, "xp": 1},
 99    "boss": {"hp": 220, "dmg": 18.0, "speed": 60.0, "radius": 14.0, "colour": PURPLE, "xp": 6},
100}
101
102
103def edge_spawn(arena_w: float, arena_h: float, *, margin: float = 36.0) -> Vec2:
104    """Pick a random spawn just inside the arena bounds."""
105    side = random.randint(0, 3)
106    if side == 0:
107        return Vec2(random.uniform(margin, arena_w - margin), margin)
108    if side == 1:
109        return Vec2(arena_w - margin, random.uniform(margin, arena_h - margin))
110    if side == 2:
111        return Vec2(random.uniform(margin, arena_w - margin), arena_h - margin)
112    return Vec2(margin, random.uniform(margin, arena_h - margin))
113
114
115def wave_for_level(level: int) -> list[tuple[str, int]]:
116    """Compose an enemy list (kind, level) for arena round *level* (1-based)."""
117    out: list[tuple[str, int]] = []
118    seekers = 5 + level * 2
119    rushers = max(0, (level - 1) * 3)
120    for _ in range(seekers):
121        out.append(("seeker", level))
122    for _ in range(rushers):
123        out.append(("rusher", level))
124    if level >= 5:
125        out.append(("boss", level))
126    return out
127
128
129def wave_duration(level: int) -> float:
130    """Soft cap on a wave so it doesn't drag if the player kites."""
131    return 25.0 + level * 4.0