crimsonland 0.1.0.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +652 -0
  34. crimson/gameplay.py +2467 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1133 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
crimson/projectiles.py ADDED
@@ -0,0 +1,1133 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+ import math
6
+ from typing import Callable, Protocol
7
+
8
+ from .creatures.spawn import CreatureFlags
9
+ from .perks import PerkId
10
+ from .weapons import weapon_entry_for_projectile_type_id
11
+
12
+
13
+ class Damageable(Protocol):
14
+ x: float
15
+ y: float
16
+ hp: float
17
+
18
+
19
+ class PlayerDamageable(Protocol):
20
+ pos_x: float
21
+ pos_y: float
22
+ health: float
23
+ size: float
24
+
25
+
26
+ class FxQueueLike(Protocol):
27
+ def add(
28
+ self,
29
+ *,
30
+ effect_id: int,
31
+ pos_x: float,
32
+ pos_y: float,
33
+ width: float,
34
+ height: float,
35
+ rotation: float,
36
+ rgba: tuple[float, float, float, float],
37
+ ) -> bool: ...
38
+
39
+ def add_random(self, *, pos_x: float, pos_y: float, rand: Callable[[], int]) -> bool: ...
40
+
41
+
42
+ MAIN_PROJECTILE_POOL_SIZE = 0x60
43
+ SECONDARY_PROJECTILE_POOL_SIZE = 0x40
44
+
45
+
46
+ class ProjectileTypeId(IntEnum):
47
+ # Values are projectile type ids (not weapon ids). Based on the decompile
48
+ # for `player_fire_weapon` and `projectile_update`.
49
+ PISTOL = 0x01
50
+ MEAN_MINIGUN = 0x01
51
+ ASSAULT_RIFLE = 0x02
52
+ SHOTGUN = 0x03
53
+ SAWED_OFF_SHOTGUN = 0x03
54
+ JACKHAMMER = 0x03
55
+ SUBMACHINE_GUN = 0x05
56
+ GAUSS_GUN = 0x06
57
+ GAUSS_SHOTGUN = 0x06
58
+ PLASMA_RIFLE = 0x09
59
+ MULTI_PLASMA = 0x09
60
+ PLASMA_MINIGUN = 0x0B
61
+ PLASMA_SHOTGUN = 0x0B
62
+ PULSE_GUN = 0x13
63
+ ION_RIFLE = 0x15
64
+ ION_MINIGUN = 0x16
65
+ ION_CANNON = 0x17
66
+ SHRINKIFIER = 0x18
67
+ BLADE_GUN = 0x19
68
+ SPIDER_PLASMA = 0x1A
69
+ PLASMA_CANNON = 0x1C
70
+ SPLITTER_GUN = 0x1D
71
+ PLAGUE_SPREADER = 0x29
72
+ RAINBOW_GUN = 0x2B
73
+ FIRE_BULLETS = 0x2D
74
+
75
+
76
+ def _rng_zero() -> int:
77
+ return 0
78
+
79
+
80
+ CreatureDamageApplier = Callable[[int, float, int, float, float, int], None]
81
+
82
+
83
+ @dataclass(slots=True)
84
+ class Projectile:
85
+ active: bool = False
86
+ angle: float = 0.0
87
+ pos_x: float = 0.0
88
+ pos_y: float = 0.0
89
+ origin_x: float = 0.0
90
+ origin_y: float = 0.0
91
+ vel_x: float = 0.0
92
+ vel_y: float = 0.0
93
+ type_id: int = 0
94
+ life_timer: float = 0.0
95
+ reserved: float = 0.0
96
+ speed_scale: float = 1.0
97
+ damage_pool: float = 1.0
98
+ hit_radius: float = 1.0
99
+ base_damage: float = 0.0
100
+ owner_id: int = 0
101
+ hits_players: bool = False
102
+
103
+
104
+ @dataclass(slots=True)
105
+ class SecondaryProjectile:
106
+ active: bool = False
107
+ angle: float = 0.0
108
+ speed: float = 0.0
109
+ pos_x: float = 0.0
110
+ pos_y: float = 0.0
111
+ vel_x: float = 0.0
112
+ vel_y: float = 0.0
113
+ type_id: int = 0
114
+ owner_id: int = -100
115
+ lifetime: float = 0.0
116
+ target_id: int = -1
117
+
118
+
119
+ def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
120
+ dx = x1 - x0
121
+ dy = y1 - y0
122
+ return dx * dx + dy * dy
123
+
124
+
125
+ def _hit_radius_for(creature: Damageable) -> float:
126
+ """Approximate `creature_find_in_radius`/`creatures_apply_radius_damage` sizing.
127
+
128
+ The native code compares `distance - radius < creature.size * 0.14285715 + 3.0`.
129
+ """
130
+
131
+ raw = getattr(creature, "size", None)
132
+ if raw is None:
133
+ size = 50.0
134
+ else:
135
+ size = float(raw)
136
+ return max(0.0, size * 0.14285715 + 3.0)
137
+
138
+
139
+ class ProjectilePool:
140
+ def __init__(self, *, size: int = MAIN_PROJECTILE_POOL_SIZE) -> None:
141
+ self._entries = [Projectile() for _ in range(size)]
142
+
143
+ @property
144
+ def entries(self) -> list[Projectile]:
145
+ return self._entries
146
+
147
+ def reset(self) -> None:
148
+ for entry in self._entries:
149
+ entry.active = False
150
+
151
+ def spawn(
152
+ self,
153
+ *,
154
+ pos_x: float,
155
+ pos_y: float,
156
+ angle: float,
157
+ type_id: int,
158
+ owner_id: int,
159
+ base_damage: float = 0.0,
160
+ hits_players: bool = False,
161
+ ) -> int:
162
+ index = None
163
+ for i, entry in enumerate(self._entries):
164
+ if not entry.active:
165
+ index = i
166
+ break
167
+ if index is None:
168
+ index = len(self._entries) - 1
169
+ entry = self._entries[index]
170
+
171
+ entry.active = True
172
+ entry.angle = angle
173
+ entry.pos_x = pos_x
174
+ entry.pos_y = pos_y
175
+ entry.origin_x = pos_x
176
+ entry.origin_y = pos_y
177
+ entry.vel_x = math.cos(angle) * 1.5
178
+ entry.vel_y = math.sin(angle) * 1.5
179
+ entry.type_id = int(type_id)
180
+ entry.life_timer = 0.4
181
+ entry.reserved = 0.0
182
+ entry.speed_scale = 1.0
183
+ entry.base_damage = float(base_damage)
184
+ entry.owner_id = int(owner_id)
185
+ entry.hits_players = bool(hits_players)
186
+
187
+ if type_id == ProjectileTypeId.ION_MINIGUN:
188
+ entry.hit_radius = 3.0
189
+ entry.damage_pool = 1.0
190
+ return index
191
+ if type_id == ProjectileTypeId.ION_RIFLE:
192
+ entry.hit_radius = 5.0
193
+ entry.damage_pool = 1.0
194
+ return index
195
+ if type_id in (ProjectileTypeId.ION_CANNON, ProjectileTypeId.PLASMA_CANNON):
196
+ entry.hit_radius = 10.0
197
+ else:
198
+ entry.hit_radius = 1.0
199
+ if type_id == ProjectileTypeId.GAUSS_GUN:
200
+ entry.damage_pool = 300.0
201
+ return index
202
+ if type_id == ProjectileTypeId.FIRE_BULLETS:
203
+ entry.damage_pool = 240.0
204
+ return index
205
+ if type_id == ProjectileTypeId.BLADE_GUN:
206
+ entry.damage_pool = 50.0
207
+ return index
208
+ entry.damage_pool = 1.0
209
+ return index
210
+
211
+ def iter_active(self) -> list[Projectile]:
212
+ return [entry for entry in self._entries if entry.active]
213
+
214
+ def update(
215
+ self,
216
+ dt: float,
217
+ creatures: list[Damageable],
218
+ *,
219
+ world_size: float,
220
+ damage_scale_by_type: dict[int, float] | None = None,
221
+ damage_scale_default: float = 1.0,
222
+ ion_aoe_scale: float = 1.0,
223
+ detail_preset: int = 5,
224
+ rng: Callable[[], int] | None = None,
225
+ runtime_state: object | None = None,
226
+ players: list[PlayerDamageable] | None = None,
227
+ apply_player_damage: Callable[[int, float], None] | None = None,
228
+ apply_creature_damage: CreatureDamageApplier | None = None,
229
+ ) -> list[tuple[int, float, float, float, float, float, float]]:
230
+ """Update the main projectile pool.
231
+
232
+ Modeled after `projectile_update` (0x00420b90) for the subset used by demo/state-9 work.
233
+
234
+ Returns a list of hit tuples: (type_id, origin_x, origin_y, hit_x, hit_y, target_x, target_y).
235
+ """
236
+
237
+ if dt <= 0.0:
238
+ return []
239
+
240
+ barrel_greaser_active = False
241
+ ion_gun_master_active = False
242
+ ion_scale = float(ion_aoe_scale)
243
+ poison_idx = int(PerkId.POISON_BULLETS)
244
+ if players is not None:
245
+ barrel_idx = int(PerkId.BARREL_GREASER)
246
+ ion_idx = int(PerkId.ION_GUN_MASTER)
247
+ for player in players:
248
+ perk_counts = getattr(player, "perk_counts", None)
249
+ if not isinstance(perk_counts, list):
250
+ continue
251
+
252
+ if 0 <= barrel_idx < len(perk_counts) and int(perk_counts[barrel_idx]) > 0:
253
+ barrel_greaser_active = True
254
+ if 0 <= ion_idx < len(perk_counts) and int(perk_counts[ion_idx]) > 0:
255
+ ion_gun_master_active = True
256
+ if barrel_greaser_active and ion_gun_master_active:
257
+ break
258
+
259
+ if ion_scale == 1.0 and ion_gun_master_active:
260
+ ion_scale = 1.2
261
+
262
+ def _owner_perk_active(owner_id: int, perk_idx: int) -> bool:
263
+ if players is None:
264
+ return False
265
+ if owner_id == -100:
266
+ player_index = 0
267
+ elif owner_id < 0:
268
+ player_index = -1 - int(owner_id)
269
+ else:
270
+ return False
271
+ if not (0 <= player_index < len(players)):
272
+ return False
273
+ perk_counts = getattr(players[player_index], "perk_counts", None)
274
+ if not isinstance(perk_counts, list):
275
+ return False
276
+ return 0 <= perk_idx < len(perk_counts) and int(perk_counts[perk_idx]) > 0
277
+
278
+ if damage_scale_by_type is None:
279
+ damage_scale_by_type = {}
280
+
281
+ if rng is None:
282
+ rng = _rng_zero
283
+
284
+ effects = None
285
+ sfx_queue = None
286
+ if runtime_state is not None:
287
+ effects = getattr(runtime_state, "effects", None)
288
+ sfx_queue = getattr(runtime_state, "sfx_queue", None)
289
+
290
+ hits: list[tuple[int, float, float, float, float, float, float]] = []
291
+ margin = 64.0
292
+
293
+ def _damage_scale(type_id: int) -> float:
294
+ value = damage_scale_by_type.get(type_id)
295
+ if value is None:
296
+ return float(damage_scale_default)
297
+ return float(value)
298
+
299
+ def _damage_type_for() -> int:
300
+ return 1
301
+
302
+ def _spawn_ion_hit_effects(type_id: int, pos_x: float, pos_y: float) -> None:
303
+ if effects is None or not hasattr(effects, "spawn"):
304
+ return
305
+
306
+ ring_scale = 0.0
307
+ ring_strength = 0.0
308
+ burst_scale = 0.0
309
+ if type_id == int(ProjectileTypeId.ION_MINIGUN):
310
+ ring_scale = 1.5
311
+ ring_strength = 0.1
312
+ burst_scale = 0.8
313
+ elif type_id == int(ProjectileTypeId.ION_RIFLE):
314
+ ring_scale = 1.2
315
+ ring_strength = 0.4
316
+ burst_scale = 1.2
317
+ elif type_id == int(ProjectileTypeId.ION_CANNON):
318
+ ring_scale = 1.0
319
+ ring_strength = 1.0
320
+ burst_scale = 2.2
321
+ if isinstance(sfx_queue, list):
322
+ sfx_queue.append("sfx_shockwave")
323
+ else:
324
+ return
325
+
326
+ detail = int(detail_preset)
327
+
328
+ # Port of `FUN_0042f270(pos, ring_scale, ring_strength)`: ring burst (effect_id=1).
329
+ effects.spawn(
330
+ effect_id=1,
331
+ pos_x=float(pos_x),
332
+ pos_y=float(pos_y),
333
+ vel_x=0.0,
334
+ vel_y=0.0,
335
+ rotation=0.0,
336
+ scale=1.0,
337
+ half_width=4.0,
338
+ half_height=4.0,
339
+ age=0.0,
340
+ lifetime=float(ring_strength) * 0.8,
341
+ flags=0x19,
342
+ color_r=0.6,
343
+ color_g=0.6,
344
+ color_b=0.9,
345
+ color_a=1.0,
346
+ rotation_step=0.0,
347
+ scale_step=float(ring_scale) * 45.0,
348
+ detail_preset=detail,
349
+ )
350
+
351
+ # Port of `FUN_0042f540(pos, burst_scale)`: burst cloud (effect_id=0).
352
+ burst = float(burst_scale) * 0.8
353
+ lifetime = min(burst * 0.7, 1.1)
354
+ half = burst * 32.0
355
+ count = int(half)
356
+ if detail < 3:
357
+ count //= 2
358
+
359
+ for _ in range(max(0, count)):
360
+ rotation = float(int(rng()) & 0x7F) * 0.049087387
361
+ vel_x = float((int(rng()) & 0x7F) - 0x40) * burst * 1.4
362
+ vel_y = float((int(rng()) & 0x7F) - 0x40) * burst * 1.4
363
+ scale_step = (float(int(rng()) % 100) * 0.01 + 0.1) * burst
364
+ effects.spawn(
365
+ effect_id=0,
366
+ pos_x=float(pos_x),
367
+ pos_y=float(pos_y),
368
+ vel_x=vel_x,
369
+ vel_y=vel_y,
370
+ rotation=rotation,
371
+ scale=1.0,
372
+ half_width=half,
373
+ half_height=half,
374
+ age=0.0,
375
+ lifetime=float(lifetime),
376
+ flags=0x1D,
377
+ color_r=0.4,
378
+ color_g=0.5,
379
+ color_b=1.0,
380
+ color_a=0.5,
381
+ rotation_step=0.0,
382
+ scale_step=scale_step,
383
+ detail_preset=detail,
384
+ )
385
+
386
+ def _apply_damage_to_creature(
387
+ creature_index: int,
388
+ damage: float,
389
+ *,
390
+ damage_type: int,
391
+ impulse_x: float,
392
+ impulse_y: float,
393
+ owner_id: int,
394
+ ) -> None:
395
+ if damage <= 0.0:
396
+ return
397
+ idx = int(creature_index)
398
+ if not (0 <= idx < len(creatures)):
399
+ return
400
+ if apply_creature_damage is not None:
401
+ apply_creature_damage(
402
+ idx,
403
+ float(damage),
404
+ int(damage_type),
405
+ float(impulse_x),
406
+ float(impulse_y),
407
+ int(owner_id),
408
+ )
409
+ else:
410
+ creatures[idx].hp -= float(damage)
411
+
412
+ def _reset_shock_chain_if_owner(index: int) -> None:
413
+ if runtime_state is None:
414
+ return
415
+ if getattr(runtime_state, "shock_chain_projectile_id", -1) != index:
416
+ return
417
+ setattr(runtime_state, "shock_chain_projectile_id", -1)
418
+ setattr(runtime_state, "shock_chain_links_left", 0)
419
+
420
+ def _try_spawn_shock_chain_link(index: int, hit_creature: int) -> None:
421
+ if runtime_state is None:
422
+ return
423
+ if getattr(runtime_state, "shock_chain_projectile_id", -1) != index:
424
+ return
425
+ links_left = int(getattr(runtime_state, "shock_chain_links_left", 0) or 0)
426
+ if links_left <= 0:
427
+ return
428
+ if not (0 <= hit_creature < len(creatures)):
429
+ return
430
+
431
+ origin = creatures[hit_creature]
432
+ best_idx = -1
433
+ best_dist = 0.0
434
+ max_dist = 100.0
435
+ for creature_id, creature in enumerate(creatures):
436
+ if creature_id == hit_creature:
437
+ continue
438
+ if creature.hp <= 0.0:
439
+ continue
440
+ d = _distance_sq(origin.x, origin.y, creature.x, creature.y)
441
+ if d > max_dist * max_dist:
442
+ continue
443
+ if best_idx == -1 or d < best_dist:
444
+ best_idx = creature_id
445
+ best_dist = d
446
+
447
+ setattr(runtime_state, "shock_chain_links_left", links_left - 1)
448
+ if best_idx == -1:
449
+ return
450
+
451
+ target = creatures[best_idx]
452
+ angle = math.atan2(target.y - origin.y, target.x - origin.x) + math.pi / 2.0
453
+
454
+ set_guard = hasattr(runtime_state, "bonus_spawn_guard")
455
+ if set_guard:
456
+ setattr(runtime_state, "bonus_spawn_guard", True)
457
+ proj_id = self.spawn(
458
+ pos_x=proj.pos_x,
459
+ pos_y=proj.pos_y,
460
+ angle=angle,
461
+ type_id=int(proj.type_id),
462
+ owner_id=hit_creature,
463
+ base_damage=proj.base_damage,
464
+ )
465
+ if set_guard:
466
+ setattr(runtime_state, "bonus_spawn_guard", False)
467
+ setattr(runtime_state, "shock_chain_projectile_id", proj_id)
468
+
469
+ for proj_index, proj in enumerate(self._entries):
470
+ if not proj.active:
471
+ continue
472
+
473
+ if proj.life_timer <= 0.0:
474
+ _reset_shock_chain_if_owner(proj_index)
475
+ proj.active = False
476
+ continue
477
+
478
+ if runtime_state is not None and getattr(runtime_state, "shock_chain_projectile_id", -1) == proj_index:
479
+ pending_hit = int(getattr(proj, "reserved", 0.0) or 0.0)
480
+ if pending_hit > 0:
481
+ proj.reserved = 0.0
482
+ _try_spawn_shock_chain_link(proj_index, pending_hit - 1)
483
+
484
+ if proj.life_timer < 0.4:
485
+ type_id = proj.type_id
486
+ if type_id in (ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_MINIGUN):
487
+ proj.life_timer -= dt
488
+ if type_id == ProjectileTypeId.ION_RIFLE:
489
+ damage = dt * 100.0
490
+ radius = ion_scale * 88.0
491
+ else:
492
+ damage = dt * 40.0
493
+ radius = ion_scale * 60.0
494
+ for creature_idx, creature in enumerate(creatures):
495
+ if creature.hp <= 0.0:
496
+ continue
497
+ creature_radius = _hit_radius_for(creature)
498
+ hit_r = radius + creature_radius
499
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
500
+ _apply_damage_to_creature(
501
+ creature_idx,
502
+ damage,
503
+ damage_type=7,
504
+ impulse_x=0.0,
505
+ impulse_y=0.0,
506
+ owner_id=int(proj.owner_id),
507
+ )
508
+ elif type_id == ProjectileTypeId.ION_CANNON:
509
+ proj.life_timer -= dt * 0.7
510
+ damage = dt * 300.0
511
+ radius = ion_scale * 128.0
512
+ for creature_idx, creature in enumerate(creatures):
513
+ if creature.hp <= 0.0:
514
+ continue
515
+ creature_radius = _hit_radius_for(creature)
516
+ hit_r = radius + creature_radius
517
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
518
+ _apply_damage_to_creature(
519
+ creature_idx,
520
+ damage,
521
+ damage_type=7,
522
+ impulse_x=0.0,
523
+ impulse_y=0.0,
524
+ owner_id=int(proj.owner_id),
525
+ )
526
+ elif type_id == ProjectileTypeId.GAUSS_GUN:
527
+ proj.life_timer -= dt * 0.1
528
+ else:
529
+ proj.life_timer -= dt
530
+
531
+ if proj.life_timer <= 0.0:
532
+ proj.active = False
533
+ continue
534
+
535
+ if (
536
+ proj.pos_x < -margin
537
+ or proj.pos_y < -margin
538
+ or proj.pos_x > world_size + margin
539
+ or proj.pos_y > world_size + margin
540
+ ):
541
+ proj.life_timer -= dt
542
+ if proj.life_timer <= 0.0:
543
+ proj.active = False
544
+ continue
545
+
546
+ steps = int(proj.base_damage)
547
+ if steps <= 0:
548
+ steps = 1
549
+ if barrel_greaser_active and int(proj.owner_id) < 0:
550
+ steps *= 2
551
+
552
+ dir_x = math.cos(proj.angle - math.pi / 2.0)
553
+ dir_y = math.sin(proj.angle - math.pi / 2.0)
554
+
555
+ acc_x = 0.0
556
+ acc_y = 0.0
557
+ step = 0
558
+ while step < steps:
559
+ acc_x += dir_x * dt * 20.0 * proj.speed_scale * 3.0
560
+ acc_y += dir_y * dt * 20.0 * proj.speed_scale * 3.0
561
+
562
+ if math.hypot(acc_x, acc_y) >= 4.0 or steps <= step + 3:
563
+ move_dx = acc_x
564
+ move_dy = acc_y
565
+ proj.pos_x += move_dx
566
+ proj.pos_y += move_dy
567
+ acc_x = 0.0
568
+ acc_y = 0.0
569
+
570
+ if proj.hits_players:
571
+ hit_player_idx = None
572
+ if players is not None:
573
+ for idx, player in enumerate(players):
574
+ if float(player.health) <= 0.0:
575
+ continue
576
+ player_radius = _hit_radius_for(player)
577
+ hit_r = proj.hit_radius + player_radius
578
+ if _distance_sq(proj.pos_x, proj.pos_y, player.pos_x, player.pos_y) <= hit_r * hit_r:
579
+ hit_player_idx = idx
580
+ break
581
+
582
+ if hit_player_idx is None:
583
+ step += 3
584
+ continue
585
+
586
+ type_id = proj.type_id
587
+ hit_x = float(proj.pos_x)
588
+ hit_y = float(proj.pos_y)
589
+ player = players[int(hit_player_idx)] if players is not None else None
590
+ target_x = float(getattr(player, "pos_x", hit_x) if player is not None else hit_x)
591
+ target_y = float(getattr(player, "pos_y", hit_y) if player is not None else hit_y)
592
+ hits.append((type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, target_x, target_y))
593
+
594
+ if proj.life_timer != 0.25 and type_id not in (
595
+ ProjectileTypeId.FIRE_BULLETS,
596
+ ProjectileTypeId.GAUSS_GUN,
597
+ ProjectileTypeId.BLADE_GUN,
598
+ ):
599
+ proj.life_timer = 0.25
600
+ jitter = rng() & 3
601
+ proj.pos_x += dir_x * float(jitter)
602
+ proj.pos_y += dir_y * float(jitter)
603
+
604
+ dist = math.hypot(proj.origin_x - proj.pos_x, proj.origin_y - proj.pos_y)
605
+ if dist < 50.0:
606
+ dist = 50.0
607
+
608
+ damage_scale = _damage_scale(type_id)
609
+ damage_amount = ((100.0 / dist) * damage_scale * 30.0 + 10.0) * 0.95
610
+ if damage_amount > 0.0:
611
+ if apply_player_damage is not None:
612
+ apply_player_damage(int(hit_player_idx), float(damage_amount))
613
+ elif players is not None:
614
+ players[int(hit_player_idx)].health -= float(damage_amount)
615
+
616
+ break
617
+
618
+ hit_idx = None
619
+ for idx, creature in enumerate(creatures):
620
+ if creature.hp <= 0.0:
621
+ continue
622
+ if idx == proj.owner_id:
623
+ continue
624
+ creature_radius = _hit_radius_for(creature)
625
+ hit_r = proj.hit_radius + creature_radius
626
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
627
+ hit_idx = idx
628
+ break
629
+
630
+ if hit_idx is None:
631
+ step += 3
632
+ continue
633
+
634
+ type_id = proj.type_id
635
+ creature = creatures[hit_idx]
636
+
637
+ if _owner_perk_active(int(proj.owner_id), poison_idx) and (int(rng()) & 7) == 1:
638
+ if hasattr(creature, "flags"):
639
+ creature.flags |= CreatureFlags.SELF_DAMAGE_TICK
640
+
641
+ if type_id == ProjectileTypeId.SPLITTER_GUN:
642
+ self.spawn(
643
+ pos_x=proj.pos_x,
644
+ pos_y=proj.pos_y,
645
+ angle=proj.angle - 1.0471976,
646
+ type_id=ProjectileTypeId.SPLITTER_GUN,
647
+ owner_id=hit_idx,
648
+ base_damage=proj.base_damage,
649
+ hits_players=proj.hits_players,
650
+ )
651
+ self.spawn(
652
+ pos_x=proj.pos_x,
653
+ pos_y=proj.pos_y,
654
+ angle=proj.angle + 1.0471976,
655
+ type_id=ProjectileTypeId.SPLITTER_GUN,
656
+ owner_id=hit_idx,
657
+ base_damage=proj.base_damage,
658
+ hits_players=proj.hits_players,
659
+ )
660
+
661
+ shots_hit = getattr(runtime_state, "shots_hit", None) if runtime_state is not None else None
662
+ if isinstance(shots_hit, list):
663
+ owner_id = int(proj.owner_id)
664
+ if owner_id < 0 and owner_id != -100:
665
+ player_index = -1 - owner_id
666
+ if 0 <= player_index < len(shots_hit):
667
+ shots_hit[player_index] += 1
668
+
669
+ hit_x = float(proj.pos_x)
670
+ hit_y = float(proj.pos_y)
671
+ target_x = float(creature.x)
672
+ target_y = float(creature.y)
673
+ hits.append((type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, target_x, target_y))
674
+
675
+ if proj.life_timer != 0.25 and type_id not in (
676
+ ProjectileTypeId.FIRE_BULLETS,
677
+ ProjectileTypeId.GAUSS_GUN,
678
+ ProjectileTypeId.BLADE_GUN,
679
+ ):
680
+ proj.life_timer = 0.25
681
+ jitter = rng() & 3
682
+ proj.pos_x += dir_x * float(jitter)
683
+ proj.pos_y += dir_y * float(jitter)
684
+
685
+ dist = math.hypot(proj.origin_x - proj.pos_x, proj.origin_y - proj.pos_y)
686
+ if dist < 50.0:
687
+ dist = 50.0
688
+
689
+ if type_id == ProjectileTypeId.ION_RIFLE:
690
+ if runtime_state is not None and getattr(runtime_state, "shock_chain_projectile_id", -1) == proj_index:
691
+ proj.reserved = float(int(hit_idx) + 1)
692
+ if type_id in (ProjectileTypeId.ION_MINIGUN, ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_CANNON):
693
+ _spawn_ion_hit_effects(int(type_id), target_x, target_y)
694
+ elif type_id == ProjectileTypeId.PLASMA_CANNON:
695
+ size = float(getattr(creature, "size", 50.0) or 50.0)
696
+ ring_radius = size * 0.5 + 1.0
697
+ plasma_entry = weapon_entry_for_projectile_type_id(int(ProjectileTypeId.PLASMA_RIFLE))
698
+ plasma_meta = (
699
+ float(plasma_entry.projectile_meta)
700
+ if plasma_entry and plasma_entry.projectile_meta is not None
701
+ else proj.base_damage
702
+ )
703
+ for ring_idx in range(12):
704
+ ring_angle = float(ring_idx) * (math.pi / 6.0)
705
+ self.spawn(
706
+ pos_x=proj.pos_x + math.cos(ring_angle) * ring_radius,
707
+ pos_y=proj.pos_y + math.sin(ring_angle) * ring_radius,
708
+ angle=ring_angle,
709
+ type_id=ProjectileTypeId.PLASMA_RIFLE,
710
+ owner_id=-100,
711
+ base_damage=plasma_meta,
712
+ )
713
+ elif type_id == ProjectileTypeId.SHRINKIFIER:
714
+ if hasattr(creature, "size"):
715
+ new_size = float(getattr(creature, "size", 50.0) or 50.0) * 0.65
716
+ setattr(creature, "size", new_size)
717
+ if new_size < 16.0:
718
+ _apply_damage_to_creature(
719
+ hit_idx,
720
+ float(creature.hp) + 1.0,
721
+ damage_type=_damage_type_for(),
722
+ impulse_x=0.0,
723
+ impulse_y=0.0,
724
+ owner_id=int(proj.owner_id),
725
+ )
726
+ proj.life_timer = 0.25
727
+ elif type_id == ProjectileTypeId.PULSE_GUN:
728
+ creature.x += move_dx * 3.0
729
+ creature.y += move_dy * 3.0
730
+ elif type_id == ProjectileTypeId.PLAGUE_SPREADER and hasattr(creature, "collision_flag"):
731
+ setattr(creature, "collision_flag", 1)
732
+
733
+ damage_scale = _damage_scale(type_id)
734
+ damage_amount = ((100.0 / dist) * damage_scale * 30.0 + 10.0) * 0.95
735
+
736
+ if damage_amount > 0.0 and creature.hp > 0.0:
737
+ remaining = proj.damage_pool - 1.0
738
+ proj.damage_pool = remaining
739
+ impulse_x = dir_x * float(proj.speed_scale)
740
+ impulse_y = dir_y * float(proj.speed_scale)
741
+ damage_type = _damage_type_for()
742
+ if remaining <= 0.0:
743
+ _apply_damage_to_creature(
744
+ hit_idx,
745
+ damage_amount,
746
+ damage_type=damage_type,
747
+ impulse_x=impulse_x,
748
+ impulse_y=impulse_y,
749
+ owner_id=int(proj.owner_id),
750
+ )
751
+ if proj.life_timer != 0.25:
752
+ proj.life_timer = 0.25
753
+ else:
754
+ hp_before = float(creature.hp)
755
+ _apply_damage_to_creature(
756
+ hit_idx,
757
+ remaining,
758
+ damage_type=damage_type,
759
+ impulse_x=impulse_x,
760
+ impulse_y=impulse_y,
761
+ owner_id=int(proj.owner_id),
762
+ )
763
+ proj.damage_pool -= hp_before
764
+
765
+ if proj.damage_pool == 1.0 and proj.life_timer != 0.25:
766
+ proj.damage_pool = 0.0
767
+ proj.life_timer = 0.25
768
+
769
+ if proj.life_timer == 0.25 and type_id not in (
770
+ ProjectileTypeId.FIRE_BULLETS,
771
+ ProjectileTypeId.GAUSS_GUN,
772
+ ProjectileTypeId.BLADE_GUN,
773
+ ):
774
+ break
775
+
776
+ if proj.damage_pool <= 0.0:
777
+ break
778
+
779
+ step += 3
780
+
781
+ return hits
782
+
783
+ def update_demo(
784
+ self,
785
+ dt: float,
786
+ creatures: list[Damageable],
787
+ *,
788
+ world_size: float,
789
+ speed_by_type: dict[int, float],
790
+ damage_by_type: dict[int, float],
791
+ ) -> list[tuple[int, float, float, float, float, float, float]]:
792
+ """Update a small projectile subset for the demo view.
793
+
794
+ Returns a list of hit tuples: (type_id, origin_x, origin_y, hit_x, hit_y, target_x, target_y).
795
+ """
796
+
797
+ if dt <= 0.0:
798
+ return []
799
+
800
+ hits: list[tuple[int, float, float, float, float, float, float]] = []
801
+ margin = 64.0
802
+
803
+ for proj in self._entries:
804
+ if not proj.active:
805
+ continue
806
+
807
+ if proj.life_timer <= 0.0:
808
+ proj.active = False
809
+ continue
810
+
811
+ if proj.life_timer < 0.4:
812
+ if proj.type_id == ProjectileTypeId.ION_RIFLE:
813
+ damage = dt * 100.0
814
+ radius = 88.0
815
+ for creature in creatures:
816
+ if creature.hp <= 0.0:
817
+ continue
818
+ creature_radius = _hit_radius_for(creature)
819
+ hit_r = radius + creature_radius
820
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
821
+ creature.hp -= damage
822
+ elif proj.type_id == ProjectileTypeId.ION_MINIGUN:
823
+ damage = dt * 40.0
824
+ radius = 60.0
825
+ for creature in creatures:
826
+ if creature.hp <= 0.0:
827
+ continue
828
+ creature_radius = _hit_radius_for(creature)
829
+ hit_r = radius + creature_radius
830
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
831
+ creature.hp -= damage
832
+ proj.life_timer -= dt
833
+ if proj.life_timer <= 0.0:
834
+ proj.active = False
835
+ continue
836
+
837
+ if (
838
+ proj.pos_x < -margin
839
+ or proj.pos_y < -margin
840
+ or proj.pos_x > world_size + margin
841
+ or proj.pos_y > world_size + margin
842
+ ):
843
+ proj.life_timer -= dt
844
+ if proj.life_timer <= 0.0:
845
+ proj.active = False
846
+ continue
847
+
848
+ speed = speed_by_type.get(proj.type_id, 650.0) * proj.speed_scale
849
+ direction_x = math.cos(proj.angle - math.pi / 2.0)
850
+ direction_y = math.sin(proj.angle - math.pi / 2.0)
851
+ proj.pos_x += direction_x * speed * dt
852
+ proj.pos_y += direction_y * speed * dt
853
+
854
+ hit_idx = None
855
+ for idx, creature in enumerate(creatures):
856
+ if creature.hp <= 0.0:
857
+ continue
858
+ creature_radius = _hit_radius_for(creature)
859
+ hit_r = proj.hit_radius + creature_radius
860
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
861
+ hit_idx = idx
862
+ break
863
+ if hit_idx is None:
864
+ continue
865
+
866
+ hit_x = float(proj.pos_x)
867
+ hit_y = float(proj.pos_y)
868
+ creature = creatures[hit_idx]
869
+ hits.append((proj.type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, float(creature.x), float(creature.y)))
870
+
871
+ creature = creatures[hit_idx]
872
+ creature.hp -= damage_by_type.get(proj.type_id, 10.0)
873
+
874
+ proj.life_timer = 0.25
875
+
876
+ return hits
877
+
878
+
879
+ class SecondaryProjectilePool:
880
+ def __init__(self, *, size: int = SECONDARY_PROJECTILE_POOL_SIZE) -> None:
881
+ self._entries = [SecondaryProjectile() for _ in range(size)]
882
+
883
+ @property
884
+ def entries(self) -> list[SecondaryProjectile]:
885
+ return self._entries
886
+
887
+ def reset(self) -> None:
888
+ for entry in self._entries:
889
+ entry.active = False
890
+
891
+ def spawn(
892
+ self,
893
+ *,
894
+ pos_x: float,
895
+ pos_y: float,
896
+ angle: float,
897
+ type_id: int,
898
+ owner_id: int = -100,
899
+ time_to_live: float = 2.0,
900
+ ) -> int:
901
+ index = None
902
+ for i, entry in enumerate(self._entries):
903
+ if not entry.active:
904
+ index = i
905
+ break
906
+ if index is None:
907
+ index = len(self._entries) - 1
908
+
909
+ entry = self._entries[index]
910
+ entry.active = True
911
+ entry.angle = float(angle)
912
+ entry.type_id = int(type_id)
913
+ entry.pos_x = float(pos_x)
914
+ entry.pos_y = float(pos_y)
915
+ entry.owner_id = int(owner_id)
916
+ entry.target_id = -1
917
+
918
+ if entry.type_id == 3:
919
+ entry.vel_x = 0.0
920
+ entry.vel_y = 0.0
921
+ entry.speed = float(time_to_live)
922
+ entry.lifetime = 0.0
923
+ return index
924
+
925
+ # Effects.md: vel = cos/sin(angle - PI/2) * 90 (190 for type 2).
926
+ base_speed = 90.0
927
+ if entry.type_id == 2:
928
+ base_speed = 190.0
929
+ vx = math.cos(angle - math.pi / 2.0) * base_speed
930
+ vy = math.sin(angle - math.pi / 2.0) * base_speed
931
+ entry.vel_x = vx
932
+ entry.vel_y = vy
933
+ entry.speed = float(time_to_live)
934
+ entry.lifetime = 0.0
935
+ return index
936
+
937
+ def iter_active(self) -> list[SecondaryProjectile]:
938
+ return [entry for entry in self._entries if entry.active]
939
+
940
+ def update_pulse_gun(
941
+ self,
942
+ dt: float,
943
+ creatures: list[Damageable],
944
+ *,
945
+ apply_creature_damage: CreatureDamageApplier | None = None,
946
+ runtime_state: object | None = None,
947
+ fx_queue: FxQueueLike | None = None,
948
+ detail_preset: int = 5,
949
+ ) -> None:
950
+ """Update the secondary projectile pool subset (types 1/2/4 + detonation type 3)."""
951
+
952
+ if dt <= 0.0:
953
+ return
954
+
955
+ def _apply_damage_to_creature(creature_index: int, damage: float, *, owner_id: int) -> None:
956
+ if damage <= 0.0:
957
+ return
958
+ idx = int(creature_index)
959
+ if not (0 <= idx < len(creatures)):
960
+ return
961
+ if apply_creature_damage is not None:
962
+ apply_creature_damage(idx, float(damage), 3, 0.0, 0.0, int(owner_id))
963
+ else:
964
+ creatures[idx].hp -= float(damage)
965
+
966
+ rand = _rng_zero
967
+ freeze_active = False
968
+ effects = None
969
+ sfx_queue = None
970
+ if runtime_state is not None:
971
+ rng = getattr(runtime_state, "rng", None)
972
+ if rng is not None:
973
+ rand = getattr(rng, "rand", _rng_zero)
974
+
975
+ bonuses = getattr(runtime_state, "bonuses", None)
976
+ if bonuses is not None and float(getattr(bonuses, "freeze", 0.0)) > 0.0:
977
+ freeze_active = True
978
+
979
+ effects = getattr(runtime_state, "effects", None)
980
+ sfx_queue = getattr(runtime_state, "sfx_queue", None)
981
+
982
+ for entry in self._entries:
983
+ if not entry.active:
984
+ continue
985
+
986
+ if entry.type_id == 3:
987
+ entry.lifetime += dt * 3.0
988
+ t = entry.lifetime
989
+ scale = entry.speed
990
+ if t > 1.0:
991
+ if fx_queue is not None:
992
+ fx_queue.add(
993
+ effect_id=0x10,
994
+ pos_x=float(entry.pos_x),
995
+ pos_y=float(entry.pos_y),
996
+ width=float(scale) * 256.0,
997
+ height=float(scale) * 256.0,
998
+ rotation=0.0,
999
+ rgba=(0.0, 0.0, 0.0, 0.25),
1000
+ )
1001
+ entry.active = False
1002
+
1003
+ radius = scale * t * 80.0
1004
+ damage = dt * scale * 700.0
1005
+ for creature_idx, creature in enumerate(creatures):
1006
+ if creature.hp <= 0.0:
1007
+ continue
1008
+ creature_radius = _hit_radius_for(creature)
1009
+ hit_r = radius + creature_radius
1010
+ if _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1011
+ _apply_damage_to_creature(creature_idx, damage, owner_id=int(entry.owner_id))
1012
+ continue
1013
+
1014
+ if entry.type_id not in (1, 2, 4):
1015
+ continue
1016
+
1017
+ # Move.
1018
+ entry.pos_x += entry.vel_x * dt
1019
+ entry.pos_y += entry.vel_y * dt
1020
+
1021
+ # Update velocity + countdown.
1022
+ speed_mag = math.hypot(entry.vel_x, entry.vel_y)
1023
+ if entry.type_id == 1:
1024
+ if speed_mag < 500.0:
1025
+ factor = 1.0 + dt * 3.0
1026
+ entry.vel_x *= factor
1027
+ entry.vel_y *= factor
1028
+ entry.speed -= dt
1029
+ elif entry.type_id == 4:
1030
+ if speed_mag < 600.0:
1031
+ factor = 1.0 + dt * 4.0
1032
+ entry.vel_x *= factor
1033
+ entry.vel_y *= factor
1034
+ entry.speed -= dt
1035
+ else:
1036
+ # Type 2: homing projectile.
1037
+ target_id = entry.target_id
1038
+ if not (0 <= target_id < len(creatures)) or creatures[target_id].hp <= 0.0:
1039
+ best_idx = -1
1040
+ best_dist = 0.0
1041
+ for idx, creature in enumerate(creatures):
1042
+ if creature.hp <= 0.0:
1043
+ continue
1044
+ d = _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y)
1045
+ if best_idx == -1 or d < best_dist:
1046
+ best_idx = idx
1047
+ best_dist = d
1048
+ entry.target_id = best_idx
1049
+ target_id = best_idx
1050
+
1051
+ if 0 <= target_id < len(creatures):
1052
+ target = creatures[target_id]
1053
+ dx = target.x - entry.pos_x
1054
+ dy = target.y - entry.pos_y
1055
+ dist = math.hypot(dx, dy)
1056
+ if dist > 1e-6:
1057
+ angle = math.atan2(dy, dx) + math.pi / 2.0
1058
+ entry.angle = angle
1059
+ dir_x = math.cos(angle - math.pi / 2.0)
1060
+ dir_y = math.sin(angle - math.pi / 2.0)
1061
+ entry.vel_x += dir_x * dt * 800.0
1062
+ entry.vel_y += dir_y * dt * 800.0
1063
+ if 350.0 < math.hypot(entry.vel_x, entry.vel_y):
1064
+ entry.vel_x -= dir_x * dt * 800.0
1065
+ entry.vel_y -= dir_y * dt * 800.0
1066
+
1067
+ entry.speed -= dt * 0.5
1068
+
1069
+ # projectile_update uses creature_find_in_radius(..., 8.0, ...)
1070
+ hit_idx: int | None = None
1071
+ for idx, creature in enumerate(creatures):
1072
+ if creature.hp <= 0.0:
1073
+ continue
1074
+ creature_radius = _hit_radius_for(creature)
1075
+ hit_r = 8.0 + creature_radius
1076
+ if _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1077
+ hit_idx = idx
1078
+ break
1079
+ if hit_idx is not None:
1080
+ if isinstance(sfx_queue, list):
1081
+ sfx_queue.append("sfx_explosion_medium")
1082
+
1083
+ damage = 150.0
1084
+ if entry.type_id == 1:
1085
+ damage = entry.speed * 50.0 + 500.0
1086
+ elif entry.type_id == 2:
1087
+ damage = entry.speed * 20.0 + 80.0
1088
+ elif entry.type_id == 4:
1089
+ damage = entry.speed * 20.0 + 40.0
1090
+ _apply_damage_to_creature(hit_idx, damage, owner_id=int(entry.owner_id))
1091
+
1092
+ det_scale = 0.5
1093
+ if entry.type_id == 1:
1094
+ det_scale = 1.0
1095
+ elif entry.type_id == 2:
1096
+ det_scale = 0.35
1097
+ elif entry.type_id == 4:
1098
+ det_scale = 0.25
1099
+
1100
+ if freeze_active:
1101
+ if effects is not None and hasattr(effects, "spawn_freeze_shard"):
1102
+ for _ in range(4):
1103
+ shard_angle = float(int(rand()) % 0x264) * 0.01
1104
+ effects.spawn_freeze_shard(
1105
+ pos_x=float(entry.pos_x),
1106
+ pos_y=float(entry.pos_y),
1107
+ angle=shard_angle,
1108
+ rand=rand,
1109
+ detail_preset=int(detail_preset),
1110
+ )
1111
+ elif fx_queue is not None:
1112
+ for _ in range(3):
1113
+ off_x = float(int(rand()) % 0x14 - 10)
1114
+ off_y = float(int(rand()) % 0x14 - 10)
1115
+ fx_queue.add_random(
1116
+ pos_x=float(creatures[hit_idx].x) + off_x,
1117
+ pos_y=float(creatures[hit_idx].y) + off_y,
1118
+ rand=rand,
1119
+ )
1120
+
1121
+ entry.type_id = 3
1122
+ entry.vel_x = 0.0
1123
+ entry.vel_y = 0.0
1124
+ entry.speed = det_scale
1125
+ entry.lifetime = 0.0
1126
+ continue
1127
+
1128
+ if entry.speed <= 0.0:
1129
+ entry.type_id = 3
1130
+ entry.vel_x = 0.0
1131
+ entry.vel_y = 0.0
1132
+ entry.speed = 0.5
1133
+ entry.lifetime = 0.0