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