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 )