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/gameplay.py CHANGED
@@ -2,8 +2,9 @@ from __future__ import annotations
2
2
 
3
3
  from dataclasses import dataclass, field
4
4
  import math
5
- from typing import TYPE_CHECKING, Protocol
5
+ from typing import TYPE_CHECKING, Callable, Protocol
6
6
 
7
+ from grim.math import clamp, distance_sq
7
8
  from .bonuses import BONUS_BY_ID, BonusId
8
9
  from grim.rand import Crand
9
10
  from .effects import EffectPool, FxQueue, ParticlePool, SpriteEffectPool
@@ -277,8 +278,8 @@ class BonusPool:
277
278
  if entry is None:
278
279
  return None
279
280
 
280
- x = _clamp(float(pos_x), BONUS_SPAWN_MARGIN, float(world_width) - BONUS_SPAWN_MARGIN)
281
- y = _clamp(float(pos_y), BONUS_SPAWN_MARGIN, float(world_height) - BONUS_SPAWN_MARGIN)
281
+ x = clamp(float(pos_x), BONUS_SPAWN_MARGIN, float(world_width) - BONUS_SPAWN_MARGIN)
282
+ y = clamp(float(pos_y), BONUS_SPAWN_MARGIN, float(world_height) - BONUS_SPAWN_MARGIN)
282
283
 
283
284
  entry.bonus_id = int(bonus_id)
284
285
  entry.picked = False
@@ -316,7 +317,7 @@ class BonusPool:
316
317
  for entry in self._entries:
317
318
  if entry.bonus_id == 0:
318
319
  continue
319
- if _distance_sq(pos_x, pos_y, entry.pos_x, entry.pos_y) < min_dist_sq:
320
+ if distance_sq(pos_x, pos_y, entry.pos_x, entry.pos_y) < min_dist_sq:
320
321
  return None
321
322
 
322
323
  entry = self._alloc_slot()
@@ -421,7 +422,7 @@ class BonusPool:
421
422
 
422
423
  if entry.bonus_id == int(BonusId.WEAPON):
423
424
  near_sq = BONUS_WEAPON_NEAR_RADIUS * BONUS_WEAPON_NEAR_RADIUS
424
- if players and _distance_sq(pos_x, pos_y, players[0].pos_x, players[0].pos_y) < near_sq:
425
+ if players and distance_sq(pos_x, pos_y, players[0].pos_x, players[0].pos_y) < near_sq:
425
426
  entry.bonus_id = int(BonusId.POINTS)
426
427
  entry.amount = 100
427
428
 
@@ -466,7 +467,7 @@ class BonusPool:
466
467
  continue
467
468
 
468
469
  for player in players:
469
- if _distance_sq(entry.pos_x, entry.pos_y, player.pos_x, player.pos_y) < BONUS_PICKUP_RADIUS * BONUS_PICKUP_RADIUS:
470
+ if distance_sq(entry.pos_x, entry.pos_y, player.pos_x, player.pos_y) < BONUS_PICKUP_RADIUS * BONUS_PICKUP_RADIUS:
470
471
  bonus_apply(
471
472
  state,
472
473
  player,
@@ -503,7 +504,7 @@ def bonus_find_aim_hover_entry(player: PlayerState, bonus_pool: BonusPool) -> tu
503
504
  for idx, entry in enumerate(bonus_pool.entries):
504
505
  if entry.bonus_id == 0 or entry.picked:
505
506
  continue
506
- if _distance_sq(aim_x, aim_y, entry.pos_x, entry.pos_y) < radius_sq:
507
+ if distance_sq(aim_x, aim_y, entry.pos_x, entry.pos_y) < radius_sq:
507
508
  return idx, entry
508
509
  return None
509
510
 
@@ -596,6 +597,155 @@ def _creature_find_in_radius(creatures: list[_CreatureForPerks], *, pos_x: float
596
597
  return -1
597
598
 
598
599
 
600
+ @dataclass(slots=True)
601
+ class _PerksUpdateEffectsCtx:
602
+ state: GameplayState
603
+ players: list[PlayerState]
604
+ dt: float
605
+ creatures: list[_CreatureForPerks] | None
606
+ fx_queue: FxQueue | None
607
+ _aim_target: int | None = None
608
+
609
+ def aim_target(self) -> int:
610
+ if self._aim_target is not None:
611
+ return int(self._aim_target)
612
+
613
+ target = -1
614
+ if self.players and self.creatures is not None and (
615
+ perk_active(self.players[0], PerkId.PYROKINETIC) or perk_active(self.players[0], PerkId.EVIL_EYES)
616
+ ):
617
+ target = _creature_find_in_radius(
618
+ self.creatures,
619
+ pos_x=self.players[0].aim_x,
620
+ pos_y=self.players[0].aim_y,
621
+ radius=12.0,
622
+ start_index=0,
623
+ )
624
+ self._aim_target = int(target)
625
+ return int(target)
626
+
627
+
628
+ _PerksUpdateEffectsStep = Callable[[_PerksUpdateEffectsCtx], None]
629
+
630
+
631
+ def _perks_update_regeneration(ctx: _PerksUpdateEffectsCtx) -> None:
632
+ if ctx.players and perk_active(ctx.players[0], PerkId.REGENERATION) and (ctx.state.rng.rand() & 1):
633
+ for player in ctx.players:
634
+ if not (0.0 < float(player.health) < 100.0):
635
+ continue
636
+ player.health = float(player.health) + ctx.dt
637
+ if player.health > 100.0:
638
+ player.health = 100.0
639
+
640
+
641
+ def _perks_update_lean_mean_exp_machine(ctx: _PerksUpdateEffectsCtx) -> None:
642
+ ctx.state.lean_mean_exp_timer -= ctx.dt
643
+ if ctx.state.lean_mean_exp_timer < 0.0:
644
+ ctx.state.lean_mean_exp_timer = 0.25
645
+ for player in ctx.players:
646
+ perk_count = perk_count_get(player, PerkId.LEAN_MEAN_EXP_MACHINE)
647
+ if perk_count > 0:
648
+ player.experience += perk_count * 10
649
+
650
+
651
+ def _perks_update_death_clock(ctx: _PerksUpdateEffectsCtx) -> None:
652
+ for player in ctx.players:
653
+ if not perk_active(player, PerkId.DEATH_CLOCK):
654
+ continue
655
+
656
+ if float(player.health) <= 0.0:
657
+ player.health = 0.0
658
+ else:
659
+ player.health = float(player.health) - ctx.dt * 3.3333333
660
+
661
+
662
+ def _perks_update_evil_eyes_target(ctx: _PerksUpdateEffectsCtx) -> None:
663
+ if not ctx.players:
664
+ return
665
+
666
+ target = ctx.aim_target()
667
+ player0 = ctx.players[0]
668
+ player0.evil_eyes_target_creature = target if perk_active(player0, PerkId.EVIL_EYES) else -1
669
+
670
+
671
+ def _perks_update_pyrokinetic(ctx: _PerksUpdateEffectsCtx) -> None:
672
+ if not ctx.players:
673
+ return
674
+ if ctx.creatures is None:
675
+ return
676
+ if not perk_active(ctx.players[0], PerkId.PYROKINETIC):
677
+ return
678
+
679
+ target = ctx.aim_target()
680
+ if target == -1:
681
+ return
682
+
683
+ creature = ctx.creatures[target]
684
+ creature.collision_timer = float(creature.collision_timer) - ctx.dt
685
+ if creature.collision_timer < 0.0:
686
+ creature.collision_timer = 0.5
687
+ pos_x = float(creature.x)
688
+ pos_y = float(creature.y)
689
+ for intensity in (0.8, 0.6, 0.4, 0.3, 0.2):
690
+ angle = float(int(ctx.state.rng.rand()) % 0x274) * 0.01
691
+ ctx.state.particles.spawn_particle(pos_x=pos_x, pos_y=pos_y, angle=angle, intensity=float(intensity))
692
+ if ctx.fx_queue is not None:
693
+ ctx.fx_queue.add_random(pos_x=pos_x, pos_y=pos_y, rand=ctx.state.rng.rand)
694
+
695
+
696
+ def _perks_update_jinxed_timer(ctx: _PerksUpdateEffectsCtx) -> None:
697
+ if ctx.state.jinxed_timer >= 0.0:
698
+ ctx.state.jinxed_timer -= ctx.dt
699
+
700
+
701
+ def _perks_update_jinxed(ctx: _PerksUpdateEffectsCtx) -> None:
702
+ if ctx.state.jinxed_timer >= 0.0:
703
+ return
704
+ if not ctx.players:
705
+ return
706
+ if not perk_active(ctx.players[0], PerkId.JINXED):
707
+ return
708
+
709
+ player = ctx.players[0]
710
+ if int(ctx.state.rng.rand()) % 10 == 3:
711
+ player.health = float(player.health) - 5.0
712
+ if ctx.fx_queue is not None:
713
+ ctx.fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=ctx.state.rng.rand)
714
+ ctx.fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=ctx.state.rng.rand)
715
+
716
+ ctx.state.jinxed_timer = float(int(ctx.state.rng.rand()) % 0x14) * 0.1 + float(ctx.state.jinxed_timer) + 2.0
717
+
718
+ if float(ctx.state.bonuses.freeze) <= 0.0 and ctx.creatures is not None:
719
+ pool_mod = min(0x17F, len(ctx.creatures))
720
+ if pool_mod <= 0:
721
+ return
722
+
723
+ idx = int(ctx.state.rng.rand()) % pool_mod
724
+ attempts = 0
725
+ while attempts < 10 and not ctx.creatures[idx].active:
726
+ idx = int(ctx.state.rng.rand()) % pool_mod
727
+ attempts += 1
728
+ if not ctx.creatures[idx].active:
729
+ return
730
+
731
+ creature = ctx.creatures[idx]
732
+ creature.hp = -1.0
733
+ creature.hitbox_size = float(creature.hitbox_size) - ctx.dt * 20.0
734
+ player.experience = int(float(player.experience) + float(creature.reward_value))
735
+ ctx.state.sfx_queue.append("sfx_trooper_inpain_01")
736
+
737
+
738
+ _PERKS_UPDATE_EFFECT_STEPS: tuple[_PerksUpdateEffectsStep, ...] = (
739
+ _perks_update_regeneration,
740
+ _perks_update_lean_mean_exp_machine,
741
+ _perks_update_death_clock,
742
+ _perks_update_evil_eyes_target,
743
+ _perks_update_pyrokinetic,
744
+ _perks_update_jinxed_timer,
745
+ _perks_update_jinxed,
746
+ )
747
+
748
+
599
749
  def perks_update_effects(
600
750
  state: GameplayState,
601
751
  players: list[PlayerState],
@@ -609,83 +759,15 @@ def perks_update_effects(
609
759
  dt = float(dt)
610
760
  if dt <= 0.0:
611
761
  return
612
-
613
- if players and perk_active(players[0], PerkId.REGENERATION) and (state.rng.rand() & 1):
614
- for player in players:
615
- if not (0.0 < float(player.health) < 100.0):
616
- continue
617
- player.health = float(player.health) + dt
618
- if player.health > 100.0:
619
- player.health = 100.0
620
-
621
- state.lean_mean_exp_timer -= dt
622
- if state.lean_mean_exp_timer < 0.0:
623
- state.lean_mean_exp_timer = 0.25
624
- for player in players:
625
- perk_count = perk_count_get(player, PerkId.LEAN_MEAN_EXP_MACHINE)
626
- if perk_count > 0:
627
- player.experience += perk_count * 10
628
-
629
- target = -1
630
- if players and creatures is not None and (
631
- perk_active(players[0], PerkId.PYROKINETIC) or perk_active(players[0], PerkId.EVIL_EYES)
632
- ):
633
- target = _creature_find_in_radius(
634
- creatures,
635
- pos_x=players[0].aim_x,
636
- pos_y=players[0].aim_y,
637
- radius=12.0,
638
- start_index=0,
639
- )
640
-
641
- if players:
642
- player0 = players[0]
643
- player0.evil_eyes_target_creature = target if perk_active(player0, PerkId.EVIL_EYES) else -1
644
-
645
- if players and creatures is not None and perk_active(players[0], PerkId.PYROKINETIC) and target != -1:
646
- creature = creatures[target]
647
- creature.collision_timer = float(creature.collision_timer) - dt
648
- if creature.collision_timer < 0.0:
649
- creature.collision_timer = 0.5
650
- pos_x = float(creature.x)
651
- pos_y = float(creature.y)
652
- for intensity in (0.8, 0.6, 0.4, 0.3, 0.2):
653
- angle = float(int(state.rng.rand()) % 0x274) * 0.01
654
- state.particles.spawn_particle(pos_x=pos_x, pos_y=pos_y, angle=angle, intensity=float(intensity))
655
- if fx_queue is not None:
656
- fx_queue.add_random(pos_x=pos_x, pos_y=pos_y, rand=state.rng.rand)
657
-
658
- if state.jinxed_timer >= 0.0:
659
- state.jinxed_timer -= dt
660
-
661
- if state.jinxed_timer < 0.0 and players and perk_active(players[0], PerkId.JINXED):
662
- player = players[0]
663
- if int(state.rng.rand()) % 10 == 3:
664
- player.health = float(player.health) - 5.0
665
- if fx_queue is not None:
666
- fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
667
- fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
668
-
669
- state.jinxed_timer = float(int(state.rng.rand()) % 0x14) * 0.1 + float(state.jinxed_timer) + 2.0
670
-
671
- if float(state.bonuses.freeze) <= 0.0 and creatures is not None:
672
- pool_mod = min(0x17F, len(creatures))
673
- if pool_mod <= 0:
674
- return
675
-
676
- idx = int(state.rng.rand()) % pool_mod
677
- attempts = 0
678
- while attempts < 10 and not creatures[idx].active:
679
- idx = int(state.rng.rand()) % pool_mod
680
- attempts += 1
681
- if not creatures[idx].active:
682
- return
683
-
684
- creature = creatures[idx]
685
- creature.hp = -1.0
686
- creature.hitbox_size = float(creature.hitbox_size) - dt * 20.0
687
- player.experience = int(float(player.experience) + float(creature.reward_value))
688
- state.sfx_queue.append("sfx_trooper_inpain_01")
762
+ ctx = _PerksUpdateEffectsCtx(
763
+ state=state,
764
+ players=players,
765
+ dt=dt,
766
+ creatures=creatures,
767
+ fx_queue=fx_queue,
768
+ )
769
+ for step in _PERKS_UPDATE_EFFECT_STEPS:
770
+ step(ctx)
689
771
 
690
772
 
691
773
  def award_experience(state: GameplayState, player: PlayerState, amount: int) -> int:
@@ -943,139 +1025,193 @@ def _increment_perk_count(player: PlayerState, perk_id: PerkId, *, amount: int =
943
1025
  player.perk_counts[idx] += int(amount)
944
1026
 
945
1027
 
946
- def perk_apply(
947
- state: GameplayState,
948
- players: list[PlayerState],
949
- perk_id: PerkId,
950
- *,
951
- perk_state: PerkSelectionState | None = None,
952
- dt: float | None = None,
953
- creatures: list[_CreatureForPerks] | None = None,
954
- ) -> None:
955
- """Apply immediate perk effects and increment the perk counter."""
1028
+ @dataclass(slots=True)
1029
+ class _PerkApplyCtx:
1030
+ state: GameplayState
1031
+ players: list[PlayerState]
1032
+ owner: PlayerState
1033
+ perk_id: PerkId
1034
+ perk_state: PerkSelectionState | None
1035
+ dt: float | None
1036
+ creatures: list[_CreatureForPerks] | None
956
1037
 
957
- if not players:
1038
+ def frame_dt(self) -> float:
1039
+ return float(self.dt) if self.dt is not None else 0.0
1040
+
1041
+
1042
+ _PerkApplyHandler = Callable[[_PerkApplyCtx], None]
1043
+
1044
+
1045
+ def _perk_apply_instant_winner(ctx: _PerkApplyCtx) -> None:
1046
+ ctx.owner.experience += 2500
1047
+
1048
+
1049
+ def _perk_apply_fatal_lottery(ctx: _PerkApplyCtx) -> None:
1050
+ if ctx.state.rng.rand() & 1:
1051
+ ctx.owner.health = -1.0
1052
+ else:
1053
+ ctx.owner.experience += 10000
1054
+
1055
+
1056
+ def _perk_apply_random_weapon(ctx: _PerkApplyCtx) -> None:
1057
+ current = int(ctx.owner.weapon_id)
1058
+ weapon_id = int(current)
1059
+ for _ in range(100):
1060
+ candidate = int(weapon_pick_random_available(ctx.state))
1061
+ weapon_id = candidate
1062
+ if candidate != int(WeaponId.PISTOL) and candidate != current:
1063
+ break
1064
+ weapon_assign_player(ctx.owner, weapon_id, state=ctx.state)
1065
+
1066
+
1067
+ def _perk_apply_lifeline_50_50(ctx: _PerkApplyCtx) -> None:
1068
+ creatures = ctx.creatures
1069
+ if creatures is None:
958
1070
  return
959
- owner = players[0]
960
- try:
961
- _increment_perk_count(owner, perk_id)
962
1071
 
963
- if perk_id == PerkId.INSTANT_WINNER:
964
- owner.experience += 2500
965
- return
1072
+ kill_toggle = False
1073
+ for creature in creatures:
1074
+ if kill_toggle and creature.active and float(creature.hp) <= 500.0 and (int(creature.flags) & 0x04) == 0:
1075
+ creature.active = False
1076
+ ctx.state.effects.spawn_burst(
1077
+ pos_x=float(creature.x),
1078
+ pos_y=float(creature.y),
1079
+ count=4,
1080
+ rand=ctx.state.rng.rand,
1081
+ detail_preset=5,
1082
+ )
1083
+ kill_toggle = not kill_toggle
966
1084
 
967
- if perk_id == PerkId.FATAL_LOTTERY:
968
- if state.rng.rand() & 1:
969
- owner.health = -1.0
970
- else:
971
- owner.experience += 10000
972
- return
973
1085
 
974
- if perk_id == PerkId.RANDOM_WEAPON:
975
- current = int(owner.weapon_id)
976
- weapon_id = int(current)
977
- for _ in range(100):
978
- candidate = int(weapon_pick_random_available(state))
979
- weapon_id = candidate
980
- if candidate != int(WeaponId.PISTOL) and candidate != current:
981
- break
982
- weapon_assign_player(owner, weapon_id, state=state)
983
- return
1086
+ def _perk_apply_thick_skinned(ctx: _PerkApplyCtx) -> None:
1087
+ for player in ctx.players:
1088
+ if player.health > 0.0:
1089
+ player.health = max(1.0, player.health * (2.0 / 3.0))
984
1090
 
985
- if perk_id == PerkId.LIFELINE_50_50:
986
- if creatures is None:
987
- return
988
-
989
- kill_toggle = False
990
- for creature in creatures:
991
- if (
992
- kill_toggle
993
- and creature.active
994
- and float(creature.hp) <= 500.0
995
- and (int(creature.flags) & 0x04) == 0
996
- ):
997
- creature.active = False
998
- state.effects.spawn_burst(
999
- pos_x=float(creature.x),
1000
- pos_y=float(creature.y),
1001
- count=4,
1002
- rand=state.rng.rand,
1003
- detail_preset=5,
1004
- )
1005
- kill_toggle = not kill_toggle
1006
- return
1007
1091
 
1008
- if perk_id == PerkId.THICK_SKINNED:
1009
- for player in players:
1010
- if player.health > 0.0:
1011
- player.health = max(1.0, player.health * (2.0 / 3.0))
1012
- return
1092
+ def _perk_apply_breathing_room(ctx: _PerkApplyCtx) -> None:
1093
+ for player in ctx.players:
1094
+ player.health -= player.health * (2.0 / 3.0)
1013
1095
 
1014
- if perk_id == PerkId.BREATHING_ROOM:
1015
- for player in players:
1016
- player.health -= player.health * (2.0 / 3.0)
1096
+ frame_dt = ctx.frame_dt()
1097
+ creatures = ctx.creatures
1098
+ if creatures is not None:
1099
+ for creature in creatures:
1100
+ if creature.active:
1101
+ creature.hitbox_size = float(creature.hitbox_size) - frame_dt
1017
1102
 
1018
- frame_dt = float(dt) if dt is not None else 0.0
1019
- if creatures is not None:
1020
- for creature in creatures:
1021
- if creature.active:
1022
- creature.hitbox_size = float(creature.hitbox_size) - frame_dt
1103
+ ctx.state.bonus_spawn_guard = False
1023
1104
 
1024
- state.bonus_spawn_guard = False
1025
- return
1026
1105
 
1027
- if perk_id == PerkId.INFERNAL_CONTRACT:
1028
- owner.level += 3
1029
- if perk_state is not None:
1030
- perk_state.pending_count += 3
1031
- perk_state.choices_dirty = True
1032
- for player in players:
1033
- if player.health > 0.0:
1034
- player.health = 0.1
1035
- return
1106
+ def _perk_apply_infernal_contract(ctx: _PerkApplyCtx) -> None:
1107
+ ctx.owner.level += 3
1108
+ if ctx.perk_state is not None:
1109
+ ctx.perk_state.pending_count += 3
1110
+ ctx.perk_state.choices_dirty = True
1111
+ for player in ctx.players:
1112
+ if player.health > 0.0:
1113
+ player.health = 0.1
1036
1114
 
1037
- if perk_id == PerkId.GRIM_DEAL:
1038
- owner.health = -1.0
1039
- owner.experience += int(owner.experience * 0.18)
1040
- return
1041
1115
 
1042
- if perk_id == PerkId.AMMO_MANIAC:
1043
- if len(players) > 1:
1044
- for player in players[1:]:
1045
- player.perk_counts[:] = owner.perk_counts
1046
- for player in players:
1047
- weapon_assign_player(player, int(player.weapon_id), state=state)
1048
- return
1116
+ def _perk_apply_grim_deal(ctx: _PerkApplyCtx) -> None:
1117
+ ctx.owner.health = -1.0
1118
+ ctx.owner.experience += int(ctx.owner.experience * 0.18)
1049
1119
 
1050
- if perk_id == PerkId.DEATH_CLOCK:
1051
- _increment_perk_count(owner, PerkId.REGENERATION, amount=-perk_count_get(owner, PerkId.REGENERATION))
1052
- _increment_perk_count(owner, PerkId.GREATER_REGENERATION, amount=-perk_count_get(owner, PerkId.GREATER_REGENERATION))
1053
- for player in players:
1054
- if player.health > 0.0:
1055
- player.health = 100.0
1056
- return
1057
1120
 
1058
- if perk_id == PerkId.BANDAGE:
1059
- for player in players:
1060
- if player.health > 0.0:
1061
- scale = float(state.rng.rand() % 50 + 1)
1062
- player.health = min(100.0, player.health * scale)
1063
- state.effects.spawn_burst(
1064
- pos_x=float(player.pos_x),
1065
- pos_y=float(player.pos_y),
1066
- count=8,
1067
- rand=state.rng.rand,
1068
- detail_preset=5,
1069
- )
1070
- return
1121
+ def _perk_apply_ammo_maniac(ctx: _PerkApplyCtx) -> None:
1122
+ if len(ctx.players) > 1:
1123
+ for player in ctx.players[1:]:
1124
+ player.perk_counts[:] = ctx.owner.perk_counts
1125
+ for player in ctx.players:
1126
+ weapon_assign_player(player, int(player.weapon_id), state=ctx.state)
1071
1127
 
1072
- if perk_id == PerkId.MY_FAVOURITE_WEAPON:
1073
- for player in players:
1074
- player.clip_size += 2
1075
- return
1076
1128
 
1077
- if perk_id == PerkId.PLAGUEBEARER:
1078
- owner.plaguebearer_active = True
1129
+ def _perk_apply_death_clock(ctx: _PerkApplyCtx) -> None:
1130
+ _increment_perk_count(
1131
+ ctx.owner,
1132
+ PerkId.REGENERATION,
1133
+ amount=-perk_count_get(ctx.owner, PerkId.REGENERATION),
1134
+ )
1135
+ _increment_perk_count(
1136
+ ctx.owner,
1137
+ PerkId.GREATER_REGENERATION,
1138
+ amount=-perk_count_get(ctx.owner, PerkId.GREATER_REGENERATION),
1139
+ )
1140
+ for player in ctx.players:
1141
+ if player.health > 0.0:
1142
+ player.health = 100.0
1143
+
1144
+
1145
+ def _perk_apply_bandage(ctx: _PerkApplyCtx) -> None:
1146
+ for player in ctx.players:
1147
+ if player.health > 0.0:
1148
+ scale = float(ctx.state.rng.rand() % 50 + 1)
1149
+ player.health = min(100.0, player.health * scale)
1150
+ ctx.state.effects.spawn_burst(
1151
+ pos_x=float(player.pos_x),
1152
+ pos_y=float(player.pos_y),
1153
+ count=8,
1154
+ rand=ctx.state.rng.rand,
1155
+ detail_preset=5,
1156
+ )
1157
+
1158
+
1159
+ def _perk_apply_my_favourite_weapon(ctx: _PerkApplyCtx) -> None:
1160
+ for player in ctx.players:
1161
+ player.clip_size += 2
1162
+
1163
+
1164
+ def _perk_apply_plaguebearer(ctx: _PerkApplyCtx) -> None:
1165
+ for player in ctx.players:
1166
+ player.plaguebearer_active = True
1167
+
1168
+
1169
+ _PERK_APPLY_HANDLERS: dict[PerkId, _PerkApplyHandler] = {
1170
+ PerkId.INSTANT_WINNER: _perk_apply_instant_winner,
1171
+ PerkId.FATAL_LOTTERY: _perk_apply_fatal_lottery,
1172
+ PerkId.RANDOM_WEAPON: _perk_apply_random_weapon,
1173
+ PerkId.LIFELINE_50_50: _perk_apply_lifeline_50_50,
1174
+ PerkId.THICK_SKINNED: _perk_apply_thick_skinned,
1175
+ PerkId.BREATHING_ROOM: _perk_apply_breathing_room,
1176
+ PerkId.INFERNAL_CONTRACT: _perk_apply_infernal_contract,
1177
+ PerkId.GRIM_DEAL: _perk_apply_grim_deal,
1178
+ PerkId.AMMO_MANIAC: _perk_apply_ammo_maniac,
1179
+ PerkId.DEATH_CLOCK: _perk_apply_death_clock,
1180
+ PerkId.BANDAGE: _perk_apply_bandage,
1181
+ PerkId.MY_FAVOURITE_WEAPON: _perk_apply_my_favourite_weapon,
1182
+ PerkId.PLAGUEBEARER: _perk_apply_plaguebearer,
1183
+ }
1184
+
1185
+
1186
+ def perk_apply(
1187
+ state: GameplayState,
1188
+ players: list[PlayerState],
1189
+ perk_id: PerkId,
1190
+ *,
1191
+ perk_state: PerkSelectionState | None = None,
1192
+ dt: float | None = None,
1193
+ creatures: list[_CreatureForPerks] | None = None,
1194
+ ) -> None:
1195
+ """Apply immediate perk effects and increment the perk counter."""
1196
+
1197
+ if not players:
1198
+ return
1199
+ owner = players[0]
1200
+ try:
1201
+ _increment_perk_count(owner, perk_id)
1202
+ handler = _PERK_APPLY_HANDLERS.get(perk_id)
1203
+ if handler is not None:
1204
+ handler(
1205
+ _PerkApplyCtx(
1206
+ state=state,
1207
+ players=players,
1208
+ owner=owner,
1209
+ perk_id=perk_id,
1210
+ perk_state=perk_state,
1211
+ dt=dt,
1212
+ creatures=creatures,
1213
+ )
1214
+ )
1079
1215
  finally:
1080
1216
  if len(players) > 1:
1081
1217
  for player in players[1:]:
@@ -1200,14 +1336,6 @@ def survival_progression_update(
1200
1336
  return []
1201
1337
 
1202
1338
 
1203
- def _clamp(value: float, lo: float, hi: float) -> float:
1204
- if value < lo:
1205
- return lo
1206
- if value > hi:
1207
- return hi
1208
- return value
1209
-
1210
-
1211
1339
  def _normalize(x: float, y: float) -> tuple[float, float]:
1212
1340
  mag = math.hypot(x, y)
1213
1341
  if mag <= 1e-9:
@@ -1216,17 +1344,17 @@ def _normalize(x: float, y: float) -> tuple[float, float]:
1216
1344
  return x * inv, y * inv
1217
1345
 
1218
1346
 
1219
- def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
1220
- dx = x1 - x0
1221
- dy = y1 - y0
1222
- return dx * dx + dy * dy
1223
-
1224
-
1225
1347
  def _owner_id_for_player(player_index: int) -> int:
1226
1348
  # crimsonland.exe uses -1/-2/-3 for players (and sometimes -100 in demo paths).
1227
1349
  return -1 - int(player_index)
1228
1350
 
1229
1351
 
1352
+ def _owner_id_for_player_projectiles(state: "GameplayState", player_index: int) -> int:
1353
+ if not state.friendly_fire_enabled:
1354
+ return -100
1355
+ return _owner_id_for_player(player_index)
1356
+
1357
+
1230
1358
  def _weapon_entry(weapon_id: int) -> Weapon | None:
1231
1359
  return WEAPON_BY_ID.get(int(weapon_id))
1232
1360
 
@@ -1384,6 +1512,85 @@ def _bonus_id_from_roll(roll: int, rng: Crand) -> int:
1384
1512
  return int(v6)
1385
1513
 
1386
1514
 
1515
+ @dataclass(slots=True)
1516
+ class _BonusPickCtx:
1517
+ pool: BonusPool
1518
+ state: GameplayState
1519
+ players: list[PlayerState]
1520
+ bonus_id: int
1521
+ has_fire_bullets_drop: bool
1522
+
1523
+
1524
+ _BonusPickSuppressRule = Callable[[_BonusPickCtx], bool]
1525
+
1526
+
1527
+ def _bonus_pick_suppress_active_shock_chain(ctx: _BonusPickCtx) -> bool:
1528
+ return ctx.state.shock_chain_links_left > 0 and int(ctx.bonus_id) == int(BonusId.SHOCK_CHAIN)
1529
+
1530
+
1531
+ def _bonus_pick_suppress_quest_minor10_nuke(ctx: _BonusPickCtx) -> bool:
1532
+ if not (int(ctx.state.game_mode) == int(GameMode.QUESTS) and int(ctx.state.quest_stage_minor) == 10):
1533
+ return False
1534
+ if int(ctx.bonus_id) != int(BonusId.NUKE):
1535
+ return False
1536
+ major = int(ctx.state.quest_stage_major)
1537
+ if major in (2, 4, 5):
1538
+ return True
1539
+ return bool(ctx.state.hardcore) and major == 3
1540
+
1541
+
1542
+ def _bonus_pick_suppress_quest_minor10_freeze(ctx: _BonusPickCtx) -> bool:
1543
+ if not (int(ctx.state.game_mode) == int(GameMode.QUESTS) and int(ctx.state.quest_stage_minor) == 10):
1544
+ return False
1545
+ if int(ctx.bonus_id) != int(BonusId.FREEZE):
1546
+ return False
1547
+ major = int(ctx.state.quest_stage_major)
1548
+ return major == 4 or (bool(ctx.state.hardcore) and major == 2)
1549
+
1550
+
1551
+ def _bonus_pick_suppress_freeze_active(ctx: _BonusPickCtx) -> bool:
1552
+ return int(ctx.bonus_id) == int(BonusId.FREEZE) and float(ctx.state.bonuses.freeze) > 0.0
1553
+
1554
+
1555
+ def _bonus_pick_suppress_shield_active(ctx: _BonusPickCtx) -> bool:
1556
+ if int(ctx.bonus_id) != int(BonusId.SHIELD):
1557
+ return False
1558
+ return any(player.shield_timer > 0.0 for player in ctx.players)
1559
+
1560
+
1561
+ def _bonus_pick_suppress_weapon_when_fire_bullets_drop(ctx: _BonusPickCtx) -> bool:
1562
+ return int(ctx.bonus_id) == int(BonusId.WEAPON) and bool(ctx.has_fire_bullets_drop)
1563
+
1564
+
1565
+ def _bonus_pick_suppress_weapon_when_favourite_weapon(ctx: _BonusPickCtx) -> bool:
1566
+ if int(ctx.bonus_id) != int(BonusId.WEAPON):
1567
+ return False
1568
+ return any(perk_active(player, PerkId.MY_FAVOURITE_WEAPON) for player in ctx.players)
1569
+
1570
+
1571
+ def _bonus_pick_suppress_medikit_when_death_clock(ctx: _BonusPickCtx) -> bool:
1572
+ if int(ctx.bonus_id) != int(BonusId.MEDIKIT):
1573
+ return False
1574
+ return any(perk_active(player, PerkId.DEATH_CLOCK) for player in ctx.players)
1575
+
1576
+
1577
+ def _bonus_pick_suppress_disabled(ctx: _BonusPickCtx) -> bool:
1578
+ return not _bonus_enabled(int(ctx.bonus_id))
1579
+
1580
+
1581
+ _BONUS_PICK_SUPPRESS_RULES: tuple[_BonusPickSuppressRule, ...] = (
1582
+ _bonus_pick_suppress_active_shock_chain,
1583
+ _bonus_pick_suppress_quest_minor10_nuke,
1584
+ _bonus_pick_suppress_quest_minor10_freeze,
1585
+ _bonus_pick_suppress_freeze_active,
1586
+ _bonus_pick_suppress_shield_active,
1587
+ _bonus_pick_suppress_weapon_when_fire_bullets_drop,
1588
+ _bonus_pick_suppress_weapon_when_favourite_weapon,
1589
+ _bonus_pick_suppress_medikit_when_death_clock,
1590
+ _bonus_pick_suppress_disabled,
1591
+ )
1592
+
1593
+
1387
1594
  def bonus_pick_random_type(pool: BonusPool, state: "GameplayState", players: list["PlayerState"]) -> int:
1388
1595
  has_fire_bullets_drop = any(
1389
1596
  entry.bonus_id == int(BonusId.FIRE_BULLETS) and not entry.picked
@@ -1395,33 +1602,49 @@ def bonus_pick_random_type(pool: BonusPool, state: "GameplayState", players: lis
1395
1602
  bonus_id = _bonus_id_from_roll(roll, state.rng)
1396
1603
  if bonus_id <= 0:
1397
1604
  continue
1398
- if state.shock_chain_links_left > 0 and bonus_id == int(BonusId.SHOCK_CHAIN):
1399
- continue
1400
- if int(state.game_mode) == int(GameMode.QUESTS) and int(state.quest_stage_minor) == 10:
1401
- if bonus_id == int(BonusId.NUKE) and (
1402
- int(state.quest_stage_major) in (2, 4, 5) or (state.hardcore and int(state.quest_stage_major) == 3)
1403
- ):
1404
- continue
1405
- if bonus_id == int(BonusId.FREEZE) and (
1406
- int(state.quest_stage_major) == 4 or (state.hardcore and int(state.quest_stage_major) == 2)
1407
- ):
1408
- continue
1409
- if bonus_id == int(BonusId.FREEZE) and state.bonuses.freeze > 0.0:
1410
- continue
1411
- if bonus_id == int(BonusId.SHIELD) and any(player.shield_timer > 0.0 for player in players):
1412
- continue
1413
- if bonus_id == int(BonusId.WEAPON) and has_fire_bullets_drop:
1414
- continue
1415
- if bonus_id == int(BonusId.WEAPON) and any(perk_active(player, PerkId.MY_FAVOURITE_WEAPON) for player in players):
1416
- continue
1417
- if bonus_id == int(BonusId.MEDIKIT) and any(perk_active(player, PerkId.DEATH_CLOCK) for player in players):
1418
- continue
1419
- if not _bonus_enabled(bonus_id):
1605
+ ctx = _BonusPickCtx(
1606
+ pool=pool,
1607
+ state=state,
1608
+ players=players,
1609
+ bonus_id=int(bonus_id),
1610
+ has_fire_bullets_drop=bool(has_fire_bullets_drop),
1611
+ )
1612
+ suppressed = False
1613
+ for rule in _BONUS_PICK_SUPPRESS_RULES:
1614
+ if rule(ctx):
1615
+ suppressed = True
1616
+ break
1617
+ if suppressed:
1420
1618
  continue
1421
1619
  return bonus_id
1422
1620
  return int(BonusId.POINTS)
1423
1621
 
1424
1622
 
1623
+ @dataclass(slots=True)
1624
+ class _WeaponAssignCtx:
1625
+ player: PlayerState
1626
+ clip_size: int
1627
+
1628
+
1629
+ _WeaponAssignClipModifier = Callable[[_WeaponAssignCtx], None]
1630
+
1631
+
1632
+ def _weapon_assign_clip_ammo_maniac(ctx: _WeaponAssignCtx) -> None:
1633
+ if perk_active(ctx.player, PerkId.AMMO_MANIAC):
1634
+ ctx.clip_size += max(1, int(float(ctx.clip_size) * 0.25))
1635
+
1636
+
1637
+ def _weapon_assign_clip_my_favourite_weapon(ctx: _WeaponAssignCtx) -> None:
1638
+ if perk_active(ctx.player, PerkId.MY_FAVOURITE_WEAPON):
1639
+ ctx.clip_size += 2
1640
+
1641
+
1642
+ _WEAPON_ASSIGN_CLIP_MODIFIERS: tuple[_WeaponAssignClipModifier, ...] = (
1643
+ _weapon_assign_clip_ammo_maniac,
1644
+ _weapon_assign_clip_my_favourite_weapon,
1645
+ )
1646
+
1647
+
1425
1648
  def weapon_assign_player(player: PlayerState, weapon_id: int, *, state: GameplayState | None = None) -> None:
1426
1649
  """Assign weapon and reset per-weapon runtime state (ammo/cooldowns)."""
1427
1650
 
@@ -1436,15 +1659,10 @@ def weapon_assign_player(player: PlayerState, weapon_id: int, *, state: Gameplay
1436
1659
  player.weapon_id = weapon_id
1437
1660
 
1438
1661
  clip_size = int(weapon.clip_size) if weapon is not None and weapon.clip_size is not None else 0
1439
- clip_size = max(0, clip_size)
1440
-
1441
- # weapon_assign_player @ 0x004220B0: clip-size perks are applied on every weapon assignment.
1442
- if perk_active(player, PerkId.AMMO_MANIAC):
1443
- clip_size += max(1, int(float(clip_size) * 0.25))
1444
- if perk_active(player, PerkId.MY_FAVOURITE_WEAPON):
1445
- clip_size += 2
1446
-
1447
- player.clip_size = max(0, int(clip_size))
1662
+ clip_ctx = _WeaponAssignCtx(player=player, clip_size=max(0, clip_size))
1663
+ for modifier in _WEAPON_ASSIGN_CLIP_MODIFIERS:
1664
+ modifier(clip_ctx)
1665
+ player.clip_size = max(0, int(clip_ctx.clip_size))
1448
1666
  player.ammo = float(player.clip_size)
1449
1667
  player.weapon_reset_latch = 0
1450
1668
  player.reload_active = False
@@ -1564,8 +1782,7 @@ def _perk_update_man_bomb(player: PlayerState, dt: float, state: GameplayState)
1564
1782
  if player.man_bomb_timer <= state.perk_intervals.man_bomb:
1565
1783
  return
1566
1784
 
1567
- owner_id = _owner_id_for_player(player.index)
1568
- state.bonus_spawn_guard = True
1785
+ owner_id = _owner_id_for_player_projectiles(state, player.index)
1569
1786
  for idx in range(8):
1570
1787
  type_id = ProjectileTypeId.ION_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.ION_RIFLE
1571
1788
  angle = (float(state.rng.rand() % 50) * 0.01) + float(idx) * (math.pi / 4.0) - 0.25
@@ -1577,7 +1794,6 @@ def _perk_update_man_bomb(player: PlayerState, dt: float, state: GameplayState)
1577
1794
  owner_id=owner_id,
1578
1795
  base_damage=_projectile_meta_for_type_id(type_id),
1579
1796
  )
1580
- state.bonus_spawn_guard = False
1581
1797
  state.sfx_queue.append("sfx_explosion_small")
1582
1798
 
1583
1799
  player.man_bomb_timer -= state.perk_intervals.man_bomb
@@ -1589,7 +1805,7 @@ def _perk_update_hot_tempered(player: PlayerState, dt: float, state: GameplaySta
1589
1805
  if player.hot_tempered_timer <= state.perk_intervals.hot_tempered:
1590
1806
  return
1591
1807
 
1592
- owner_id = _owner_id_for_player(player.index)
1808
+ owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
1593
1809
  state.bonus_spawn_guard = True
1594
1810
  for idx in range(8):
1595
1811
  type_id = ProjectileTypeId.PLASMA_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.PLASMA_RIFLE
@@ -1614,13 +1830,27 @@ def _perk_update_fire_cough(player: PlayerState, dt: float, state: GameplayState
1614
1830
  if player.fire_cough_timer <= state.perk_intervals.fire_cough:
1615
1831
  return
1616
1832
 
1617
- owner_id = _owner_id_for_player(player.index)
1618
- # Fire Cough spawns a fire projectile (and a small sprite burst) from the muzzle.
1619
- theta = math.atan2(player.aim_dir_y, player.aim_dir_x)
1620
- jitter = (float(state.rng.rand() % 200) - 100.0) * 0.0015
1621
- angle = theta + jitter + math.pi / 2.0
1622
- muzzle_x = player.pos_x + player.aim_dir_x * 16.0
1623
- muzzle_y = player.pos_y + player.aim_dir_y * 16.0
1833
+ owner_id = _owner_id_for_player_projectiles(state, player.index)
1834
+ state.sfx_queue.append("sfx_autorifle_fire")
1835
+ state.sfx_queue.append("sfx_plasmaminigun_fire")
1836
+
1837
+ aim_heading = float(player.aim_heading)
1838
+ muzzle_dir = (aim_heading - math.pi / 2.0) - 0.150915
1839
+ muzzle_x = player.pos_x + math.cos(muzzle_dir) * 16.0
1840
+ muzzle_y = player.pos_y + math.sin(muzzle_dir) * 16.0
1841
+
1842
+ aim_x = float(player.aim_x)
1843
+ aim_y = float(player.aim_y)
1844
+ dx = aim_x - float(player.pos_x)
1845
+ dy = aim_y - float(player.pos_y)
1846
+ dist = math.hypot(dx, dy)
1847
+ max_offset = dist * float(player.spread_heat) * 0.5
1848
+ dir_angle = float(int(state.rng.rand()) & 0x1FF) * (math.tau / 512.0)
1849
+ mag = float(int(state.rng.rand()) & 0x1FF) * (1.0 / 512.0)
1850
+ offset = max_offset * mag
1851
+ jitter_x = aim_x + math.cos(dir_angle) * offset
1852
+ jitter_y = aim_y + math.sin(dir_angle) * offset
1853
+ angle = math.atan2(jitter_y - float(player.pos_y), jitter_x - float(player.pos_x)) + math.pi / 2.0
1624
1854
  state.projectiles.spawn(
1625
1855
  pos_x=muzzle_x,
1626
1856
  pos_y=muzzle_y,
@@ -1630,10 +1860,66 @@ def _perk_update_fire_cough(player: PlayerState, dt: float, state: GameplayState
1630
1860
  base_damage=_projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS),
1631
1861
  )
1632
1862
 
1863
+ vel_x = math.cos(aim_heading) * 25.0
1864
+ vel_y = math.sin(aim_heading) * 25.0
1865
+ sprite_id = state.sprite_effects.spawn(pos_x=muzzle_x, pos_y=muzzle_y, vel_x=vel_x, vel_y=vel_y, scale=1.0)
1866
+ sprite = state.sprite_effects.entries[int(sprite_id)]
1867
+ sprite.color_r = 0.5
1868
+ sprite.color_g = 0.5
1869
+ sprite.color_b = 0.5
1870
+ sprite.color_a = 0.413
1871
+
1633
1872
  player.fire_cough_timer -= state.perk_intervals.fire_cough
1634
1873
  state.perk_intervals.fire_cough = float(state.rng.rand() % 4) + 2.0
1635
1874
 
1636
1875
 
1876
+ @dataclass(slots=True)
1877
+ class _PlayerPerkTickCtx:
1878
+ state: GameplayState
1879
+ player: PlayerState
1880
+ dt: float
1881
+ stationary: bool
1882
+
1883
+
1884
+ _PlayerPerkTickStep = Callable[[_PlayerPerkTickCtx], None]
1885
+
1886
+
1887
+ def _player_perk_tick_man_bomb(ctx: _PlayerPerkTickCtx) -> None:
1888
+ if ctx.stationary and perk_active(ctx.player, PerkId.MAN_BOMB):
1889
+ _perk_update_man_bomb(ctx.player, ctx.dt, ctx.state)
1890
+ else:
1891
+ ctx.player.man_bomb_timer = 0.0
1892
+
1893
+
1894
+ def _player_perk_tick_living_fortress(ctx: _PlayerPerkTickCtx) -> None:
1895
+ if ctx.stationary and perk_active(ctx.player, PerkId.LIVING_FORTRESS):
1896
+ ctx.player.living_fortress_timer = min(30.0, ctx.player.living_fortress_timer + ctx.dt)
1897
+ else:
1898
+ ctx.player.living_fortress_timer = 0.0
1899
+
1900
+
1901
+ def _player_perk_tick_fire_cough(ctx: _PlayerPerkTickCtx) -> None:
1902
+ if perk_active(ctx.player, PerkId.FIRE_CAUGH):
1903
+ _perk_update_fire_cough(ctx.player, ctx.dt, ctx.state)
1904
+ else:
1905
+ ctx.player.fire_cough_timer = 0.0
1906
+
1907
+
1908
+ def _player_perk_tick_hot_tempered(ctx: _PlayerPerkTickCtx) -> None:
1909
+ if perk_active(ctx.player, PerkId.HOT_TEMPERED):
1910
+ _perk_update_hot_tempered(ctx.player, ctx.dt, ctx.state)
1911
+ else:
1912
+ ctx.player.hot_tempered_timer = 0.0
1913
+
1914
+
1915
+ _PLAYER_PERK_TICK_STEPS: tuple[_PlayerPerkTickStep, ...] = (
1916
+ _player_perk_tick_man_bomb,
1917
+ _player_perk_tick_living_fortress,
1918
+ _player_perk_tick_fire_cough,
1919
+ _player_perk_tick_hot_tempered,
1920
+ )
1921
+
1922
+
1637
1923
  def player_fire_weapon(
1638
1924
  player: PlayerState,
1639
1925
  input_state: PlayerInput,
@@ -1658,26 +1944,25 @@ def player_fire_weapon(
1658
1944
  ammo_cost = 1.0
1659
1945
  is_fire_bullets = float(player.fire_bullets_timer) > 0.0
1660
1946
  if player.reload_timer > 0.0:
1661
- if player.ammo <= 0 and player.experience > 0:
1662
- if perk_active(player, PerkId.REGRESSION_BULLETS):
1663
- firing_during_reload = True
1664
- ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1665
-
1666
- reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
1667
- factor = 4.0 if ammo_class == 1 else 200.0
1668
- player.experience = int(float(player.experience) - reload_time * factor)
1669
- if player.experience < 0:
1670
- player.experience = 0
1671
- elif perk_active(player, PerkId.AMMUNITION_WITHIN):
1672
- firing_during_reload = True
1673
- ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1674
-
1675
- from .player_damage import player_take_damage
1676
-
1677
- cost = 0.15 if ammo_class == 1 else 1.0
1678
- player_take_damage(state, player, cost, dt=dt, rand=state.rng.rand)
1679
- else:
1680
- return
1947
+ if player.experience <= 0:
1948
+ return
1949
+ if perk_active(player, PerkId.REGRESSION_BULLETS):
1950
+ firing_during_reload = True
1951
+ ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1952
+
1953
+ reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
1954
+ factor = 4.0 if ammo_class == 1 else 200.0
1955
+ player.experience = int(float(player.experience) - reload_time * factor)
1956
+ if player.experience < 0:
1957
+ player.experience = 0
1958
+ elif perk_active(player, PerkId.AMMUNITION_WITHIN):
1959
+ firing_during_reload = True
1960
+ ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1961
+
1962
+ from .player_damage import player_take_damage
1963
+
1964
+ cost = 0.15 if ammo_class == 1 else 1.0
1965
+ player_take_damage(state, player, cost, dt=dt, rand=state.rng.rand)
1681
1966
  else:
1682
1967
  return
1683
1968
 
@@ -2013,8 +2298,8 @@ def player_update(
2013
2298
  if perk_active(player, PerkId.ALTERNATE_WEAPON):
2014
2299
  speed *= 0.8
2015
2300
 
2016
- player.pos_x = _clamp(player.pos_x + move_x * speed * dt, 0.0, float(world_size))
2017
- player.pos_y = _clamp(player.pos_y + move_y * speed * dt, 0.0, float(world_size))
2301
+ player.pos_x = clamp(player.pos_x + move_x * speed * dt, 0.0, float(world_size))
2302
+ player.pos_y = clamp(player.pos_y + move_y * speed * dt, 0.0, float(world_size))
2018
2303
 
2019
2304
  player.move_phase += dt * player.move_speed * 19.0
2020
2305
 
@@ -2023,25 +2308,9 @@ def player_update(
2023
2308
  if stationary and perk_active(player, PerkId.STATIONARY_RELOADER):
2024
2309
  reload_scale = 3.0
2025
2310
 
2026
- if stationary and perk_active(player, PerkId.MAN_BOMB):
2027
- _perk_update_man_bomb(player, dt, state)
2028
- else:
2029
- player.man_bomb_timer = 0.0
2030
-
2031
- if stationary and perk_active(player, PerkId.LIVING_FORTRESS):
2032
- player.living_fortress_timer = min(30.0, player.living_fortress_timer + dt)
2033
- else:
2034
- player.living_fortress_timer = 0.0
2035
-
2036
- if perk_active(player, PerkId.FIRE_CAUGH):
2037
- _perk_update_fire_cough(player, dt, state)
2038
- else:
2039
- player.fire_cough_timer = 0.0
2040
-
2041
- if perk_active(player, PerkId.HOT_TEMPERED):
2042
- _perk_update_hot_tempered(player, dt, state)
2043
- else:
2044
- player.hot_tempered_timer = 0.0
2311
+ perk_ctx = _PlayerPerkTickCtx(state=state, player=player, dt=dt, stationary=stationary)
2312
+ for step in _PLAYER_PERK_TICK_STEPS:
2313
+ step(perk_ctx)
2045
2314
 
2046
2315
  # Reload + reload perks.
2047
2316
  if perk_active(player, PerkId.ANXIOUS_LOADER) and input_state.fire_pressed and player.reload_timer > 0.0:
@@ -2065,7 +2334,7 @@ def player_update(
2065
2334
  count=count,
2066
2335
  angle_offset=0.1,
2067
2336
  type_id=ProjectileTypeId.PLASMA_MINIGUN,
2068
- owner_id=_owner_id_for_player(player.index),
2337
+ owner_id=_owner_id_for_player_projectiles(state, player.index),
2069
2338
  )
2070
2339
  state.bonus_spawn_guard = False
2071
2340
  state.sfx_queue.append("sfx_explosion_small")
@@ -2101,309 +2370,376 @@ def player_update(
2101
2370
  player.move_phase += 14.0
2102
2371
 
2103
2372
 
2104
- def bonus_apply(
2105
- state: GameplayState,
2106
- player: PlayerState,
2107
- bonus_id: BonusId,
2108
- *,
2109
- amount: int | None = None,
2110
- origin: _HasPos | None = None,
2111
- creatures: list[Damageable] | None = None,
2112
- players: list[PlayerState] | None = None,
2113
- apply_creature_damage: CreatureDamageApplier | None = None,
2114
- detail_preset: int = 5,
2115
- ) -> None:
2116
- """Apply a bonus to player + global timers (subset of `bonus_apply`)."""
2373
+ @dataclass(slots=True)
2374
+ class _BonusApplyCtx:
2375
+ state: GameplayState
2376
+ player: PlayerState
2377
+ bonus_id: BonusId
2378
+ amount: int
2379
+ origin: _HasPos | None
2380
+ creatures: list[Damageable] | None
2381
+ players: list[PlayerState] | None
2382
+ apply_creature_damage: CreatureDamageApplier | None
2383
+ detail_preset: int
2384
+ economist_multiplier: float
2385
+ label: str
2386
+ icon_id: int
2387
+
2388
+ def register_global(self, timer_key: str) -> None:
2389
+ self.state.bonus_hud.register(
2390
+ self.bonus_id,
2391
+ label=self.label,
2392
+ icon_id=self.icon_id,
2393
+ timer_ref=_TimerRef("global", str(timer_key)),
2394
+ )
2117
2395
 
2118
- meta = BONUS_BY_ID.get(int(bonus_id))
2119
- if meta is None:
2120
- return
2121
- if amount is None:
2122
- amount = int(meta.default_amount or 0)
2396
+ def register_player(self, timer_key: str) -> None:
2397
+ if self.players is not None and len(self.players) > 1:
2398
+ self.state.bonus_hud.register(
2399
+ self.bonus_id,
2400
+ label=self.label,
2401
+ icon_id=self.icon_id,
2402
+ timer_ref=_TimerRef("player", str(timer_key), player_index=0),
2403
+ timer_ref_alt=_TimerRef("player", str(timer_key), player_index=1),
2404
+ )
2405
+ else:
2406
+ self.state.bonus_hud.register(
2407
+ self.bonus_id,
2408
+ label=self.label,
2409
+ icon_id=self.icon_id,
2410
+ timer_ref=_TimerRef("player", str(timer_key), player_index=int(self.player.index)),
2411
+ )
2123
2412
 
2124
- if bonus_id == BonusId.POINTS:
2125
- award_experience(state, player, int(amount))
2126
- return
2413
+ def origin_pos(self) -> _HasPos:
2414
+ return self.origin or self.player
2127
2415
 
2128
- economist_multiplier = 1.0 + 0.5 * float(perk_count_get(player, PerkId.BONUS_ECONOMIST))
2129
2416
 
2130
- icon_id = int(meta.icon_id) if meta.icon_id is not None else -1
2131
- label = meta.name
2417
+ _BonusApplyHandler = Callable[[_BonusApplyCtx], None]
2132
2418
 
2133
- def _register_global(timer_key: str) -> None:
2134
- state.bonus_hud.register(
2135
- bonus_id,
2136
- label=label,
2137
- icon_id=icon_id,
2138
- timer_ref=_TimerRef("global", timer_key),
2139
- )
2140
2419
 
2141
- def _register_player(timer_key: str) -> None:
2142
- if players is not None and len(players) > 1:
2143
- state.bonus_hud.register(
2144
- bonus_id,
2145
- label=label,
2146
- icon_id=icon_id,
2147
- timer_ref=_TimerRef("player", timer_key, player_index=0),
2148
- timer_ref_alt=_TimerRef("player", timer_key, player_index=1),
2149
- )
2150
- else:
2151
- state.bonus_hud.register(
2152
- bonus_id,
2153
- label=label,
2154
- icon_id=icon_id,
2155
- timer_ref=_TimerRef("player", timer_key, player_index=int(player.index)),
2156
- )
2420
+ def _bonus_apply_points(ctx: _BonusApplyCtx) -> None:
2421
+ award_experience(ctx.state, ctx.player, int(ctx.amount))
2157
2422
 
2158
- if bonus_id == BonusId.ENERGIZER:
2159
- old = float(state.bonuses.energizer)
2160
- if old <= 0.0:
2161
- _register_global("energizer")
2162
- state.bonuses.energizer = float(old + float(amount) * economist_multiplier)
2163
- return
2164
2423
 
2165
- if bonus_id == BonusId.WEAPON_POWER_UP:
2166
- old = float(state.bonuses.weapon_power_up)
2167
- if old <= 0.0:
2168
- _register_global("weapon_power_up")
2169
- state.bonuses.weapon_power_up = float(old + float(amount) * economist_multiplier)
2170
- player.weapon_reset_latch = 0
2171
- player.shot_cooldown = 0.0
2172
- player.reload_active = False
2173
- player.reload_timer = 0.0
2174
- player.reload_timer_max = 0.0
2175
- player.ammo = float(player.clip_size)
2176
- return
2424
+ def _bonus_apply_energizer(ctx: _BonusApplyCtx) -> None:
2425
+ old = float(ctx.state.bonuses.energizer)
2426
+ if old <= 0.0:
2427
+ ctx.register_global("energizer")
2428
+ ctx.state.bonuses.energizer = float(old + float(ctx.amount) * ctx.economist_multiplier)
2177
2429
 
2178
- if bonus_id == BonusId.DOUBLE_EXPERIENCE:
2179
- old = float(state.bonuses.double_experience)
2180
- if old <= 0.0:
2181
- _register_global("double_experience")
2182
- state.bonuses.double_experience = float(old + float(amount) * economist_multiplier)
2183
- return
2184
2430
 
2185
- if bonus_id == BonusId.REFLEX_BOOST:
2186
- old = float(state.bonuses.reflex_boost)
2187
- if old <= 0.0:
2188
- _register_global("reflex_boost")
2189
- state.bonuses.reflex_boost = float(old + float(amount) * economist_multiplier)
2190
-
2191
- targets = players if players is not None else [player]
2192
- for target in targets:
2193
- target.ammo = float(target.clip_size)
2194
- target.reload_active = False
2195
- target.reload_timer = 0.0
2196
- target.reload_timer_max = 0.0
2197
- return
2431
+ def _bonus_apply_weapon_power_up(ctx: _BonusApplyCtx) -> None:
2432
+ old = float(ctx.state.bonuses.weapon_power_up)
2433
+ if old <= 0.0:
2434
+ ctx.register_global("weapon_power_up")
2435
+ ctx.state.bonuses.weapon_power_up = float(old + float(ctx.amount) * ctx.economist_multiplier)
2436
+ ctx.player.weapon_reset_latch = 0
2437
+ ctx.player.shot_cooldown = 0.0
2438
+ ctx.player.reload_active = False
2439
+ ctx.player.reload_timer = 0.0
2440
+ ctx.player.reload_timer_max = 0.0
2441
+ ctx.player.ammo = float(ctx.player.clip_size)
2198
2442
 
2199
- if bonus_id == BonusId.FREEZE:
2200
- old = float(state.bonuses.freeze)
2201
- if old <= 0.0:
2202
- _register_global("freeze")
2203
- state.bonuses.freeze = float(old + float(amount) * economist_multiplier)
2204
- if creatures:
2205
- rand = state.rng.rand
2206
- for creature in creatures:
2207
- active = getattr(creature, "active", True)
2208
- if not bool(active):
2209
- continue
2210
- if float(getattr(creature, "hp", 0.0)) > 0.0:
2211
- continue
2212
- pos_x = float(getattr(creature, "x", 0.0))
2213
- pos_y = float(getattr(creature, "y", 0.0))
2214
- for _ in range(8):
2215
- angle = float(int(rand()) % 0x264) * 0.01
2216
- state.effects.spawn_freeze_shard(
2217
- pos_x=pos_x,
2218
- pos_y=pos_y,
2219
- angle=angle,
2220
- rand=rand,
2221
- detail_preset=int(detail_preset),
2222
- )
2443
+
2444
+ def _bonus_apply_double_experience(ctx: _BonusApplyCtx) -> None:
2445
+ old = float(ctx.state.bonuses.double_experience)
2446
+ if old <= 0.0:
2447
+ ctx.register_global("double_experience")
2448
+ ctx.state.bonuses.double_experience = float(old + float(ctx.amount) * ctx.economist_multiplier)
2449
+
2450
+
2451
+ def _bonus_apply_reflex_boost(ctx: _BonusApplyCtx) -> None:
2452
+ old = float(ctx.state.bonuses.reflex_boost)
2453
+ if old <= 0.0:
2454
+ ctx.register_global("reflex_boost")
2455
+ ctx.state.bonuses.reflex_boost = float(old + float(ctx.amount) * ctx.economist_multiplier)
2456
+
2457
+ targets = ctx.players if ctx.players is not None else [ctx.player]
2458
+ for target in targets:
2459
+ target.ammo = float(target.clip_size)
2460
+ target.reload_active = False
2461
+ target.reload_timer = 0.0
2462
+ target.reload_timer_max = 0.0
2463
+
2464
+
2465
+ def _bonus_apply_freeze(ctx: _BonusApplyCtx) -> None:
2466
+ old = float(ctx.state.bonuses.freeze)
2467
+ if old <= 0.0:
2468
+ ctx.register_global("freeze")
2469
+ ctx.state.bonuses.freeze = float(old + float(ctx.amount) * ctx.economist_multiplier)
2470
+
2471
+ creatures = ctx.creatures
2472
+ if creatures:
2473
+ rand = ctx.state.rng.rand
2474
+ for creature in creatures:
2475
+ active = getattr(creature, "active", True)
2476
+ if not bool(active):
2477
+ continue
2478
+ if float(getattr(creature, "hp", 0.0)) > 0.0:
2479
+ continue
2480
+ pos_x = float(getattr(creature, "x", 0.0))
2481
+ pos_y = float(getattr(creature, "y", 0.0))
2482
+ for _ in range(8):
2223
2483
  angle = float(int(rand()) % 0x264) * 0.01
2224
- state.effects.spawn_freeze_shatter(
2484
+ ctx.state.effects.spawn_freeze_shard(
2225
2485
  pos_x=pos_x,
2226
2486
  pos_y=pos_y,
2227
2487
  angle=angle,
2228
2488
  rand=rand,
2229
- detail_preset=int(detail_preset),
2489
+ detail_preset=int(ctx.detail_preset),
2230
2490
  )
2231
- if hasattr(creature, "active"):
2232
- setattr(creature, "active", False)
2233
- state.sfx_queue.append("sfx_shockwave")
2234
- return
2491
+ angle = float(int(rand()) % 0x264) * 0.01
2492
+ ctx.state.effects.spawn_freeze_shatter(
2493
+ pos_x=pos_x,
2494
+ pos_y=pos_y,
2495
+ angle=angle,
2496
+ rand=rand,
2497
+ detail_preset=int(ctx.detail_preset),
2498
+ )
2499
+ if hasattr(creature, "active"):
2500
+ setattr(creature, "active", False)
2235
2501
 
2236
- if bonus_id == BonusId.SHIELD:
2237
- should_register = float(player.shield_timer) <= 0.0
2238
- if players is not None and len(players) > 1:
2239
- should_register = float(players[0].shield_timer) <= 0.0 and float(players[1].shield_timer) <= 0.0
2240
- if should_register:
2241
- _register_player("shield_timer")
2242
- player.shield_timer = float(player.shield_timer + float(amount) * economist_multiplier)
2243
- return
2502
+ ctx.state.sfx_queue.append("sfx_shockwave")
2244
2503
 
2245
- if bonus_id == BonusId.SPEED:
2246
- should_register = float(player.speed_bonus_timer) <= 0.0
2247
- if players is not None and len(players) > 1:
2248
- should_register = float(players[0].speed_bonus_timer) <= 0.0 and float(players[1].speed_bonus_timer) <= 0.0
2249
- if should_register:
2250
- _register_player("speed_bonus_timer")
2251
- player.speed_bonus_timer = float(player.speed_bonus_timer + float(amount) * economist_multiplier)
2252
- return
2253
2504
 
2254
- if bonus_id == BonusId.FIRE_BULLETS:
2255
- should_register = float(player.fire_bullets_timer) <= 0.0
2256
- if players is not None and len(players) > 1:
2257
- should_register = float(players[0].fire_bullets_timer) <= 0.0 and float(players[1].fire_bullets_timer) <= 0.0
2258
- if should_register:
2259
- _register_player("fire_bullets_timer")
2260
- player.fire_bullets_timer = float(player.fire_bullets_timer + float(amount) * economist_multiplier)
2261
- player.weapon_reset_latch = 0
2262
- player.shot_cooldown = 0.0
2263
- player.reload_active = False
2264
- player.reload_timer = 0.0
2265
- player.reload_timer_max = 0.0
2266
- player.ammo = float(player.clip_size)
2267
- return
2505
+ def _bonus_apply_shield(ctx: _BonusApplyCtx) -> None:
2506
+ should_register = float(ctx.player.shield_timer) <= 0.0
2507
+ if ctx.players is not None and len(ctx.players) > 1:
2508
+ should_register = float(ctx.players[0].shield_timer) <= 0.0 and float(ctx.players[1].shield_timer) <= 0.0
2509
+ if should_register:
2510
+ ctx.register_player("shield_timer")
2511
+ ctx.player.shield_timer = float(ctx.player.shield_timer + float(ctx.amount) * ctx.economist_multiplier)
2268
2512
 
2269
- if bonus_id == BonusId.SHOCK_CHAIN:
2270
- if creatures:
2271
- origin_pos = origin or player
2272
- best_idx: int | None = None
2273
- best_dist = 0.0
2274
- for idx, creature in enumerate(creatures):
2275
- if creature.hp <= 0.0:
2276
- continue
2277
- d = _distance_sq(float(origin_pos.pos_x), float(origin_pos.pos_y), creature.x, creature.y)
2278
- if best_idx is None or d < best_dist:
2279
- best_idx = idx
2280
- best_dist = d
2281
- if best_idx is not None:
2282
- target = creatures[best_idx]
2283
- dx = target.x - float(origin_pos.pos_x)
2284
- dy = target.y - float(origin_pos.pos_y)
2285
- angle = math.atan2(dy, dx) + math.pi / 2.0
2286
- owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
2287
2513
 
2288
- state.bonus_spawn_guard = True
2289
- state.shock_chain_links_left = 0x20
2290
- state.shock_chain_projectile_id = state.projectiles.spawn(
2291
- pos_x=float(origin_pos.pos_x),
2292
- pos_y=float(origin_pos.pos_y),
2293
- angle=angle,
2294
- type_id=int(ProjectileTypeId.ION_RIFLE),
2295
- owner_id=int(owner_id),
2296
- base_damage=_projectile_meta_for_type_id(int(ProjectileTypeId.ION_RIFLE)),
2297
- )
2298
- state.bonus_spawn_guard = False
2514
+ def _bonus_apply_speed(ctx: _BonusApplyCtx) -> None:
2515
+ should_register = float(ctx.player.speed_bonus_timer) <= 0.0
2516
+ if ctx.players is not None and len(ctx.players) > 1:
2517
+ should_register = (
2518
+ float(ctx.players[0].speed_bonus_timer) <= 0.0 and float(ctx.players[1].speed_bonus_timer) <= 0.0
2519
+ )
2520
+ if should_register:
2521
+ ctx.register_player("speed_bonus_timer")
2522
+ ctx.player.speed_bonus_timer = float(ctx.player.speed_bonus_timer + float(ctx.amount) * ctx.economist_multiplier)
2523
+
2524
+
2525
+ def _bonus_apply_fire_bullets(ctx: _BonusApplyCtx) -> None:
2526
+ should_register = float(ctx.player.fire_bullets_timer) <= 0.0
2527
+ if ctx.players is not None and len(ctx.players) > 1:
2528
+ should_register = float(ctx.players[0].fire_bullets_timer) <= 0.0 and float(ctx.players[1].fire_bullets_timer) <= 0.0
2529
+ if should_register:
2530
+ ctx.register_player("fire_bullets_timer")
2531
+ ctx.player.fire_bullets_timer = float(ctx.player.fire_bullets_timer + float(ctx.amount) * ctx.economist_multiplier)
2532
+ ctx.player.weapon_reset_latch = 0
2533
+ ctx.player.shot_cooldown = 0.0
2534
+ ctx.player.reload_active = False
2535
+ ctx.player.reload_timer = 0.0
2536
+ ctx.player.reload_timer_max = 0.0
2537
+ ctx.player.ammo = float(ctx.player.clip_size)
2538
+
2539
+
2540
+ def _bonus_apply_shock_chain(ctx: _BonusApplyCtx) -> None:
2541
+ creatures = ctx.creatures
2542
+ if not creatures:
2299
2543
  return
2300
2544
 
2301
- if bonus_id == BonusId.WEAPON:
2302
- weapon_id = int(amount)
2303
- if perk_active(player, PerkId.ALTERNATE_WEAPON) and player.alt_weapon_id is None:
2304
- player.alt_weapon_id = int(player.weapon_id)
2305
- player.alt_clip_size = int(player.clip_size)
2306
- player.alt_ammo = float(player.ammo)
2307
- player.alt_reload_active = bool(player.reload_active)
2308
- player.alt_reload_timer = float(player.reload_timer)
2309
- player.alt_shot_cooldown = float(player.shot_cooldown)
2310
- player.alt_reload_timer_max = float(player.reload_timer_max)
2311
- weapon_assign_player(player, weapon_id, state=state)
2545
+ origin_pos = ctx.origin_pos()
2546
+ best_idx: int | None = None
2547
+ best_dist = 0.0
2548
+ for idx, creature in enumerate(creatures):
2549
+ if creature.hp <= 0.0:
2550
+ continue
2551
+ d = distance_sq(float(origin_pos.pos_x), float(origin_pos.pos_y), creature.x, creature.y)
2552
+ if best_idx is None or d < best_dist:
2553
+ best_idx = idx
2554
+ best_dist = d
2555
+ if best_idx is None:
2312
2556
  return
2313
2557
 
2314
- if bonus_id == BonusId.FIREBLAST:
2315
- origin_pos = origin or player
2316
- owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
2317
- state.bonus_spawn_guard = True
2318
- _spawn_projectile_ring(
2319
- state,
2320
- origin_pos,
2321
- count=16,
2322
- angle_offset=0.0,
2323
- type_id=ProjectileTypeId.PLASMA_RIFLE,
2324
- owner_id=int(owner_id),
2558
+ target = creatures[best_idx]
2559
+ dx = target.x - float(origin_pos.pos_x)
2560
+ dy = target.y - float(origin_pos.pos_y)
2561
+ angle = math.atan2(dy, dx) + math.pi / 2.0
2562
+ owner_id = _owner_id_for_player(ctx.player.index) if ctx.state.friendly_fire_enabled else -100
2563
+
2564
+ ctx.state.bonus_spawn_guard = True
2565
+ ctx.state.shock_chain_links_left = 0x20
2566
+ ctx.state.shock_chain_projectile_id = ctx.state.projectiles.spawn(
2567
+ pos_x=float(origin_pos.pos_x),
2568
+ pos_y=float(origin_pos.pos_y),
2569
+ angle=angle,
2570
+ type_id=int(ProjectileTypeId.ION_RIFLE),
2571
+ owner_id=int(owner_id),
2572
+ base_damage=_projectile_meta_for_type_id(int(ProjectileTypeId.ION_RIFLE)),
2573
+ )
2574
+ ctx.state.bonus_spawn_guard = False
2575
+
2576
+
2577
+ def _bonus_apply_weapon(ctx: _BonusApplyCtx) -> None:
2578
+ weapon_id = int(ctx.amount)
2579
+ if perk_active(ctx.player, PerkId.ALTERNATE_WEAPON) and ctx.player.alt_weapon_id is None:
2580
+ ctx.player.alt_weapon_id = int(ctx.player.weapon_id)
2581
+ ctx.player.alt_clip_size = int(ctx.player.clip_size)
2582
+ ctx.player.alt_ammo = float(ctx.player.ammo)
2583
+ ctx.player.alt_reload_active = bool(ctx.player.reload_active)
2584
+ ctx.player.alt_reload_timer = float(ctx.player.reload_timer)
2585
+ ctx.player.alt_shot_cooldown = float(ctx.player.shot_cooldown)
2586
+ ctx.player.alt_reload_timer_max = float(ctx.player.reload_timer_max)
2587
+ weapon_assign_player(ctx.player, weapon_id, state=ctx.state)
2588
+
2589
+
2590
+ def _bonus_apply_fireblast(ctx: _BonusApplyCtx) -> None:
2591
+ origin_pos = ctx.origin_pos()
2592
+ owner_id = _owner_id_for_player(ctx.player.index) if ctx.state.friendly_fire_enabled else -100
2593
+ ctx.state.bonus_spawn_guard = True
2594
+ _spawn_projectile_ring(
2595
+ ctx.state,
2596
+ origin_pos,
2597
+ count=16,
2598
+ angle_offset=0.0,
2599
+ type_id=ProjectileTypeId.PLASMA_RIFLE,
2600
+ owner_id=int(owner_id),
2601
+ )
2602
+ ctx.state.bonus_spawn_guard = False
2603
+ ctx.state.sfx_queue.append("sfx_explosion_medium")
2604
+
2605
+
2606
+ def _bonus_apply_nuke(ctx: _BonusApplyCtx) -> None:
2607
+ # `bonus_apply` (crimsonland.exe @ 0x00409890) starts screen shake via:
2608
+ # camera_shake_pulses = 0x14;
2609
+ # camera_shake_timer = 0.2f;
2610
+ ctx.state.camera_shake_pulses = 0x14
2611
+ ctx.state.camera_shake_timer = 0.2
2612
+
2613
+ origin_pos = ctx.origin_pos()
2614
+ ox = float(origin_pos.pos_x)
2615
+ oy = float(origin_pos.pos_y)
2616
+ rand = ctx.state.rng.rand
2617
+
2618
+ bullet_count = int(rand()) & 3
2619
+ bullet_count += 4
2620
+ assault_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.ASSAULT_RIFLE))
2621
+ for _ in range(bullet_count):
2622
+ angle = float(int(rand()) % 0x274) * 0.01
2623
+ proj_id = ctx.state.projectiles.spawn(
2624
+ pos_x=ox,
2625
+ pos_y=oy,
2626
+ angle=float(angle),
2627
+ type_id=int(ProjectileTypeId.ASSAULT_RIFLE),
2628
+ owner_id=-100,
2629
+ base_damage=assault_meta,
2325
2630
  )
2326
- state.bonus_spawn_guard = False
2327
- state.sfx_queue.append("sfx_explosion_medium")
2328
- return
2329
-
2330
- if bonus_id == BonusId.NUKE:
2331
- # `bonus_apply` (crimsonland.exe @ 0x00409890) starts screen shake via:
2332
- # camera_shake_pulses = 0x14;
2333
- # camera_shake_timer = 0.2f;
2334
- state.camera_shake_pulses = 0x14
2335
- state.camera_shake_timer = 0.2
2336
-
2337
- origin_pos = origin or player
2338
- ox = float(origin_pos.pos_x)
2339
- oy = float(origin_pos.pos_y)
2340
- rand = state.rng.rand
2341
-
2342
- bullet_count = int(rand()) & 3
2343
- bullet_count += 4
2344
- assault_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.ASSAULT_RIFLE))
2345
- for _ in range(bullet_count):
2346
- angle = float(int(rand()) % 0x274) * 0.01
2347
- proj_id = state.projectiles.spawn(
2348
- pos_x=ox,
2349
- pos_y=oy,
2350
- angle=float(angle),
2351
- type_id=int(ProjectileTypeId.ASSAULT_RIFLE),
2352
- owner_id=-100,
2353
- base_damage=assault_meta,
2354
- )
2355
- if proj_id != -1:
2356
- speed_scale = float(int(rand()) % 0x32) * 0.01 + 0.5
2357
- state.projectiles.entries[proj_id].speed_scale *= float(speed_scale)
2358
-
2359
- minigun_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.MEAN_MINIGUN))
2360
- for _ in range(2):
2361
- angle = float(int(rand()) % 0x274) * 0.01
2362
- state.projectiles.spawn(
2363
- pos_x=ox,
2364
- pos_y=oy,
2365
- angle=float(angle),
2366
- type_id=int(ProjectileTypeId.MEAN_MINIGUN),
2367
- owner_id=-100,
2368
- base_damage=minigun_meta,
2369
- )
2370
-
2371
- state.effects.spawn_explosion_burst(
2631
+ if proj_id != -1:
2632
+ speed_scale = float(int(rand()) % 0x32) * 0.01 + 0.5
2633
+ ctx.state.projectiles.entries[proj_id].speed_scale *= float(speed_scale)
2634
+
2635
+ minigun_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.MEAN_MINIGUN))
2636
+ for _ in range(2):
2637
+ angle = float(int(rand()) % 0x274) * 0.01
2638
+ ctx.state.projectiles.spawn(
2372
2639
  pos_x=ox,
2373
2640
  pos_y=oy,
2374
- scale=1.0,
2375
- rand=rand,
2376
- detail_preset=int(detail_preset),
2641
+ angle=float(angle),
2642
+ type_id=int(ProjectileTypeId.MEAN_MINIGUN),
2643
+ owner_id=-100,
2644
+ base_damage=minigun_meta,
2377
2645
  )
2378
2646
 
2379
- if creatures:
2380
- prev_guard = bool(state.bonus_spawn_guard)
2381
- state.bonus_spawn_guard = True
2382
- for idx, creature in enumerate(creatures):
2383
- if creature.hp <= 0.0:
2384
- continue
2385
- dx = float(creature.x) - ox
2386
- dy = float(creature.y) - oy
2387
- if abs(dx) > 256.0 or abs(dy) > 256.0:
2388
- continue
2389
- dist = math.hypot(dx, dy)
2390
- if dist < 256.0:
2391
- damage = (256.0 - dist) * 5.0
2392
- if apply_creature_damage is not None:
2393
- apply_creature_damage(
2394
- int(idx),
2395
- float(damage),
2396
- 3,
2397
- 0.0,
2398
- 0.0,
2399
- _owner_id_for_player(player.index),
2400
- )
2401
- else:
2402
- creature.hp -= float(damage)
2403
- state.bonus_spawn_guard = prev_guard
2404
- state.sfx_queue.append("sfx_explosion_large")
2405
- state.sfx_queue.append("sfx_shockwave")
2647
+ ctx.state.effects.spawn_explosion_burst(
2648
+ pos_x=ox,
2649
+ pos_y=oy,
2650
+ scale=1.0,
2651
+ rand=rand,
2652
+ detail_preset=int(ctx.detail_preset),
2653
+ )
2654
+
2655
+ creatures = ctx.creatures
2656
+ if creatures:
2657
+ prev_guard = bool(ctx.state.bonus_spawn_guard)
2658
+ ctx.state.bonus_spawn_guard = True
2659
+ for idx, creature in enumerate(creatures):
2660
+ if creature.hp <= 0.0:
2661
+ continue
2662
+ dx = float(creature.x) - ox
2663
+ dy = float(creature.y) - oy
2664
+ if abs(dx) > 256.0 or abs(dy) > 256.0:
2665
+ continue
2666
+ dist = math.hypot(dx, dy)
2667
+ if dist < 256.0:
2668
+ damage = (256.0 - dist) * 5.0
2669
+ if ctx.apply_creature_damage is not None:
2670
+ ctx.apply_creature_damage(
2671
+ int(idx),
2672
+ float(damage),
2673
+ 3,
2674
+ 0.0,
2675
+ 0.0,
2676
+ _owner_id_for_player(ctx.player.index),
2677
+ )
2678
+ else:
2679
+ creature.hp -= float(damage)
2680
+ ctx.state.bonus_spawn_guard = prev_guard
2681
+
2682
+ ctx.state.sfx_queue.append("sfx_explosion_large")
2683
+ ctx.state.sfx_queue.append("sfx_shockwave")
2684
+
2685
+
2686
+ _BONUS_APPLY_HANDLERS: dict[BonusId, _BonusApplyHandler] = {
2687
+ BonusId.POINTS: _bonus_apply_points,
2688
+ BonusId.ENERGIZER: _bonus_apply_energizer,
2689
+ BonusId.WEAPON_POWER_UP: _bonus_apply_weapon_power_up,
2690
+ BonusId.DOUBLE_EXPERIENCE: _bonus_apply_double_experience,
2691
+ BonusId.REFLEX_BOOST: _bonus_apply_reflex_boost,
2692
+ BonusId.FREEZE: _bonus_apply_freeze,
2693
+ BonusId.SHIELD: _bonus_apply_shield,
2694
+ BonusId.SPEED: _bonus_apply_speed,
2695
+ BonusId.FIRE_BULLETS: _bonus_apply_fire_bullets,
2696
+ BonusId.SHOCK_CHAIN: _bonus_apply_shock_chain,
2697
+ BonusId.WEAPON: _bonus_apply_weapon,
2698
+ BonusId.FIREBLAST: _bonus_apply_fireblast,
2699
+ BonusId.NUKE: _bonus_apply_nuke,
2700
+ }
2701
+
2702
+
2703
+ def bonus_apply(
2704
+ state: GameplayState,
2705
+ player: PlayerState,
2706
+ bonus_id: BonusId,
2707
+ *,
2708
+ amount: int | None = None,
2709
+ origin: _HasPos | None = None,
2710
+ creatures: list[Damageable] | None = None,
2711
+ players: list[PlayerState] | None = None,
2712
+ apply_creature_damage: CreatureDamageApplier | None = None,
2713
+ detail_preset: int = 5,
2714
+ ) -> None:
2715
+ """Apply a bonus to player + global timers (subset of `bonus_apply`)."""
2716
+
2717
+ meta = BONUS_BY_ID.get(int(bonus_id))
2718
+ if meta is None:
2406
2719
  return
2720
+ if amount is None:
2721
+ amount = int(meta.default_amount or 0)
2722
+
2723
+ economist_multiplier = 1.0 + 0.5 * float(perk_count_get(player, PerkId.BONUS_ECONOMIST))
2724
+ icon_id = int(meta.icon_id) if meta.icon_id is not None else -1
2725
+ label = meta.name
2726
+ ctx = _BonusApplyCtx(
2727
+ state=state,
2728
+ player=player,
2729
+ bonus_id=bonus_id,
2730
+ amount=int(amount),
2731
+ origin=origin,
2732
+ creatures=creatures,
2733
+ players=players,
2734
+ apply_creature_damage=apply_creature_damage,
2735
+ detail_preset=int(detail_preset),
2736
+ economist_multiplier=float(economist_multiplier),
2737
+ label=str(label),
2738
+ icon_id=int(icon_id),
2739
+ )
2740
+ handler = _BONUS_APPLY_HANDLERS.get(bonus_id)
2741
+ if handler is not None:
2742
+ handler(ctx)
2407
2743
 
2408
2744
  # Bonus types not modeled yet.
2409
2745
  return