crimsonland 0.1.0.dev14__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 (73) hide show
  1. crimson/cli.py +63 -0
  2. crimson/creatures/damage.py +111 -36
  3. crimson/creatures/runtime.py +246 -156
  4. crimson/creatures/spawn.py +7 -3
  5. crimson/debug.py +9 -0
  6. crimson/demo.py +38 -45
  7. crimson/effects.py +7 -13
  8. crimson/frontend/high_scores_layout.py +81 -0
  9. crimson/frontend/panels/base.py +4 -1
  10. crimson/frontend/panels/controls.py +0 -15
  11. crimson/frontend/panels/databases.py +291 -3
  12. crimson/frontend/panels/mods.py +0 -15
  13. crimson/frontend/panels/play_game.py +0 -16
  14. crimson/game.py +689 -3
  15. crimson/gameplay.py +921 -569
  16. crimson/modes/base_gameplay_mode.py +33 -12
  17. crimson/modes/components/__init__.py +2 -0
  18. crimson/modes/components/highscore_record_builder.py +58 -0
  19. crimson/modes/components/perk_menu_controller.py +325 -0
  20. crimson/modes/quest_mode.py +94 -272
  21. crimson/modes/rush_mode.py +12 -43
  22. crimson/modes/survival_mode.py +109 -330
  23. crimson/modes/tutorial_mode.py +46 -247
  24. crimson/modes/typo_mode.py +11 -38
  25. crimson/oracle.py +396 -0
  26. crimson/perks.py +5 -2
  27. crimson/player_damage.py +95 -36
  28. crimson/projectiles.py +539 -320
  29. crimson/render/projectile_draw_registry.py +637 -0
  30. crimson/render/projectile_render_registry.py +110 -0
  31. crimson/render/secondary_projectile_draw_registry.py +206 -0
  32. crimson/render/world_renderer.py +58 -707
  33. crimson/sim/world_state.py +118 -61
  34. crimson/typo/spawns.py +5 -12
  35. crimson/ui/demo_trial_overlay.py +3 -11
  36. crimson/ui/formatting.py +24 -0
  37. crimson/ui/game_over.py +12 -58
  38. crimson/ui/hud.py +72 -39
  39. crimson/ui/layout.py +20 -0
  40. crimson/ui/perk_menu.py +9 -34
  41. crimson/ui/quest_results.py +28 -70
  42. crimson/ui/text_input.py +20 -0
  43. crimson/views/_ui_helpers.py +27 -0
  44. crimson/views/aim_debug.py +15 -32
  45. crimson/views/animations.py +18 -28
  46. crimson/views/arsenal_debug.py +22 -32
  47. crimson/views/bonuses.py +23 -36
  48. crimson/views/camera_debug.py +16 -29
  49. crimson/views/camera_shake.py +9 -33
  50. crimson/views/corpse_stamp_debug.py +13 -21
  51. crimson/views/decals_debug.py +36 -23
  52. crimson/views/fonts.py +8 -25
  53. crimson/views/ground.py +4 -21
  54. crimson/views/lighting_debug.py +42 -45
  55. crimson/views/particles.py +33 -42
  56. crimson/views/perk_menu_debug.py +3 -10
  57. crimson/views/player.py +50 -44
  58. crimson/views/player_sprite_debug.py +24 -31
  59. crimson/views/projectile_fx.py +57 -52
  60. crimson/views/projectile_render_debug.py +24 -33
  61. crimson/views/projectiles.py +24 -37
  62. crimson/views/spawn_plan.py +13 -29
  63. crimson/views/sprites.py +14 -29
  64. crimson/views/terrain.py +6 -23
  65. crimson/views/ui.py +7 -24
  66. crimson/views/wicons.py +28 -33
  67. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/METADATA +1 -1
  68. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/RECORD +73 -62
  69. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/WHEEL +1 -1
  70. grim/config.py +29 -1
  71. grim/console.py +7 -10
  72. grim/math.py +12 -0
  73. {crimsonland-0.1.0.dev14.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
 
@@ -534,6 +535,7 @@ class GameplayState:
534
535
  weapon_available: list[bool] = field(default_factory=lambda: [False] * WEAPON_COUNT_SIZE)
535
536
  _weapon_available_game_mode: int = -1
536
537
  _weapon_available_unlock_index: int = -1
538
+ _weapon_available_unlock_index_full: int = -1
537
539
  friendly_fire_enabled: bool = False
538
540
  bonus_spawn_guard: bool = False
539
541
  bonus_hud: BonusHudState = field(default_factory=BonusHudState)
@@ -547,6 +549,7 @@ class GameplayState:
547
549
  shots_fired: list[int] = field(default_factory=lambda: [0] * 4)
548
550
  shots_hit: list[int] = field(default_factory=lambda: [0] * 4)
549
551
  weapon_shots_fired: list[list[int]] = field(default_factory=lambda: [[0] * WEAPON_COUNT_SIZE for _ in range(4)])
552
+ debug_god_mode: bool = False
550
553
 
551
554
  def __post_init__(self) -> None:
552
555
  rand = self.rng.rand
@@ -594,6 +597,155 @@ def _creature_find_in_radius(creatures: list[_CreatureForPerks], *, pos_x: float
594
597
  return -1
595
598
 
596
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
+
597
749
  def perks_update_effects(
598
750
  state: GameplayState,
599
751
  players: list[PlayerState],
@@ -607,83 +759,15 @@ def perks_update_effects(
607
759
  dt = float(dt)
608
760
  if dt <= 0.0:
609
761
  return
610
-
611
- if players and perk_active(players[0], PerkId.REGENERATION) and (state.rng.rand() & 1):
612
- for player in players:
613
- if not (0.0 < float(player.health) < 100.0):
614
- continue
615
- player.health = float(player.health) + dt
616
- if player.health > 100.0:
617
- player.health = 100.0
618
-
619
- state.lean_mean_exp_timer -= dt
620
- if state.lean_mean_exp_timer < 0.0:
621
- state.lean_mean_exp_timer = 0.25
622
- for player in players:
623
- perk_count = perk_count_get(player, PerkId.LEAN_MEAN_EXP_MACHINE)
624
- if perk_count > 0:
625
- player.experience += perk_count * 10
626
-
627
- target = -1
628
- if players and creatures is not None and (
629
- perk_active(players[0], PerkId.PYROKINETIC) or perk_active(players[0], PerkId.EVIL_EYES)
630
- ):
631
- target = _creature_find_in_radius(
632
- creatures,
633
- pos_x=players[0].aim_x,
634
- pos_y=players[0].aim_y,
635
- radius=12.0,
636
- start_index=0,
637
- )
638
-
639
- if players:
640
- player0 = players[0]
641
- player0.evil_eyes_target_creature = target if perk_active(player0, PerkId.EVIL_EYES) else -1
642
-
643
- if players and creatures is not None and perk_active(players[0], PerkId.PYROKINETIC) and target != -1:
644
- creature = creatures[target]
645
- creature.collision_timer = float(creature.collision_timer) - dt
646
- if creature.collision_timer < 0.0:
647
- creature.collision_timer = 0.5
648
- pos_x = float(creature.x)
649
- pos_y = float(creature.y)
650
- for intensity in (0.8, 0.6, 0.4, 0.3, 0.2):
651
- angle = float(int(state.rng.rand()) % 0x274) * 0.01
652
- state.particles.spawn_particle(pos_x=pos_x, pos_y=pos_y, angle=angle, intensity=float(intensity))
653
- if fx_queue is not None:
654
- fx_queue.add_random(pos_x=pos_x, pos_y=pos_y, rand=state.rng.rand)
655
-
656
- if state.jinxed_timer >= 0.0:
657
- state.jinxed_timer -= dt
658
-
659
- if state.jinxed_timer < 0.0 and players and perk_active(players[0], PerkId.JINXED):
660
- player = players[0]
661
- if int(state.rng.rand()) % 10 == 3:
662
- player.health = float(player.health) - 5.0
663
- if fx_queue is not None:
664
- fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
665
- fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
666
-
667
- state.jinxed_timer = float(int(state.rng.rand()) % 0x14) * 0.1 + float(state.jinxed_timer) + 2.0
668
-
669
- if float(state.bonuses.freeze) <= 0.0 and creatures is not None:
670
- pool_mod = min(0x17F, len(creatures))
671
- if pool_mod <= 0:
672
- return
673
-
674
- idx = int(state.rng.rand()) % pool_mod
675
- attempts = 0
676
- while attempts < 10 and not creatures[idx].active:
677
- idx = int(state.rng.rand()) % pool_mod
678
- attempts += 1
679
- if not creatures[idx].active:
680
- return
681
-
682
- creature = creatures[idx]
683
- creature.hp = -1.0
684
- creature.hitbox_size = float(creature.hitbox_size) - dt * 20.0
685
- player.experience = int(float(player.experience) + float(creature.reward_value))
686
- 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)
687
771
 
688
772
 
689
773
  def award_experience(state: GameplayState, player: PlayerState, amount: int) -> int:
@@ -941,139 +1025,193 @@ def _increment_perk_count(player: PlayerState, perk_id: PerkId, *, amount: int =
941
1025
  player.perk_counts[idx] += int(amount)
942
1026
 
943
1027
 
944
- def perk_apply(
945
- state: GameplayState,
946
- players: list[PlayerState],
947
- perk_id: PerkId,
948
- *,
949
- perk_state: PerkSelectionState | None = None,
950
- dt: float | None = None,
951
- creatures: list[_CreatureForPerks] | None = None,
952
- ) -> None:
953
- """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
954
1037
 
955
- 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:
956
1070
  return
957
- owner = players[0]
958
- try:
959
- _increment_perk_count(owner, perk_id)
960
1071
 
961
- if perk_id == PerkId.INSTANT_WINNER:
962
- owner.experience += 2500
963
- 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
964
1084
 
965
- if perk_id == PerkId.FATAL_LOTTERY:
966
- if state.rng.rand() & 1:
967
- owner.health = -1.0
968
- else:
969
- owner.experience += 10000
970
- return
971
1085
 
972
- if perk_id == PerkId.RANDOM_WEAPON:
973
- current = int(owner.weapon_id)
974
- weapon_id = int(current)
975
- for _ in range(100):
976
- candidate = int(weapon_pick_random_available(state))
977
- weapon_id = candidate
978
- if candidate != int(WeaponId.PISTOL) and candidate != current:
979
- break
980
- weapon_assign_player(owner, weapon_id, state=state)
981
- 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))
982
1090
 
983
- if perk_id == PerkId.LIFELINE_50_50:
984
- if creatures is None:
985
- return
986
-
987
- kill_toggle = False
988
- for creature in creatures:
989
- if (
990
- kill_toggle
991
- and creature.active
992
- and float(creature.hp) <= 500.0
993
- and (int(creature.flags) & 0x04) == 0
994
- ):
995
- creature.active = False
996
- state.effects.spawn_burst(
997
- pos_x=float(creature.x),
998
- pos_y=float(creature.y),
999
- count=4,
1000
- rand=state.rng.rand,
1001
- detail_preset=5,
1002
- )
1003
- kill_toggle = not kill_toggle
1004
- return
1005
1091
 
1006
- if perk_id == PerkId.THICK_SKINNED:
1007
- for player in players:
1008
- if player.health > 0.0:
1009
- player.health = max(1.0, player.health * (2.0 / 3.0))
1010
- 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)
1011
1095
 
1012
- if perk_id == PerkId.BREATHING_ROOM:
1013
- for player in players:
1014
- 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
1015
1102
 
1016
- frame_dt = float(dt) if dt is not None else 0.0
1017
- if creatures is not None:
1018
- for creature in creatures:
1019
- if creature.active:
1020
- creature.hitbox_size = float(creature.hitbox_size) - frame_dt
1103
+ ctx.state.bonus_spawn_guard = False
1021
1104
 
1022
- state.bonus_spawn_guard = False
1023
- return
1024
1105
 
1025
- if perk_id == PerkId.INFERNAL_CONTRACT:
1026
- owner.level += 3
1027
- if perk_state is not None:
1028
- perk_state.pending_count += 3
1029
- perk_state.choices_dirty = True
1030
- for player in players:
1031
- if player.health > 0.0:
1032
- player.health = 0.1
1033
- 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
1034
1114
 
1035
- if perk_id == PerkId.GRIM_DEAL:
1036
- owner.health = -1.0
1037
- owner.experience += int(owner.experience * 0.18)
1038
- return
1039
1115
 
1040
- if perk_id == PerkId.AMMO_MANIAC:
1041
- if len(players) > 1:
1042
- for player in players[1:]:
1043
- player.perk_counts[:] = owner.perk_counts
1044
- for player in players:
1045
- weapon_assign_player(player, int(player.weapon_id), state=state)
1046
- 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)
1047
1119
 
1048
- if perk_id == PerkId.DEATH_CLOCK:
1049
- _increment_perk_count(owner, PerkId.REGENERATION, amount=-perk_count_get(owner, PerkId.REGENERATION))
1050
- _increment_perk_count(owner, PerkId.GREATER_REGENERATION, amount=-perk_count_get(owner, PerkId.GREATER_REGENERATION))
1051
- for player in players:
1052
- if player.health > 0.0:
1053
- player.health = 100.0
1054
- return
1055
1120
 
1056
- if perk_id == PerkId.BANDAGE:
1057
- for player in players:
1058
- if player.health > 0.0:
1059
- scale = float(state.rng.rand() % 50 + 1)
1060
- player.health = min(100.0, player.health * scale)
1061
- state.effects.spawn_burst(
1062
- pos_x=float(player.pos_x),
1063
- pos_y=float(player.pos_y),
1064
- count=8,
1065
- rand=state.rng.rand,
1066
- detail_preset=5,
1067
- )
1068
- 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)
1069
1127
 
1070
- if perk_id == PerkId.MY_FAVOURITE_WEAPON:
1071
- for player in players:
1072
- player.clip_size += 2
1073
- return
1074
1128
 
1075
- if perk_id == PerkId.PLAGUEBEARER:
1076
- 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
+ )
1077
1215
  finally:
1078
1216
  if len(players) > 1:
1079
1217
  for player in players[1:]:
@@ -1198,14 +1336,6 @@ def survival_progression_update(
1198
1336
  return []
1199
1337
 
1200
1338
 
1201
- def _clamp(value: float, lo: float, hi: float) -> float:
1202
- if value < lo:
1203
- return lo
1204
- if value > hi:
1205
- return hi
1206
- return value
1207
-
1208
-
1209
1339
  def _normalize(x: float, y: float) -> tuple[float, float]:
1210
1340
  mag = math.hypot(x, y)
1211
1341
  if mag <= 1e-9:
@@ -1214,17 +1344,17 @@ def _normalize(x: float, y: float) -> tuple[float, float]:
1214
1344
  return x * inv, y * inv
1215
1345
 
1216
1346
 
1217
- def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
1218
- dx = x1 - x0
1219
- dy = y1 - y0
1220
- return dx * dx + dy * dy
1221
-
1222
-
1223
1347
  def _owner_id_for_player(player_index: int) -> int:
1224
1348
  # crimsonland.exe uses -1/-2/-3 for players (and sometimes -100 in demo paths).
1225
1349
  return -1 - int(player_index)
1226
1350
 
1227
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
+
1228
1358
  def _weapon_entry(weapon_id: int) -> Weapon | None:
1229
1359
  return WEAPON_BY_ID.get(int(weapon_id))
1230
1360
 
@@ -1236,17 +1366,23 @@ def weapon_refresh_available(state: "GameplayState") -> None:
1236
1366
  """
1237
1367
 
1238
1368
  unlock_index = 0
1369
+ unlock_index_full = 0
1239
1370
  status = state.status
1240
1371
  if status is not None:
1241
1372
  try:
1242
1373
  unlock_index = int(status.quest_unlock_index)
1243
1374
  except Exception:
1244
1375
  unlock_index = 0
1376
+ try:
1377
+ unlock_index_full = int(status.quest_unlock_index_full)
1378
+ except Exception:
1379
+ unlock_index_full = 0
1245
1380
 
1246
1381
  game_mode = int(state.game_mode)
1247
1382
  if (
1248
1383
  int(state._weapon_available_game_mode) == game_mode
1249
1384
  and int(state._weapon_available_unlock_index) == unlock_index
1385
+ and int(state._weapon_available_unlock_index_full) == unlock_index_full
1250
1386
  ):
1251
1387
  return
1252
1388
 
@@ -1281,8 +1417,16 @@ def weapon_refresh_available(state: "GameplayState") -> None:
1281
1417
  if 0 <= idx < len(available):
1282
1418
  available[idx] = True
1283
1419
 
1420
+ # Secret unlock: Splitter Gun (weapon id 29) becomes available once the hardcore
1421
+ # unlock track reaches stage 5 (quest_unlock_index_full >= 40).
1422
+ if (not state.demo_mode_active) and unlock_index_full >= 0x28:
1423
+ splitter_id = int(WeaponId.SPLITTER_GUN)
1424
+ if 0 <= splitter_id < len(available):
1425
+ available[splitter_id] = True
1426
+
1284
1427
  state._weapon_available_game_mode = game_mode
1285
1428
  state._weapon_available_unlock_index = unlock_index
1429
+ state._weapon_available_unlock_index_full = unlock_index_full
1286
1430
 
1287
1431
 
1288
1432
  def weapon_pick_random_available(state: "GameplayState") -> int:
@@ -1368,6 +1512,85 @@ def _bonus_id_from_roll(roll: int, rng: Crand) -> int:
1368
1512
  return int(v6)
1369
1513
 
1370
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
+
1371
1594
  def bonus_pick_random_type(pool: BonusPool, state: "GameplayState", players: list["PlayerState"]) -> int:
1372
1595
  has_fire_bullets_drop = any(
1373
1596
  entry.bonus_id == int(BonusId.FIRE_BULLETS) and not entry.picked
@@ -1379,33 +1602,49 @@ def bonus_pick_random_type(pool: BonusPool, state: "GameplayState", players: lis
1379
1602
  bonus_id = _bonus_id_from_roll(roll, state.rng)
1380
1603
  if bonus_id <= 0:
1381
1604
  continue
1382
- if state.shock_chain_links_left > 0 and bonus_id == int(BonusId.SHOCK_CHAIN):
1383
- continue
1384
- if int(state.game_mode) == int(GameMode.QUESTS) and int(state.quest_stage_minor) == 10:
1385
- if bonus_id == int(BonusId.NUKE) and (
1386
- int(state.quest_stage_major) in (2, 4, 5) or (state.hardcore and int(state.quest_stage_major) == 3)
1387
- ):
1388
- continue
1389
- if bonus_id == int(BonusId.FREEZE) and (
1390
- int(state.quest_stage_major) == 4 or (state.hardcore and int(state.quest_stage_major) == 2)
1391
- ):
1392
- continue
1393
- if bonus_id == int(BonusId.FREEZE) and state.bonuses.freeze > 0.0:
1394
- continue
1395
- if bonus_id == int(BonusId.SHIELD) and any(player.shield_timer > 0.0 for player in players):
1396
- continue
1397
- if bonus_id == int(BonusId.WEAPON) and has_fire_bullets_drop:
1398
- continue
1399
- if bonus_id == int(BonusId.WEAPON) and any(perk_active(player, PerkId.MY_FAVOURITE_WEAPON) for player in players):
1400
- continue
1401
- if bonus_id == int(BonusId.MEDIKIT) and any(perk_active(player, PerkId.DEATH_CLOCK) for player in players):
1402
- continue
1403
- 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:
1404
1618
  continue
1405
1619
  return bonus_id
1406
1620
  return int(BonusId.POINTS)
1407
1621
 
1408
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
+
1409
1648
  def weapon_assign_player(player: PlayerState, weapon_id: int, *, state: GameplayState | None = None) -> None:
1410
1649
  """Assign weapon and reset per-weapon runtime state (ammo/cooldowns)."""
1411
1650
 
@@ -1420,15 +1659,10 @@ def weapon_assign_player(player: PlayerState, weapon_id: int, *, state: Gameplay
1420
1659
  player.weapon_id = weapon_id
1421
1660
 
1422
1661
  clip_size = int(weapon.clip_size) if weapon is not None and weapon.clip_size is not None else 0
1423
- clip_size = max(0, clip_size)
1424
-
1425
- # weapon_assign_player @ 0x004220B0: clip-size perks are applied on every weapon assignment.
1426
- if perk_active(player, PerkId.AMMO_MANIAC):
1427
- clip_size += max(1, int(float(clip_size) * 0.25))
1428
- if perk_active(player, PerkId.MY_FAVOURITE_WEAPON):
1429
- clip_size += 2
1430
-
1431
- 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))
1432
1666
  player.ammo = float(player.clip_size)
1433
1667
  player.weapon_reset_latch = 0
1434
1668
  player.reload_active = False
@@ -1548,8 +1782,7 @@ def _perk_update_man_bomb(player: PlayerState, dt: float, state: GameplayState)
1548
1782
  if player.man_bomb_timer <= state.perk_intervals.man_bomb:
1549
1783
  return
1550
1784
 
1551
- owner_id = _owner_id_for_player(player.index)
1552
- state.bonus_spawn_guard = True
1785
+ owner_id = _owner_id_for_player_projectiles(state, player.index)
1553
1786
  for idx in range(8):
1554
1787
  type_id = ProjectileTypeId.ION_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.ION_RIFLE
1555
1788
  angle = (float(state.rng.rand() % 50) * 0.01) + float(idx) * (math.pi / 4.0) - 0.25
@@ -1561,7 +1794,6 @@ def _perk_update_man_bomb(player: PlayerState, dt: float, state: GameplayState)
1561
1794
  owner_id=owner_id,
1562
1795
  base_damage=_projectile_meta_for_type_id(type_id),
1563
1796
  )
1564
- state.bonus_spawn_guard = False
1565
1797
  state.sfx_queue.append("sfx_explosion_small")
1566
1798
 
1567
1799
  player.man_bomb_timer -= state.perk_intervals.man_bomb
@@ -1573,7 +1805,7 @@ def _perk_update_hot_tempered(player: PlayerState, dt: float, state: GameplaySta
1573
1805
  if player.hot_tempered_timer <= state.perk_intervals.hot_tempered:
1574
1806
  return
1575
1807
 
1576
- owner_id = _owner_id_for_player(player.index)
1808
+ owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
1577
1809
  state.bonus_spawn_guard = True
1578
1810
  for idx in range(8):
1579
1811
  type_id = ProjectileTypeId.PLASMA_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.PLASMA_RIFLE
@@ -1598,13 +1830,27 @@ def _perk_update_fire_cough(player: PlayerState, dt: float, state: GameplayState
1598
1830
  if player.fire_cough_timer <= state.perk_intervals.fire_cough:
1599
1831
  return
1600
1832
 
1601
- owner_id = _owner_id_for_player(player.index)
1602
- # Fire Cough spawns a fire projectile (and a small sprite burst) from the muzzle.
1603
- theta = math.atan2(player.aim_dir_y, player.aim_dir_x)
1604
- jitter = (float(state.rng.rand() % 200) - 100.0) * 0.0015
1605
- angle = theta + jitter + math.pi / 2.0
1606
- muzzle_x = player.pos_x + player.aim_dir_x * 16.0
1607
- 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
1608
1854
  state.projectiles.spawn(
1609
1855
  pos_x=muzzle_x,
1610
1856
  pos_y=muzzle_y,
@@ -1614,10 +1860,66 @@ def _perk_update_fire_cough(player: PlayerState, dt: float, state: GameplayState
1614
1860
  base_damage=_projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS),
1615
1861
  )
1616
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
+
1617
1872
  player.fire_cough_timer -= state.perk_intervals.fire_cough
1618
1873
  state.perk_intervals.fire_cough = float(state.rng.rand() % 4) + 2.0
1619
1874
 
1620
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
+
1621
1923
  def player_fire_weapon(
1622
1924
  player: PlayerState,
1623
1925
  input_state: PlayerInput,
@@ -1642,26 +1944,25 @@ def player_fire_weapon(
1642
1944
  ammo_cost = 1.0
1643
1945
  is_fire_bullets = float(player.fire_bullets_timer) > 0.0
1644
1946
  if player.reload_timer > 0.0:
1645
- if player.ammo <= 0 and player.experience > 0:
1646
- if perk_active(player, PerkId.REGRESSION_BULLETS):
1647
- firing_during_reload = True
1648
- ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1649
-
1650
- reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
1651
- factor = 4.0 if ammo_class == 1 else 200.0
1652
- player.experience = int(float(player.experience) - reload_time * factor)
1653
- if player.experience < 0:
1654
- player.experience = 0
1655
- elif perk_active(player, PerkId.AMMUNITION_WITHIN):
1656
- firing_during_reload = True
1657
- ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1658
-
1659
- from .player_damage import player_take_damage
1660
-
1661
- cost = 0.15 if ammo_class == 1 else 1.0
1662
- player_take_damage(state, player, cost, dt=dt, rand=state.rng.rand)
1663
- else:
1664
- 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)
1665
1966
  else:
1666
1967
  return
1667
1968
 
@@ -1997,8 +2298,8 @@ def player_update(
1997
2298
  if perk_active(player, PerkId.ALTERNATE_WEAPON):
1998
2299
  speed *= 0.8
1999
2300
 
2000
- player.pos_x = _clamp(player.pos_x + move_x * speed * dt, 0.0, float(world_size))
2001
- 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))
2002
2303
 
2003
2304
  player.move_phase += dt * player.move_speed * 19.0
2004
2305
 
@@ -2007,25 +2308,9 @@ def player_update(
2007
2308
  if stationary and perk_active(player, PerkId.STATIONARY_RELOADER):
2008
2309
  reload_scale = 3.0
2009
2310
 
2010
- if stationary and perk_active(player, PerkId.MAN_BOMB):
2011
- _perk_update_man_bomb(player, dt, state)
2012
- else:
2013
- player.man_bomb_timer = 0.0
2014
-
2015
- if stationary and perk_active(player, PerkId.LIVING_FORTRESS):
2016
- player.living_fortress_timer = min(30.0, player.living_fortress_timer + dt)
2017
- else:
2018
- player.living_fortress_timer = 0.0
2019
-
2020
- if perk_active(player, PerkId.FIRE_CAUGH):
2021
- _perk_update_fire_cough(player, dt, state)
2022
- else:
2023
- player.fire_cough_timer = 0.0
2024
-
2025
- if perk_active(player, PerkId.HOT_TEMPERED):
2026
- _perk_update_hot_tempered(player, dt, state)
2027
- else:
2028
- 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)
2029
2314
 
2030
2315
  # Reload + reload perks.
2031
2316
  if perk_active(player, PerkId.ANXIOUS_LOADER) and input_state.fire_pressed and player.reload_timer > 0.0:
@@ -2049,7 +2334,7 @@ def player_update(
2049
2334
  count=count,
2050
2335
  angle_offset=0.1,
2051
2336
  type_id=ProjectileTypeId.PLASMA_MINIGUN,
2052
- owner_id=_owner_id_for_player(player.index),
2337
+ owner_id=_owner_id_for_player_projectiles(state, player.index),
2053
2338
  )
2054
2339
  state.bonus_spawn_guard = False
2055
2340
  state.sfx_queue.append("sfx_explosion_small")
@@ -2085,309 +2370,376 @@ def player_update(
2085
2370
  player.move_phase += 14.0
2086
2371
 
2087
2372
 
2088
- def bonus_apply(
2089
- state: GameplayState,
2090
- player: PlayerState,
2091
- bonus_id: BonusId,
2092
- *,
2093
- amount: int | None = None,
2094
- origin: _HasPos | None = None,
2095
- creatures: list[Damageable] | None = None,
2096
- players: list[PlayerState] | None = None,
2097
- apply_creature_damage: CreatureDamageApplier | None = None,
2098
- detail_preset: int = 5,
2099
- ) -> None:
2100
- """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
+ )
2101
2395
 
2102
- meta = BONUS_BY_ID.get(int(bonus_id))
2103
- if meta is None:
2104
- return
2105
- if amount is None:
2106
- 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
+ )
2107
2412
 
2108
- if bonus_id == BonusId.POINTS:
2109
- award_experience(state, player, int(amount))
2110
- return
2413
+ def origin_pos(self) -> _HasPos:
2414
+ return self.origin or self.player
2111
2415
 
2112
- economist_multiplier = 1.0 + 0.5 * float(perk_count_get(player, PerkId.BONUS_ECONOMIST))
2113
2416
 
2114
- icon_id = int(meta.icon_id) if meta.icon_id is not None else -1
2115
- label = meta.name
2417
+ _BonusApplyHandler = Callable[[_BonusApplyCtx], None]
2116
2418
 
2117
- def _register_global(timer_key: str) -> None:
2118
- state.bonus_hud.register(
2119
- bonus_id,
2120
- label=label,
2121
- icon_id=icon_id,
2122
- timer_ref=_TimerRef("global", timer_key),
2123
- )
2124
2419
 
2125
- def _register_player(timer_key: str) -> None:
2126
- if players is not None and len(players) > 1:
2127
- state.bonus_hud.register(
2128
- bonus_id,
2129
- label=label,
2130
- icon_id=icon_id,
2131
- timer_ref=_TimerRef("player", timer_key, player_index=0),
2132
- timer_ref_alt=_TimerRef("player", timer_key, player_index=1),
2133
- )
2134
- else:
2135
- state.bonus_hud.register(
2136
- bonus_id,
2137
- label=label,
2138
- icon_id=icon_id,
2139
- timer_ref=_TimerRef("player", timer_key, player_index=int(player.index)),
2140
- )
2420
+ def _bonus_apply_points(ctx: _BonusApplyCtx) -> None:
2421
+ award_experience(ctx.state, ctx.player, int(ctx.amount))
2141
2422
 
2142
- if bonus_id == BonusId.ENERGIZER:
2143
- old = float(state.bonuses.energizer)
2144
- if old <= 0.0:
2145
- _register_global("energizer")
2146
- state.bonuses.energizer = float(old + float(amount) * economist_multiplier)
2147
- return
2148
2423
 
2149
- if bonus_id == BonusId.WEAPON_POWER_UP:
2150
- old = float(state.bonuses.weapon_power_up)
2151
- if old <= 0.0:
2152
- _register_global("weapon_power_up")
2153
- state.bonuses.weapon_power_up = float(old + float(amount) * economist_multiplier)
2154
- player.weapon_reset_latch = 0
2155
- player.shot_cooldown = 0.0
2156
- player.reload_active = False
2157
- player.reload_timer = 0.0
2158
- player.reload_timer_max = 0.0
2159
- player.ammo = float(player.clip_size)
2160
- 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)
2161
2429
 
2162
- if bonus_id == BonusId.DOUBLE_EXPERIENCE:
2163
- old = float(state.bonuses.double_experience)
2164
- if old <= 0.0:
2165
- _register_global("double_experience")
2166
- state.bonuses.double_experience = float(old + float(amount) * economist_multiplier)
2167
- return
2168
2430
 
2169
- if bonus_id == BonusId.REFLEX_BOOST:
2170
- old = float(state.bonuses.reflex_boost)
2171
- if old <= 0.0:
2172
- _register_global("reflex_boost")
2173
- state.bonuses.reflex_boost = float(old + float(amount) * economist_multiplier)
2174
-
2175
- targets = players if players is not None else [player]
2176
- for target in targets:
2177
- target.ammo = float(target.clip_size)
2178
- target.reload_active = False
2179
- target.reload_timer = 0.0
2180
- target.reload_timer_max = 0.0
2181
- 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)
2182
2442
 
2183
- if bonus_id == BonusId.FREEZE:
2184
- old = float(state.bonuses.freeze)
2185
- if old <= 0.0:
2186
- _register_global("freeze")
2187
- state.bonuses.freeze = float(old + float(amount) * economist_multiplier)
2188
- if creatures:
2189
- rand = state.rng.rand
2190
- for creature in creatures:
2191
- active = getattr(creature, "active", True)
2192
- if not bool(active):
2193
- continue
2194
- if float(getattr(creature, "hp", 0.0)) > 0.0:
2195
- continue
2196
- pos_x = float(getattr(creature, "x", 0.0))
2197
- pos_y = float(getattr(creature, "y", 0.0))
2198
- for _ in range(8):
2199
- angle = float(int(rand()) % 0x264) * 0.01
2200
- state.effects.spawn_freeze_shard(
2201
- pos_x=pos_x,
2202
- pos_y=pos_y,
2203
- angle=angle,
2204
- rand=rand,
2205
- detail_preset=int(detail_preset),
2206
- )
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):
2207
2483
  angle = float(int(rand()) % 0x264) * 0.01
2208
- state.effects.spawn_freeze_shatter(
2484
+ ctx.state.effects.spawn_freeze_shard(
2209
2485
  pos_x=pos_x,
2210
2486
  pos_y=pos_y,
2211
2487
  angle=angle,
2212
2488
  rand=rand,
2213
- detail_preset=int(detail_preset),
2489
+ detail_preset=int(ctx.detail_preset),
2214
2490
  )
2215
- if hasattr(creature, "active"):
2216
- setattr(creature, "active", False)
2217
- state.sfx_queue.append("sfx_shockwave")
2218
- 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)
2219
2501
 
2220
- if bonus_id == BonusId.SHIELD:
2221
- should_register = float(player.shield_timer) <= 0.0
2222
- if players is not None and len(players) > 1:
2223
- should_register = float(players[0].shield_timer) <= 0.0 and float(players[1].shield_timer) <= 0.0
2224
- if should_register:
2225
- _register_player("shield_timer")
2226
- player.shield_timer = float(player.shield_timer + float(amount) * economist_multiplier)
2227
- return
2502
+ ctx.state.sfx_queue.append("sfx_shockwave")
2228
2503
 
2229
- if bonus_id == BonusId.SPEED:
2230
- should_register = float(player.speed_bonus_timer) <= 0.0
2231
- if players is not None and len(players) > 1:
2232
- should_register = float(players[0].speed_bonus_timer) <= 0.0 and float(players[1].speed_bonus_timer) <= 0.0
2233
- if should_register:
2234
- _register_player("speed_bonus_timer")
2235
- player.speed_bonus_timer = float(player.speed_bonus_timer + float(amount) * economist_multiplier)
2236
- return
2237
2504
 
2238
- if bonus_id == BonusId.FIRE_BULLETS:
2239
- should_register = float(player.fire_bullets_timer) <= 0.0
2240
- if players is not None and len(players) > 1:
2241
- should_register = float(players[0].fire_bullets_timer) <= 0.0 and float(players[1].fire_bullets_timer) <= 0.0
2242
- if should_register:
2243
- _register_player("fire_bullets_timer")
2244
- player.fire_bullets_timer = float(player.fire_bullets_timer + float(amount) * economist_multiplier)
2245
- player.weapon_reset_latch = 0
2246
- player.shot_cooldown = 0.0
2247
- player.reload_active = False
2248
- player.reload_timer = 0.0
2249
- player.reload_timer_max = 0.0
2250
- player.ammo = float(player.clip_size)
2251
- 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)
2252
2512
 
2253
- if bonus_id == BonusId.SHOCK_CHAIN:
2254
- if creatures:
2255
- origin_pos = origin or player
2256
- best_idx: int | None = None
2257
- best_dist = 0.0
2258
- for idx, creature in enumerate(creatures):
2259
- if creature.hp <= 0.0:
2260
- continue
2261
- d = _distance_sq(float(origin_pos.pos_x), float(origin_pos.pos_y), creature.x, creature.y)
2262
- if best_idx is None or d < best_dist:
2263
- best_idx = idx
2264
- best_dist = d
2265
- if best_idx is not None:
2266
- target = creatures[best_idx]
2267
- dx = target.x - float(origin_pos.pos_x)
2268
- dy = target.y - float(origin_pos.pos_y)
2269
- angle = math.atan2(dy, dx) + math.pi / 2.0
2270
- owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
2271
2513
 
2272
- state.bonus_spawn_guard = True
2273
- state.shock_chain_links_left = 0x20
2274
- state.shock_chain_projectile_id = state.projectiles.spawn(
2275
- pos_x=float(origin_pos.pos_x),
2276
- pos_y=float(origin_pos.pos_y),
2277
- angle=angle,
2278
- type_id=int(ProjectileTypeId.ION_RIFLE),
2279
- owner_id=int(owner_id),
2280
- base_damage=_projectile_meta_for_type_id(int(ProjectileTypeId.ION_RIFLE)),
2281
- )
2282
- 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:
2283
2543
  return
2284
2544
 
2285
- if bonus_id == BonusId.WEAPON:
2286
- weapon_id = int(amount)
2287
- if perk_active(player, PerkId.ALTERNATE_WEAPON) and player.alt_weapon_id is None:
2288
- player.alt_weapon_id = int(player.weapon_id)
2289
- player.alt_clip_size = int(player.clip_size)
2290
- player.alt_ammo = float(player.ammo)
2291
- player.alt_reload_active = bool(player.reload_active)
2292
- player.alt_reload_timer = float(player.reload_timer)
2293
- player.alt_shot_cooldown = float(player.shot_cooldown)
2294
- player.alt_reload_timer_max = float(player.reload_timer_max)
2295
- 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:
2296
2556
  return
2297
2557
 
2298
- if bonus_id == BonusId.FIREBLAST:
2299
- origin_pos = origin or player
2300
- owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
2301
- state.bonus_spawn_guard = True
2302
- _spawn_projectile_ring(
2303
- state,
2304
- origin_pos,
2305
- count=16,
2306
- angle_offset=0.0,
2307
- type_id=ProjectileTypeId.PLASMA_RIFLE,
2308
- 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,
2309
2630
  )
2310
- state.bonus_spawn_guard = False
2311
- state.sfx_queue.append("sfx_explosion_medium")
2312
- return
2313
-
2314
- if bonus_id == BonusId.NUKE:
2315
- # `bonus_apply` (crimsonland.exe @ 0x00409890) starts screen shake via:
2316
- # camera_shake_pulses = 0x14;
2317
- # camera_shake_timer = 0.2f;
2318
- state.camera_shake_pulses = 0x14
2319
- state.camera_shake_timer = 0.2
2320
-
2321
- origin_pos = origin or player
2322
- ox = float(origin_pos.pos_x)
2323
- oy = float(origin_pos.pos_y)
2324
- rand = state.rng.rand
2325
-
2326
- bullet_count = int(rand()) & 3
2327
- bullet_count += 4
2328
- assault_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.ASSAULT_RIFLE))
2329
- for _ in range(bullet_count):
2330
- angle = float(int(rand()) % 0x274) * 0.01
2331
- proj_id = state.projectiles.spawn(
2332
- pos_x=ox,
2333
- pos_y=oy,
2334
- angle=float(angle),
2335
- type_id=int(ProjectileTypeId.ASSAULT_RIFLE),
2336
- owner_id=-100,
2337
- base_damage=assault_meta,
2338
- )
2339
- if proj_id != -1:
2340
- speed_scale = float(int(rand()) % 0x32) * 0.01 + 0.5
2341
- state.projectiles.entries[proj_id].speed_scale *= float(speed_scale)
2342
-
2343
- minigun_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.MEAN_MINIGUN))
2344
- for _ in range(2):
2345
- angle = float(int(rand()) % 0x274) * 0.01
2346
- state.projectiles.spawn(
2347
- pos_x=ox,
2348
- pos_y=oy,
2349
- angle=float(angle),
2350
- type_id=int(ProjectileTypeId.MEAN_MINIGUN),
2351
- owner_id=-100,
2352
- base_damage=minigun_meta,
2353
- )
2354
-
2355
- 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(
2356
2639
  pos_x=ox,
2357
2640
  pos_y=oy,
2358
- scale=1.0,
2359
- rand=rand,
2360
- 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,
2361
2645
  )
2362
2646
 
2363
- if creatures:
2364
- prev_guard = bool(state.bonus_spawn_guard)
2365
- state.bonus_spawn_guard = True
2366
- for idx, creature in enumerate(creatures):
2367
- if creature.hp <= 0.0:
2368
- continue
2369
- dx = float(creature.x) - ox
2370
- dy = float(creature.y) - oy
2371
- if abs(dx) > 256.0 or abs(dy) > 256.0:
2372
- continue
2373
- dist = math.hypot(dx, dy)
2374
- if dist < 256.0:
2375
- damage = (256.0 - dist) * 5.0
2376
- if apply_creature_damage is not None:
2377
- apply_creature_damage(
2378
- int(idx),
2379
- float(damage),
2380
- 3,
2381
- 0.0,
2382
- 0.0,
2383
- _owner_id_for_player(player.index),
2384
- )
2385
- else:
2386
- creature.hp -= float(damage)
2387
- state.bonus_spawn_guard = prev_guard
2388
- state.sfx_queue.append("sfx_explosion_large")
2389
- 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:
2390
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)
2391
2743
 
2392
2744
  # Bonus types not modeled yet.
2393
2745
  return