crimsonland 0.1.0.dev15__py3-none-any.whl → 0.1.0.dev16__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 (75) hide show
  1. crimson/cli.py +61 -0
  2. crimson/creatures/damage.py +111 -36
  3. crimson/creatures/runtime.py +246 -156
  4. crimson/creatures/spawn.py +7 -3
  5. crimson/demo.py +38 -45
  6. crimson/effects.py +7 -13
  7. crimson/frontend/high_scores_layout.py +81 -0
  8. crimson/frontend/panels/base.py +4 -1
  9. crimson/frontend/panels/controls.py +0 -15
  10. crimson/frontend/panels/databases.py +291 -3
  11. crimson/frontend/panels/mods.py +0 -15
  12. crimson/frontend/panels/play_game.py +0 -16
  13. crimson/game.py +441 -1
  14. crimson/gameplay.py +905 -569
  15. crimson/modes/base_gameplay_mode.py +33 -12
  16. crimson/modes/components/__init__.py +2 -0
  17. crimson/modes/components/highscore_record_builder.py +58 -0
  18. crimson/modes/components/perk_menu_controller.py +325 -0
  19. crimson/modes/quest_mode.py +58 -273
  20. crimson/modes/rush_mode.py +12 -43
  21. crimson/modes/survival_mode.py +71 -328
  22. crimson/modes/tutorial_mode.py +46 -247
  23. crimson/modes/typo_mode.py +11 -38
  24. crimson/oracle.py +396 -0
  25. crimson/perks.py +5 -2
  26. crimson/player_damage.py +94 -37
  27. crimson/projectiles.py +539 -320
  28. crimson/render/projectile_draw_registry.py +637 -0
  29. crimson/render/projectile_render_registry.py +110 -0
  30. crimson/render/secondary_projectile_draw_registry.py +206 -0
  31. crimson/render/world_renderer.py +58 -707
  32. crimson/sim/world_state.py +118 -61
  33. crimson/typo/spawns.py +5 -12
  34. crimson/ui/demo_trial_overlay.py +3 -11
  35. crimson/ui/formatting.py +24 -0
  36. crimson/ui/game_over.py +12 -58
  37. crimson/ui/hud.py +72 -39
  38. crimson/ui/layout.py +20 -0
  39. crimson/ui/perk_menu.py +9 -34
  40. crimson/ui/quest_results.py +12 -64
  41. crimson/ui/text_input.py +20 -0
  42. crimson/views/_ui_helpers.py +27 -0
  43. crimson/views/aim_debug.py +15 -32
  44. crimson/views/animations.py +18 -28
  45. crimson/views/arsenal_debug.py +22 -32
  46. crimson/views/bonuses.py +23 -36
  47. crimson/views/camera_debug.py +16 -29
  48. crimson/views/camera_shake.py +9 -33
  49. crimson/views/corpse_stamp_debug.py +13 -21
  50. crimson/views/decals_debug.py +36 -23
  51. crimson/views/fonts.py +8 -25
  52. crimson/views/ground.py +4 -21
  53. crimson/views/lighting_debug.py +42 -45
  54. crimson/views/particles.py +33 -42
  55. crimson/views/perk_menu_debug.py +3 -10
  56. crimson/views/player.py +50 -44
  57. crimson/views/player_sprite_debug.py +24 -31
  58. crimson/views/projectile_fx.py +57 -52
  59. crimson/views/projectile_render_debug.py +24 -33
  60. crimson/views/projectiles.py +24 -37
  61. crimson/views/spawn_plan.py +13 -29
  62. crimson/views/sprites.py +14 -29
  63. crimson/views/terrain.py +6 -23
  64. crimson/views/ui.py +7 -24
  65. crimson/views/wicons.py +28 -33
  66. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/METADATA +1 -1
  67. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/RECORD +72 -64
  68. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/WHEEL +2 -2
  69. grim/config.py +29 -1
  70. grim/console.py +7 -10
  71. grim/math.py +12 -0
  72. crimson/.DS_Store +0 -0
  73. crimson/creatures/.DS_Store +0 -0
  74. grim/.DS_Store +0 -0
  75. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
crimson/projectiles.py CHANGED
@@ -5,6 +5,8 @@ from enum import IntEnum
5
5
  import math
6
6
  from typing import Callable, Protocol
7
7
 
8
+ from grim.math import distance_sq
9
+
8
10
  from .creatures.spawn import CreatureFlags
9
11
  from .perks import PerkId
10
12
  from .weapons import weapon_entry_for_projectile_type_id
@@ -116,12 +118,6 @@ class SecondaryProjectile:
116
118
  target_id: int = -1
117
119
 
118
120
 
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
121
  def _hit_radius_for(creature: Damageable) -> float:
126
122
  """Approximate `creature_find_in_radius`/`creatures_apply_radius_damage` sizing.
127
123
 
@@ -136,6 +132,488 @@ def _hit_radius_for(creature: Damageable) -> float:
136
132
  return max(0.0, size * 0.14285715 + 3.0)
137
133
 
138
134
 
135
+ def _apply_damage_to_creature(
136
+ creatures: list[Damageable],
137
+ creature_index: int,
138
+ damage: float,
139
+ *,
140
+ damage_type: int,
141
+ impulse_x: float,
142
+ impulse_y: float,
143
+ owner_id: int,
144
+ apply_creature_damage: CreatureDamageApplier | None = None,
145
+ ) -> None:
146
+ if damage <= 0.0:
147
+ return
148
+ idx = int(creature_index)
149
+ if not (0 <= idx < len(creatures)):
150
+ return
151
+ if apply_creature_damage is not None:
152
+ apply_creature_damage(
153
+ idx,
154
+ float(damage),
155
+ int(damage_type),
156
+ float(impulse_x),
157
+ float(impulse_y),
158
+ int(owner_id),
159
+ )
160
+ else:
161
+ creatures[idx].hp -= float(damage)
162
+
163
+
164
+ def _spawn_ion_hit_effects(
165
+ effects: object | None,
166
+ sfx_queue: object | None,
167
+ *,
168
+ type_id: int,
169
+ pos_x: float,
170
+ pos_y: float,
171
+ rng: Callable[[], int],
172
+ detail_preset: int,
173
+ ) -> None:
174
+ if effects is None or not hasattr(effects, "spawn"):
175
+ return
176
+
177
+ ring_scale = 0.0
178
+ ring_strength = 0.0
179
+ burst_scale = 0.0
180
+ if type_id == int(ProjectileTypeId.ION_MINIGUN):
181
+ ring_scale = 1.5
182
+ ring_strength = 0.1
183
+ burst_scale = 0.8
184
+ elif type_id == int(ProjectileTypeId.ION_RIFLE):
185
+ ring_scale = 1.2
186
+ ring_strength = 0.4
187
+ burst_scale = 1.2
188
+ elif type_id == int(ProjectileTypeId.ION_CANNON):
189
+ ring_scale = 1.0
190
+ ring_strength = 1.0
191
+ burst_scale = 2.2
192
+ if isinstance(sfx_queue, list):
193
+ sfx_queue.append("sfx_shockwave")
194
+ else:
195
+ return
196
+
197
+ detail = int(detail_preset)
198
+
199
+ # Port of `FUN_0042f270(pos, ring_scale, ring_strength)`: ring burst (effect_id=1).
200
+ effects.spawn(
201
+ effect_id=1,
202
+ pos_x=float(pos_x),
203
+ pos_y=float(pos_y),
204
+ vel_x=0.0,
205
+ vel_y=0.0,
206
+ rotation=0.0,
207
+ scale=1.0,
208
+ half_width=4.0,
209
+ half_height=4.0,
210
+ age=0.0,
211
+ lifetime=float(ring_strength) * 0.8,
212
+ flags=0x19,
213
+ color_r=0.6,
214
+ color_g=0.6,
215
+ color_b=0.9,
216
+ color_a=1.0,
217
+ rotation_step=0.0,
218
+ scale_step=float(ring_scale) * 45.0,
219
+ detail_preset=detail,
220
+ )
221
+
222
+ # Port of `FUN_0042f540(pos, burst_scale)`: burst cloud (effect_id=0).
223
+ burst = float(burst_scale) * 0.8
224
+ lifetime = min(burst * 0.7, 1.1)
225
+ half = burst * 32.0
226
+ count = int(half)
227
+ if detail < 3:
228
+ count //= 2
229
+
230
+ for _ in range(max(0, count)):
231
+ rotation = float(int(rng()) & 0x7F) * 0.049087387
232
+ vel_x = float((int(rng()) & 0x7F) - 0x40) * burst * 1.4
233
+ vel_y = float((int(rng()) & 0x7F) - 0x40) * burst * 1.4
234
+ scale_step = (float(int(rng()) % 100) * 0.01 + 0.1) * burst
235
+ effects.spawn(
236
+ effect_id=0,
237
+ pos_x=float(pos_x),
238
+ pos_y=float(pos_y),
239
+ vel_x=vel_x,
240
+ vel_y=vel_y,
241
+ rotation=rotation,
242
+ scale=1.0,
243
+ half_width=half,
244
+ half_height=half,
245
+ age=0.0,
246
+ lifetime=float(lifetime),
247
+ flags=0x1D,
248
+ color_r=0.4,
249
+ color_g=0.5,
250
+ color_b=1.0,
251
+ color_a=0.5,
252
+ rotation_step=0.0,
253
+ scale_step=scale_step,
254
+ detail_preset=detail,
255
+ )
256
+
257
+
258
+ def _spawn_plasma_cannon_hit_effects(
259
+ effects: object | None,
260
+ sfx_queue: object | None,
261
+ *,
262
+ pos_x: float,
263
+ pos_y: float,
264
+ detail_preset: int,
265
+ ) -> None:
266
+ """Port of `projectile_update` Plasma Cannon hit extras.
267
+
268
+ Native does:
269
+ - `sfx_play_panned(sfx_explosion_medium)`
270
+ - `sfx_play_panned(sfx_shockwave)`
271
+ - `FUN_0042f330(pos, 1.5, 1.0)`
272
+ - `FUN_0042f330(pos, 1.0, 1.0)`
273
+ """
274
+
275
+ if effects is None or not hasattr(effects, "spawn"):
276
+ return
277
+
278
+ if isinstance(sfx_queue, list):
279
+ sfx_queue.append("sfx_explosion_medium")
280
+ sfx_queue.append("sfx_shockwave")
281
+
282
+ detail = int(detail_preset)
283
+
284
+ def _spawn_ring(*, scale: float) -> None:
285
+ effects.spawn(
286
+ effect_id=1,
287
+ pos_x=float(pos_x),
288
+ pos_y=float(pos_y),
289
+ vel_x=0.0,
290
+ vel_y=0.0,
291
+ rotation=0.0,
292
+ scale=1.0,
293
+ half_width=4.0,
294
+ half_height=4.0,
295
+ age=0.1,
296
+ lifetime=1.0,
297
+ flags=0x19,
298
+ color_r=0.9,
299
+ color_g=0.6,
300
+ color_b=0.3,
301
+ color_a=1.0,
302
+ rotation_step=0.0,
303
+ scale_step=float(scale) * 45.0,
304
+ detail_preset=detail,
305
+ )
306
+
307
+ _spawn_ring(scale=1.5)
308
+ _spawn_ring(scale=1.0)
309
+
310
+
311
+ def _spawn_splitter_hit_effects(
312
+ effects: object | None,
313
+ *,
314
+ pos_x: float,
315
+ pos_y: float,
316
+ rng: Callable[[], int],
317
+ detail_preset: int,
318
+ ) -> None:
319
+ """Port of `FUN_0042f3f0(pos, 26.0, 3)` from the Splitter Gun hit branch."""
320
+
321
+ if effects is None or not hasattr(effects, "spawn"):
322
+ return
323
+
324
+ detail = int(detail_preset)
325
+ for _ in range(3):
326
+ angle = float(int(rng()) & 0x1FF) * (math.tau / 512.0)
327
+ radius = float(int(rng()) % 26)
328
+ jitter_age = -float(int(rng()) & 0xFF) * 0.0012
329
+ lifetime = 0.1 - jitter_age
330
+
331
+ effects.spawn(
332
+ effect_id=0,
333
+ pos_x=float(pos_x) + math.cos(angle) * radius,
334
+ pos_y=float(pos_y) + math.sin(angle) * radius,
335
+ vel_x=0.0,
336
+ vel_y=0.0,
337
+ rotation=0.0,
338
+ scale=1.0,
339
+ half_width=4.0,
340
+ half_height=4.0,
341
+ age=jitter_age,
342
+ lifetime=lifetime,
343
+ flags=0x19,
344
+ color_r=1.0,
345
+ color_g=0.9,
346
+ color_b=0.1,
347
+ color_a=1.0,
348
+ rotation_step=0.0,
349
+ scale_step=55.0,
350
+ detail_preset=detail,
351
+ )
352
+
353
+
354
+ @dataclass(slots=True)
355
+ class _ProjectileUpdateCtx:
356
+ pool: ProjectilePool
357
+ creatures: list[Damageable]
358
+ dt: float
359
+ ion_scale: float
360
+ detail_preset: int
361
+ rng: Callable[[], int]
362
+ runtime_state: object | None
363
+ effects: object | None
364
+ sfx_queue: object | None
365
+ apply_creature_damage: CreatureDamageApplier | None
366
+
367
+
368
+ @dataclass(slots=True)
369
+ class _ProjectileHitInfo:
370
+ proj_index: int
371
+ proj: Projectile
372
+ hit_idx: int
373
+ move_dx: float
374
+ move_dy: float
375
+ target_x: float
376
+ target_y: float
377
+
378
+
379
+ @dataclass(slots=True)
380
+ class _ProjectileHitPerkCtx:
381
+ proj: Projectile
382
+ creature: Damageable
383
+ rng: Callable[[], int]
384
+ owner_perk_active: Callable[[int, int], bool]
385
+ poison_idx: int
386
+
387
+
388
+ _ProjectileHitPerkHook = Callable[[_ProjectileHitPerkCtx], None]
389
+
390
+
391
+ def _projectile_hit_perk_poison_bullets(ctx: _ProjectileHitPerkCtx) -> None:
392
+ if ctx.owner_perk_active(int(ctx.proj.owner_id), int(ctx.poison_idx)) and (int(ctx.rng()) & 7) == 1:
393
+ if hasattr(ctx.creature, "flags"):
394
+ ctx.creature.flags |= CreatureFlags.SELF_DAMAGE_TICK
395
+
396
+
397
+ _PROJECTILE_HIT_PERK_HOOKS: tuple[_ProjectileHitPerkHook, ...] = (_projectile_hit_perk_poison_bullets,)
398
+
399
+
400
+ ProjectileLingerHandler = Callable[[_ProjectileUpdateCtx, Projectile], None]
401
+ ProjectilePreHitCreatureHandler = Callable[[_ProjectileUpdateCtx, Projectile, int], None]
402
+ ProjectilePostHitCreatureHandler = Callable[[_ProjectileUpdateCtx, _ProjectileHitInfo], None]
403
+
404
+
405
+ @dataclass(frozen=True, slots=True)
406
+ class ProjectileBehavior:
407
+ linger: ProjectileLingerHandler
408
+ pre_hit_creature: ProjectilePreHitCreatureHandler | None = None
409
+ post_hit_creature: ProjectilePostHitCreatureHandler | None = None
410
+
411
+
412
+ def _linger_default(ctx: _ProjectileUpdateCtx, proj: Projectile) -> None:
413
+ proj.life_timer -= ctx.dt
414
+
415
+
416
+ def _linger_gauss_gun(ctx: _ProjectileUpdateCtx, proj: Projectile) -> None:
417
+ proj.life_timer -= ctx.dt * 0.1
418
+
419
+
420
+ def _linger_ion_minigun(ctx: _ProjectileUpdateCtx, proj: Projectile) -> None:
421
+ proj.life_timer -= ctx.dt
422
+ damage = ctx.dt * 40.0
423
+ radius = ctx.ion_scale * 60.0
424
+ for creature_idx, creature in enumerate(ctx.creatures):
425
+ if creature.hp <= 0.0:
426
+ continue
427
+ creature_radius = _hit_radius_for(creature)
428
+ hit_r = radius + creature_radius
429
+ if distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
430
+ _apply_damage_to_creature(
431
+ ctx.creatures,
432
+ creature_idx,
433
+ damage,
434
+ damage_type=7,
435
+ impulse_x=0.0,
436
+ impulse_y=0.0,
437
+ owner_id=int(proj.owner_id),
438
+ apply_creature_damage=ctx.apply_creature_damage,
439
+ )
440
+
441
+
442
+ def _linger_ion_rifle(ctx: _ProjectileUpdateCtx, proj: Projectile) -> None:
443
+ proj.life_timer -= ctx.dt
444
+ damage = ctx.dt * 100.0
445
+ radius = ctx.ion_scale * 88.0
446
+ for creature_idx, creature in enumerate(ctx.creatures):
447
+ if creature.hp <= 0.0:
448
+ continue
449
+ creature_radius = _hit_radius_for(creature)
450
+ hit_r = radius + creature_radius
451
+ if distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
452
+ _apply_damage_to_creature(
453
+ ctx.creatures,
454
+ creature_idx,
455
+ damage,
456
+ damage_type=7,
457
+ impulse_x=0.0,
458
+ impulse_y=0.0,
459
+ owner_id=int(proj.owner_id),
460
+ apply_creature_damage=ctx.apply_creature_damage,
461
+ )
462
+
463
+
464
+ def _linger_ion_cannon(ctx: _ProjectileUpdateCtx, proj: Projectile) -> None:
465
+ proj.life_timer -= ctx.dt * 0.7
466
+ damage = ctx.dt * 300.0
467
+ radius = ctx.ion_scale * 128.0
468
+ for creature_idx, creature in enumerate(ctx.creatures):
469
+ if creature.hp <= 0.0:
470
+ continue
471
+ creature_radius = _hit_radius_for(creature)
472
+ hit_r = radius + creature_radius
473
+ if distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
474
+ _apply_damage_to_creature(
475
+ ctx.creatures,
476
+ creature_idx,
477
+ damage,
478
+ damage_type=7,
479
+ impulse_x=0.0,
480
+ impulse_y=0.0,
481
+ owner_id=int(proj.owner_id),
482
+ apply_creature_damage=ctx.apply_creature_damage,
483
+ )
484
+
485
+
486
+ def _pre_hit_splitter(ctx: _ProjectileUpdateCtx, proj: Projectile, hit_idx: int) -> None:
487
+ _spawn_splitter_hit_effects(
488
+ ctx.effects,
489
+ pos_x=float(proj.pos_x),
490
+ pos_y=float(proj.pos_y),
491
+ rng=ctx.rng,
492
+ detail_preset=ctx.detail_preset,
493
+ )
494
+ ctx.pool.spawn(
495
+ pos_x=proj.pos_x,
496
+ pos_y=proj.pos_y,
497
+ angle=proj.angle - 1.0471976,
498
+ type_id=ProjectileTypeId.SPLITTER_GUN,
499
+ owner_id=int(hit_idx),
500
+ base_damage=proj.base_damage,
501
+ hits_players=proj.hits_players,
502
+ )
503
+ ctx.pool.spawn(
504
+ pos_x=proj.pos_x,
505
+ pos_y=proj.pos_y,
506
+ angle=proj.angle + 1.0471976,
507
+ type_id=ProjectileTypeId.SPLITTER_GUN,
508
+ owner_id=int(hit_idx),
509
+ base_damage=proj.base_damage,
510
+ hits_players=proj.hits_players,
511
+ )
512
+
513
+
514
+ def _post_hit_ion_common(ctx: _ProjectileUpdateCtx, hit: _ProjectileHitInfo) -> None:
515
+ _spawn_ion_hit_effects(
516
+ ctx.effects,
517
+ ctx.sfx_queue,
518
+ type_id=int(hit.proj.type_id),
519
+ pos_x=float(hit.target_x),
520
+ pos_y=float(hit.target_y),
521
+ rng=ctx.rng,
522
+ detail_preset=ctx.detail_preset,
523
+ )
524
+
525
+
526
+ def _post_hit_ion_rifle(ctx: _ProjectileUpdateCtx, hit: _ProjectileHitInfo) -> None:
527
+ if ctx.runtime_state is not None and getattr(ctx.runtime_state, "shock_chain_projectile_id", -1) == hit.proj_index:
528
+ hit.proj.reserved = float(int(hit.hit_idx) + 1)
529
+ _post_hit_ion_common(ctx, hit)
530
+
531
+
532
+ def _post_hit_plasma_cannon(ctx: _ProjectileUpdateCtx, hit: _ProjectileHitInfo) -> None:
533
+ creature = ctx.creatures[int(hit.hit_idx)]
534
+ size = float(getattr(creature, "size", 50.0) or 50.0)
535
+ ring_radius = size * 0.5 + 1.0
536
+
537
+ plasma_entry = weapon_entry_for_projectile_type_id(int(ProjectileTypeId.PLASMA_RIFLE))
538
+ plasma_meta = float(plasma_entry.projectile_meta) if plasma_entry and plasma_entry.projectile_meta is not None else hit.proj.base_damage
539
+
540
+ for ring_idx in range(12):
541
+ ring_angle = float(ring_idx) * (math.pi / 6.0)
542
+ ctx.pool.spawn(
543
+ pos_x=hit.proj.pos_x + math.cos(ring_angle) * ring_radius,
544
+ pos_y=hit.proj.pos_y + math.sin(ring_angle) * ring_radius,
545
+ angle=ring_angle,
546
+ type_id=ProjectileTypeId.PLASMA_RIFLE,
547
+ owner_id=-100,
548
+ base_damage=plasma_meta,
549
+ )
550
+
551
+ _spawn_plasma_cannon_hit_effects(
552
+ ctx.effects,
553
+ ctx.sfx_queue,
554
+ pos_x=float(hit.proj.pos_x),
555
+ pos_y=float(hit.proj.pos_y),
556
+ detail_preset=ctx.detail_preset,
557
+ )
558
+
559
+
560
+ def _post_hit_shrinkifier(ctx: _ProjectileUpdateCtx, hit: _ProjectileHitInfo) -> None:
561
+ creature = ctx.creatures[int(hit.hit_idx)]
562
+ if hasattr(creature, "size"):
563
+ new_size = float(getattr(creature, "size", 50.0) or 50.0) * 0.65
564
+ setattr(creature, "size", new_size)
565
+ if new_size < 16.0:
566
+ _apply_damage_to_creature(
567
+ ctx.creatures,
568
+ int(hit.hit_idx),
569
+ float(creature.hp) + 1.0,
570
+ damage_type=1,
571
+ impulse_x=0.0,
572
+ impulse_y=0.0,
573
+ owner_id=int(hit.proj.owner_id),
574
+ apply_creature_damage=ctx.apply_creature_damage,
575
+ )
576
+ hit.proj.life_timer = 0.25
577
+
578
+
579
+ def _post_hit_pulse_gun(ctx: _ProjectileUpdateCtx, hit: _ProjectileHitInfo) -> None:
580
+ creature = ctx.creatures[int(hit.hit_idx)]
581
+ creature.x += hit.move_dx * 3.0
582
+ creature.y += hit.move_dy * 3.0
583
+
584
+
585
+ def _post_hit_plague_spreader(ctx: _ProjectileUpdateCtx, hit: _ProjectileHitInfo) -> None:
586
+ creature = ctx.creatures[int(hit.hit_idx)]
587
+ if hasattr(creature, "plague_infected"):
588
+ setattr(creature, "plague_infected", True)
589
+
590
+
591
+ _DEFAULT_BEHAVIOR = ProjectileBehavior(linger=_linger_default)
592
+
593
+ # Public: used by tests to ensure handler coverage.
594
+ PROJECTILE_BEHAVIOR_BY_TYPE_ID: dict[int, ProjectileBehavior] = {
595
+ int(ProjectileTypeId.PISTOL): _DEFAULT_BEHAVIOR,
596
+ int(ProjectileTypeId.ASSAULT_RIFLE): _DEFAULT_BEHAVIOR,
597
+ int(ProjectileTypeId.SHOTGUN): _DEFAULT_BEHAVIOR,
598
+ int(ProjectileTypeId.SUBMACHINE_GUN): _DEFAULT_BEHAVIOR,
599
+ int(ProjectileTypeId.GAUSS_GUN): ProjectileBehavior(linger=_linger_gauss_gun),
600
+ int(ProjectileTypeId.PLASMA_RIFLE): _DEFAULT_BEHAVIOR,
601
+ int(ProjectileTypeId.PLASMA_MINIGUN): _DEFAULT_BEHAVIOR,
602
+ int(ProjectileTypeId.PULSE_GUN): ProjectileBehavior(linger=_linger_default, post_hit_creature=_post_hit_pulse_gun),
603
+ int(ProjectileTypeId.ION_RIFLE): ProjectileBehavior(linger=_linger_ion_rifle, post_hit_creature=_post_hit_ion_rifle),
604
+ int(ProjectileTypeId.ION_MINIGUN): ProjectileBehavior(linger=_linger_ion_minigun, post_hit_creature=_post_hit_ion_common),
605
+ int(ProjectileTypeId.ION_CANNON): ProjectileBehavior(linger=_linger_ion_cannon, post_hit_creature=_post_hit_ion_common),
606
+ int(ProjectileTypeId.SHRINKIFIER): ProjectileBehavior(linger=_linger_default, post_hit_creature=_post_hit_shrinkifier),
607
+ int(ProjectileTypeId.BLADE_GUN): _DEFAULT_BEHAVIOR,
608
+ int(ProjectileTypeId.SPIDER_PLASMA): _DEFAULT_BEHAVIOR,
609
+ int(ProjectileTypeId.PLASMA_CANNON): ProjectileBehavior(linger=_linger_default, post_hit_creature=_post_hit_plasma_cannon),
610
+ int(ProjectileTypeId.SPLITTER_GUN): ProjectileBehavior(linger=_linger_default, pre_hit_creature=_pre_hit_splitter),
611
+ int(ProjectileTypeId.PLAGUE_SPREADER): ProjectileBehavior(linger=_linger_default, post_hit_creature=_post_hit_plague_spreader),
612
+ int(ProjectileTypeId.RAINBOW_GUN): _DEFAULT_BEHAVIOR,
613
+ int(ProjectileTypeId.FIRE_BULLETS): _DEFAULT_BEHAVIOR,
614
+ }
615
+
616
+
139
617
  class ProjectilePool:
140
618
  def __init__(self, *, size: int = MAIN_PROJECTILE_POOL_SIZE) -> None:
141
619
  self._entries = [Projectile() for _ in range(size)]
@@ -299,195 +777,18 @@ class ProjectilePool:
299
777
  def _damage_type_for() -> int:
300
778
  return 1
301
779
 
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 _spawn_plasma_cannon_hit_effects(pos_x: float, pos_y: float) -> None:
387
- """Port of `projectile_update` Plasma Cannon hit extras.
388
-
389
- Native does:
390
- - `sfx_play_panned(sfx_explosion_medium)`
391
- - `sfx_play_panned(sfx_shockwave)`
392
- - `FUN_0042f330(pos, 1.5, 1.0)`
393
- - `FUN_0042f330(pos, 1.0, 1.0)`
394
- """
395
-
396
- if effects is None or not hasattr(effects, "spawn"):
397
- return
398
-
399
- if isinstance(sfx_queue, list):
400
- sfx_queue.append("sfx_explosion_medium")
401
- sfx_queue.append("sfx_shockwave")
402
-
403
- detail = int(detail_preset)
404
-
405
- def _spawn_ring(*, scale: float) -> None:
406
- effects.spawn(
407
- effect_id=1,
408
- pos_x=float(pos_x),
409
- pos_y=float(pos_y),
410
- vel_x=0.0,
411
- vel_y=0.0,
412
- rotation=0.0,
413
- scale=1.0,
414
- half_width=4.0,
415
- half_height=4.0,
416
- age=0.1,
417
- lifetime=1.0,
418
- flags=0x19,
419
- color_r=0.9,
420
- color_g=0.6,
421
- color_b=0.3,
422
- color_a=1.0,
423
- rotation_step=0.0,
424
- scale_step=float(scale) * 45.0,
425
- detail_preset=detail,
426
- )
427
-
428
- _spawn_ring(scale=1.5)
429
- _spawn_ring(scale=1.0)
430
-
431
- def _spawn_splitter_hit_effects(pos_x: float, pos_y: float) -> None:
432
- """Port of `FUN_0042f3f0(pos, 26.0, 3)` from the Splitter Gun hit branch."""
433
-
434
- if effects is None or not hasattr(effects, "spawn"):
435
- return
436
-
437
- detail = int(detail_preset)
438
- for _ in range(3):
439
- angle = float(int(rng()) & 0x1FF) * (math.tau / 512.0)
440
- radius = float(int(rng()) % 26)
441
- jitter_age = -float(int(rng()) & 0xFF) * 0.0012
442
- lifetime = 0.1 - jitter_age
443
-
444
- effects.spawn(
445
- effect_id=0,
446
- pos_x=float(pos_x) + math.cos(angle) * radius,
447
- pos_y=float(pos_y) + math.sin(angle) * radius,
448
- vel_x=0.0,
449
- vel_y=0.0,
450
- rotation=0.0,
451
- scale=1.0,
452
- half_width=4.0,
453
- half_height=4.0,
454
- age=jitter_age,
455
- lifetime=lifetime,
456
- flags=0x19,
457
- color_r=1.0,
458
- color_g=0.9,
459
- color_b=0.1,
460
- color_a=1.0,
461
- rotation_step=0.0,
462
- scale_step=55.0,
463
- detail_preset=detail,
464
- )
465
-
466
- def _apply_damage_to_creature(
467
- creature_index: int,
468
- damage: float,
469
- *,
470
- damage_type: int,
471
- impulse_x: float,
472
- impulse_y: float,
473
- owner_id: int,
474
- ) -> None:
475
- if damage <= 0.0:
476
- return
477
- idx = int(creature_index)
478
- if not (0 <= idx < len(creatures)):
479
- return
480
- if apply_creature_damage is not None:
481
- apply_creature_damage(
482
- idx,
483
- float(damage),
484
- int(damage_type),
485
- float(impulse_x),
486
- float(impulse_y),
487
- int(owner_id),
488
- )
489
- else:
490
- creatures[idx].hp -= float(damage)
780
+ ctx = _ProjectileUpdateCtx(
781
+ pool=self,
782
+ creatures=creatures,
783
+ dt=float(dt),
784
+ ion_scale=float(ion_scale),
785
+ detail_preset=int(detail_preset),
786
+ rng=rng,
787
+ runtime_state=runtime_state,
788
+ effects=effects,
789
+ sfx_queue=sfx_queue,
790
+ apply_creature_damage=apply_creature_damage,
791
+ )
491
792
 
492
793
  def _reset_shock_chain_if_owner(index: int) -> None:
493
794
  if runtime_state is None:
@@ -517,7 +818,7 @@ class ProjectilePool:
517
818
  continue
518
819
  if creature.hp <= 0.0:
519
820
  continue
520
- d = _distance_sq(origin.x, origin.y, creature.x, creature.y)
821
+ d = distance_sq(origin.x, origin.y, creature.x, creature.y)
521
822
  if d > max_dist * max_dist:
522
823
  continue
523
824
  if best_idx == -1 or d < best_dist:
@@ -549,6 +850,7 @@ class ProjectilePool:
549
850
  for proj_index, proj in enumerate(self._entries):
550
851
  if not proj.active:
551
852
  continue
853
+ behavior = PROJECTILE_BEHAVIOR_BY_TYPE_ID.get(int(proj.type_id), _DEFAULT_BEHAVIOR)
552
854
 
553
855
  if proj.life_timer <= 0.0:
554
856
  _reset_shock_chain_if_owner(proj_index)
@@ -562,51 +864,7 @@ class ProjectilePool:
562
864
  _try_spawn_shock_chain_link(proj_index, pending_hit - 1)
563
865
 
564
866
  if proj.life_timer < 0.4:
565
- type_id = proj.type_id
566
- if type_id in (ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_MINIGUN):
567
- proj.life_timer -= dt
568
- if type_id == ProjectileTypeId.ION_RIFLE:
569
- damage = dt * 100.0
570
- radius = ion_scale * 88.0
571
- else:
572
- damage = dt * 40.0
573
- radius = ion_scale * 60.0
574
- for creature_idx, creature in enumerate(creatures):
575
- if creature.hp <= 0.0:
576
- continue
577
- creature_radius = _hit_radius_for(creature)
578
- hit_r = radius + creature_radius
579
- if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
580
- _apply_damage_to_creature(
581
- creature_idx,
582
- damage,
583
- damage_type=7,
584
- impulse_x=0.0,
585
- impulse_y=0.0,
586
- owner_id=int(proj.owner_id),
587
- )
588
- elif type_id == ProjectileTypeId.ION_CANNON:
589
- proj.life_timer -= dt * 0.7
590
- damage = dt * 300.0
591
- radius = ion_scale * 128.0
592
- for creature_idx, creature in enumerate(creatures):
593
- if creature.hp <= 0.0:
594
- continue
595
- creature_radius = _hit_radius_for(creature)
596
- hit_r = radius + creature_radius
597
- if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
598
- _apply_damage_to_creature(
599
- creature_idx,
600
- damage,
601
- damage_type=7,
602
- impulse_x=0.0,
603
- impulse_y=0.0,
604
- owner_id=int(proj.owner_id),
605
- )
606
- elif type_id == ProjectileTypeId.GAUSS_GUN:
607
- proj.life_timer -= dt * 0.1
608
- else:
609
- proj.life_timer -= dt
867
+ behavior.linger(ctx, proj)
610
868
 
611
869
  if proj.life_timer <= 0.0:
612
870
  proj.active = False
@@ -655,7 +913,7 @@ class ProjectilePool:
655
913
  continue
656
914
  creature_radius = _hit_radius_for(creature)
657
915
  hit_r = proj.hit_radius + creature_radius
658
- if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
916
+ if distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
659
917
  hit_idx = idx
660
918
  break
661
919
 
@@ -673,7 +931,7 @@ class ProjectilePool:
673
931
  player_radius = _hit_radius_for(player)
674
932
  hit_r = proj.hit_radius + player_radius
675
933
  if (
676
- _distance_sq(proj.pos_x, proj.pos_y, player.pos_x, player.pos_y)
934
+ distance_sq(proj.pos_x, proj.pos_y, player.pos_x, player.pos_y)
677
935
  <= hit_r * hit_r
678
936
  ):
679
937
  hit_player_idx = idx
@@ -721,30 +979,18 @@ class ProjectilePool:
721
979
  type_id = proj.type_id
722
980
  creature = creatures[hit_idx]
723
981
 
724
- if _owner_perk_active(int(proj.owner_id), poison_idx) and (int(rng()) & 7) == 1:
725
- if hasattr(creature, "flags"):
726
- creature.flags |= CreatureFlags.SELF_DAMAGE_TICK
727
-
728
- if type_id == ProjectileTypeId.SPLITTER_GUN:
729
- _spawn_splitter_hit_effects(proj.pos_x, proj.pos_y)
730
- self.spawn(
731
- pos_x=proj.pos_x,
732
- pos_y=proj.pos_y,
733
- angle=proj.angle - 1.0471976,
734
- type_id=ProjectileTypeId.SPLITTER_GUN,
735
- owner_id=hit_idx,
736
- base_damage=proj.base_damage,
737
- hits_players=proj.hits_players,
738
- )
739
- self.spawn(
740
- pos_x=proj.pos_x,
741
- pos_y=proj.pos_y,
742
- angle=proj.angle + 1.0471976,
743
- type_id=ProjectileTypeId.SPLITTER_GUN,
744
- owner_id=hit_idx,
745
- base_damage=proj.base_damage,
746
- hits_players=proj.hits_players,
747
- )
982
+ perk_ctx = _ProjectileHitPerkCtx(
983
+ proj=proj,
984
+ creature=creature,
985
+ rng=rng,
986
+ owner_perk_active=_owner_perk_active,
987
+ poison_idx=poison_idx,
988
+ )
989
+ for hook in _PROJECTILE_HIT_PERK_HOOKS:
990
+ hook(perk_ctx)
991
+
992
+ if behavior.pre_hit_creature is not None:
993
+ behavior.pre_hit_creature(ctx, proj, int(hit_idx))
748
994
 
749
995
  shots_hit = getattr(runtime_state, "shots_hit", None) if runtime_state is not None else None
750
996
  if isinstance(shots_hit, list):
@@ -774,50 +1020,19 @@ class ProjectilePool:
774
1020
  if dist < 50.0:
775
1021
  dist = 50.0
776
1022
 
777
- if type_id == ProjectileTypeId.ION_RIFLE:
778
- if runtime_state is not None and getattr(runtime_state, "shock_chain_projectile_id", -1) == proj_index:
779
- proj.reserved = float(int(hit_idx) + 1)
780
- if type_id in (ProjectileTypeId.ION_MINIGUN, ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_CANNON):
781
- _spawn_ion_hit_effects(int(type_id), target_x, target_y)
782
- elif type_id == ProjectileTypeId.PLASMA_CANNON:
783
- size = float(getattr(creature, "size", 50.0) or 50.0)
784
- ring_radius = size * 0.5 + 1.0
785
- plasma_entry = weapon_entry_for_projectile_type_id(int(ProjectileTypeId.PLASMA_RIFLE))
786
- plasma_meta = (
787
- float(plasma_entry.projectile_meta)
788
- if plasma_entry and plasma_entry.projectile_meta is not None
789
- else proj.base_damage
1023
+ if behavior.post_hit_creature is not None:
1024
+ behavior.post_hit_creature(
1025
+ ctx,
1026
+ _ProjectileHitInfo(
1027
+ proj_index=int(proj_index),
1028
+ proj=proj,
1029
+ hit_idx=int(hit_idx),
1030
+ move_dx=float(move_dx),
1031
+ move_dy=float(move_dy),
1032
+ target_x=float(target_x),
1033
+ target_y=float(target_y),
1034
+ ),
790
1035
  )
791
- for ring_idx in range(12):
792
- ring_angle = float(ring_idx) * (math.pi / 6.0)
793
- self.spawn(
794
- pos_x=proj.pos_x + math.cos(ring_angle) * ring_radius,
795
- pos_y=proj.pos_y + math.sin(ring_angle) * ring_radius,
796
- angle=ring_angle,
797
- type_id=ProjectileTypeId.PLASMA_RIFLE,
798
- owner_id=-100,
799
- base_damage=plasma_meta,
800
- )
801
- _spawn_plasma_cannon_hit_effects(proj.pos_x, proj.pos_y)
802
- elif type_id == ProjectileTypeId.SHRINKIFIER:
803
- if hasattr(creature, "size"):
804
- new_size = float(getattr(creature, "size", 50.0) or 50.0) * 0.65
805
- setattr(creature, "size", new_size)
806
- if new_size < 16.0:
807
- _apply_damage_to_creature(
808
- hit_idx,
809
- float(creature.hp) + 1.0,
810
- damage_type=_damage_type_for(),
811
- impulse_x=0.0,
812
- impulse_y=0.0,
813
- owner_id=int(proj.owner_id),
814
- )
815
- proj.life_timer = 0.25
816
- elif type_id == ProjectileTypeId.PULSE_GUN:
817
- creature.x += move_dx * 3.0
818
- creature.y += move_dy * 3.0
819
- elif type_id == ProjectileTypeId.PLAGUE_SPREADER and hasattr(creature, "collision_flag"):
820
- setattr(creature, "collision_flag", 1)
821
1036
 
822
1037
  damage_scale = _damage_scale(type_id)
823
1038
  damage_amount = ((100.0 / dist) * damage_scale * 30.0 + 10.0) * 0.95
@@ -830,24 +1045,28 @@ class ProjectilePool:
830
1045
  damage_type = _damage_type_for()
831
1046
  if remaining <= 0.0:
832
1047
  _apply_damage_to_creature(
833
- hit_idx,
834
- damage_amount,
1048
+ creatures,
1049
+ int(hit_idx),
1050
+ float(damage_amount),
835
1051
  damage_type=damage_type,
836
1052
  impulse_x=impulse_x,
837
1053
  impulse_y=impulse_y,
838
1054
  owner_id=int(proj.owner_id),
1055
+ apply_creature_damage=apply_creature_damage,
839
1056
  )
840
1057
  if proj.life_timer != 0.25:
841
1058
  proj.life_timer = 0.25
842
1059
  else:
843
1060
  hp_before = float(creature.hp)
844
1061
  _apply_damage_to_creature(
845
- hit_idx,
846
- remaining,
1062
+ creatures,
1063
+ int(hit_idx),
1064
+ float(remaining),
847
1065
  damage_type=damage_type,
848
1066
  impulse_x=impulse_x,
849
1067
  impulse_y=impulse_y,
850
1068
  owner_id=int(proj.owner_id),
1069
+ apply_creature_damage=apply_creature_damage,
851
1070
  )
852
1071
  proj.damage_pool -= hp_before
853
1072
 
@@ -906,7 +1125,7 @@ class ProjectilePool:
906
1125
  continue
907
1126
  creature_radius = _hit_radius_for(creature)
908
1127
  hit_r = radius + creature_radius
909
- if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1128
+ if distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
910
1129
  creature.hp -= damage
911
1130
  elif proj.type_id == ProjectileTypeId.ION_MINIGUN:
912
1131
  damage = dt * 40.0
@@ -916,7 +1135,7 @@ class ProjectilePool:
916
1135
  continue
917
1136
  creature_radius = _hit_radius_for(creature)
918
1137
  hit_r = radius + creature_radius
919
- if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1138
+ if distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
920
1139
  creature.hp -= damage
921
1140
  proj.life_timer -= dt
922
1141
  if proj.life_timer <= 0.0:
@@ -946,7 +1165,7 @@ class ProjectilePool:
946
1165
  continue
947
1166
  creature_radius = _hit_radius_for(creature)
948
1167
  hit_r = proj.hit_radius + creature_radius
949
- if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1168
+ if distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
950
1169
  hit_idx = idx
951
1170
  break
952
1171
  if hit_idx is None:
@@ -1099,7 +1318,7 @@ class SecondaryProjectilePool:
1099
1318
  continue
1100
1319
  creature_radius = _hit_radius_for(creature)
1101
1320
  hit_r = radius + creature_radius
1102
- if _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1321
+ if distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1103
1322
  _apply_damage_to_creature(creature_idx, damage, owner_id=int(entry.owner_id))
1104
1323
  continue
1105
1324
 
@@ -1133,7 +1352,7 @@ class SecondaryProjectilePool:
1133
1352
  for idx, creature in enumerate(creatures):
1134
1353
  if creature.hp <= 0.0:
1135
1354
  continue
1136
- d = _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y)
1355
+ d = distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y)
1137
1356
  if best_idx == -1 or d < best_dist:
1138
1357
  best_idx = idx
1139
1358
  best_dist = d
@@ -1182,7 +1401,7 @@ class SecondaryProjectilePool:
1182
1401
  continue
1183
1402
  creature_radius = _hit_radius_for(creature)
1184
1403
  hit_r = 8.0 + creature_radius
1185
- if _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1404
+ if distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
1186
1405
  hit_idx = idx
1187
1406
  break
1188
1407
  if hit_idx is not None: