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.circle((0, 0), radius + 1.5, fill=(1.0, 1.0, 1.0, 0.35), segments=18)
172        # Body: flash white briefly when hit
173        if self._hit_flash > 0:
174            body_c = (1.0, 1.0, 1.0, 1.0)
175        else:
176            body_c = c
177        renderer.circle((0, 0), radius, fill=body_c, segments=18)
178        # Head facing pip
179        if self.slot == 0:
180            tip = Vec2(radius + 4, 0.0)  # ahead of the head; the node's rotation turns it
181            renderer.circle((tip.x, tip.y), 2.0, fill=(1.0, 1.0, 1.0, 0.95), segments=10)
182
183
184# ---------------------------------------------------------------------------- PlayerSnake
185
186
187class PlayerSnake(Node2D):
188    """Head plus a chain of follower units, with shared steering."""
189
190    def __init__(self, *, units: list[tuple[str, int]], spawn: Vec2, **kwargs):
191        super().__init__(name="PlayerSnake", **kwargs)
192        # PlayerSnake itself stays at world origin; units carry world-space
193        # positions. This avoids parent-transform composition surprises when
194        # the camera follows individual units.
195        self.position = Vec2(0, 0)
196        self.units: list[Unit] = []
197        self._arc = _ArcBuffer()
198        for i, (klass, level) in enumerate(units):
199            u = Unit(klass=klass, level=level, slot=i, position=Vec2(spawn.x, spawn.y + i * BODY_SPACING))
200            u.rotation = -math.pi / 2  # face up
201            self.add_child(u)
202            self.units.append(u)
203        self._steer_input = 0.0  # -1 left, +1 right
204        self._aim: Vec2 | None = None  # if set, rotate toward this point
205        self.dead = Signal()
206
207    # Steering --------------------------------------------------------------
208
209    def steer(self, value: float) -> None:
210        """Set steering input. Range [-1, 1]."""
211        self._steer_input = max(-1.0, min(1.0, value))
212
213    def aim_at(self, point: Vec2 | None) -> None:
214        self._aim = Vec2(point) if point is not None else None
215
216    # Lifecycle -------------------------------------------------------------
217
218    @property
219    def head(self) -> Unit:
220        return self.units[0]
221
222    @property
223    def alive(self) -> bool:
224        return self.units and self.units[0].alive
225
226    def remove_dead(self) -> None:
227        """Drop trailing units that died this frame; emits ``self.dead`` if head is gone."""
228        # Remove dead from the tail backwards. The head dying ends the run.
229        if not self.units:
230            return
231        # Tail trimming
232        while len(self.units) > 1 and not self.units[-1].alive:
233            u = self.units.pop()
234            u.destroy()
235        if not self.units[0].alive:
236            self.dead.emit()
237
238    # Snake update ----------------------------------------------------------
239
240    def update(self, dt: float):
241        """Driven explicitly by ``Arena.on_update`` so slow-mo can scale dt."""
242        if dt <= 0 or not self.alive:
243            return
244        head = self.units[0]
245        # Steering: aim_at takes precedence (mouse), else keyboard turn rate
246        if self._aim is not None:
247            target = self._aim - head.position
248            tlen = target.length()
249            if tlen > 1e-3:
250                target_angle = math.atan2(target.y, target.x)
251                # Shortest-arc rotation
252                diff = (target_angle - head.rotation + math.pi) % math.tau - math.pi
253                step = max(-TURN_SPEED * dt, min(TURN_SPEED * dt, diff))
254                head.rotation += step
255        else:
256            head.rotation += self._steer_input * TURN_SPEED * dt
257
258        # Move head forward
259        forward = Vec2(math.cos(head.rotation), math.sin(head.rotation))
260        head.position = head.position + forward * (SNAKE_SPEED * dt)
261        self._arc.push(head.position)
262
263        # Followers sample behind
264        for i in range(1, len(self.units)):
265            u = self.units[i]
266            if not u.alive:
267                continue
268            target = self._arc.sample_behind(self._arc.head_arc, BODY_SPACING * i)
269            u.position = target
270            ahead = self._arc.sample_behind(self._arc.head_arc, BODY_SPACING * (i - 0.5))
271            d = ahead - u.position
272            if d.length() > 1e-3:
273                u.rotation = math.atan2(d.y, d.x)
274
275        # Tick each unit's per-unit timers
276        for u in self.units:
277            u.update(dt)