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)