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