nodes/arena.py¶

Part of SNKRX.

  1"""Arena round: snake vs enemy waves with juice (particles, screenshake, slow-mo).
  2
  3Owns a ``PlayerSnake``, a ``Particles2D`` pool, projectiles, and enemies.
  4Drives all gameplay updates inside ``on_update`` so slow-mo can scale time.
  5``Camera2D.shake(...)`` is used directly for impact feedback.
  6"""
  7
  8from __future__ import annotations
  9
 10import math
 11import random
 12
 13from simvx.core import Camera2D, Node2D, Signal, Vec2
 14
 15from .colours import BG2
 16from .enemies import Enemy, edge_spawn, wave_duration, wave_for_level
 17from .particles import Particles2D
 18from .projectile import Projectile, archer_arrow, mage_bolt
 19from .units import BODY_RADIUS, HEAD_RADIUS, PlayerSnake, Unit
 20
 21ARENA_W = 1100
 22ARENA_H = 700
 23
 24# Slow-mo curve: when a meaningful hit lands we set ``time_scale`` to the
 25# floor and then linearly recover.
 26SLOW_FLOOR = 0.18
 27SLOW_RECOVER = 1.6  # units of "scale per second" recovery
 28
 29# Per-action shake intensities
 30SHAKE_HIT = 2.5
 31SHAKE_KILL = 6.0
 32SHAKE_BOSS = 12.0
 33
 34
 35class Arena(Node2D):
 36    """One round of SNKRX-style snake-arena combat."""
 37
 38    finished = Signal()  # arena cleared, emit (xp_gained, gold_gained)
 39    failed = Signal()  # snake head died
 40
 41    def __init__(self, *, level: int, build: list[tuple[str, int]], **kwargs):
 42        super().__init__(name=f"Arena_L{level}", **kwargs)
 43        self.level = level
 44        self.time_scale = 1.0
 45        self._slow_target = 1.0
 46        self.elapsed = 0.0
 47        self.spawn_queue = wave_for_level(level)
 48        self.duration = wave_duration(level)
 49        self.spawn_interval = max(0.35, self.duration / max(1, len(self.spawn_queue) + 4))
 50        self._spawn_timer = 0.6
 51        self.kills = 0
 52        self.gold_gained = 0
 53        self.xp_gained = 0
 54        self._done = False
 55
 56        # Background (a child Node2D draws first, then siblings on top)
 57        self.particles = self.add_child(Particles2D(capacity=2000, name="ArenaParticles"))
 58
 59        # Player snake at centre
 60        spawn = Vec2(ARENA_W // 2, ARENA_H // 2)
 61        self.snake = self.add_child(PlayerSnake(units=build, spawn=spawn))
 62
 63        @self.snake.dead.connect
 64        def _on_dead():
 65            if not self._done:
 66                self._done = True
 67                self.failed.emit()
 68
 69        # Wire unit attack signals
 70        for unit in self.snake.units:
 71            unit.fired.connect(self._on_unit_fired)
 72            unit.melee.connect(self._on_unit_melee)
 73
 74        # Enemies / projectiles tracked here; enemies are children of self for
 75        # rendering, but updated explicitly in our on_update.
 76        self.enemies: list[Enemy] = []
 77        self.projectiles: list[Projectile] = []
 78
 79        # Camera centred on arena, follows snake with mild smoothing
 80        self.camera = self.add_child(
 81            Camera2D(
 82                name="ArenaCamera",
 83                position=Vec2(spawn),
 84                smoothing=8.0,
 85                zoom=1.0,
 86            )
 87        )
 88        self.camera.target = self.snake.head
 89
 90    # ------------------------------------------------------------------ helpers
 91
 92    def _on_unit_fired(self, unit: Unit, direction: Vec2):
 93        if unit.klass == "archer":
 94            proj = archer_arrow(unit.position, direction, unit.dmg)
 95        elif unit.klass == "mage":
 96            proj = mage_bolt(unit.position, direction, unit.dmg)
 97        else:
 98            return
 99        self.add_child(proj)
100        self.projectiles.append(proj)
101        # Muzzle spark
102        self.particles.emit_burst(
103            unit.position + direction * 8,
104            count=4,
105            speed=120.0,
106            speed_var=40.0,
107            life=0.18,
108            life_var=0.05,
109            scale0=2.5,
110            scale1=0.0,
111            colour=proj.colour,
112            cone=0.4,
113            direction=math.atan2(direction.y, direction.x),
114        )
115
116    def _on_unit_melee(self, unit: Unit, target_pos: Vec2):
117        # Apply damage to enemies inside swipe arc
118        radius = unit.range
119        hit_any = False
120        for enemy in self.enemies:
121            if not enemy.alive:
122                continue
123            if (enemy.position - unit.position).length() <= radius + enemy.radius:
124                killed = enemy.take_damage(unit.dmg)
125                hit_any = True
126                self._on_enemy_hit(enemy, killed)
127        # Visual: yellow swipe arc particles
128        for _ in range(10):
129            a = math.atan2(target_pos.y - unit.position.y, target_pos.x - unit.position.x)
130            offset_a = a + random.uniform(-0.6, 0.6)
131            offset_r = random.uniform(radius * 0.4, radius)
132            ppos = unit.position + Vec2(math.cos(offset_a) * offset_r, math.sin(offset_a) * offset_r)
133            self.particles.emit_burst(
134                ppos,
135                count=2,
136                speed=80.0,
137                speed_var=40.0,
138                life=0.2,
139                life_var=0.05,
140                scale0=2.5,
141                scale1=0.0,
142                colour=(1.0, 0.95, 0.4, 1.0),
143                cone=0.8,
144                direction=offset_a,
145            )
146        if hit_any:
147            self.camera.shake(intensity=SHAKE_HIT, duration=0.18)
148
149    def _on_enemy_hit(self, enemy: Enemy, killed: bool):
150        # Hit spark
151        self.particles.emit_burst(
152            enemy.position,
153            count=8 if not killed else 26,
154            speed=180.0 if not killed else 320.0,
155            speed_var=80.0,
156            life=0.35,
157            life_var=0.15,
158            scale0=2.8 if not killed else 4.5,
159            scale1=0.0,
160            colour=enemy.colour if not killed else (1.0, 1.0, 1.0, 1.0),
161        )
162        if killed:
163            self.kills += 1
164            self.xp_gained += enemy.xp_value
165            self.gold_gained += enemy.xp_value
166            shake = SHAKE_BOSS if enemy.kind == "boss" else SHAKE_KILL
167            self.camera.shake(intensity=shake, duration=0.3 if enemy.kind != "boss" else 0.6)
168            # Slow-mo on big kills
169            if enemy.kind == "boss" or random.random() < 0.18:
170                self.start_slowmo()
171
172    def start_slowmo(self):
173        self.time_scale = SLOW_FLOOR
174        self._slow_target = 1.0
175
176    # --------------------------------------------------------------- targetting
177
178    def _find_target(self, origin: Vec2, max_range: float) -> Enemy | None:
179        best: Enemy | None = None
180        best_d = max_range
181        for enemy in self.enemies:
182            if not enemy.alive:
183                continue
184            d = (enemy.position - origin).length()
185            if d < best_d:
186                best_d = d
187                best = enemy
188        return best
189
190    # ----------------------------------------------------------------- update
191
192    def on_update(self, dt: float):
193        if self._done or dt <= 0:
194            return
195        # Recover slow-mo
196        if self.time_scale < self._slow_target:
197            self.time_scale = min(self._slow_target, self.time_scale + SLOW_RECOVER * dt)
198
199        scaled_dt = dt * self.time_scale
200        self.elapsed += dt  # wall clock controls spawn pacing
201
202        # Spawn waves
203        self._spawn_timer -= dt
204        if self.spawn_queue and self._spawn_timer <= 0:
205            kind, lvl = self.spawn_queue.pop(0)
206            enemy = Enemy(kind=kind, position=edge_spawn(ARENA_W, ARENA_H), level=lvl)
207            self.add_child(enemy)
208            self.enemies.append(enemy)
209            self._spawn_timer = self.spawn_interval
210
211        # Snake update (steered by Arena's parent SNKRXRoot via aim_at/steer)
212        self.snake.update(scaled_dt)
213        # Bounce the head off arena walls: invert facing on hit
214        head = self.snake.head
215        bounced = False
216        if head.position.x < 24:
217            head.position = Vec2(24, head.position.y)
218            head.rotation = math.pi - head.rotation
219            bounced = True
220        elif head.position.x > ARENA_W - 24:
221            head.position = Vec2(ARENA_W - 24, head.position.y)
222            head.rotation = math.pi - head.rotation
223            bounced = True
224        if head.position.y < 24:
225            head.position = Vec2(head.position.x, 24)
226            head.rotation = -head.rotation
227            bounced = True
228        elif head.position.y > ARENA_H - 24:
229            head.position = Vec2(head.position.x, ARENA_H - 24)
230            head.rotation = -head.rotation
231            bounced = True
232        if bounced:
233            self.particles.emit_burst(
234                head.position, count=8, speed=160.0, life=0.25, scale0=2.5, colour=(1.0, 1.0, 1.0, 1.0)
235            )
236
237        # Unit attacks
238        for unit in self.snake.units:
239            unit.try_attack(scaled_dt, self._find_target)
240
241        # Enemy chase
242        head_pos = self.snake.head.position if self.snake.alive else Vec2(ARENA_W / 2, ARENA_H / 2)
243        for enemy in self.enemies:
244            enemy.chase(head_pos, scaled_dt)
245            # Clamp inside arena
246            enemy.position = Vec2(
247                max(20, min(ARENA_W - 20, enemy.position.x)),
248                max(20, min(ARENA_H - 20, enemy.position.y)),
249            )
250
251        # Projectile collisions
252        for proj in self.projectiles:
253            proj.update(scaled_dt)
254            if not proj.alive:
255                continue
256            for enemy in self.enemies:
257                if not enemy.alive or id(enemy) in proj._hit_set:
258                    continue
259                if (enemy.position - proj.position).length() <= enemy.radius + proj.radius:
260                    proj._hit_set.add(id(enemy))
261                    killed = enemy.take_damage(proj.damage)
262                    self._on_enemy_hit(enemy, killed)
263                    # AOE blast
264                    if proj.aoe_radius > 0:
265                        for other in self.enemies:
266                            if other is enemy or not other.alive:
267                                continue
268                            if (other.position - proj.position).length() <= proj.aoe_radius + other.radius:
269                                killed_o = other.take_damage(proj.damage * 0.6)
270                                self._on_enemy_hit(other, killed_o)
271                        self.particles.emit_burst(
272                            proj.position,
273                            count=22,
274                            speed=240.0,
275                            life=0.45,
276                            scale0=5.0,
277                            scale1=0.0,
278                            colour=proj.colour,
279                        )
280                        proj.alive = False
281                        break
282                    proj.pierce_left -= 1
283                    if proj.pierce_left <= 0:
284                        proj.alive = False
285                        break
286
287        # Snake-vs-enemy contact damage
288        if self.snake.alive:
289            for enemy in self.enemies:
290                if not enemy.alive:
291                    continue
292                for unit in self.snake.units:
293                    if not unit.alive:
294                        continue
295                    r = (BODY_RADIUS if unit.slot > 0 else HEAD_RADIUS) + enemy.radius
296                    if (enemy.position - unit.position).length() <= r:
297                        unit.take_damage(enemy.contact_dmg * scaled_dt * 4.0)
298                        # Push the enemy out a bit so contact is a tap, not stick
299                        push = (enemy.position - unit.position).normalized() * 6.0
300                        enemy.position = enemy.position + push
301                        if not unit.alive:
302                            self.particles.emit_burst(
303                                unit.position,
304                                count=18,
305                                speed=240.0,
306                                life=0.5,
307                                scale0=4.0,
308                                scale1=0.0,
309                                colour=(1.0, 0.4, 0.4, 1.0),
310                            )
311                            self.camera.shake(intensity=SHAKE_KILL, duration=0.35)
312
313        # Particles tick
314        self.particles.update(scaled_dt)
315
316        # Cleanup dead snake tail / projectiles / enemies
317        self.snake.remove_dead()
318        for proj in list(self.projectiles):
319            if not proj.alive:
320                proj.destroy()
321                self.projectiles.remove(proj)
322        for enemy in list(self.enemies):
323            if not enemy.alive:
324                enemy.destroy()
325                self.enemies.remove(enemy)
326
327        # Round complete?
328        if not self._done and not self.spawn_queue and not self.enemies:
329            self._done = True
330            self.finished.emit(self.xp_gained, self.gold_gained)
331        # Wave timeout
332        if not self._done and self.elapsed >= self.duration + 6.0 and self.snake.alive:
333            # Force-finish: emit anyway so the player can advance
334            self._done = True
335            self.finished.emit(self.xp_gained, self.gold_gained)
336
337    # ------------------------------------------------------------- background
338
339    def on_draw(self, renderer):
340        # Arena bounds: drawn before children via Node2D's on_draw position
341        renderer.draw_rect(
342            (0, 0),
343            (ARENA_W, ARENA_H),
344            colour=BG2,
345            filled=True,
346        )
347        renderer.draw_rect(
348            (0, 0),
349            (ARENA_W, ARENA_H),
350            colour=(0.25, 0.27, 0.32, 1.0),
351            filled=False,
352            thickness=2.0,
353        )