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/oracle.py ADDED
@@ -0,0 +1,396 @@
1
+ """Headless oracle mode for differential testing.
2
+
3
+ Runs the game simulation without rendering, accepts inputs from a JSON file,
4
+ and emits game state to stdout each frame for comparison with other implementations.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import sys
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .gameplay import GameplayState, PlayerInput, PlayerState
17
+ from .creatures.runtime import CreaturePool
18
+ from .bonuses import BonusId
19
+ from .sim.world_state import WorldState
20
+
21
+
22
+ class OutputMode:
23
+ """Output modes for oracle state emission."""
24
+ FULL = "full" # Complete state every sample
25
+ SUMMARY = "summary" # Score, kills, player pos/health only
26
+ HASH = "hash" # SHA256 hash of full state for fast comparison
27
+ CHECKPOINTS = "checkpoints" # Only on significant events
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class OracleConfig:
32
+ """Configuration for headless oracle mode."""
33
+
34
+ seed: int
35
+ input_file: Path | None
36
+ max_frames: int = 36000 # 10 minutes at 60fps
37
+ frame_rate: int = 60
38
+ sample_rate: int = 1 # Emit state every N frames (1 = every frame, 60 = once per second)
39
+ output_mode: str = OutputMode.SUMMARY
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class FrameInput:
44
+ """Input for a single frame."""
45
+
46
+ frame: int
47
+ move_x: float = 0.0
48
+ move_y: float = 0.0
49
+ aim_x: float = 0.0
50
+ aim_y: float = 0.0
51
+ fire_down: bool = False
52
+ fire_pressed: bool = False
53
+ reload_pressed: bool = False
54
+
55
+
56
+ def load_inputs(path: Path) -> list[FrameInput]:
57
+ """Load input sequence from JSON file.
58
+
59
+ Expected format:
60
+ {
61
+ "frames": [
62
+ {"frame": 0, "move_x": 1.0, "move_y": 0.0, "aim_x": 100, "aim_y": 200, "fire_down": true},
63
+ {"frame": 60, "move_x": 0.0, "move_y": -1.0, "fire_pressed": true},
64
+ ...
65
+ ]
66
+ }
67
+ """
68
+ data = json.loads(path.read_text())
69
+ inputs: list[FrameInput] = []
70
+ for entry in data.get("frames", []):
71
+ inputs.append(
72
+ FrameInput(
73
+ frame=int(entry.get("frame", 0)),
74
+ move_x=float(entry.get("move_x", 0.0)),
75
+ move_y=float(entry.get("move_y", 0.0)),
76
+ aim_x=float(entry.get("aim_x", 0.0)),
77
+ aim_y=float(entry.get("aim_y", 0.0)),
78
+ fire_down=bool(entry.get("fire_down", False)),
79
+ fire_pressed=bool(entry.get("fire_pressed", False)),
80
+ reload_pressed=bool(entry.get("reload_pressed", False)),
81
+ )
82
+ )
83
+ return sorted(inputs, key=lambda i: i.frame)
84
+
85
+
86
+ def export_player_state(player: PlayerState) -> dict[str, Any]:
87
+ """Export player state to JSON-serializable dict."""
88
+ return {
89
+ "index": int(player.index),
90
+ "pos_x": round(float(player.pos_x), 4),
91
+ "pos_y": round(float(player.pos_y), 4),
92
+ "health": round(float(player.health), 4),
93
+ "weapon_id": int(player.weapon_id),
94
+ "ammo": round(float(player.ammo), 4),
95
+ "experience": int(player.experience),
96
+ "level": int(player.level),
97
+ "reload_active": bool(player.reload_active),
98
+ "heading": round(float(player.heading), 4),
99
+ "aim_heading": round(float(player.aim_heading), 4),
100
+ }
101
+
102
+
103
+ def export_creature_state(creature: Any) -> dict[str, Any]:
104
+ """Export creature state to JSON-serializable dict."""
105
+ return {
106
+ "id": int(creature.id) if hasattr(creature, "id") else -1,
107
+ "type_id": int(creature.type_id),
108
+ "x": round(float(creature.x), 4),
109
+ "y": round(float(creature.y), 4),
110
+ "hp": round(float(creature.hp), 4),
111
+ "active": bool(creature.active),
112
+ }
113
+
114
+
115
+ def export_bonus_state(bonus: Any) -> dict[str, Any]:
116
+ """Export bonus state to JSON-serializable dict."""
117
+ return {
118
+ "bonus_id": int(bonus.bonus_id),
119
+ "pos_x": round(float(bonus.pos_x), 4),
120
+ "pos_y": round(float(bonus.pos_y), 4),
121
+ "time_left": round(float(bonus.time_left), 4),
122
+ "picked": bool(bonus.picked),
123
+ }
124
+
125
+
126
+ def export_projectile_state(proj: Any) -> dict[str, Any]:
127
+ """Export projectile state to JSON-serializable dict."""
128
+ return {
129
+ "type_id": int(proj.type_id),
130
+ "x": round(float(proj.x), 4),
131
+ "y": round(float(proj.y), 4),
132
+ "active": bool(proj.active),
133
+ }
134
+
135
+
136
+ def export_game_state_full(
137
+ frame: int,
138
+ world_state: WorldState,
139
+ players: list[PlayerState],
140
+ rng_state: int,
141
+ elapsed_ms: float,
142
+ ) -> dict[str, Any]:
143
+ """Export complete game state for a frame."""
144
+ state = world_state.state
145
+
146
+ # Collect active creatures
147
+ creatures = []
148
+ for creature in world_state.creatures.entries:
149
+ if creature.active:
150
+ creatures.append(export_creature_state(creature))
151
+
152
+ # Collect active bonuses
153
+ bonuses = []
154
+ for bonus in state.bonus_pool.iter_active():
155
+ bonuses.append(export_bonus_state(bonus))
156
+
157
+ # Collect active projectiles
158
+ projectiles = []
159
+ for proj in state.projectiles.entries:
160
+ if proj.active:
161
+ projectiles.append(export_projectile_state(proj))
162
+
163
+ # Score is player experience, kills tracked on creatures pool
164
+ total_experience = sum(p.experience for p in players)
165
+ kill_count = world_state.creatures.kill_count
166
+
167
+ return {
168
+ "frame": frame,
169
+ "rng_state": rng_state,
170
+ "elapsed_ms": round(elapsed_ms, 4),
171
+ "score": int(total_experience),
172
+ "kills": int(kill_count),
173
+ "players": [export_player_state(p) for p in players],
174
+ "creatures": creatures,
175
+ "bonuses": bonuses,
176
+ "projectiles": projectiles,
177
+ "bonus_timers": {
178
+ "weapon_power_up": round(float(state.bonuses.weapon_power_up), 4),
179
+ "reflex_boost": round(float(state.bonuses.reflex_boost), 4),
180
+ "freeze": round(float(state.bonuses.freeze), 4),
181
+ },
182
+ }
183
+
184
+
185
+ def export_game_state_summary(
186
+ frame: int,
187
+ world_state: WorldState,
188
+ players: list[PlayerState],
189
+ rng_state: int,
190
+ elapsed_ms: float,
191
+ ) -> dict[str, Any]:
192
+ """Export minimal game state for fast comparison."""
193
+ total_experience = sum(p.experience for p in players)
194
+ kill_count = world_state.creatures.kill_count
195
+ creature_count = sum(1 for c in world_state.creatures.entries if c.active)
196
+
197
+ return {
198
+ "frame": frame,
199
+ "rng_state": rng_state,
200
+ "elapsed_ms": round(elapsed_ms, 4),
201
+ "score": int(total_experience),
202
+ "kills": int(kill_count),
203
+ "creature_count": creature_count,
204
+ "players": [
205
+ {
206
+ "pos_x": round(float(p.pos_x), 2),
207
+ "pos_y": round(float(p.pos_y), 2),
208
+ "health": round(float(p.health), 2),
209
+ "weapon_id": int(p.weapon_id),
210
+ "level": int(p.level),
211
+ }
212
+ for p in players
213
+ ],
214
+ }
215
+
216
+
217
+ def export_game_state_hash(
218
+ frame: int,
219
+ world_state: WorldState,
220
+ players: list[PlayerState],
221
+ rng_state: int,
222
+ elapsed_ms: float,
223
+ ) -> dict[str, Any]:
224
+ """Export hash of game state for ultra-fast comparison."""
225
+ # Get full state and hash it
226
+ full_state = export_game_state_full(
227
+ frame, world_state, players, rng_state, elapsed_ms
228
+ )
229
+ # Remove frame from hash computation (it's metadata)
230
+ hashable = {k: v for k, v in full_state.items() if k != "frame"}
231
+ state_bytes = json.dumps(hashable, sort_keys=True).encode()
232
+ state_hash = hashlib.sha256(state_bytes).hexdigest()[:16]
233
+
234
+ return {
235
+ "frame": frame,
236
+ "hash": state_hash,
237
+ "score": full_state["score"],
238
+ "kills": full_state["kills"],
239
+ }
240
+
241
+
242
+ @dataclass(slots=True)
243
+ class CheckpointTracker:
244
+ """Track significant events for checkpoint-only output."""
245
+ last_score: int = 0
246
+ last_kills: int = 0
247
+ last_level: int = 1
248
+ last_health: float = 100.0
249
+ last_weapon_id: int = 1
250
+
251
+ def check_and_update(self, players: list[PlayerState], world_state: WorldState) -> bool:
252
+ """Return True if any significant change occurred."""
253
+ score = sum(p.experience for p in players)
254
+ kills = world_state.creatures.kill_count
255
+ level = players[0].level if players else 1
256
+ health = players[0].health if players else 0.0
257
+ weapon_id = players[0].weapon_id if players else 1
258
+
259
+ changed = (
260
+ score != self.last_score or
261
+ kills != self.last_kills or
262
+ level != self.last_level or
263
+ int(health) != int(self.last_health) or # Only trigger on integer health change
264
+ weapon_id != self.last_weapon_id
265
+ )
266
+
267
+ if changed:
268
+ self.last_score = score
269
+ self.last_kills = kills
270
+ self.last_level = level
271
+ self.last_health = health
272
+ self.last_weapon_id = weapon_id
273
+
274
+ return changed
275
+
276
+
277
+ def run_headless(config: OracleConfig) -> None:
278
+ """Run the game in headless mode, emitting state JSON each frame."""
279
+ from .sim.world_state import WorldState
280
+ from .effects import FxQueue, FxQueueRotated
281
+ from .game_modes import GameMode
282
+
283
+ # Build world state
284
+ world_state = WorldState.build(
285
+ world_size=1024.0,
286
+ demo_mode_active=False,
287
+ hardcore=False,
288
+ difficulty_level=0,
289
+ )
290
+
291
+ # Initialize with seed
292
+ world_state.state.rng.srand(config.seed)
293
+
294
+ # Set up player at center
295
+ players = world_state.players
296
+ if not players:
297
+ from .gameplay import PlayerState, weapon_assign_player
298
+
299
+ player = PlayerState(index=0, pos_x=512.0, pos_y=512.0)
300
+ weapon_assign_player(player, 1)
301
+ players.append(player)
302
+
303
+ # Load inputs if provided
304
+ inputs_by_frame: dict[int, FrameInput] = {}
305
+ if config.input_file is not None:
306
+ for inp in load_inputs(config.input_file):
307
+ inputs_by_frame[inp.frame] = inp
308
+
309
+ dt = 1.0 / float(config.frame_rate)
310
+ current_input = FrameInput(frame=0)
311
+ elapsed_ms = 0.0
312
+
313
+ # Create dummy FX queues (not used in headless mode)
314
+ fx_queue = FxQueue()
315
+ fx_queue_rotated = FxQueueRotated()
316
+
317
+ # Checkpoint tracker for event-driven output
318
+ checkpoint_tracker = CheckpointTracker()
319
+
320
+ # Select export function based on output mode
321
+ export_fn = {
322
+ OutputMode.FULL: export_game_state_full,
323
+ OutputMode.SUMMARY: export_game_state_summary,
324
+ OutputMode.HASH: export_game_state_hash,
325
+ OutputMode.CHECKPOINTS: export_game_state_summary, # Same format, different trigger
326
+ }.get(config.output_mode, export_game_state_summary)
327
+
328
+ for frame in range(config.max_frames):
329
+ # Update current input if we have one for this frame
330
+ if frame in inputs_by_frame:
331
+ current_input = inputs_by_frame[frame]
332
+
333
+ # Convert to PlayerInput
334
+ player_inputs = [
335
+ PlayerInput(
336
+ move_x=current_input.move_x,
337
+ move_y=current_input.move_y,
338
+ aim_x=current_input.aim_x,
339
+ aim_y=current_input.aim_y,
340
+ fire_down=current_input.fire_down,
341
+ fire_pressed=current_input.fire_pressed,
342
+ reload_pressed=current_input.reload_pressed,
343
+ )
344
+ ]
345
+
346
+ # Step simulation
347
+ world_state.step(
348
+ dt,
349
+ inputs=player_inputs,
350
+ world_size=1024.0,
351
+ damage_scale_by_type={},
352
+ detail_preset=5,
353
+ fx_queue=fx_queue,
354
+ fx_queue_rotated=fx_queue_rotated,
355
+ auto_pick_perks=True,
356
+ game_mode=int(GameMode.SURVIVAL),
357
+ perk_progression_enabled=True,
358
+ )
359
+
360
+ elapsed_ms += dt * 1000.0
361
+
362
+ # Determine if we should emit state this frame
363
+ should_emit = False
364
+ if config.output_mode == OutputMode.CHECKPOINTS:
365
+ # Only emit on significant changes
366
+ should_emit = checkpoint_tracker.check_and_update(players, world_state)
367
+ # Always emit first and last frame
368
+ if frame == 0:
369
+ should_emit = True
370
+ else:
371
+ # Sample rate based emission
372
+ should_emit = (frame % config.sample_rate == 0)
373
+
374
+ if should_emit:
375
+ state_json = export_fn(
376
+ frame=frame,
377
+ world_state=world_state,
378
+ players=players,
379
+ rng_state=world_state.state.rng.state,
380
+ elapsed_ms=elapsed_ms,
381
+ )
382
+ print(json.dumps(state_json), flush=True)
383
+
384
+ # Check if all players dead
385
+ if all(p.health <= 0 for p in players):
386
+ # Always emit final state on death
387
+ if not should_emit:
388
+ state_json = export_fn(
389
+ frame=frame,
390
+ world_state=world_state,
391
+ players=players,
392
+ rng_state=world_state.state.rng.state,
393
+ elapsed_ms=elapsed_ms,
394
+ )
395
+ print(json.dumps(state_json), flush=True)
396
+ break
crimson/perks.py CHANGED
@@ -219,7 +219,8 @@ PERK_TABLE = [
219
219
  prereq=(),
220
220
  notes=(
221
221
  "Sets `player_plaguebearer_active` (`DAT_004908b9`). In `creature_update_all`, infected creatures "
222
- "(`collision_flag != 0`) take `15` damage every `0.5` seconds via `collision_timer`; on an infection kill, "
222
+ "(`collision_flag != 0`, i.e. `CreatureState.plague_infected`) take `15` damage every `0.5` seconds via "
223
+ "`collision_timer`; on an infection kill, "
223
224
  "increments `plaguebearer_infection_count`. While `plaguebearer_infection_count < 60`, "
224
225
  "`FUN_00425d80` spreads infection between creatures within `45` units when the target has `<150` HP. "
225
226
  "While `plaguebearer_infection_count < 50`, the player infects nearby creatures (`<30` units) with `<150` HP."
@@ -673,7 +674,9 @@ PERK_TABLE = [
673
674
  notes=(
674
675
  "`player_take_damage` (0x00425e50): if Death Clock is active, returns immediately (immune to damage). "
675
676
  "`perk_apply` (0x004055e0): clears Regeneration and Greater Regeneration perk counts and sets "
676
- "`player.health = 100.0` when `health > 0.0`."
677
+ "`player.health = 100.0` when `health > 0.0`. "
678
+ "`perks_update_effects` (0x00406b40): if `health <= 0.0`, sets it to `0.0`; otherwise decrements "
679
+ "`health -= frame_dt * 3.3333333`."
677
680
  ),
678
681
  ),
679
682
  PerkMeta(
crimson/player_damage.py CHANGED
@@ -6,6 +6,7 @@ This is a minimal, rewrite-focused port of `player_take_damage` (0x00425e50).
6
6
  See: `docs/crimsonland-exe/player-damage.md`.
7
7
  """
8
8
 
9
+ from dataclasses import dataclass
9
10
  from typing import Callable
10
11
 
11
12
  from .gameplay import GameplayState, PlayerState, perk_active
@@ -14,6 +15,87 @@ from .perks import PerkId
14
15
  __all__ = ["player_take_damage"]
15
16
 
16
17
 
18
+ @dataclass(slots=True)
19
+ class _PlayerDamageCtx:
20
+ state: GameplayState
21
+ player: PlayerState
22
+ dmg: float
23
+ dt: float | None
24
+ rng: Callable[[], int]
25
+
26
+
27
+ _PlayerDamagePreStep = Callable[[_PlayerDamageCtx], bool]
28
+
29
+
30
+ def _player_damage_gate_death_clock(ctx: _PlayerDamageCtx) -> bool:
31
+ return perk_active(ctx.player, PerkId.DEATH_CLOCK)
32
+
33
+
34
+ def _player_damage_scale_tough_reloader(ctx: _PlayerDamageCtx) -> bool:
35
+ if perk_active(ctx.player, PerkId.TOUGH_RELOADER) and bool(ctx.player.reload_active):
36
+ ctx.dmg *= 0.5
37
+ return False
38
+
39
+
40
+ def _player_damage_gate_shield(ctx: _PlayerDamageCtx) -> bool:
41
+ return float(ctx.player.shield_timer) > 0.0
42
+
43
+
44
+ def _player_damage_scale_thick_skinned(ctx: _PlayerDamageCtx) -> bool:
45
+ if perk_active(ctx.player, PerkId.THICK_SKINNED):
46
+ ctx.dmg *= 2.0 / 3.0
47
+ return False
48
+
49
+
50
+ def _player_damage_gate_dodge(ctx: _PlayerDamageCtx) -> bool:
51
+ if perk_active(ctx.player, PerkId.NINJA):
52
+ return (ctx.rng() % 3) == 0
53
+ if perk_active(ctx.player, PerkId.DODGER):
54
+ return (ctx.rng() % 5) == 0
55
+ return False
56
+
57
+
58
+ _PLAYER_DAMAGE_PRE_STEPS: tuple[_PlayerDamagePreStep, ...] = (
59
+ _player_damage_gate_death_clock,
60
+ _player_damage_scale_tough_reloader,
61
+ _player_damage_gate_shield,
62
+ _player_damage_scale_thick_skinned,
63
+ _player_damage_gate_dodge,
64
+ )
65
+
66
+
67
+ def _player_damage_apply_health(ctx: _PlayerDamageCtx) -> None:
68
+ if perk_active(ctx.player, PerkId.HIGHLANDER):
69
+ if (ctx.rng() % 10) == 0:
70
+ ctx.player.health = 0.0
71
+ else:
72
+ ctx.player.health -= ctx.dmg
73
+ if ctx.player.health < 0.0 and ctx.dt is not None and float(ctx.dt) > 0.0:
74
+ ctx.player.death_timer -= float(ctx.dt) * 28.0
75
+
76
+
77
+ _PlayerDamagePostStep = Callable[[_PlayerDamageCtx], None]
78
+
79
+
80
+ def _player_damage_post_hit_disruption(ctx: _PlayerDamageCtx) -> None:
81
+ if perk_active(ctx.player, PerkId.UNSTOPPABLE):
82
+ return
83
+ # player_take_damage @ 0x00425e50: on-hit camera/spread disruption.
84
+ ctx.player.heading += float((ctx.rng() % 100) - 50) * 0.04
85
+ ctx.player.spread_heat = min(0.48, float(ctx.player.spread_heat) + ctx.dmg * 0.01)
86
+
87
+
88
+ def _player_damage_post_low_health_warning(ctx: _PlayerDamageCtx) -> None:
89
+ if ctx.player.health <= 20.0 and (ctx.rng() & 7) == 3:
90
+ ctx.player.low_health_timer = 0.0
91
+
92
+
93
+ _PLAYER_DAMAGE_POST_STEPS: tuple[_PlayerDamagePostStep, ...] = (
94
+ _player_damage_post_hit_disruption,
95
+ _player_damage_post_low_health_warning,
96
+ )
97
+
98
+
17
99
  def player_take_damage(
18
100
  state: GameplayState,
19
101
  player: PlayerState,
@@ -32,46 +114,23 @@ def player_take_damage(
32
114
  dmg = float(damage)
33
115
  if dmg <= 0.0:
34
116
  return 0.0
35
-
36
- # 1) Death Clock immunity.
37
- if perk_active(player, PerkId.DEATH_CLOCK):
117
+ if state.debug_god_mode:
38
118
  return 0.0
39
119
 
40
- # 2) Tough Reloader mitigation while reloading.
41
- if perk_active(player, PerkId.TOUGH_RELOADER) and bool(player.reload_active):
42
- dmg *= 0.5
43
-
44
- # 3) Shield immunity.
45
- if float(player.shield_timer) > 0.0:
46
- return 0.0
47
-
48
- # Damage scaling perks.
49
- if perk_active(player, PerkId.THICK_SKINNED):
50
- dmg *= 2.0 / 3.0
51
-
52
- rng = rand or state.rng.rand
53
- if perk_active(player, PerkId.NINJA):
54
- if (rng() % 3) == 0:
55
- return 0.0
56
- elif perk_active(player, PerkId.DODGER):
57
- if (rng() % 5) == 0:
120
+ ctx = _PlayerDamageCtx(
121
+ state=state,
122
+ player=player,
123
+ dmg=dmg,
124
+ dt=dt,
125
+ rng=rand or state.rng.rand,
126
+ )
127
+ for step in _PLAYER_DAMAGE_PRE_STEPS:
128
+ if step(ctx):
58
129
  return 0.0
59
130
 
60
131
  health_before = float(player.health)
61
132
 
62
- if perk_active(player, PerkId.HIGHLANDER):
63
- if (rng() % 10) == 0:
64
- player.health = 0.0
65
- else:
66
- player.health -= dmg
67
- if player.health < 0.0 and dt is not None and float(dt) > 0.0:
68
- player.death_timer -= float(dt) * 28.0
69
-
70
- if not perk_active(player, PerkId.UNSTOPPABLE):
71
- # player_take_damage @ 0x00425e50: on-hit camera/spread disruption.
72
- player.heading += float((rng() % 100) - 50) * 0.04
73
- player.spread_heat = min(0.48, float(player.spread_heat) + dmg * 0.01)
74
-
75
- if player.health <= 20.0 and (rng() & 7) == 3:
76
- player.low_health_timer = 0.0
133
+ _player_damage_apply_health(ctx)
134
+ for step in _PLAYER_DAMAGE_POST_STEPS:
135
+ step(ctx)
77
136
  return max(0.0, health_before - float(player.health))