nodes/units.pyΒΆ
Part of SNKRX.
1"""Player snake: head plus a chain of follower units.
2
3The head is steered (mouse aim or A/D rotation). A constant move-speed pushes
4the snake forward. Followers are sampled from a rolling buffer of head
5positions, indexed by their slot * spacing, same trick as classic snake but
6using a continuous arc-length buffer so movement reads as a smooth curve.
7
8Three classes ship with the port: warrior (melee swipe), archer (pierce shot),
9mage (AOE burst). Each carries its own attack cooldown and per-level stat
10multiplier. They scroll forward with the snake; their attacks are spawned by
11``Arena`` so collisions and particles share one space.
12"""
13
14from __future__ import annotations
15
16import math
17import random
18
19from simvx.core import Node2D, Property, Signal, Vec2
20
21from .colours import CLASS_COLOURS, FG
22
23# Tuning ------------------------------------------------------------------------
24
25SNAKE_SPEED = 220.0 # constant forward speed
26TURN_SPEED = math.radians(280.0) # rad/s steering rate
27BODY_SPACING = 14.0 # arc-length between followers
28BODY_HISTORY_SECONDS = 8.0 # how much head trail to keep
29HEAD_RADIUS = 7.0
30BODY_RADIUS = 6.0
31
32# Per-class base stats (HP, damage, attack cooldown, range)
33CLASS_STATS = {
34 "warrior": {"hp": 50, "dmg": 8.0, "cooldown": 0.55, "range": 38.0},
35 "archer": {"hp": 35, "dmg": 6.0, "cooldown": 0.85, "range": 260.0},
36 "mage": {"hp": 30, "dmg": 14.0, "cooldown": 1.25, "range": 180.0},
37}
38
39
40# ---------------------------------------------------------------------------- ArcBuffer
41
42
43class _ArcBuffer:
44 """Rolling list of (Vec2 position, arc_length) samples for snake-body sampling.
45
46 Adds the latest head position each tick; followers query for the point at
47 a target arc-length behind the head.
48 """
49
50 __slots__ = ("samples", "max_len")
51
52 def __init__(self, max_len: int = 1024) -> None:
53 self.samples: list[tuple[Vec2, float]] = [(Vec2(0, 0), 0.0)]
54 self.max_len = max_len
55
56 def push(self, pos: Vec2) -> None:
57 prev_pos, prev_arc = self.samples[-1]
58 d = (pos - prev_pos).length()
59 self.samples.append((Vec2(pos), prev_arc + d))
60 if len(self.samples) > self.max_len:
61 # Trim oldest. Keep arc-lengths monotone.
62 del self.samples[: len(self.samples) - self.max_len]
63
64 def sample_behind(self, head_arc: float, distance: float) -> Vec2:
65 """Return the position at *distance* arc-units behind the head."""
66 target = head_arc - distance
67 s = self.samples
68 # Binary search for the first sample with arc >= target
69 lo, hi = 0, len(s) - 1
70 while lo < hi:
71 mid = (lo + hi) // 2
72 if s[mid][1] < target:
73 lo = mid + 1
74 else:
75 hi = mid
76 if lo == 0:
77 return Vec2(s[0][0])
78 a_pos, a_arc = s[lo - 1]
79 b_pos, b_arc = s[lo]
80 if b_arc <= a_arc:
81 return Vec2(b_pos)
82 t = (target - a_arc) / (b_arc - a_arc)
83 return Vec2(a_pos + (b_pos - a_pos) * t)
84
85 @property
86 def head_arc(self) -> float:
87 return self.samples[-1][1] if self.samples else 0.0
88
89
90# ---------------------------------------------------------------------------- Unit
91
92
93class Unit(Node2D):
94 """One unit in the snake: either head (slot 0) or a follower."""
95
96 klass = Property("warrior", enum=["warrior", "archer", "mage"])
97 level = Property(1, range=(1, 5))
98
99 # Public API -----------------------------------------------------------------
100
101 fired = Signal() # emits (Unit, direction Vec2)
102 melee = Signal() # emits (Unit, target_position Vec2)
103 died = Signal() # emits (Unit)
104
105 def __init__(self, *, klass: str = "warrior", level: int = 1, slot: int = 0, **kwargs):
106 super().__init__(**kwargs)
107 self.klass = klass
108 self.level = level
109 self.slot = slot
110 stats = CLASS_STATS[klass]
111 # Per-level scaling: gentle ramp, mirrors SNKRX feel
112 scale = 1.0 + 0.25 * (level - 1)
113 self.max_hp = int(stats["hp"] * scale)
114 self.hp = self.max_hp
115 self.dmg = stats["dmg"] * scale
116 self.cooldown = stats["cooldown"]
117 self.range = stats["range"]
118 self._cd_timer = random.uniform(0.0, self.cooldown)
119 self._hit_flash = 0.0
120 self.alive = True
121
122 # Helpers --------------------------------------------------------------------
123
124 @property
125 def colour(self) -> tuple[float, float, float, float]:
126 return CLASS_COLOURS.get(self.klass, FG)
127
128 def take_damage(self, amount: float) -> None:
129 if not self.alive:
130 return
131 self.hp -= amount
132 self._hit_flash = 0.12
133 if self.hp <= 0:
134 self.alive = False
135 self.died.emit(self)
136
137 def try_attack(self, dt: float, find_target):
138 """Tick attack cooldown; spawn an attack against *find_target()* if ready.
139
140 ``find_target`` is a callable that returns the closest enemy ``Node2D``
141 within ``self.range`` or None.
142 """
143 if not self.alive:
144 return
145 self._cd_timer -= dt
146 if self._cd_timer > 0:
147 return
148 target = find_target(self.position, self.range)
149 if not target:
150 return
151 self._cd_timer = self.cooldown
152 if self.klass == "warrior":
153 # Quick melee swipe: emit signal for arena-side resolution
154 self.melee.emit(self, Vec2(target.position))
155 else:
156 direction = (target.position - self.position).normalized()
157 self.fired.emit(self, direction)
158
159 # Drawing --------------------------------------------------------------------
160
161 def update(self, dt: float):
162 if self._hit_flash > 0:
163 self._hit_flash -= dt
164
165 def on_draw(self, renderer):
166 if not self.alive:
167 return
168 c = self.colour
169 radius = HEAD_RADIUS if self.slot == 0 else BODY_RADIUS
170 # Outer ring (thicker for head)
171 renderer.draw_circle(
172 (self.position.x, self.position.y),
173 radius + 1.5,
174 colour=(1.0, 1.0, 1.0, 0.35),
175 filled=True,
176 segments=18,
177 )
178 # Body: flash white briefly when hit
179 if self._hit_flash > 0:
180 body_c = (1.0, 1.0, 1.0, 1.0)
181 else:
182 body_c = c
183 renderer.draw_circle(
184 (self.position.x, self.position.y),
185 radius,
186 colour=body_c,
187 filled=True,
188 segments=18,
189 )
190 # Head facing pip
191 if self.slot == 0:
192 tip = self.position + Vec2(math.cos(self.rotation), math.sin(self.rotation)) * (radius + 4)
193 renderer.draw_circle(
194 (tip.x, tip.y),
195 2.0,
196 colour=(1.0, 1.0, 1.0, 0.95),
197 filled=True,
198 segments=10,
199 )
200
201
202# ---------------------------------------------------------------------------- PlayerSnake
203
204
205class PlayerSnake(Node2D):
206 """Head plus a chain of follower units, with shared steering."""
207
208 def __init__(self, *, units: list[tuple[str, int]], spawn: Vec2, **kwargs):
209 super().__init__(name="PlayerSnake", **kwargs)
210 # PlayerSnake itself stays at world origin; units carry world-space
211 # positions. This avoids parent-transform composition surprises when
212 # the camera follows individual units.
213 self.position = Vec2(0, 0)
214 self.units: list[Unit] = []
215 self._arc = _ArcBuffer()
216 for i, (klass, level) in enumerate(units):
217 u = Unit(klass=klass, level=level, slot=i, position=Vec2(spawn.x, spawn.y + i * BODY_SPACING))
218 u.rotation = -math.pi / 2 # face up
219 self.add_child(u)
220 self.units.append(u)
221 self._steer_input = 0.0 # -1 left, +1 right
222 self._aim: Vec2 | None = None # if set, rotate toward this point
223 self.dead = Signal()
224
225 # Steering --------------------------------------------------------------
226
227 def steer(self, value: float) -> None:
228 """Set steering input. Range [-1, 1]."""
229 self._steer_input = max(-1.0, min(1.0, value))
230
231 def aim_at(self, point: Vec2 | None) -> None:
232 self._aim = Vec2(point) if point is not None else None
233
234 # Lifecycle -------------------------------------------------------------
235
236 @property
237 def head(self) -> Unit:
238 return self.units[0]
239
240 @property
241 def alive(self) -> bool:
242 return self.units and self.units[0].alive
243
244 def remove_dead(self) -> None:
245 """Drop trailing units that died this frame; emits ``self.dead`` if head is gone."""
246 # Remove dead from the tail backwards. The head dying ends the run.
247 if not self.units:
248 return
249 # Tail trimming
250 while len(self.units) > 1 and not self.units[-1].alive:
251 u = self.units.pop()
252 u.destroy()
253 if not self.units[0].alive:
254 self.dead.emit()
255
256 # Snake update ----------------------------------------------------------
257
258 def update(self, dt: float):
259 """Driven explicitly by ``Arena.on_update`` so slow-mo can scale dt."""
260 if dt <= 0 or not self.alive:
261 return
262 head = self.units[0]
263 # Steering: aim_at takes precedence (mouse), else keyboard turn rate
264 if self._aim is not None:
265 target = self._aim - head.position
266 tlen = target.length()
267 if tlen > 1e-3:
268 target_angle = math.atan2(target.y, target.x)
269 # Shortest-arc rotation
270 diff = (target_angle - head.rotation + math.pi) % math.tau - math.pi
271 step = max(-TURN_SPEED * dt, min(TURN_SPEED * dt, diff))
272 head.rotation += step
273 else:
274 head.rotation += self._steer_input * TURN_SPEED * dt
275
276 # Move head forward
277 forward = Vec2(math.cos(head.rotation), math.sin(head.rotation))
278 head.position = head.position + forward * (SNAKE_SPEED * dt)
279 self._arc.push(head.position)
280
281 # Followers sample behind
282 for i in range(1, len(self.units)):
283 u = self.units[i]
284 if not u.alive:
285 continue
286 target = self._arc.sample_behind(self._arc.head_arc, BODY_SPACING * i)
287 u.position = target
288 ahead = self._arc.sample_behind(self._arc.head_arc, BODY_SPACING * (i - 0.5))
289 d = ahead - u.position
290 if d.length() > 1e-3:
291 u.rotation = math.atan2(d.y, d.x)
292
293 # Tick each unit's per-unit timers
294 for u in self.units:
295 u.update(dt)