crimsonland 0.1.0.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +652 -0
  34. crimson/gameplay.py +2467 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1133 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
crimson/gameplay.py ADDED
@@ -0,0 +1,2467 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import math
5
+ from typing import TYPE_CHECKING, Protocol
6
+
7
+ from .bonuses import BONUS_BY_ID, BonusId
8
+ from grim.rand import Crand
9
+ from .effects import EffectPool, FxQueue, ParticlePool, SpriteEffectPool
10
+ from .game_modes import GameMode
11
+ from .perks import PerkFlags, PerkId, PERK_BY_ID, PERK_TABLE
12
+ from .projectiles import CreatureDamageApplier, Damageable, ProjectilePool, ProjectileTypeId, SecondaryProjectilePool
13
+ from .weapons import (
14
+ WEAPON_BY_ID,
15
+ WEAPON_TABLE,
16
+ Weapon,
17
+ WeaponId,
18
+ projectile_type_id_from_weapon_id,
19
+ weapon_entry_for_projectile_type_id,
20
+ )
21
+
22
+ if TYPE_CHECKING:
23
+ from .persistence.save_status import GameStatus
24
+
25
+
26
+ class _HasPos(Protocol):
27
+ pos_x: float
28
+ pos_y: float
29
+
30
+
31
+ class _CreatureForPerks(Protocol):
32
+ active: bool
33
+ x: float
34
+ y: float
35
+ hp: float
36
+ flags: int
37
+ hitbox_size: float
38
+ collision_timer: float
39
+ reward_value: float
40
+ size: float
41
+
42
+
43
+ @dataclass(frozen=True, slots=True)
44
+ class PlayerInput:
45
+ move_x: float = 0.0
46
+ move_y: float = 0.0
47
+ aim_x: float = 0.0
48
+ aim_y: float = 0.0
49
+ fire_down: bool = False
50
+ fire_pressed: bool = False
51
+ reload_pressed: bool = False
52
+
53
+
54
+ PERK_COUNT_SIZE = 0x80
55
+ PERK_ID_MAX = max(int(meta.perk_id) for meta in PERK_TABLE)
56
+ WEAPON_COUNT_SIZE = max(int(entry.weapon_id) for entry in WEAPON_TABLE) + 1
57
+
58
+
59
+ @dataclass(slots=True)
60
+ class PlayerState:
61
+ index: int
62
+ pos_x: float
63
+ pos_y: float
64
+ health: float = 100.0
65
+ size: float = 50.0
66
+
67
+ speed_multiplier: float = 2.0
68
+ move_speed: float = 0.0
69
+ move_phase: float = 0.0
70
+ heading: float = 0.0
71
+ death_timer: float = 16.0
72
+ low_health_timer: float = 100.0
73
+
74
+ aim_x: float = 0.0
75
+ aim_y: float = 0.0
76
+ aim_heading: float = 0.0
77
+ aim_dir_x: float = 1.0
78
+ aim_dir_y: float = 0.0
79
+ evil_eyes_target_creature: int = -1
80
+
81
+ bonus_aim_hover_index: int = -1
82
+ bonus_aim_hover_timer_ms: float = 0.0
83
+
84
+ weapon_id: int = 1
85
+ clip_size: int = 0
86
+ ammo: float = 0.0
87
+ reload_active: bool = False
88
+ reload_timer: float = 0.0
89
+ reload_timer_max: float = 0.0
90
+ shot_cooldown: float = 0.0
91
+ shot_seq: int = 0
92
+ weapon_reset_latch: int = 0
93
+ aux_timer: float = 0.0
94
+ spread_heat: float = 0.01
95
+ muzzle_flash_alpha: float = 0.0
96
+
97
+ alt_weapon_id: int | None = None
98
+ alt_clip_size: int = 0
99
+ alt_ammo: float = 0.0
100
+ alt_reload_active: bool = False
101
+ alt_reload_timer: float = 0.0
102
+ alt_reload_timer_max: float = 0.0
103
+ alt_shot_cooldown: float = 0.0
104
+
105
+ experience: int = 0
106
+ level: int = 1
107
+
108
+ perk_counts: list[int] = field(default_factory=lambda: [0] * PERK_COUNT_SIZE)
109
+ plaguebearer_active: bool = False
110
+ hot_tempered_timer: float = 0.0
111
+ man_bomb_timer: float = 0.0
112
+ living_fortress_timer: float = 0.0
113
+ fire_cough_timer: float = 0.0
114
+
115
+ speed_bonus_timer: float = 0.0
116
+ shield_timer: float = 0.0
117
+ fire_bullets_timer: float = 0.0
118
+
119
+
120
+ @dataclass(slots=True)
121
+ class BonusTimers:
122
+ weapon_power_up: float = 0.0
123
+ reflex_boost: float = 0.0
124
+ energizer: float = 0.0
125
+ double_experience: float = 0.0
126
+ freeze: float = 0.0
127
+
128
+
129
+ @dataclass(slots=True)
130
+ class PerkEffectIntervals:
131
+ """Global thresholds used by perk timers in `player_update`.
132
+
133
+ These are global (not per-player) in crimsonland.exe: `flt_473310`,
134
+ `flt_473314`, and `flt_473318`.
135
+ """
136
+
137
+ man_bomb: float = 4.0
138
+ fire_cough: float = 2.0
139
+ hot_tempered: float = 2.0
140
+
141
+
142
+ @dataclass(slots=True)
143
+ class PerkSelectionState:
144
+ pending_count: int = 0
145
+ choices: list[int] = field(default_factory=list)
146
+ choices_dirty: bool = True
147
+
148
+
149
+ @dataclass(frozen=True, slots=True)
150
+ class _TimerRef:
151
+ kind: str # "global" or "player"
152
+ key: str
153
+ player_index: int | None = None
154
+
155
+
156
+ @dataclass(slots=True)
157
+ class BonusHudSlot:
158
+ active: bool = False
159
+ bonus_id: int = 0
160
+ label: str = ""
161
+ icon_id: int = -1
162
+ timer_ref: _TimerRef | None = None
163
+ timer_ref_alt: _TimerRef | None = None
164
+
165
+
166
+ BONUS_HUD_SLOT_COUNT = 16
167
+
168
+ BONUS_POOL_SIZE = 16
169
+ BONUS_SPAWN_MARGIN = 32.0
170
+ BONUS_SPAWN_MIN_DISTANCE = 32.0
171
+ BONUS_PICKUP_RADIUS = 26.0
172
+ BONUS_PICKUP_DECAY_RATE = 3.0
173
+ BONUS_PICKUP_LINGER = 0.5
174
+ BONUS_TIME_MAX = 10.0
175
+ BONUS_WEAPON_NEAR_RADIUS = 56.0
176
+ BONUS_AIM_HOVER_RADIUS = 24.0
177
+ BONUS_TELEKINETIC_PICKUP_MS = 650.0
178
+
179
+ WEAPON_DROP_ID_COUNT = 0x21 # weapon ids 1..33
180
+
181
+
182
+ @dataclass(slots=True)
183
+ class BonusHudState:
184
+ slots: list[BonusHudSlot] = field(default_factory=lambda: [BonusHudSlot() for _ in range(BONUS_HUD_SLOT_COUNT)])
185
+
186
+ def register(self, bonus_id: BonusId, *, label: str, icon_id: int, timer_ref: _TimerRef, timer_ref_alt: _TimerRef | None = None) -> None:
187
+ existing = None
188
+ free = None
189
+ for slot in self.slots:
190
+ if slot.active and slot.bonus_id == int(bonus_id):
191
+ existing = slot
192
+ break
193
+ if (not slot.active) and free is None:
194
+ free = slot
195
+ slot = existing or free
196
+ if slot is None:
197
+ slot = self.slots[-1]
198
+ slot.active = True
199
+ slot.bonus_id = int(bonus_id)
200
+ slot.label = label
201
+ slot.icon_id = int(icon_id)
202
+ slot.timer_ref = timer_ref
203
+ slot.timer_ref_alt = timer_ref_alt
204
+
205
+
206
+ @dataclass(slots=True)
207
+ class BonusEntry:
208
+ bonus_id: int = 0
209
+ picked: bool = False
210
+ time_left: float = 0.0
211
+ time_max: float = 0.0
212
+ pos_x: float = 0.0
213
+ pos_y: float = 0.0
214
+ amount: int = 0
215
+
216
+
217
+ @dataclass(frozen=True, slots=True)
218
+ class BonusPickupEvent:
219
+ player_index: int
220
+ bonus_id: int
221
+ amount: int
222
+ pos_x: float
223
+ pos_y: float
224
+
225
+
226
+ class BonusPool:
227
+ def __init__(self, *, size: int = BONUS_POOL_SIZE) -> None:
228
+ self._entries = [BonusEntry() for _ in range(int(size))]
229
+
230
+ @property
231
+ def entries(self) -> list[BonusEntry]:
232
+ return self._entries
233
+
234
+ def reset(self) -> None:
235
+ for entry in self._entries:
236
+ entry.bonus_id = 0
237
+ entry.picked = False
238
+ entry.time_left = 0.0
239
+ entry.time_max = 0.0
240
+ entry.amount = 0
241
+
242
+ def iter_active(self) -> list[BonusEntry]:
243
+ return [entry for entry in self._entries if entry.bonus_id != 0]
244
+
245
+ def _alloc_slot(self) -> BonusEntry | None:
246
+ for entry in self._entries:
247
+ if entry.bonus_id == 0:
248
+ return entry
249
+ return None
250
+
251
+ def _clear_entry(self, entry: BonusEntry) -> None:
252
+ entry.bonus_id = 0
253
+ entry.picked = False
254
+ entry.time_left = 0.0
255
+ entry.time_max = 0.0
256
+ entry.amount = 0
257
+
258
+ def spawn_at(
259
+ self,
260
+ pos_x: float,
261
+ pos_y: float,
262
+ bonus_id: int | BonusId,
263
+ duration_override: int = -1,
264
+ *,
265
+ world_width: float = 1024.0,
266
+ world_height: float = 1024.0,
267
+ ) -> BonusEntry | None:
268
+ if int(bonus_id) == 0:
269
+ return None
270
+ entry = self._alloc_slot()
271
+ if entry is None:
272
+ return None
273
+
274
+ x = _clamp(float(pos_x), BONUS_SPAWN_MARGIN, float(world_width) - BONUS_SPAWN_MARGIN)
275
+ y = _clamp(float(pos_y), BONUS_SPAWN_MARGIN, float(world_height) - BONUS_SPAWN_MARGIN)
276
+
277
+ entry.bonus_id = int(bonus_id)
278
+ entry.picked = False
279
+ entry.pos_x = x
280
+ entry.pos_y = y
281
+ entry.time_left = BONUS_TIME_MAX
282
+ entry.time_max = BONUS_TIME_MAX
283
+
284
+ amount = duration_override
285
+ if amount == -1:
286
+ meta = BONUS_BY_ID.get(int(bonus_id))
287
+ amount = int(meta.default_amount or 0) if meta is not None else 0
288
+ entry.amount = int(amount)
289
+ return entry
290
+
291
+ def spawn_at_pos(
292
+ self,
293
+ pos_x: float,
294
+ pos_y: float,
295
+ *,
296
+ state: "GameplayState",
297
+ players: list["PlayerState"],
298
+ world_width: float = 1024.0,
299
+ world_height: float = 1024.0,
300
+ ) -> BonusEntry | None:
301
+ if (
302
+ pos_x < BONUS_SPAWN_MARGIN
303
+ or pos_y < BONUS_SPAWN_MARGIN
304
+ or pos_x > world_width - BONUS_SPAWN_MARGIN
305
+ or pos_y > world_height - BONUS_SPAWN_MARGIN
306
+ ):
307
+ return None
308
+
309
+ min_dist_sq = BONUS_SPAWN_MIN_DISTANCE * BONUS_SPAWN_MIN_DISTANCE
310
+ for entry in self._entries:
311
+ if entry.bonus_id == 0:
312
+ continue
313
+ if _distance_sq(pos_x, pos_y, entry.pos_x, entry.pos_y) < min_dist_sq:
314
+ return None
315
+
316
+ entry = self._alloc_slot()
317
+ if entry is None:
318
+ return None
319
+
320
+ bonus_id = bonus_pick_random_type(self, state, players)
321
+ entry.bonus_id = int(bonus_id)
322
+ entry.picked = False
323
+ entry.pos_x = float(pos_x)
324
+ entry.pos_y = float(pos_y)
325
+ entry.time_left = BONUS_TIME_MAX
326
+ entry.time_max = BONUS_TIME_MAX
327
+
328
+ rng = state.rng
329
+ if entry.bonus_id == int(BonusId.WEAPON):
330
+ entry.amount = weapon_pick_random_available(state)
331
+ elif entry.bonus_id == int(BonusId.POINTS):
332
+ entry.amount = 1000 if (rng.rand() & 7) < 3 else 500
333
+ else:
334
+ meta = BONUS_BY_ID.get(entry.bonus_id)
335
+ entry.amount = int(meta.default_amount or 0) if meta is not None else 0
336
+ return entry
337
+
338
+ def try_spawn_on_kill(
339
+ self,
340
+ pos_x: float,
341
+ pos_y: float,
342
+ *,
343
+ state: "GameplayState",
344
+ players: list["PlayerState"],
345
+ world_width: float = 1024.0,
346
+ world_height: float = 1024.0,
347
+ ) -> BonusEntry | None:
348
+ game_mode = int(state.game_mode)
349
+ if game_mode == int(GameMode.TYPO):
350
+ return None
351
+ if state.demo_mode_active:
352
+ return None
353
+ if game_mode == int(GameMode.RUSH):
354
+ return None
355
+ if game_mode == int(GameMode.TUTORIAL):
356
+ return None
357
+ if state.bonus_spawn_guard:
358
+ return None
359
+
360
+ rng = state.rng
361
+ if rng.rand() % 9 != 1:
362
+ if not any(perk_active(player, PerkId.BONUS_MAGNET) for player in players):
363
+ return None
364
+ if rng.rand() % 10 != 2:
365
+ return None
366
+
367
+ entry = self.spawn_at_pos(
368
+ pos_x,
369
+ pos_y,
370
+ state=state,
371
+ players=players,
372
+ world_width=world_width,
373
+ world_height=world_height,
374
+ )
375
+ if entry is None:
376
+ return None
377
+
378
+ if entry.bonus_id == int(BonusId.WEAPON):
379
+ near_sq = BONUS_WEAPON_NEAR_RADIUS * BONUS_WEAPON_NEAR_RADIUS
380
+ for player in players:
381
+ if _distance_sq(pos_x, pos_y, player.pos_x, player.pos_y) < near_sq:
382
+ entry.bonus_id = int(BonusId.POINTS)
383
+ entry.amount = 100
384
+ break
385
+
386
+ if entry.bonus_id != int(BonusId.POINTS):
387
+ matches = sum(1 for bonus in self._entries if bonus.bonus_id == entry.bonus_id)
388
+ if matches > 1:
389
+ self._clear_entry(entry)
390
+ return None
391
+
392
+ if entry.bonus_id == int(BonusId.WEAPON):
393
+ for player in players:
394
+ if entry.amount == player.weapon_id:
395
+ self._clear_entry(entry)
396
+ return None
397
+
398
+ return entry
399
+
400
+ def update(
401
+ self,
402
+ dt: float,
403
+ *,
404
+ state: "GameplayState",
405
+ players: list["PlayerState"],
406
+ creatures: list[Damageable] | None = None,
407
+ apply_creature_damage: CreatureDamageApplier | None = None,
408
+ detail_preset: int = 5,
409
+ ) -> list[BonusPickupEvent]:
410
+ if dt <= 0.0:
411
+ return []
412
+
413
+ pickups: list[BonusPickupEvent] = []
414
+ for entry in self._entries:
415
+ if entry.bonus_id == 0:
416
+ continue
417
+
418
+ decay = dt * (BONUS_PICKUP_DECAY_RATE if entry.picked else 1.0)
419
+ entry.time_left -= decay
420
+ if entry.time_left < 0.0:
421
+ self._clear_entry(entry)
422
+ continue
423
+
424
+ if entry.picked:
425
+ continue
426
+
427
+ for player in players:
428
+ if _distance_sq(entry.pos_x, entry.pos_y, player.pos_x, player.pos_y) < BONUS_PICKUP_RADIUS * BONUS_PICKUP_RADIUS:
429
+ bonus_apply(
430
+ state,
431
+ player,
432
+ BonusId(entry.bonus_id),
433
+ amount=entry.amount,
434
+ origin=player,
435
+ creatures=creatures,
436
+ players=players,
437
+ apply_creature_damage=apply_creature_damage,
438
+ detail_preset=int(detail_preset),
439
+ )
440
+ entry.picked = True
441
+ entry.time_left = BONUS_PICKUP_LINGER
442
+ pickups.append(
443
+ BonusPickupEvent(
444
+ player_index=player.index,
445
+ bonus_id=entry.bonus_id,
446
+ amount=entry.amount,
447
+ pos_x=entry.pos_x,
448
+ pos_y=entry.pos_y,
449
+ )
450
+ )
451
+ break
452
+
453
+ return pickups
454
+
455
+
456
+ def bonus_find_aim_hover_entry(player: PlayerState, bonus_pool: BonusPool) -> tuple[int, BonusEntry] | None:
457
+ """Return the first bonus entry within the aim hover radius, matching the exe scan order."""
458
+
459
+ aim_x = float(getattr(player, "aim_x", player.pos_x))
460
+ aim_y = float(getattr(player, "aim_y", player.pos_y))
461
+ radius_sq = BONUS_AIM_HOVER_RADIUS * BONUS_AIM_HOVER_RADIUS
462
+ for idx, entry in enumerate(bonus_pool.entries):
463
+ if entry.bonus_id == 0 or entry.picked:
464
+ continue
465
+ if _distance_sq(aim_x, aim_y, entry.pos_x, entry.pos_y) < radius_sq:
466
+ return idx, entry
467
+ return None
468
+
469
+
470
+ @dataclass(slots=True)
471
+ class GameplayState:
472
+ rng: Crand = field(default_factory=lambda: Crand(0xBEEF))
473
+ effects: EffectPool = field(default_factory=EffectPool)
474
+ particles: ParticlePool = field(init=False)
475
+ sprite_effects: SpriteEffectPool = field(init=False)
476
+ projectiles: ProjectilePool = field(default_factory=ProjectilePool)
477
+ secondary_projectiles: SecondaryProjectilePool = field(default_factory=SecondaryProjectilePool)
478
+ bonuses: BonusTimers = field(default_factory=BonusTimers)
479
+ perk_intervals: PerkEffectIntervals = field(default_factory=PerkEffectIntervals)
480
+ lean_mean_exp_timer: float = 0.25
481
+ jinxed_timer: float = 0.0
482
+ plaguebearer_infection_count: int = 0
483
+ perk_selection: PerkSelectionState = field(default_factory=PerkSelectionState)
484
+ sfx_queue: list[str] = field(default_factory=list)
485
+ game_mode: int = int(GameMode.SURVIVAL)
486
+ demo_mode_active: bool = False
487
+ hardcore: bool = False
488
+ status: GameStatus | None = None
489
+ quest_stage_major: int = 0
490
+ quest_stage_minor: int = 0
491
+ perk_available: list[bool] = field(default_factory=lambda: [False] * PERK_COUNT_SIZE)
492
+ _perk_available_unlock_index: int = -1
493
+ weapon_available: list[bool] = field(default_factory=lambda: [False] * WEAPON_COUNT_SIZE)
494
+ _weapon_available_game_mode: int = -1
495
+ _weapon_available_unlock_index: int = -1
496
+ friendly_fire_enabled: bool = False
497
+ bonus_spawn_guard: bool = False
498
+ bonus_hud: BonusHudState = field(default_factory=BonusHudState)
499
+ bonus_pool: BonusPool = field(default_factory=BonusPool)
500
+ shock_chain_links_left: int = 0
501
+ shock_chain_projectile_id: int = -1
502
+ camera_shake_offset_x: float = 0.0
503
+ camera_shake_offset_y: float = 0.0
504
+ camera_shake_timer: float = 0.0
505
+ camera_shake_pulses: int = 0
506
+ shots_fired: list[int] = field(default_factory=lambda: [0] * 4)
507
+ shots_hit: list[int] = field(default_factory=lambda: [0] * 4)
508
+ weapon_shots_fired: list[list[int]] = field(default_factory=lambda: [[0] * WEAPON_COUNT_SIZE for _ in range(4)])
509
+
510
+ def __post_init__(self) -> None:
511
+ rand = self.rng.rand
512
+ self.particles = ParticlePool(rand=rand)
513
+ self.sprite_effects = SpriteEffectPool(rand=rand)
514
+
515
+
516
+ def perk_count_get(player: PlayerState, perk_id: PerkId) -> int:
517
+ idx = int(perk_id)
518
+ if idx < 0:
519
+ return 0
520
+ if idx >= len(player.perk_counts):
521
+ return 0
522
+ return int(player.perk_counts[idx])
523
+
524
+
525
+ def perk_active(player: PlayerState, perk_id: PerkId) -> bool:
526
+ return perk_count_get(player, perk_id) > 0
527
+
528
+
529
+ def _creature_find_in_radius(creatures: list[_CreatureForPerks], *, pos_x: float, pos_y: float, radius: float, start_index: int) -> int:
530
+ """Port of `creature_find_in_radius` (0x004206a0)."""
531
+
532
+ start_index = max(0, int(start_index))
533
+ max_index = min(len(creatures), 0x180)
534
+ if start_index >= max_index:
535
+ return -1
536
+
537
+ pos_x = float(pos_x)
538
+ pos_y = float(pos_y)
539
+ radius = float(radius)
540
+
541
+ for idx in range(start_index, max_index):
542
+ creature = creatures[idx]
543
+ if not creature.active:
544
+ continue
545
+
546
+ dist = math.hypot(float(creature.x) - pos_x, float(creature.y) - pos_y) - radius
547
+ threshold = float(creature.size) * 0.142857149 + 3.0
548
+ if threshold < dist:
549
+ continue
550
+ if float(creature.hitbox_size) < 5.0:
551
+ continue
552
+ return idx
553
+ return -1
554
+
555
+
556
+ def perks_update_effects(
557
+ state: GameplayState,
558
+ players: list[PlayerState],
559
+ dt: float,
560
+ *,
561
+ creatures: list[_CreatureForPerks] | None = None,
562
+ fx_queue: FxQueue | None = None,
563
+ ) -> None:
564
+ """Port subset of `perks_update_effects` (0x00406b40)."""
565
+
566
+ dt = float(dt)
567
+ if dt <= 0.0:
568
+ return
569
+
570
+ if players and perk_active(players[0], PerkId.REGENERATION) and (state.rng.rand() & 1):
571
+ for player in players:
572
+ if not (0.0 < float(player.health) < 100.0):
573
+ continue
574
+ player.health = float(player.health) + dt
575
+ if player.health > 100.0:
576
+ player.health = 100.0
577
+
578
+ state.lean_mean_exp_timer -= dt
579
+ if state.lean_mean_exp_timer < 0.0:
580
+ state.lean_mean_exp_timer = 0.25
581
+ for player in players:
582
+ perk_count = perk_count_get(player, PerkId.LEAN_MEAN_EXP_MACHINE)
583
+ if perk_count > 0:
584
+ player.experience += perk_count * 10
585
+
586
+ target = -1
587
+ if players and creatures is not None and (
588
+ perk_active(players[0], PerkId.PYROKINETIC) or perk_active(players[0], PerkId.EVIL_EYES)
589
+ ):
590
+ target = _creature_find_in_radius(
591
+ creatures,
592
+ pos_x=players[0].aim_x,
593
+ pos_y=players[0].aim_y,
594
+ radius=12.0,
595
+ start_index=0,
596
+ )
597
+
598
+ if players:
599
+ player0 = players[0]
600
+ player0.evil_eyes_target_creature = target if perk_active(player0, PerkId.EVIL_EYES) else -1
601
+
602
+ if players and creatures is not None and perk_active(players[0], PerkId.PYROKINETIC) and target != -1:
603
+ creature = creatures[target]
604
+ creature.collision_timer = float(creature.collision_timer) - dt
605
+ if creature.collision_timer < 0.0:
606
+ creature.collision_timer = 0.5
607
+ pos_x = float(creature.x)
608
+ pos_y = float(creature.y)
609
+ for intensity in (0.8, 0.6, 0.4, 0.3, 0.2):
610
+ angle = float(int(state.rng.rand()) % 0x274) * 0.01
611
+ state.particles.spawn_particle(pos_x=pos_x, pos_y=pos_y, angle=angle, intensity=float(intensity))
612
+ if fx_queue is not None:
613
+ fx_queue.add_random(pos_x=pos_x, pos_y=pos_y, rand=state.rng.rand)
614
+
615
+ if state.jinxed_timer >= 0.0:
616
+ state.jinxed_timer -= dt
617
+
618
+ if state.jinxed_timer < 0.0 and players and perk_active(players[0], PerkId.JINXED):
619
+ player = players[0]
620
+ if int(state.rng.rand()) % 10 == 3:
621
+ player.health = float(player.health) - 5.0
622
+ if fx_queue is not None:
623
+ fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
624
+ fx_queue.add_random(pos_x=player.pos_x, pos_y=player.pos_y, rand=state.rng.rand)
625
+
626
+ state.jinxed_timer = float(int(state.rng.rand()) % 0x14) * 0.1 + float(state.jinxed_timer) + 2.0
627
+
628
+ if float(state.bonuses.freeze) <= 0.0 and creatures is not None:
629
+ pool_mod = min(0x17F, len(creatures))
630
+ if pool_mod <= 0:
631
+ return
632
+
633
+ idx = int(state.rng.rand()) % pool_mod
634
+ attempts = 0
635
+ while attempts < 10 and not creatures[idx].active:
636
+ idx = int(state.rng.rand()) % pool_mod
637
+ attempts += 1
638
+ if not creatures[idx].active:
639
+ return
640
+
641
+ creature = creatures[idx]
642
+ creature.hp = -1.0
643
+ creature.hitbox_size = float(creature.hitbox_size) - dt * 20.0
644
+ player.experience = int(float(player.experience) + float(creature.reward_value))
645
+ state.sfx_queue.append("sfx_trooper_inpain_01")
646
+
647
+
648
+ def award_experience(state: GameplayState, player: PlayerState, amount: int) -> int:
649
+ """Grant XP while honoring active bonus multipliers."""
650
+
651
+ xp = int(amount)
652
+ if xp <= 0:
653
+ return 0
654
+ if state.bonuses.double_experience > 0.0:
655
+ xp *= 2
656
+ player.experience += xp
657
+ return xp
658
+
659
+
660
+ def survival_level_threshold(level: int) -> int:
661
+ """Return the XP threshold for advancing past the given level."""
662
+
663
+ level = max(1, int(level))
664
+ return int(1000.0 + (math.pow(float(level), 1.8) * 1000.0))
665
+
666
+
667
+ def survival_check_level_up(player: PlayerState, perk_state: PerkSelectionState) -> int:
668
+ """Advance survival levels if XP exceeds thresholds, returning number of level-ups."""
669
+
670
+ advanced = 0
671
+ while player.experience > survival_level_threshold(player.level):
672
+ player.level += 1
673
+ perk_state.pending_count += 1
674
+ perk_state.choices_dirty = True
675
+ advanced += 1
676
+ return advanced
677
+
678
+
679
+ def perk_choice_count(player: PlayerState) -> int:
680
+ if perk_active(player, PerkId.PERK_MASTER):
681
+ return 7
682
+ if perk_active(player, PerkId.PERK_EXPERT):
683
+ return 6
684
+ return 5
685
+
686
+
687
+ _PERK_BASE_AVAILABLE_MAX_ID = int(PerkId.BONUS_MAGNET) # perks_rebuild_available @ 0x0042fc30
688
+ _PERK_ALWAYS_AVAILABLE: tuple[PerkId, ...] = (
689
+ PerkId.MAN_BOMB,
690
+ PerkId.LIVING_FORTRESS,
691
+ PerkId.FIRE_CAUGH,
692
+ PerkId.TOUGH_RELOADER,
693
+ )
694
+
695
+ _DEATH_CLOCK_BLOCKED: frozenset[PerkId] = frozenset(
696
+ (
697
+ PerkId.JINXED,
698
+ PerkId.BREATHING_ROOM,
699
+ PerkId.GRIM_DEAL,
700
+ PerkId.HIGHLANDER,
701
+ PerkId.FATAL_LOTTERY,
702
+ PerkId.AMMUNITION_WITHIN,
703
+ PerkId.INFERNAL_CONTRACT,
704
+ PerkId.REGENERATION,
705
+ PerkId.GREATER_REGENERATION,
706
+ PerkId.THICK_SKINNED,
707
+ PerkId.BANDAGE,
708
+ )
709
+ )
710
+
711
+ _PERK_RARITY_GATE: frozenset[PerkId] = frozenset(
712
+ (
713
+ PerkId.JINXED,
714
+ PerkId.AMMUNITION_WITHIN,
715
+ PerkId.ANXIOUS_LOADER,
716
+ PerkId.MONSTER_VISION,
717
+ )
718
+ )
719
+
720
+
721
+ def perks_rebuild_available(state: GameplayState) -> None:
722
+ """Rebuild quest unlock driven `perk_meta_table[perk_id].available` flags.
723
+
724
+ Port of `perks_rebuild_available` (0x0042fc30).
725
+ """
726
+
727
+ unlock_index = 0
728
+ if state.status is not None:
729
+ try:
730
+ unlock_index = int(state.status.quest_unlock_index)
731
+ except Exception:
732
+ unlock_index = 0
733
+
734
+ if int(state._perk_available_unlock_index) == unlock_index:
735
+ return
736
+
737
+ available = state.perk_available
738
+ for idx in range(len(available)):
739
+ available[idx] = False
740
+
741
+ for perk_id in range(1, _PERK_BASE_AVAILABLE_MAX_ID + 1):
742
+ if 0 <= perk_id < len(available):
743
+ available[perk_id] = True
744
+
745
+ for perk_id in _PERK_ALWAYS_AVAILABLE:
746
+ idx = int(perk_id)
747
+ if 0 <= idx < len(available):
748
+ available[idx] = True
749
+
750
+ if unlock_index > 0:
751
+ try:
752
+ from .quests import all_quests
753
+
754
+ quests = all_quests()
755
+ except Exception:
756
+ quests = []
757
+
758
+ for quest in quests[:unlock_index]:
759
+ perk_id = int(getattr(quest, "unlock_perk_id", 0) or 0)
760
+ if 0 < perk_id < len(available):
761
+ available[perk_id] = True
762
+
763
+ available[int(PerkId.ANTIPERK)] = False
764
+ state._perk_available_unlock_index = unlock_index
765
+
766
+
767
+ def perk_can_offer(state: GameplayState, player: PlayerState, perk_id: PerkId, *, game_mode: int, player_count: int) -> bool:
768
+ """Return whether `perk_id` is eligible for selection.
769
+
770
+ Used by `perk_select_random` and modeled after `perk_can_offer` (0x0042fb10).
771
+ """
772
+
773
+ if perk_id == PerkId.ANTIPERK:
774
+ return False
775
+
776
+ # Hardcore quest 2-10 blocks poison-related perks.
777
+ if (
778
+ int(game_mode) == int(GameMode.QUESTS)
779
+ and state.hardcore
780
+ and int(state.quest_stage_major) == 2
781
+ and int(state.quest_stage_minor) == 10
782
+ and perk_id in (PerkId.POISON_BULLETS, PerkId.VEINS_OF_POISON, PerkId.PLAGUEBEARER)
783
+ ):
784
+ return False
785
+
786
+ meta = PERK_BY_ID.get(int(perk_id))
787
+ if meta is None:
788
+ return False
789
+
790
+ flags = meta.flags or PerkFlags(0)
791
+ if (flags & PerkFlags.MODE_3_ONLY) and int(game_mode) != int(GameMode.QUESTS):
792
+ return False
793
+ if (flags & PerkFlags.TWO_PLAYER_ONLY) and int(player_count) != 2:
794
+ return False
795
+
796
+ if meta.prereq and any(perk_count_get(player, req) <= 0 for req in meta.prereq):
797
+ return False
798
+
799
+ return True
800
+
801
+
802
+ def perk_select_random(state: GameplayState, player: PlayerState, *, game_mode: int, player_count: int) -> PerkId:
803
+ """Randomly select an eligible perk id.
804
+
805
+ Port of `perk_select_random` (0x0042fbd0).
806
+ """
807
+
808
+ perks_rebuild_available(state)
809
+
810
+ for _ in range(1000):
811
+ perk_id = PerkId(int(state.rng.rand()) % PERK_ID_MAX + 1)
812
+ if not (0 <= int(perk_id) < len(state.perk_available)):
813
+ continue
814
+ if not state.perk_available[int(perk_id)]:
815
+ continue
816
+ if perk_can_offer(state, player, perk_id, game_mode=game_mode, player_count=player_count):
817
+ return perk_id
818
+
819
+ return PerkId.INSTANT_WINNER
820
+
821
+
822
+ def perk_generate_choices(
823
+ state: GameplayState,
824
+ player: PlayerState,
825
+ *,
826
+ game_mode: int,
827
+ player_count: int,
828
+ count: int | None = None,
829
+ ) -> list[PerkId]:
830
+ """Generate a unique list of perk choices for the current selection."""
831
+
832
+ if count is None:
833
+ count = perk_choice_count(player)
834
+
835
+ # `perks_generate_choices` always fills a fixed array of 7 entries, even if the UI
836
+ # only shows 5/6 (Perk Expert/Master). Preserve RNG consumption by generating the
837
+ # full list, then slicing.
838
+ choices: list[PerkId] = [PerkId.ANTIPERK] * 7
839
+ choice_index = 0
840
+
841
+ # Quest 1-7 special-case: force Monster Vision as the first choice if not owned.
842
+ if (
843
+ int(state.quest_stage_major) == 1
844
+ and int(state.quest_stage_minor) == 7
845
+ and perk_count_get(player, PerkId.MONSTER_VISION) == 0
846
+ ):
847
+ choices[0] = PerkId.MONSTER_VISION
848
+ choice_index = 1
849
+
850
+ while choice_index < 7:
851
+ attempts = 0
852
+ while True:
853
+ attempts += 1
854
+ perk_id = perk_select_random(state, player, game_mode=game_mode, player_count=player_count)
855
+
856
+ # Pyromaniac can only be offered if the current weapon is Flamethrower.
857
+ if perk_id == PerkId.PYROMANIAC and int(player.weapon_id) != int(WeaponId.FLAMETHROWER):
858
+ continue
859
+
860
+ if perk_count_get(player, PerkId.DEATH_CLOCK) > 0 and perk_id in _DEATH_CLOCK_BLOCKED:
861
+ continue
862
+
863
+ # Global rarity gate: certain perks have a 25% chance to be rejected.
864
+ if perk_id in _PERK_RARITY_GATE and (int(state.rng.rand()) & 3) == 1:
865
+ continue
866
+
867
+ meta = PERK_BY_ID.get(int(perk_id))
868
+ flags = meta.flags if meta is not None and meta.flags is not None else PerkFlags(0)
869
+ stackable = (flags & PerkFlags.STACKABLE) != 0
870
+
871
+ if attempts > 10_000 and stackable:
872
+ break
873
+
874
+ if perk_id in choices[:choice_index]:
875
+ continue
876
+
877
+ if stackable or perk_count_get(player, perk_id) < 1 or attempts > 29_999:
878
+ break
879
+
880
+ choices[choice_index] = perk_id
881
+ choice_index += 1
882
+
883
+ if int(game_mode) == int(GameMode.TUTORIAL):
884
+ choices = [
885
+ PerkId.SHARPSHOOTER,
886
+ PerkId.LONG_DISTANCE_RUNNER,
887
+ PerkId.EVIL_EYES,
888
+ PerkId.RADIOACTIVE,
889
+ PerkId.FASTSHOT,
890
+ PerkId.FASTSHOT,
891
+ PerkId.FASTSHOT,
892
+ ]
893
+
894
+ return choices[: int(count)]
895
+
896
+
897
+ def _increment_perk_count(player: PlayerState, perk_id: PerkId, *, amount: int = 1) -> None:
898
+ idx = int(perk_id)
899
+ if 0 <= idx < len(player.perk_counts):
900
+ player.perk_counts[idx] += int(amount)
901
+
902
+
903
+ def perk_apply(
904
+ state: GameplayState,
905
+ players: list[PlayerState],
906
+ perk_id: PerkId,
907
+ *,
908
+ perk_state: PerkSelectionState | None = None,
909
+ dt: float | None = None,
910
+ creatures: list[_CreatureForPerks] | None = None,
911
+ ) -> None:
912
+ """Apply immediate perk effects and increment the perk counter."""
913
+
914
+ if not players:
915
+ return
916
+ owner = players[0]
917
+ try:
918
+ _increment_perk_count(owner, perk_id)
919
+
920
+ if perk_id == PerkId.INSTANT_WINNER:
921
+ owner.experience += 2500
922
+ return
923
+
924
+ if perk_id == PerkId.FATAL_LOTTERY:
925
+ if state.rng.rand() & 1:
926
+ owner.health = -1.0
927
+ else:
928
+ owner.experience += 10000
929
+ return
930
+
931
+ if perk_id == PerkId.RANDOM_WEAPON:
932
+ current = int(owner.weapon_id)
933
+ weapon_id = int(current)
934
+ for _ in range(100):
935
+ candidate = int(weapon_pick_random_available(state))
936
+ if candidate != 0 and candidate != current:
937
+ weapon_id = candidate
938
+ break
939
+ weapon_assign_player(owner, weapon_id, state=state)
940
+ return
941
+
942
+ if perk_id == PerkId.LIFELINE_50_50:
943
+ if creatures is None:
944
+ return
945
+
946
+ kill_toggle = False
947
+ for creature in creatures:
948
+ if (
949
+ kill_toggle
950
+ and creature.active
951
+ and float(creature.hp) <= 500.0
952
+ and (int(creature.flags) & 0x04) == 0
953
+ ):
954
+ creature.active = False
955
+ state.effects.spawn_burst(
956
+ pos_x=float(creature.x),
957
+ pos_y=float(creature.y),
958
+ count=4,
959
+ rand=state.rng.rand,
960
+ detail_preset=5,
961
+ )
962
+ kill_toggle = not kill_toggle
963
+ return
964
+
965
+ if perk_id == PerkId.THICK_SKINNED:
966
+ for player in players:
967
+ if player.health > 0.0:
968
+ player.health = max(1.0, player.health * (2.0 / 3.0))
969
+ return
970
+
971
+ if perk_id == PerkId.BREATHING_ROOM:
972
+ for player in players:
973
+ player.health -= player.health * (2.0 / 3.0)
974
+
975
+ frame_dt = float(dt) if dt is not None else 0.0
976
+ if creatures is not None:
977
+ for creature in creatures:
978
+ if creature.active:
979
+ creature.hitbox_size = float(creature.hitbox_size) - frame_dt
980
+
981
+ state.bonus_spawn_guard = False
982
+ return
983
+
984
+ if perk_id == PerkId.INFERNAL_CONTRACT:
985
+ owner.level += 3
986
+ if perk_state is not None:
987
+ perk_state.pending_count += 3
988
+ perk_state.choices_dirty = True
989
+ for player in players:
990
+ if player.health > 0.0:
991
+ player.health = 0.1
992
+ return
993
+
994
+ if perk_id == PerkId.GRIM_DEAL:
995
+ owner.health = -1.0
996
+ owner.experience += int(owner.experience * 0.18)
997
+ return
998
+
999
+ if perk_id == PerkId.AMMO_MANIAC:
1000
+ if len(players) > 1:
1001
+ for player in players[1:]:
1002
+ player.perk_counts[:] = owner.perk_counts
1003
+ for player in players:
1004
+ weapon_assign_player(player, int(player.weapon_id), state=state)
1005
+ return
1006
+
1007
+ if perk_id == PerkId.DEATH_CLOCK:
1008
+ _increment_perk_count(owner, PerkId.REGENERATION, amount=-perk_count_get(owner, PerkId.REGENERATION))
1009
+ _increment_perk_count(owner, PerkId.GREATER_REGENERATION, amount=-perk_count_get(owner, PerkId.GREATER_REGENERATION))
1010
+ for player in players:
1011
+ if player.health > 0.0:
1012
+ player.health = 100.0
1013
+ return
1014
+
1015
+ if perk_id == PerkId.BANDAGE:
1016
+ for player in players:
1017
+ if player.health > 0.0:
1018
+ scale = float(state.rng.rand() % 50 + 1)
1019
+ player.health = min(100.0, player.health * scale)
1020
+ state.effects.spawn_burst(
1021
+ pos_x=float(player.pos_x),
1022
+ pos_y=float(player.pos_y),
1023
+ count=8,
1024
+ rand=state.rng.rand,
1025
+ detail_preset=5,
1026
+ )
1027
+ return
1028
+
1029
+ if perk_id == PerkId.MY_FAVOURITE_WEAPON:
1030
+ for player in players:
1031
+ player.clip_size += 2
1032
+ return
1033
+
1034
+ if perk_id == PerkId.PLAGUEBEARER:
1035
+ owner.plaguebearer_active = True
1036
+ finally:
1037
+ if len(players) > 1:
1038
+ for player in players[1:]:
1039
+ player.perk_counts[:] = owner.perk_counts
1040
+
1041
+
1042
+ def perk_auto_pick(
1043
+ state: GameplayState,
1044
+ players: list[PlayerState],
1045
+ perk_state: PerkSelectionState,
1046
+ *,
1047
+ game_mode: int,
1048
+ player_count: int | None = None,
1049
+ dt: float | None = None,
1050
+ creatures: list[_CreatureForPerks] | None = None,
1051
+ ) -> list[PerkId]:
1052
+ """Resolve pending perks by auto-selecting from generated choices."""
1053
+
1054
+ if not players:
1055
+ return []
1056
+ if player_count is None:
1057
+ player_count = len(players)
1058
+ picks: list[PerkId] = []
1059
+ while perk_state.pending_count > 0:
1060
+ if perk_state.choices_dirty or not perk_state.choices:
1061
+ perk_state.choices = [int(perk) for perk in perk_generate_choices(state, players[0], game_mode=game_mode, player_count=player_count)]
1062
+ perk_state.choices_dirty = False
1063
+ if not perk_state.choices:
1064
+ break
1065
+ idx = int(state.rng.rand() % len(perk_state.choices))
1066
+ perk_id = PerkId(perk_state.choices[idx])
1067
+ perk_apply(state, players, perk_id, perk_state=perk_state, dt=dt, creatures=creatures)
1068
+ picks.append(perk_id)
1069
+ perk_state.pending_count -= 1
1070
+ perk_state.choices_dirty = True
1071
+ return picks
1072
+
1073
+
1074
+ def perk_selection_current_choices(
1075
+ state: GameplayState,
1076
+ players: list[PlayerState],
1077
+ perk_state: PerkSelectionState,
1078
+ *,
1079
+ game_mode: int,
1080
+ player_count: int | None = None,
1081
+ ) -> list[PerkId]:
1082
+ """Return the current perk choices, generating them if needed.
1083
+
1084
+ Mirrors `perk_choices_dirty` + `perks_generate_choices` before entering the
1085
+ perk selection screen (state 6).
1086
+ """
1087
+
1088
+ if not players:
1089
+ return []
1090
+ if player_count is None:
1091
+ player_count = len(players)
1092
+ if perk_state.choices_dirty or not perk_state.choices:
1093
+ perk_state.choices = [int(perk) for perk in perk_generate_choices(state, players[0], game_mode=game_mode, player_count=player_count)]
1094
+ perk_state.choices_dirty = False
1095
+ return [PerkId(perk_id) for perk_id in perk_state.choices]
1096
+
1097
+
1098
+ def perk_selection_pick(
1099
+ state: GameplayState,
1100
+ players: list[PlayerState],
1101
+ perk_state: PerkSelectionState,
1102
+ choice_index: int,
1103
+ *,
1104
+ game_mode: int,
1105
+ player_count: int | None = None,
1106
+ dt: float | None = None,
1107
+ creatures: list[_CreatureForPerks] | None = None,
1108
+ ) -> PerkId | None:
1109
+ """Pick a perk from the current choice list and apply it.
1110
+
1111
+ On success, decrements `pending_count` (one perk resolved) and marks the
1112
+ choice list dirty, matching `perk_selection_screen_update`.
1113
+ """
1114
+
1115
+ if perk_state.pending_count <= 0:
1116
+ return None
1117
+ choices = perk_selection_current_choices(state, players, perk_state, game_mode=game_mode, player_count=player_count)
1118
+ if not choices:
1119
+ return None
1120
+ idx = int(choice_index)
1121
+ if idx < 0 or idx >= len(choices):
1122
+ return None
1123
+ perk_id = choices[idx]
1124
+ perk_apply(state, players, perk_id, perk_state=perk_state, dt=dt, creatures=creatures)
1125
+ perk_state.pending_count = max(0, int(perk_state.pending_count) - 1)
1126
+ perk_state.choices_dirty = True
1127
+ return perk_id
1128
+
1129
+
1130
+ def survival_progression_update(
1131
+ state: GameplayState,
1132
+ players: list[PlayerState],
1133
+ *,
1134
+ game_mode: int,
1135
+ player_count: int | None = None,
1136
+ auto_pick: bool = True,
1137
+ dt: float | None = None,
1138
+ creatures: list[_CreatureForPerks] | None = None,
1139
+ ) -> list[PerkId]:
1140
+ """Advance survival level/perk progression and optionally auto-pick perks."""
1141
+
1142
+ if not players:
1143
+ return []
1144
+ if player_count is None:
1145
+ player_count = len(players)
1146
+ survival_check_level_up(players[0], state.perk_selection)
1147
+ if auto_pick:
1148
+ return perk_auto_pick(
1149
+ state,
1150
+ players,
1151
+ state.perk_selection,
1152
+ game_mode=game_mode,
1153
+ player_count=player_count,
1154
+ dt=dt,
1155
+ creatures=creatures,
1156
+ )
1157
+ return []
1158
+
1159
+
1160
+ def _clamp(value: float, lo: float, hi: float) -> float:
1161
+ if value < lo:
1162
+ return lo
1163
+ if value > hi:
1164
+ return hi
1165
+ return value
1166
+
1167
+
1168
+ def _normalize(x: float, y: float) -> tuple[float, float]:
1169
+ mag = math.hypot(x, y)
1170
+ if mag <= 1e-9:
1171
+ return 0.0, 0.0
1172
+ inv = 1.0 / mag
1173
+ return x * inv, y * inv
1174
+
1175
+
1176
+ def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
1177
+ dx = x1 - x0
1178
+ dy = y1 - y0
1179
+ return dx * dx + dy * dy
1180
+
1181
+
1182
+ def _owner_id_for_player(player_index: int) -> int:
1183
+ # crimsonland.exe uses -1/-2/-3 for players (and sometimes -100 in demo paths).
1184
+ return -1 - int(player_index)
1185
+
1186
+
1187
+ def _weapon_entry(weapon_id: int) -> Weapon | None:
1188
+ return WEAPON_BY_ID.get(int(weapon_id))
1189
+
1190
+
1191
+ def weapon_refresh_available(state: "GameplayState") -> None:
1192
+ """Rebuild `weapon_table[weapon_id].unlocked` equivalents from quest progression.
1193
+
1194
+ Port of `weapon_refresh_available` (0x00452e40).
1195
+ """
1196
+
1197
+ unlock_index = 0
1198
+ status = state.status
1199
+ if status is not None:
1200
+ try:
1201
+ unlock_index = int(status.quest_unlock_index)
1202
+ except Exception:
1203
+ unlock_index = 0
1204
+
1205
+ game_mode = int(state.game_mode)
1206
+ if (
1207
+ int(state._weapon_available_game_mode) == game_mode
1208
+ and int(state._weapon_available_unlock_index) == unlock_index
1209
+ ):
1210
+ return
1211
+
1212
+ # Clear unlocked flags.
1213
+ available = state.weapon_available
1214
+ for idx in range(len(available)):
1215
+ available[idx] = False
1216
+
1217
+ # Pistol is always available.
1218
+ pistol_id = int(WeaponId.PISTOL)
1219
+ if 0 <= pistol_id < len(available):
1220
+ available[pistol_id] = True
1221
+
1222
+ # Unlock weapons from the quest list (first `quest_unlock_index` entries).
1223
+ if unlock_index > 0:
1224
+ try:
1225
+ from .quests import all_quests
1226
+
1227
+ quests = all_quests()
1228
+ except Exception:
1229
+ quests = []
1230
+
1231
+ for quest in quests[:unlock_index]:
1232
+ weapon_id = int(getattr(quest, "unlock_weapon_id", 0) or 0)
1233
+ if 0 < weapon_id < len(available):
1234
+ available[weapon_id] = True
1235
+
1236
+ # Survival default loadout: Assault Rifle, Shotgun, Submachine Gun.
1237
+ if game_mode == int(GameMode.SURVIVAL):
1238
+ for weapon_id in (WeaponId.ASSAULT_RIFLE, WeaponId.SHOTGUN, WeaponId.SUBMACHINE_GUN):
1239
+ idx = int(weapon_id)
1240
+ if 0 <= idx < len(available):
1241
+ available[idx] = True
1242
+
1243
+ state._weapon_available_game_mode = game_mode
1244
+ state._weapon_available_unlock_index = unlock_index
1245
+
1246
+
1247
+ def weapon_pick_random_available(state: "GameplayState") -> int:
1248
+ """Select a random available weapon id (1..33).
1249
+
1250
+ Port of `weapon_pick_random_available` (0x00452cd0).
1251
+ """
1252
+
1253
+ weapon_refresh_available(state)
1254
+ status = state.status
1255
+
1256
+ for _ in range(1000):
1257
+ base_rand = int(state.rng.rand())
1258
+ weapon_id = base_rand % WEAPON_DROP_ID_COUNT + 1
1259
+
1260
+ # Bias: used weapons have a 50% chance to reroll once.
1261
+ if status is not None:
1262
+ try:
1263
+ if status.weapon_usage_count(weapon_id) != 0:
1264
+ if (int(state.rng.rand()) & 1) == 0:
1265
+ base_rand = int(state.rng.rand())
1266
+ weapon_id = base_rand % WEAPON_DROP_ID_COUNT + 1
1267
+ except Exception:
1268
+ pass
1269
+
1270
+ if not (0 <= weapon_id < len(state.weapon_available)):
1271
+ continue
1272
+ if not state.weapon_available[weapon_id]:
1273
+ continue
1274
+
1275
+ # Quest 5-10 special-case: suppress Ion Cannon.
1276
+ if (
1277
+ int(state.game_mode) == int(GameMode.QUESTS)
1278
+ and int(state.quest_stage_major) == 5
1279
+ and int(state.quest_stage_minor) == 10
1280
+ and weapon_id == int(WeaponId.ION_CANNON)
1281
+ ):
1282
+ continue
1283
+
1284
+ return weapon_id
1285
+
1286
+ return int(WeaponId.PISTOL)
1287
+
1288
+
1289
+ def _projectile_meta_for_type_id(type_id: int) -> float:
1290
+ entry = weapon_entry_for_projectile_type_id(int(type_id))
1291
+ meta = entry.projectile_meta if entry is not None else None
1292
+ return float(meta if meta is not None else 45.0)
1293
+
1294
+
1295
+ def _bonus_enabled(bonus_id: int) -> bool:
1296
+ meta = BONUS_BY_ID.get(int(bonus_id))
1297
+ if meta is None:
1298
+ return False
1299
+ return meta.bonus_id != BonusId.UNUSED
1300
+
1301
+
1302
+ def _bonus_id_from_roll(roll: int, rng: Crand) -> int:
1303
+ # Mirrors `bonus_pick_random_type` (0x412470) mapping:
1304
+ # - roll = rand() % 162 + 1 (1..162)
1305
+ # - Points: roll 1..13
1306
+ # - Energizer: roll 14 with (rand & 0x3F) == 0, else Weapon
1307
+ # - Bucketed ids 3..14 via a 10-step loop; if it would exceed 14, returns 0
1308
+ # to force a reroll (matching the `goto LABEL_18` path leaving `v3 == 0`).
1309
+ if roll < 1 or roll > 162:
1310
+ return 0
1311
+
1312
+ if roll <= 13:
1313
+ return int(BonusId.POINTS)
1314
+
1315
+ if roll == 14:
1316
+ if (rng.rand() & 0x3F) == 0:
1317
+ return int(BonusId.ENERGIZER)
1318
+ return int(BonusId.WEAPON)
1319
+
1320
+ v5 = roll - 14
1321
+ v6 = int(BonusId.WEAPON)
1322
+ while v5 > 10:
1323
+ v5 -= 10
1324
+ v6 += 1
1325
+ if v6 >= 15:
1326
+ return 0
1327
+ return int(v6)
1328
+
1329
+
1330
+ def bonus_pick_random_type(pool: BonusPool, state: "GameplayState", players: list["PlayerState"]) -> int:
1331
+ has_fire_bullets_drop = any(
1332
+ entry.bonus_id == int(BonusId.FIRE_BULLETS) and not entry.picked
1333
+ for entry in pool.entries
1334
+ )
1335
+
1336
+ for _ in range(101):
1337
+ roll = int(state.rng.rand()) % 162 + 1
1338
+ bonus_id = _bonus_id_from_roll(roll, state.rng)
1339
+ if bonus_id <= 0:
1340
+ continue
1341
+ if state.shock_chain_links_left > 0 and bonus_id == int(BonusId.SHOCK_CHAIN):
1342
+ continue
1343
+ if int(state.game_mode) == int(GameMode.QUESTS) and int(state.quest_stage_minor) == 10:
1344
+ if bonus_id == int(BonusId.NUKE) and (
1345
+ int(state.quest_stage_major) in (2, 4, 5) or (state.hardcore and int(state.quest_stage_major) == 3)
1346
+ ):
1347
+ continue
1348
+ if bonus_id == int(BonusId.FREEZE) and (
1349
+ int(state.quest_stage_major) == 4 or (state.hardcore and int(state.quest_stage_major) == 2)
1350
+ ):
1351
+ continue
1352
+ if bonus_id == int(BonusId.FREEZE) and state.bonuses.freeze > 0.0:
1353
+ continue
1354
+ if bonus_id == int(BonusId.SHIELD) and any(player.shield_timer > 0.0 for player in players):
1355
+ continue
1356
+ if bonus_id == int(BonusId.WEAPON) and has_fire_bullets_drop:
1357
+ continue
1358
+ if bonus_id == int(BonusId.WEAPON) and any(perk_active(player, PerkId.MY_FAVOURITE_WEAPON) for player in players):
1359
+ continue
1360
+ if bonus_id == int(BonusId.MEDIKIT) and any(perk_active(player, PerkId.DEATH_CLOCK) for player in players):
1361
+ continue
1362
+ if not _bonus_enabled(bonus_id):
1363
+ continue
1364
+ return bonus_id
1365
+ return int(BonusId.POINTS)
1366
+
1367
+
1368
+ def weapon_assign_player(player: PlayerState, weapon_id: int, *, state: GameplayState | None = None) -> None:
1369
+ """Assign weapon and reset per-weapon runtime state (ammo/cooldowns)."""
1370
+
1371
+ weapon_id = int(weapon_id)
1372
+ if state is not None and state.status is not None and not state.demo_mode_active:
1373
+ try:
1374
+ state.status.increment_weapon_usage(weapon_id)
1375
+ except Exception:
1376
+ pass
1377
+
1378
+ weapon = _weapon_entry(weapon_id)
1379
+ player.weapon_id = weapon_id
1380
+
1381
+ clip_size = int(weapon.clip_size) if weapon is not None and weapon.clip_size is not None else 0
1382
+ clip_size = max(0, clip_size)
1383
+
1384
+ # weapon_assign_player @ 0x004220B0: clip-size perks are applied on every weapon assignment.
1385
+ if perk_active(player, PerkId.AMMO_MANIAC):
1386
+ clip_size += max(1, int(float(clip_size) * 0.25))
1387
+ if perk_active(player, PerkId.MY_FAVOURITE_WEAPON):
1388
+ clip_size += 2
1389
+
1390
+ player.clip_size = max(0, int(clip_size))
1391
+ player.ammo = float(player.clip_size)
1392
+ player.weapon_reset_latch = 0
1393
+ player.reload_active = False
1394
+ player.reload_timer = 0.0
1395
+ player.reload_timer_max = 0.0
1396
+ player.shot_cooldown = 0.0
1397
+ player.aux_timer = 2.0
1398
+
1399
+ if state is not None and weapon is not None:
1400
+ from .weapon_sfx import resolve_weapon_sfx_ref
1401
+
1402
+ key = resolve_weapon_sfx_ref(weapon.reload_sound)
1403
+ if key is not None:
1404
+ state.sfx_queue.append(key)
1405
+
1406
+
1407
+ def most_used_weapon_id_for_player(state: GameplayState, *, player_index: int, fallback_weapon_id: int) -> int:
1408
+ """Return a 1-based weapon id for the player's most-used weapon."""
1409
+
1410
+ idx = int(player_index)
1411
+ if 0 <= idx < len(state.weapon_shots_fired):
1412
+ counts = state.weapon_shots_fired[idx]
1413
+ if counts:
1414
+ start = 1 if len(counts) > 1 else 0
1415
+ best = max(range(start, len(counts)), key=counts.__getitem__)
1416
+ if int(counts[best]) > 0:
1417
+ return int(best)
1418
+ return int(fallback_weapon_id)
1419
+
1420
+
1421
+ def player_swap_alt_weapon(player: PlayerState) -> bool:
1422
+ """Swap primary and alternate weapon runtime blocks (Alternate Weapon perk)."""
1423
+
1424
+ if player.alt_weapon_id is None:
1425
+ return False
1426
+ (
1427
+ player.weapon_id,
1428
+ player.clip_size,
1429
+ player.reload_active,
1430
+ player.ammo,
1431
+ player.reload_timer,
1432
+ player.shot_cooldown,
1433
+ player.reload_timer_max,
1434
+ player.alt_weapon_id,
1435
+ player.alt_clip_size,
1436
+ player.alt_reload_active,
1437
+ player.alt_ammo,
1438
+ player.alt_reload_timer,
1439
+ player.alt_shot_cooldown,
1440
+ player.alt_reload_timer_max,
1441
+ ) = (
1442
+ player.alt_weapon_id,
1443
+ player.alt_clip_size,
1444
+ player.alt_reload_active,
1445
+ player.alt_ammo,
1446
+ player.alt_reload_timer,
1447
+ player.alt_shot_cooldown,
1448
+ player.alt_reload_timer_max,
1449
+ player.weapon_id,
1450
+ player.clip_size,
1451
+ player.reload_active,
1452
+ player.ammo,
1453
+ player.reload_timer,
1454
+ player.shot_cooldown,
1455
+ player.reload_timer_max,
1456
+ )
1457
+ return True
1458
+
1459
+
1460
+ def player_start_reload(player: PlayerState, state: GameplayState) -> None:
1461
+ """Start or refresh a reload timer (`player_start_reload` @ 0x00413430)."""
1462
+
1463
+ if player.reload_active and (perk_active(player, PerkId.AMMUNITION_WITHIN) or perk_active(player, PerkId.REGRESSION_BULLETS)):
1464
+ return
1465
+
1466
+ weapon = _weapon_entry(player.weapon_id)
1467
+ reload_time = float(weapon.reload_time) if weapon is not None and weapon.reload_time is not None else 0.0
1468
+
1469
+ if not player.reload_active:
1470
+ player.reload_active = True
1471
+
1472
+ if perk_active(player, PerkId.FASTLOADER):
1473
+ reload_time *= 0.69999999
1474
+ if state.bonuses.weapon_power_up > 0.0:
1475
+ reload_time *= 0.60000002
1476
+
1477
+ player.reload_timer = max(0.0, reload_time)
1478
+ player.reload_timer_max = player.reload_timer
1479
+
1480
+
1481
+ def _spawn_projectile_ring(
1482
+ state: GameplayState,
1483
+ origin: _HasPos,
1484
+ *,
1485
+ count: int,
1486
+ angle_offset: float,
1487
+ type_id: int,
1488
+ owner_id: int,
1489
+ ) -> None:
1490
+ if count <= 0:
1491
+ return
1492
+ step = math.tau / float(count)
1493
+ meta = _projectile_meta_for_type_id(type_id)
1494
+ for idx in range(count):
1495
+ state.projectiles.spawn(
1496
+ pos_x=float(origin.pos_x),
1497
+ pos_y=float(origin.pos_y),
1498
+ angle=float(idx) * step + float(angle_offset),
1499
+ type_id=int(type_id),
1500
+ owner_id=int(owner_id),
1501
+ base_damage=meta,
1502
+ )
1503
+
1504
+
1505
+ def _perk_update_man_bomb(player: PlayerState, dt: float, state: GameplayState) -> None:
1506
+ player.man_bomb_timer += dt
1507
+ if player.man_bomb_timer <= state.perk_intervals.man_bomb:
1508
+ return
1509
+
1510
+ owner_id = _owner_id_for_player(player.index)
1511
+ state.bonus_spawn_guard = True
1512
+ for idx in range(8):
1513
+ type_id = ProjectileTypeId.ION_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.ION_RIFLE
1514
+ angle = (float(state.rng.rand() % 50) * 0.01) + float(idx) * (math.pi / 4.0) - 0.25
1515
+ state.projectiles.spawn(
1516
+ pos_x=player.pos_x,
1517
+ pos_y=player.pos_y,
1518
+ angle=angle,
1519
+ type_id=type_id,
1520
+ owner_id=owner_id,
1521
+ base_damage=_projectile_meta_for_type_id(type_id),
1522
+ )
1523
+ state.bonus_spawn_guard = False
1524
+ state.sfx_queue.append("sfx_explosion_small")
1525
+
1526
+ player.man_bomb_timer -= state.perk_intervals.man_bomb
1527
+ state.perk_intervals.man_bomb = 4.0
1528
+
1529
+
1530
+ def _perk_update_hot_tempered(player: PlayerState, dt: float, state: GameplayState) -> None:
1531
+ player.hot_tempered_timer += dt
1532
+ if player.hot_tempered_timer <= state.perk_intervals.hot_tempered:
1533
+ return
1534
+
1535
+ owner_id = _owner_id_for_player(player.index)
1536
+ state.bonus_spawn_guard = True
1537
+ for idx in range(8):
1538
+ type_id = ProjectileTypeId.PLASMA_MINIGUN if ((idx & 1) == 0) else ProjectileTypeId.PLASMA_RIFLE
1539
+ angle = float(idx) * (math.pi / 4.0)
1540
+ state.projectiles.spawn(
1541
+ pos_x=player.pos_x,
1542
+ pos_y=player.pos_y,
1543
+ angle=angle,
1544
+ type_id=type_id,
1545
+ owner_id=owner_id,
1546
+ base_damage=_projectile_meta_for_type_id(type_id),
1547
+ )
1548
+ state.bonus_spawn_guard = False
1549
+ state.sfx_queue.append("sfx_explosion_small")
1550
+
1551
+ player.hot_tempered_timer -= state.perk_intervals.hot_tempered
1552
+ state.perk_intervals.hot_tempered = float(state.rng.rand() % 8) + 2.0
1553
+
1554
+
1555
+ def _perk_update_fire_cough(player: PlayerState, dt: float, state: GameplayState) -> None:
1556
+ player.fire_cough_timer += dt
1557
+ if player.fire_cough_timer <= state.perk_intervals.fire_cough:
1558
+ return
1559
+
1560
+ owner_id = _owner_id_for_player(player.index)
1561
+ # Fire Cough spawns a fire projectile (and a small sprite burst) from the muzzle.
1562
+ theta = math.atan2(player.aim_dir_y, player.aim_dir_x)
1563
+ jitter = (float(state.rng.rand() % 200) - 100.0) * 0.0015
1564
+ angle = theta + jitter + math.pi / 2.0
1565
+ muzzle_x = player.pos_x + player.aim_dir_x * 16.0
1566
+ muzzle_y = player.pos_y + player.aim_dir_y * 16.0
1567
+ state.projectiles.spawn(
1568
+ pos_x=muzzle_x,
1569
+ pos_y=muzzle_y,
1570
+ angle=angle,
1571
+ type_id=ProjectileTypeId.FIRE_BULLETS,
1572
+ owner_id=owner_id,
1573
+ base_damage=_projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS),
1574
+ )
1575
+
1576
+ player.fire_cough_timer -= state.perk_intervals.fire_cough
1577
+ state.perk_intervals.fire_cough = float(state.rng.rand() % 4) + 2.0
1578
+
1579
+
1580
+ def player_fire_weapon(player: PlayerState, input_state: PlayerInput, dt: float, state: GameplayState) -> None:
1581
+ dt = float(dt)
1582
+
1583
+ weapon_id = int(player.weapon_id)
1584
+ weapon = _weapon_entry(weapon_id)
1585
+ if weapon is None:
1586
+ return
1587
+
1588
+ if player.shot_cooldown > 0.0:
1589
+ return
1590
+ if not input_state.fire_down:
1591
+ return
1592
+
1593
+ firing_during_reload = False
1594
+ ammo_cost = 1.0
1595
+ is_fire_bullets = float(player.fire_bullets_timer) > 0.0
1596
+ if player.reload_timer > 0.0:
1597
+ if player.ammo <= 0 and player.experience > 0:
1598
+ if perk_active(player, PerkId.REGRESSION_BULLETS):
1599
+ firing_during_reload = True
1600
+ ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1601
+
1602
+ reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
1603
+ factor = 4.0 if ammo_class == 1 else 200.0
1604
+ player.experience = int(float(player.experience) - reload_time * factor)
1605
+ if player.experience < 0:
1606
+ player.experience = 0
1607
+ elif perk_active(player, PerkId.AMMUNITION_WITHIN):
1608
+ firing_during_reload = True
1609
+ ammo_class = int(weapon.ammo_class) if weapon.ammo_class is not None else 0
1610
+
1611
+ from .player_damage import player_take_damage
1612
+
1613
+ cost = 0.15 if ammo_class == 1 else 1.0
1614
+ player_take_damage(state, player, cost, dt=dt, rand=state.rng.rand)
1615
+ else:
1616
+ return
1617
+ else:
1618
+ return
1619
+
1620
+ if player.ammo <= 0 and not firing_during_reload and not is_fire_bullets:
1621
+ player_start_reload(player, state)
1622
+ return
1623
+
1624
+ pellet_count = int(weapon.pellet_count) if weapon.pellet_count is not None else 0
1625
+ fire_bullets_weapon = weapon_entry_for_projectile_type_id(int(ProjectileTypeId.FIRE_BULLETS))
1626
+
1627
+ shot_cooldown = float(weapon.shot_cooldown) if weapon.shot_cooldown is not None else 0.0
1628
+ spread_heat_base = float(weapon.spread_heat_inc) if weapon.spread_heat_inc is not None else 0.0
1629
+ if is_fire_bullets and fire_bullets_weapon is not None and fire_bullets_weapon.spread_heat_inc is not None:
1630
+ spread_heat_base = float(fire_bullets_weapon.spread_heat_inc)
1631
+
1632
+ if is_fire_bullets and pellet_count == 1 and fire_bullets_weapon is not None:
1633
+ shot_cooldown = (
1634
+ float(fire_bullets_weapon.shot_cooldown)
1635
+ if fire_bullets_weapon.shot_cooldown is not None
1636
+ else 0.0
1637
+ )
1638
+
1639
+ spread_inc = spread_heat_base * 1.3
1640
+
1641
+ if perk_active(player, PerkId.FASTSHOT):
1642
+ shot_cooldown *= 0.88
1643
+ if perk_active(player, PerkId.SHARPSHOOTER):
1644
+ shot_cooldown *= 1.05
1645
+ player.shot_cooldown = max(0.0, shot_cooldown)
1646
+
1647
+ aim_x = float(input_state.aim_x)
1648
+ aim_y = float(input_state.aim_y)
1649
+ dx = aim_x - float(player.pos_x)
1650
+ dy = aim_y - float(player.pos_y)
1651
+ dist = math.hypot(dx, dy)
1652
+ max_offset = dist * float(player.spread_heat) * 0.5
1653
+ dir_angle = float(int(state.rng.rand()) & 0x1FF) * (math.tau / 512.0)
1654
+ mag = float(int(state.rng.rand()) & 0x1FF) * (1.0 / 512.0)
1655
+ offset = max_offset * mag
1656
+ aim_jitter_x = aim_x + math.cos(dir_angle) * offset
1657
+ aim_jitter_y = aim_y + math.sin(dir_angle) * offset
1658
+ shot_angle = math.atan2(aim_jitter_y - float(player.pos_y), aim_jitter_x - float(player.pos_x)) + math.pi / 2.0
1659
+ particle_angle = shot_angle - math.pi / 2.0
1660
+
1661
+ muzzle_x = player.pos_x + player.aim_dir_x * 16.0
1662
+ muzzle_y = player.pos_y + player.aim_dir_y * 16.0
1663
+
1664
+ owner_id = _owner_id_for_player(player.index)
1665
+ shot_count = 1
1666
+
1667
+ # `player_fire_weapon` (crimsonland.exe) uses weapon-specific extra angular jitter for pellet
1668
+ # weapons. This is separate from aim-point jitter driven by `player.spread_heat`.
1669
+ def _pellet_jitter_step(weapon_id: int) -> float:
1670
+ weapon_id = int(weapon_id)
1671
+ if weapon_id == WeaponId.SHOTGUN:
1672
+ return 0.0013
1673
+ if weapon_id == WeaponId.SAWED_OFF_SHOTGUN:
1674
+ return 0.004
1675
+ if weapon_id == WeaponId.JACKHAMMER:
1676
+ return 0.0013
1677
+ return 0.0015
1678
+
1679
+ if is_fire_bullets:
1680
+ pellets = max(1, int(pellet_count))
1681
+ shot_count = pellets
1682
+ meta = _projectile_meta_for_type_id(ProjectileTypeId.FIRE_BULLETS)
1683
+ for _ in range(pellets):
1684
+ angle = shot_angle
1685
+ if pellets > 1:
1686
+ angle += float(int(state.rng.rand()) % 200 - 100) * 0.0015
1687
+ state.projectiles.spawn(
1688
+ pos_x=muzzle_x,
1689
+ pos_y=muzzle_y,
1690
+ angle=angle,
1691
+ type_id=ProjectileTypeId.FIRE_BULLETS,
1692
+ owner_id=owner_id,
1693
+ base_damage=meta,
1694
+ )
1695
+ elif weapon_id == WeaponId.ROCKET_LAUNCHER:
1696
+ # Rocket Launcher -> secondary type 1.
1697
+ state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle, type_id=1, owner_id=owner_id)
1698
+ elif weapon_id == WeaponId.SEEKER_ROCKETS:
1699
+ # Seeker Rockets -> secondary type 2.
1700
+ state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle, type_id=2, owner_id=owner_id)
1701
+ elif weapon_id == WeaponId.MINI_ROCKET_SWARMERS:
1702
+ # Mini-Rocket Swarmers -> secondary type 2 (fires the full clip in a spread).
1703
+ rocket_count = max(1, int(player.ammo))
1704
+ step = float(rocket_count) * (math.pi / 3.0)
1705
+ angle = (shot_angle - math.pi) - step * float(rocket_count) * 0.5
1706
+ for _ in range(rocket_count):
1707
+ state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=angle, type_id=2, owner_id=owner_id)
1708
+ angle += step
1709
+ ammo_cost = float(rocket_count)
1710
+ shot_count = rocket_count
1711
+ elif weapon_id == WeaponId.ROCKET_MINIGUN:
1712
+ # Rocket Minigun -> secondary type 4.
1713
+ state.secondary_projectiles.spawn(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle, type_id=4, owner_id=owner_id)
1714
+ elif weapon_id == WeaponId.FLAMETHROWER:
1715
+ # Flamethrower -> fast particle weapon (style 0), fractional ammo drain.
1716
+ state.particles.spawn_particle(pos_x=muzzle_x, pos_y=muzzle_y, angle=particle_angle, intensity=1.0, owner_id=owner_id)
1717
+ ammo_cost = 0.1
1718
+ elif weapon_id == WeaponId.BLOW_TORCH:
1719
+ # Blow Torch -> fast particle weapon (style 1), fractional ammo drain.
1720
+ particle_id = state.particles.spawn_particle(pos_x=muzzle_x, pos_y=muzzle_y, angle=particle_angle, intensity=1.0, owner_id=owner_id)
1721
+ state.particles.entries[particle_id].style_id = 1
1722
+ ammo_cost = 0.05
1723
+ elif weapon_id == WeaponId.HR_FLAMER:
1724
+ # HR Flamer -> fast particle weapon (style 2), fractional ammo drain.
1725
+ particle_id = state.particles.spawn_particle(pos_x=muzzle_x, pos_y=muzzle_y, angle=particle_angle, intensity=1.0, owner_id=owner_id)
1726
+ state.particles.entries[particle_id].style_id = 2
1727
+ ammo_cost = 0.1
1728
+ elif weapon_id == WeaponId.BUBBLEGUN:
1729
+ # Bubblegun -> slow particle weapon (style 8), fractional ammo drain.
1730
+ state.particles.spawn_particle_slow(pos_x=muzzle_x, pos_y=muzzle_y, angle=shot_angle - math.pi / 2.0, owner_id=owner_id)
1731
+ ammo_cost = 0.15
1732
+ elif weapon_id == WeaponId.MULTI_PLASMA:
1733
+ # Multi-Plasma: 5-shot fixed spread using type 0x09 and 0x0B.
1734
+ # (`player_update` weapon_id==0x0a in crimsonland.exe)
1735
+ shot_count = 5
1736
+ # Native literals: 0.31415927 (~ pi/10), 0.5235988 (~ pi/6).
1737
+ spread_small = math.pi / 10
1738
+ spread_large = math.pi / 6
1739
+ patterns: tuple[tuple[float, ProjectileTypeId], ...] = (
1740
+ (-spread_small, ProjectileTypeId.PLASMA_RIFLE),
1741
+ (-spread_large, ProjectileTypeId.PLASMA_MINIGUN),
1742
+ (0.0, ProjectileTypeId.PLASMA_RIFLE),
1743
+ (spread_large, ProjectileTypeId.PLASMA_MINIGUN),
1744
+ (spread_small, ProjectileTypeId.PLASMA_RIFLE),
1745
+ )
1746
+ for angle_offset, type_id in patterns:
1747
+ state.projectiles.spawn(
1748
+ pos_x=muzzle_x,
1749
+ pos_y=muzzle_y,
1750
+ angle=shot_angle + angle_offset,
1751
+ type_id=type_id,
1752
+ owner_id=owner_id,
1753
+ base_damage=_projectile_meta_for_type_id(type_id),
1754
+ )
1755
+ elif weapon_id == WeaponId.PLASMA_SHOTGUN:
1756
+ # Plasma Shotgun: 14 plasma-minigun pellets with wide jitter and random speed_scale.
1757
+ # (`player_update` weapon_id==0x0e in crimsonland.exe)
1758
+ shot_count = 14
1759
+ meta = _projectile_meta_for_type_id(int(ProjectileTypeId.PLASMA_MINIGUN))
1760
+ for _ in range(14):
1761
+ jitter = float((int(state.rng.rand()) & 0xFF) - 0x80) * 0.002
1762
+ proj_id = state.projectiles.spawn(
1763
+ pos_x=muzzle_x,
1764
+ pos_y=muzzle_y,
1765
+ angle=shot_angle + jitter,
1766
+ type_id=ProjectileTypeId.PLASMA_MINIGUN,
1767
+ owner_id=owner_id,
1768
+ base_damage=meta,
1769
+ )
1770
+ state.projectiles.entries[int(proj_id)].speed_scale = 1.0 + float(int(state.rng.rand()) % 100) * 0.01
1771
+ elif weapon_id == WeaponId.GAUSS_SHOTGUN:
1772
+ # Gauss Shotgun: 6 gauss pellets, jitter 0.002 and speed_scale 1.4..(1.4 + 0.79).
1773
+ # (`player_update` weapon_id==0x1e in crimsonland.exe)
1774
+ shot_count = 6
1775
+ meta = _projectile_meta_for_type_id(int(ProjectileTypeId.GAUSS_GUN))
1776
+ for _ in range(6):
1777
+ jitter = float(int(state.rng.rand()) % 200 - 100) * 0.002
1778
+ proj_id = state.projectiles.spawn(
1779
+ pos_x=muzzle_x,
1780
+ pos_y=muzzle_y,
1781
+ angle=shot_angle + jitter,
1782
+ type_id=ProjectileTypeId.GAUSS_GUN,
1783
+ owner_id=owner_id,
1784
+ base_damage=meta,
1785
+ )
1786
+ state.projectiles.entries[int(proj_id)].speed_scale = 1.4 + float(int(state.rng.rand()) % 0x50) * 0.01
1787
+ elif weapon_id == WeaponId.ION_SHOTGUN:
1788
+ # Ion Shotgun: 8 ion-minigun pellets, jitter 0.0026 and speed_scale 1.4..(1.4 + 0.79).
1789
+ # (`player_update` weapon_id==0x1f in crimsonland.exe)
1790
+ shot_count = 8
1791
+ meta = _projectile_meta_for_type_id(int(ProjectileTypeId.ION_MINIGUN))
1792
+ for _ in range(8):
1793
+ jitter = float(int(state.rng.rand()) % 200 - 100) * 0.0026
1794
+ proj_id = state.projectiles.spawn(
1795
+ pos_x=muzzle_x,
1796
+ pos_y=muzzle_y,
1797
+ angle=shot_angle + jitter,
1798
+ type_id=ProjectileTypeId.ION_MINIGUN,
1799
+ owner_id=owner_id,
1800
+ base_damage=meta,
1801
+ )
1802
+ state.projectiles.entries[int(proj_id)].speed_scale = 1.4 + float(int(state.rng.rand()) % 0x50) * 0.01
1803
+ else:
1804
+ pellets = max(1, int(pellet_count))
1805
+ shot_count = pellets
1806
+ type_id = projectile_type_id_from_weapon_id(weapon_id)
1807
+ if type_id is None:
1808
+ return
1809
+ meta = _projectile_meta_for_type_id(type_id)
1810
+ jitter_step = _pellet_jitter_step(weapon_id)
1811
+ for _ in range(pellets):
1812
+ angle = shot_angle
1813
+ if pellets > 1:
1814
+ angle += float(int(state.rng.rand()) % 200 - 100) * jitter_step
1815
+ proj_id = state.projectiles.spawn(
1816
+ pos_x=muzzle_x,
1817
+ pos_y=muzzle_y,
1818
+ angle=angle,
1819
+ type_id=type_id,
1820
+ owner_id=owner_id,
1821
+ base_damage=meta,
1822
+ )
1823
+ # Shotgun variants randomize speed_scale per pellet (rand%100 * 0.01 + 1.0).
1824
+ if pellets > 1 and weapon_id in (WeaponId.SHOTGUN, WeaponId.SAWED_OFF_SHOTGUN, WeaponId.JACKHAMMER):
1825
+ state.projectiles.entries[int(proj_id)].speed_scale = 1.0 + float(int(state.rng.rand()) % 100) * 0.01
1826
+
1827
+ if 0 <= int(player.index) < len(state.shots_fired):
1828
+ state.shots_fired[int(player.index)] += int(shot_count)
1829
+ if 0 <= weapon_id < WEAPON_COUNT_SIZE:
1830
+ state.weapon_shots_fired[int(player.index)][weapon_id] += int(shot_count)
1831
+
1832
+ if not perk_active(player, PerkId.SHARPSHOOTER):
1833
+ player.spread_heat = min(0.48, max(0.0, player.spread_heat + spread_inc))
1834
+
1835
+ muzzle_inc = float(weapon.spread_heat_inc) if weapon.spread_heat_inc is not None else 0.0
1836
+ if is_fire_bullets and pellet_count == 1 and fire_bullets_weapon is not None and fire_bullets_weapon.spread_heat_inc is not None:
1837
+ muzzle_inc = float(fire_bullets_weapon.spread_heat_inc)
1838
+ player.muzzle_flash_alpha = min(1.0, player.muzzle_flash_alpha)
1839
+ player.muzzle_flash_alpha = min(1.0, player.muzzle_flash_alpha + muzzle_inc)
1840
+ player.muzzle_flash_alpha = min(0.8, player.muzzle_flash_alpha)
1841
+
1842
+ player.shot_seq += 1
1843
+ if (not firing_during_reload) and state.bonuses.reflex_boost <= 0.0 and not is_fire_bullets:
1844
+ player.ammo = max(0.0, float(player.ammo) - float(ammo_cost))
1845
+ if (not firing_during_reload) and player.ammo <= 0.0 and player.reload_timer <= 0.0:
1846
+ player_start_reload(player, state)
1847
+
1848
+
1849
+ def player_update(player: PlayerState, input_state: PlayerInput, dt: float, state: GameplayState, *, world_size: float = 1024.0) -> None:
1850
+ """Port of `player_update` (0x004136b0) for the rewrite runtime."""
1851
+
1852
+ if dt <= 0.0:
1853
+ return
1854
+
1855
+ prev_x = player.pos_x
1856
+ prev_y = player.pos_y
1857
+
1858
+ if player.health <= 0.0:
1859
+ player.death_timer -= dt * 20.0
1860
+ return
1861
+
1862
+ player.muzzle_flash_alpha = max(0.0, player.muzzle_flash_alpha - dt * 2.0)
1863
+ cooldown_decay = dt * (1.5 if state.bonuses.weapon_power_up > 0.0 else 1.0)
1864
+ player.shot_cooldown = max(0.0, player.shot_cooldown - cooldown_decay)
1865
+
1866
+ if perk_active(player, PerkId.SHARPSHOOTER):
1867
+ player.spread_heat = 0.02
1868
+ else:
1869
+ player.spread_heat = max(0.01, player.spread_heat - dt * 0.4)
1870
+
1871
+ player.shield_timer = max(0.0, player.shield_timer - dt)
1872
+ player.fire_bullets_timer = max(0.0, player.fire_bullets_timer - dt)
1873
+ player.speed_bonus_timer = max(0.0, player.speed_bonus_timer - dt)
1874
+ if player.aux_timer > 0.0:
1875
+ aux_decay = 1.4 if player.aux_timer >= 1.0 else 0.5
1876
+ player.aux_timer = max(0.0, player.aux_timer - dt * aux_decay)
1877
+
1878
+ # Aim: compute direction from (player -> aim point).
1879
+ player.aim_x = float(input_state.aim_x)
1880
+ player.aim_y = float(input_state.aim_y)
1881
+ aim_dx = player.aim_x - player.pos_x
1882
+ aim_dy = player.aim_y - player.pos_y
1883
+ aim_dir_x, aim_dir_y = _normalize(aim_dx, aim_dy)
1884
+ if aim_dir_x != 0.0 or aim_dir_y != 0.0:
1885
+ player.aim_dir_x = aim_dir_x
1886
+ player.aim_dir_y = aim_dir_y
1887
+ player.aim_heading = math.atan2(aim_dir_y, aim_dir_x) + math.pi / 2.0
1888
+
1889
+ # Movement.
1890
+ raw_move_x = float(input_state.move_x)
1891
+ raw_move_y = float(input_state.move_y)
1892
+ raw_mag = math.hypot(raw_move_x, raw_move_y)
1893
+ moving_input = raw_mag > 0.2
1894
+
1895
+ if moving_input:
1896
+ inv = 1.0 / raw_mag if raw_mag > 1e-9 else 0.0
1897
+ move_x = raw_move_x * inv
1898
+ move_y = raw_move_y * inv
1899
+ player.heading = math.atan2(move_y, move_x) + math.pi / 2.0
1900
+ if perk_active(player, PerkId.LONG_DISTANCE_RUNNER):
1901
+ if player.move_speed < 2.0:
1902
+ player.move_speed = float(player.move_speed + dt * 4.0)
1903
+ player.move_speed = float(player.move_speed + dt)
1904
+ if player.move_speed > 2.8:
1905
+ player.move_speed = 2.8
1906
+ else:
1907
+ player.move_speed = float(player.move_speed + dt * 5.0)
1908
+ if player.move_speed > 2.0:
1909
+ player.move_speed = 2.0
1910
+ else:
1911
+ player.move_speed = float(player.move_speed - dt * 15.0)
1912
+ if player.move_speed < 0.0:
1913
+ player.move_speed = 0.0
1914
+ move_x = math.cos(player.heading - math.pi / 2.0)
1915
+ move_y = math.sin(player.heading - math.pi / 2.0)
1916
+
1917
+ if player.weapon_id == WeaponId.MEAN_MINIGUN and player.move_speed > 0.8:
1918
+ player.move_speed = 0.8
1919
+
1920
+ speed_multiplier = float(player.speed_multiplier)
1921
+ if player.speed_bonus_timer > 0.0:
1922
+ speed_multiplier += 1.0
1923
+
1924
+ speed = player.move_speed * speed_multiplier * 25.0
1925
+ if moving_input:
1926
+ speed *= min(1.0, raw_mag)
1927
+ if perk_active(player, PerkId.ALTERNATE_WEAPON):
1928
+ speed *= 0.8
1929
+
1930
+ player.pos_x = _clamp(player.pos_x + move_x * speed * dt, 0.0, float(world_size))
1931
+ player.pos_y = _clamp(player.pos_y + move_y * speed * dt, 0.0, float(world_size))
1932
+
1933
+ player.move_phase += dt * player.move_speed * 19.0
1934
+
1935
+ stationary = abs(player.pos_x - prev_x) <= 1e-9 and abs(player.pos_y - prev_y) <= 1e-9
1936
+ reload_scale = 1.0
1937
+ if stationary and perk_active(player, PerkId.STATIONARY_RELOADER):
1938
+ reload_scale = 3.0
1939
+
1940
+ if stationary and perk_active(player, PerkId.MAN_BOMB):
1941
+ _perk_update_man_bomb(player, dt, state)
1942
+ else:
1943
+ player.man_bomb_timer = 0.0
1944
+
1945
+ if stationary and perk_active(player, PerkId.LIVING_FORTRESS):
1946
+ player.living_fortress_timer = min(30.0, player.living_fortress_timer + dt)
1947
+ else:
1948
+ player.living_fortress_timer = 0.0
1949
+
1950
+ if perk_active(player, PerkId.FIRE_CAUGH):
1951
+ _perk_update_fire_cough(player, dt, state)
1952
+ else:
1953
+ player.fire_cough_timer = 0.0
1954
+
1955
+ if perk_active(player, PerkId.HOT_TEMPERED):
1956
+ _perk_update_hot_tempered(player, dt, state)
1957
+ else:
1958
+ player.hot_tempered_timer = 0.0
1959
+
1960
+ # Reload + reload perks.
1961
+ if perk_active(player, PerkId.ANXIOUS_LOADER) and input_state.fire_pressed and player.reload_timer > 0.0:
1962
+ player.reload_timer = max(0.0, player.reload_timer - 0.05)
1963
+
1964
+ if player.reload_timer > 0.0:
1965
+ if (
1966
+ perk_active(player, PerkId.ANGRY_RELOADER)
1967
+ and player.reload_timer_max > 0.5
1968
+ and (player.reload_timer_max * 0.5) < player.reload_timer
1969
+ ):
1970
+ half = player.reload_timer_max * 0.5
1971
+ next_timer = player.reload_timer - reload_scale * dt
1972
+ player.reload_timer = next_timer
1973
+ if next_timer <= half:
1974
+ count = 7 + int(player.reload_timer_max * 4.0)
1975
+ state.bonus_spawn_guard = True
1976
+ _spawn_projectile_ring(
1977
+ state,
1978
+ player,
1979
+ count=count,
1980
+ angle_offset=0.1,
1981
+ type_id=ProjectileTypeId.PLASMA_MINIGUN,
1982
+ owner_id=_owner_id_for_player(player.index),
1983
+ )
1984
+ state.bonus_spawn_guard = False
1985
+ state.sfx_queue.append("sfx_explosion_small")
1986
+ else:
1987
+ player.reload_timer -= reload_scale * dt
1988
+
1989
+ if player.reload_timer < 0.0:
1990
+ player.reload_timer = 0.0
1991
+
1992
+ if player.reload_active and player.reload_timer <= 0.0 and player.reload_timer_max > 0.0:
1993
+ player.ammo = float(player.clip_size)
1994
+ player.reload_active = False
1995
+ player.reload_timer_max = 0.0
1996
+
1997
+ if input_state.reload_pressed:
1998
+ if perk_active(player, PerkId.ALTERNATE_WEAPON) and player_swap_alt_weapon(player):
1999
+ weapon = _weapon_entry(player.weapon_id)
2000
+ if weapon is not None and weapon.reload_sound is not None:
2001
+ from .weapon_sfx import resolve_weapon_sfx_ref
2002
+
2003
+ key = resolve_weapon_sfx_ref(weapon.reload_sound)
2004
+ if key is not None:
2005
+ state.sfx_queue.append(key)
2006
+ player.shot_cooldown = float(player.shot_cooldown) + 0.1
2007
+ elif player.reload_timer == 0.0:
2008
+ player_start_reload(player, state)
2009
+
2010
+ player_fire_weapon(player, input_state, dt, state)
2011
+
2012
+ while player.move_phase > 14.0:
2013
+ player.move_phase -= 14.0
2014
+ while player.move_phase < 0.0:
2015
+ player.move_phase += 14.0
2016
+
2017
+
2018
+ def bonus_apply(
2019
+ state: GameplayState,
2020
+ player: PlayerState,
2021
+ bonus_id: BonusId,
2022
+ *,
2023
+ amount: int | None = None,
2024
+ origin: _HasPos | None = None,
2025
+ creatures: list[Damageable] | None = None,
2026
+ players: list[PlayerState] | None = None,
2027
+ apply_creature_damage: CreatureDamageApplier | None = None,
2028
+ detail_preset: int = 5,
2029
+ ) -> None:
2030
+ """Apply a bonus to player + global timers (subset of `bonus_apply`)."""
2031
+
2032
+ meta = BONUS_BY_ID.get(int(bonus_id))
2033
+ if meta is None:
2034
+ return
2035
+ if amount is None:
2036
+ amount = int(meta.default_amount or 0)
2037
+
2038
+ if bonus_id == BonusId.POINTS:
2039
+ award_experience(state, player, int(amount))
2040
+ return
2041
+
2042
+ economist_multiplier = 1.0 + 0.5 * float(perk_count_get(player, PerkId.BONUS_ECONOMIST))
2043
+
2044
+ icon_id = int(meta.icon_id) if meta.icon_id is not None else -1
2045
+ label = meta.name
2046
+
2047
+ def _register_global(timer_key: str) -> None:
2048
+ state.bonus_hud.register(
2049
+ bonus_id,
2050
+ label=label,
2051
+ icon_id=icon_id,
2052
+ timer_ref=_TimerRef("global", timer_key),
2053
+ )
2054
+
2055
+ def _register_player(timer_key: str) -> None:
2056
+ if players is not None and len(players) > 1:
2057
+ state.bonus_hud.register(
2058
+ bonus_id,
2059
+ label=label,
2060
+ icon_id=icon_id,
2061
+ timer_ref=_TimerRef("player", timer_key, player_index=0),
2062
+ timer_ref_alt=_TimerRef("player", timer_key, player_index=1),
2063
+ )
2064
+ else:
2065
+ state.bonus_hud.register(
2066
+ bonus_id,
2067
+ label=label,
2068
+ icon_id=icon_id,
2069
+ timer_ref=_TimerRef("player", timer_key, player_index=int(player.index)),
2070
+ )
2071
+
2072
+ if bonus_id == BonusId.ENERGIZER:
2073
+ old = float(state.bonuses.energizer)
2074
+ if old <= 0.0:
2075
+ _register_global("energizer")
2076
+ state.bonuses.energizer = float(old + float(amount) * economist_multiplier)
2077
+ return
2078
+
2079
+ if bonus_id == BonusId.WEAPON_POWER_UP:
2080
+ old = float(state.bonuses.weapon_power_up)
2081
+ if old <= 0.0:
2082
+ _register_global("weapon_power_up")
2083
+ state.bonuses.weapon_power_up = float(old + float(amount) * economist_multiplier)
2084
+ player.weapon_reset_latch = 0
2085
+ player.shot_cooldown = 0.0
2086
+ player.reload_active = False
2087
+ player.reload_timer = 0.0
2088
+ player.reload_timer_max = 0.0
2089
+ player.ammo = float(player.clip_size)
2090
+ return
2091
+
2092
+ if bonus_id == BonusId.DOUBLE_EXPERIENCE:
2093
+ old = float(state.bonuses.double_experience)
2094
+ if old <= 0.0:
2095
+ _register_global("double_experience")
2096
+ state.bonuses.double_experience = float(old + float(amount) * economist_multiplier)
2097
+ return
2098
+
2099
+ if bonus_id == BonusId.REFLEX_BOOST:
2100
+ old = float(state.bonuses.reflex_boost)
2101
+ if old <= 0.0:
2102
+ _register_global("reflex_boost")
2103
+ state.bonuses.reflex_boost = float(old + float(amount) * economist_multiplier)
2104
+
2105
+ targets = players if players is not None else [player]
2106
+ for target in targets:
2107
+ target.ammo = float(target.clip_size)
2108
+ target.reload_active = False
2109
+ target.reload_timer = 0.0
2110
+ target.reload_timer_max = 0.0
2111
+ return
2112
+
2113
+ if bonus_id == BonusId.FREEZE:
2114
+ old = float(state.bonuses.freeze)
2115
+ if old <= 0.0:
2116
+ _register_global("freeze")
2117
+ state.bonuses.freeze = float(old + float(amount) * economist_multiplier)
2118
+ if creatures:
2119
+ rand = state.rng.rand
2120
+ for creature in creatures:
2121
+ active = getattr(creature, "active", True)
2122
+ if not bool(active):
2123
+ continue
2124
+ if float(getattr(creature, "hp", 0.0)) > 0.0:
2125
+ continue
2126
+ pos_x = float(getattr(creature, "x", 0.0))
2127
+ pos_y = float(getattr(creature, "y", 0.0))
2128
+ for _ in range(8):
2129
+ angle = float(int(rand()) % 0x264) * 0.01
2130
+ state.effects.spawn_freeze_shard(
2131
+ pos_x=pos_x,
2132
+ pos_y=pos_y,
2133
+ angle=angle,
2134
+ rand=rand,
2135
+ detail_preset=int(detail_preset),
2136
+ )
2137
+ angle = float(int(rand()) % 0x264) * 0.01
2138
+ state.effects.spawn_freeze_shatter(
2139
+ pos_x=pos_x,
2140
+ pos_y=pos_y,
2141
+ angle=angle,
2142
+ rand=rand,
2143
+ detail_preset=int(detail_preset),
2144
+ )
2145
+ if hasattr(creature, "active"):
2146
+ setattr(creature, "active", False)
2147
+ state.sfx_queue.append("sfx_shockwave")
2148
+ return
2149
+
2150
+ if bonus_id == BonusId.SHIELD:
2151
+ should_register = float(player.shield_timer) <= 0.0
2152
+ if players is not None and len(players) > 1:
2153
+ should_register = float(players[0].shield_timer) <= 0.0 and float(players[1].shield_timer) <= 0.0
2154
+ if should_register:
2155
+ _register_player("shield_timer")
2156
+ player.shield_timer = float(player.shield_timer + float(amount) * economist_multiplier)
2157
+ return
2158
+
2159
+ if bonus_id == BonusId.SPEED:
2160
+ should_register = float(player.speed_bonus_timer) <= 0.0
2161
+ if players is not None and len(players) > 1:
2162
+ should_register = float(players[0].speed_bonus_timer) <= 0.0 and float(players[1].speed_bonus_timer) <= 0.0
2163
+ if should_register:
2164
+ _register_player("speed_bonus_timer")
2165
+ player.speed_bonus_timer = float(player.speed_bonus_timer + float(amount) * economist_multiplier)
2166
+ return
2167
+
2168
+ if bonus_id == BonusId.FIRE_BULLETS:
2169
+ should_register = float(player.fire_bullets_timer) <= 0.0
2170
+ if players is not None and len(players) > 1:
2171
+ should_register = float(players[0].fire_bullets_timer) <= 0.0 and float(players[1].fire_bullets_timer) <= 0.0
2172
+ if should_register:
2173
+ _register_player("fire_bullets_timer")
2174
+ player.fire_bullets_timer = float(player.fire_bullets_timer + float(amount) * economist_multiplier)
2175
+ player.weapon_reset_latch = 0
2176
+ player.shot_cooldown = 0.0
2177
+ player.reload_active = False
2178
+ player.reload_timer = 0.0
2179
+ player.reload_timer_max = 0.0
2180
+ player.ammo = float(player.clip_size)
2181
+ return
2182
+
2183
+ if bonus_id == BonusId.SHOCK_CHAIN:
2184
+ if creatures:
2185
+ origin_pos = origin or player
2186
+ best_idx: int | None = None
2187
+ best_dist = 0.0
2188
+ for idx, creature in enumerate(creatures):
2189
+ if creature.hp <= 0.0:
2190
+ continue
2191
+ d = _distance_sq(float(origin_pos.pos_x), float(origin_pos.pos_y), creature.x, creature.y)
2192
+ if best_idx is None or d < best_dist:
2193
+ best_idx = idx
2194
+ best_dist = d
2195
+ if best_idx is not None:
2196
+ target = creatures[best_idx]
2197
+ dx = target.x - float(origin_pos.pos_x)
2198
+ dy = target.y - float(origin_pos.pos_y)
2199
+ angle = math.atan2(dy, dx) + math.pi / 2.0
2200
+ owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
2201
+
2202
+ state.bonus_spawn_guard = True
2203
+ state.shock_chain_links_left = 0x20
2204
+ state.shock_chain_projectile_id = state.projectiles.spawn(
2205
+ pos_x=float(origin_pos.pos_x),
2206
+ pos_y=float(origin_pos.pos_y),
2207
+ angle=angle,
2208
+ type_id=int(ProjectileTypeId.ION_RIFLE),
2209
+ owner_id=int(owner_id),
2210
+ base_damage=_projectile_meta_for_type_id(int(ProjectileTypeId.ION_RIFLE)),
2211
+ )
2212
+ state.bonus_spawn_guard = False
2213
+ return
2214
+
2215
+ if bonus_id == BonusId.WEAPON:
2216
+ weapon_id = int(amount)
2217
+ if perk_active(player, PerkId.ALTERNATE_WEAPON) and player.alt_weapon_id is None:
2218
+ player.alt_weapon_id = int(player.weapon_id)
2219
+ player.alt_clip_size = int(player.clip_size)
2220
+ player.alt_ammo = float(player.ammo)
2221
+ player.alt_reload_active = bool(player.reload_active)
2222
+ player.alt_reload_timer = float(player.reload_timer)
2223
+ player.alt_shot_cooldown = float(player.shot_cooldown)
2224
+ player.alt_reload_timer_max = float(player.reload_timer_max)
2225
+ weapon_assign_player(player, weapon_id, state=state)
2226
+ return
2227
+
2228
+ if bonus_id == BonusId.FIREBLAST:
2229
+ origin_pos = origin or player
2230
+ owner_id = _owner_id_for_player(player.index) if state.friendly_fire_enabled else -100
2231
+ state.bonus_spawn_guard = True
2232
+ _spawn_projectile_ring(
2233
+ state,
2234
+ origin_pos,
2235
+ count=16,
2236
+ angle_offset=0.0,
2237
+ type_id=ProjectileTypeId.PLASMA_RIFLE,
2238
+ owner_id=int(owner_id),
2239
+ )
2240
+ state.bonus_spawn_guard = False
2241
+ state.sfx_queue.append("sfx_explosion_medium")
2242
+ return
2243
+
2244
+ if bonus_id == BonusId.NUKE:
2245
+ # `bonus_apply` (crimsonland.exe @ 0x00409890) starts screen shake via:
2246
+ # camera_shake_pulses = 0x14;
2247
+ # camera_shake_timer = 0.2f;
2248
+ state.camera_shake_pulses = 0x14
2249
+ state.camera_shake_timer = 0.2
2250
+
2251
+ origin_pos = origin or player
2252
+ ox = float(origin_pos.pos_x)
2253
+ oy = float(origin_pos.pos_y)
2254
+ rand = state.rng.rand
2255
+
2256
+ bullet_count = int(rand()) & 3
2257
+ bullet_count += 4
2258
+ assault_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.ASSAULT_RIFLE))
2259
+ for _ in range(bullet_count):
2260
+ angle = float(int(rand()) % 0x274) * 0.01
2261
+ proj_id = state.projectiles.spawn(
2262
+ pos_x=ox,
2263
+ pos_y=oy,
2264
+ angle=float(angle),
2265
+ type_id=int(ProjectileTypeId.ASSAULT_RIFLE),
2266
+ owner_id=-100,
2267
+ base_damage=assault_meta,
2268
+ )
2269
+ if proj_id != -1:
2270
+ speed_scale = float(int(rand()) % 0x32) * 0.01 + 0.5
2271
+ state.projectiles.entries[proj_id].speed_scale *= float(speed_scale)
2272
+
2273
+ minigun_meta = _projectile_meta_for_type_id(int(ProjectileTypeId.MEAN_MINIGUN))
2274
+ for _ in range(2):
2275
+ angle = float(int(rand()) % 0x274) * 0.01
2276
+ state.projectiles.spawn(
2277
+ pos_x=ox,
2278
+ pos_y=oy,
2279
+ angle=float(angle),
2280
+ type_id=int(ProjectileTypeId.MEAN_MINIGUN),
2281
+ owner_id=-100,
2282
+ base_damage=minigun_meta,
2283
+ )
2284
+
2285
+ state.effects.spawn_explosion_burst(
2286
+ pos_x=ox,
2287
+ pos_y=oy,
2288
+ scale=1.0,
2289
+ rand=rand,
2290
+ detail_preset=int(detail_preset),
2291
+ )
2292
+
2293
+ if creatures:
2294
+ prev_guard = bool(state.bonus_spawn_guard)
2295
+ state.bonus_spawn_guard = True
2296
+ for idx, creature in enumerate(creatures):
2297
+ if creature.hp <= 0.0:
2298
+ continue
2299
+ dx = float(creature.x) - ox
2300
+ dy = float(creature.y) - oy
2301
+ if abs(dx) > 256.0 or abs(dy) > 256.0:
2302
+ continue
2303
+ dist = math.hypot(dx, dy)
2304
+ if dist < 256.0:
2305
+ damage = (256.0 - dist) * 5.0
2306
+ if apply_creature_damage is not None:
2307
+ apply_creature_damage(
2308
+ int(idx),
2309
+ float(damage),
2310
+ 3,
2311
+ 0.0,
2312
+ 0.0,
2313
+ _owner_id_for_player(player.index),
2314
+ )
2315
+ else:
2316
+ creature.hp -= float(damage)
2317
+ state.bonus_spawn_guard = prev_guard
2318
+ state.sfx_queue.append("sfx_explosion_large")
2319
+ state.sfx_queue.append("sfx_shockwave")
2320
+ return
2321
+
2322
+ # Bonus types not modeled yet.
2323
+ return
2324
+
2325
+
2326
+ def bonus_hud_update(state: GameplayState, players: list[PlayerState]) -> None:
2327
+ """Refresh HUD slots based on current timer values."""
2328
+
2329
+ def _timer_value(ref: _TimerRef | None) -> float:
2330
+ if ref is None:
2331
+ return 0.0
2332
+ if ref.kind == "global":
2333
+ return float(getattr(state.bonuses, ref.key, 0.0) or 0.0)
2334
+ if ref.kind == "player":
2335
+ idx = ref.player_index
2336
+ if idx is None or not (0 <= idx < len(players)):
2337
+ return 0.0
2338
+ return float(getattr(players[idx], ref.key, 0.0) or 0.0)
2339
+ return 0.0
2340
+
2341
+ for slot in state.bonus_hud.slots:
2342
+ if not slot.active:
2343
+ continue
2344
+ timer = _timer_value(slot.timer_ref)
2345
+ if slot.timer_ref_alt is not None:
2346
+ timer = max(timer, _timer_value(slot.timer_ref_alt))
2347
+ if timer <= 0.0:
2348
+ slot.active = False
2349
+ slot.timer_ref = None
2350
+ slot.timer_ref_alt = None
2351
+
2352
+
2353
+ def bonus_telekinetic_update(
2354
+ state: GameplayState,
2355
+ players: list[PlayerState],
2356
+ dt: float,
2357
+ *,
2358
+ creatures: list[Damageable] | None = None,
2359
+ apply_creature_damage: CreatureDamageApplier | None = None,
2360
+ detail_preset: int = 5,
2361
+ ) -> list[BonusPickupEvent]:
2362
+ """Allow Telekinetic perk owners to pick up bonuses by aiming at them."""
2363
+
2364
+ if dt <= 0.0:
2365
+ return []
2366
+
2367
+ pickups: list[BonusPickupEvent] = []
2368
+ dt_ms = float(dt) * 1000.0
2369
+
2370
+ for player in players:
2371
+ if player.health <= 0.0:
2372
+ player.bonus_aim_hover_index = -1
2373
+ player.bonus_aim_hover_timer_ms = 0.0
2374
+ continue
2375
+
2376
+ hovered = bonus_find_aim_hover_entry(player, state.bonus_pool)
2377
+ if hovered is None:
2378
+ player.bonus_aim_hover_index = -1
2379
+ player.bonus_aim_hover_timer_ms = 0.0
2380
+ continue
2381
+
2382
+ idx, entry = hovered
2383
+ if idx != int(player.bonus_aim_hover_index):
2384
+ player.bonus_aim_hover_index = int(idx)
2385
+ player.bonus_aim_hover_timer_ms = dt_ms
2386
+ else:
2387
+ player.bonus_aim_hover_timer_ms += dt_ms
2388
+
2389
+ if player.bonus_aim_hover_timer_ms <= BONUS_TELEKINETIC_PICKUP_MS:
2390
+ continue
2391
+ if not perk_active(player, PerkId.TELEKINETIC):
2392
+ continue
2393
+ if entry.picked or entry.bonus_id == 0:
2394
+ continue
2395
+
2396
+ bonus_apply(
2397
+ state,
2398
+ player,
2399
+ BonusId(int(entry.bonus_id)),
2400
+ amount=int(entry.amount),
2401
+ origin=player,
2402
+ creatures=creatures,
2403
+ players=players,
2404
+ apply_creature_damage=apply_creature_damage,
2405
+ detail_preset=int(detail_preset),
2406
+ )
2407
+ entry.picked = True
2408
+ entry.time_left = BONUS_PICKUP_LINGER
2409
+ pickups.append(
2410
+ BonusPickupEvent(
2411
+ player_index=int(player.index),
2412
+ bonus_id=int(entry.bonus_id),
2413
+ amount=int(entry.amount),
2414
+ pos_x=float(entry.pos_x),
2415
+ pos_y=float(entry.pos_y),
2416
+ )
2417
+ )
2418
+
2419
+ # Match the exe: after a telekinetic pickup, reset the hover accumulator.
2420
+ player.bonus_aim_hover_index = -1
2421
+ player.bonus_aim_hover_timer_ms = 0.0
2422
+
2423
+ return pickups
2424
+
2425
+
2426
+ def bonus_update(
2427
+ state: GameplayState,
2428
+ players: list[PlayerState],
2429
+ dt: float,
2430
+ *,
2431
+ creatures: list[Damageable] | None = None,
2432
+ update_hud: bool = True,
2433
+ apply_creature_damage: CreatureDamageApplier | None = None,
2434
+ detail_preset: int = 5,
2435
+ ) -> list[BonusPickupEvent]:
2436
+ """Advance world bonuses and global timers (subset of `bonus_update`)."""
2437
+
2438
+ pickups = bonus_telekinetic_update(
2439
+ state,
2440
+ players,
2441
+ dt,
2442
+ creatures=creatures,
2443
+ apply_creature_damage=apply_creature_damage,
2444
+ detail_preset=int(detail_preset),
2445
+ )
2446
+ pickups.extend(
2447
+ state.bonus_pool.update(
2448
+ dt,
2449
+ state=state,
2450
+ players=players,
2451
+ creatures=creatures,
2452
+ apply_creature_damage=apply_creature_damage,
2453
+ detail_preset=int(detail_preset),
2454
+ )
2455
+ )
2456
+
2457
+ if dt > 0.0:
2458
+ state.bonuses.weapon_power_up = max(0.0, state.bonuses.weapon_power_up - dt)
2459
+ state.bonuses.reflex_boost = max(0.0, state.bonuses.reflex_boost - dt)
2460
+ state.bonuses.energizer = max(0.0, state.bonuses.energizer - dt)
2461
+ state.bonuses.double_experience = max(0.0, state.bonuses.double_experience - dt)
2462
+ state.bonuses.freeze = max(0.0, state.bonuses.freeze - dt)
2463
+
2464
+ if update_hud:
2465
+ bonus_hud_update(state, players)
2466
+
2467
+ return pickups