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/game_modes.py ADDED
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import IntEnum
4
+
5
+
6
+ class GameMode(IntEnum):
7
+ """Known `game_mode` ids from the original config / highscore tables."""
8
+
9
+ DEMO = 0
10
+ SURVIVAL = 1
11
+ RUSH = 2
12
+ QUESTS = 3
13
+ TYPO = 4
14
+ TUTORIAL = 8
15
+
crimson/game_world.py ADDED
@@ -0,0 +1,652 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ import math
5
+ import random
6
+ from pathlib import Path
7
+
8
+ import pyray as rl
9
+
10
+ from grim.assets import PaqTextureCache, TextureLoader
11
+ from grim.audio import AudioState
12
+ from grim.config import CrimsonConfig
13
+ from grim.terrain_render import GroundRenderer
14
+
15
+ from .camera import camera_shake_update
16
+ from .creatures.anim import creature_corpse_frame_for_type
17
+ from .creatures.runtime import CreaturePool
18
+ from .creatures.spawn import SpawnEnv
19
+ from .effects import FxQueue, FxQueueRotated
20
+ from .gameplay import (
21
+ GameplayState,
22
+ PlayerInput,
23
+ PlayerState,
24
+ perk_active,
25
+ perks_rebuild_available,
26
+ weapon_assign_player,
27
+ weapon_refresh_available,
28
+ )
29
+ from .render.terrain_fx import FxQueueTextures, bake_fx_queues
30
+ from .render.world_renderer import WorldRenderer
31
+ from .audio_router import AudioRouter
32
+ from .perks import PerkId
33
+ from .projectiles import ProjectileTypeId
34
+ from .sim.world_defs import BEAM_TYPES, CREATURE_ASSET, ION_TYPES
35
+ from .sim.world_state import ProjectileHit, WorldState
36
+ from .weapons import WEAPON_TABLE
37
+ from .game_modes import GameMode
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class GameWorld:
42
+ assets_dir: Path
43
+ world_size: float = 1024.0
44
+ demo_mode_active: bool = False
45
+ difficulty_level: int = 0
46
+ hardcore: bool = False
47
+ texture_cache: PaqTextureCache | None = None
48
+ config: CrimsonConfig | None = None
49
+ audio: AudioState | None = None
50
+ audio_rng: random.Random | None = None
51
+ audio_router: AudioRouter = field(init=False)
52
+ renderer: WorldRenderer = field(init=False)
53
+ world_state: WorldState = field(init=False)
54
+
55
+ spawn_env: SpawnEnv = field(init=False)
56
+ state: GameplayState = field(init=False)
57
+ players: list[PlayerState] = field(init=False)
58
+ creatures: CreaturePool = field(init=False)
59
+ camera_x: float = field(init=False, default=-1.0)
60
+ camera_y: float = field(init=False, default=-1.0)
61
+ _damage_scale_by_type: dict[int, float] = field(init=False, default_factory=dict)
62
+ missing_assets: list[str] = field(init=False, default_factory=list)
63
+ ground: GroundRenderer | None = field(init=False, default=None)
64
+ fx_queue: FxQueue = field(init=False)
65
+ fx_queue_rotated: FxQueueRotated = field(init=False)
66
+ fx_textures: FxQueueTextures | None = field(init=False, default=None)
67
+ creature_textures: dict[str, rl.Texture] = field(init=False, default_factory=dict)
68
+ projs_texture: rl.Texture | None = field(init=False, default=None)
69
+ particles_texture: rl.Texture | None = field(init=False, default=None)
70
+ bullet_texture: rl.Texture | None = field(init=False, default=None)
71
+ bullet_trail_texture: rl.Texture | None = field(init=False, default=None)
72
+ bonuses_texture: rl.Texture | None = field(init=False, default=None)
73
+ bodyset_texture: rl.Texture | None = field(init=False, default=None)
74
+ clock_table_texture: rl.Texture | None = field(init=False, default=None)
75
+ clock_pointer_texture: rl.Texture | None = field(init=False, default=None)
76
+ muzzle_flash_texture: rl.Texture | None = field(init=False, default=None)
77
+ wicons_texture: rl.Texture | None = field(init=False, default=None)
78
+ _elapsed_ms: float = field(init=False, default=0.0)
79
+ _bonus_anim_phase: float = field(init=False, default=0.0)
80
+ _texture_loader: TextureLoader | None = field(init=False, default=None)
81
+
82
+ def __post_init__(self) -> None:
83
+ self.world_state = WorldState.build(
84
+ world_size=float(self.world_size),
85
+ demo_mode_active=bool(self.demo_mode_active),
86
+ hardcore=bool(self.hardcore),
87
+ difficulty_level=int(self.difficulty_level),
88
+ )
89
+ self.spawn_env = self.world_state.spawn_env
90
+ self.state = self.world_state.state
91
+ self.players = self.world_state.players
92
+ self.creatures = self.world_state.creatures
93
+ self.fx_queue = FxQueue()
94
+ self.fx_queue_rotated = FxQueueRotated()
95
+ self.camera_x = -1.0
96
+ self.camera_y = -1.0
97
+ self.audio_router = AudioRouter(
98
+ audio=self.audio,
99
+ audio_rng=self.audio_rng,
100
+ demo_mode_active=self.demo_mode_active,
101
+ )
102
+ self.renderer = WorldRenderer(self)
103
+ self._damage_scale_by_type = {}
104
+ # Native `projectile_spawn` indexes the weapon table by `type_id`, so
105
+ # `damage_scale_by_type` is just `weapon_table[type_id].damage_scale`.
106
+ for entry in WEAPON_TABLE:
107
+ if entry.weapon_id <= 0:
108
+ continue
109
+ self._damage_scale_by_type[int(entry.weapon_id)] = float(entry.damage_scale or 1.0)
110
+ player_count = 1
111
+ if self.config is not None:
112
+ try:
113
+ player_count = int(self.config.data.get("player_count", 1) or 1)
114
+ except Exception:
115
+ player_count = 1
116
+ self.reset(player_count=max(1, min(4, player_count)))
117
+
118
+ def reset(
119
+ self,
120
+ *,
121
+ seed: int = 0xBEEF,
122
+ player_count: int = 1,
123
+ spawn_x: float | None = None,
124
+ spawn_y: float | None = None,
125
+ ) -> None:
126
+ self.world_state = WorldState.build(
127
+ world_size=float(self.world_size),
128
+ demo_mode_active=bool(self.demo_mode_active),
129
+ hardcore=bool(self.hardcore),
130
+ difficulty_level=int(self.difficulty_level),
131
+ )
132
+ self.spawn_env = self.world_state.spawn_env
133
+ self.state = self.world_state.state
134
+ self.players = self.world_state.players
135
+ self.creatures = self.world_state.creatures
136
+ self.state.rng.srand(int(seed))
137
+ self.fx_queue.clear()
138
+ self.fx_queue_rotated.clear()
139
+ self._elapsed_ms = 0.0
140
+ self._bonus_anim_phase = 0.0
141
+ base_x = float(self.world_size) * 0.5 if spawn_x is None else float(spawn_x)
142
+ base_y = float(self.world_size) * 0.5 if spawn_y is None else float(spawn_y)
143
+ count = max(1, int(player_count))
144
+ if count <= 1:
145
+ offsets = [(0.0, 0.0)]
146
+ else:
147
+ radius = 32.0
148
+ step = math.tau / float(count)
149
+ offsets = [
150
+ (math.cos(float(idx) * step) * radius, math.sin(float(idx) * step) * radius) for idx in range(count)
151
+ ]
152
+
153
+ for idx in range(count):
154
+ offset_x, offset_y = offsets[idx]
155
+ x = base_x + float(offset_x)
156
+ y = base_y + float(offset_y)
157
+ x = max(0.0, min(float(self.world_size), x))
158
+ y = max(0.0, min(float(self.world_size), y))
159
+ player = PlayerState(index=idx, pos_x=x, pos_y=y)
160
+ weapon_assign_player(player, 1)
161
+ self.players.append(player)
162
+ self.camera_x = -1.0
163
+ self.camera_y = -1.0
164
+ if self.ground is not None:
165
+ terrain_seed = int(self.state.rng.rand() % 10_000)
166
+ self.ground.schedule_generate(seed=terrain_seed, layers=3)
167
+
168
+ def _ensure_texture_loader(self) -> TextureLoader:
169
+ if self._texture_loader is not None:
170
+ return self._texture_loader
171
+ if self.texture_cache is not None:
172
+ loader = TextureLoader(
173
+ assets_root=self.assets_dir,
174
+ cache=self.texture_cache,
175
+ missing=self.missing_assets,
176
+ )
177
+ else:
178
+ loader = TextureLoader.from_assets_root(self.assets_dir)
179
+ loader.missing = self.missing_assets
180
+ if loader.cache is not None:
181
+ self.texture_cache = loader.cache
182
+ self._texture_loader = loader
183
+ return loader
184
+
185
+ def _load_texture(self, name: str, *, cache_path: str, file_path: str) -> rl.Texture | None:
186
+ loader = self._ensure_texture_loader()
187
+ return loader.get(name=name, paq_rel=cache_path, fs_rel=file_path)
188
+
189
+ @staticmethod
190
+ def _png_path_for(rel_path: str) -> str:
191
+ lower = rel_path.lower()
192
+ if lower.endswith(".jaz"):
193
+ return rel_path[:-4] + ".png"
194
+ return rel_path
195
+
196
+ def _sync_ground_settings(self) -> None:
197
+ if self.ground is None:
198
+ return
199
+ if self.config is None:
200
+ self.ground.texture_scale = 1.0
201
+ self.ground.screen_width = None
202
+ self.ground.screen_height = None
203
+ return
204
+ self.ground.texture_scale = float(self.config.texture_scale)
205
+ self.ground.screen_width = float(self.config.screen_width)
206
+ self.ground.screen_height = float(self.config.screen_height)
207
+
208
+ def set_terrain(
209
+ self,
210
+ *,
211
+ base_key: str,
212
+ overlay_key: str,
213
+ base_path: str,
214
+ overlay_path: str,
215
+ detail_key: str | None = None,
216
+ detail_path: str | None = None,
217
+ ) -> None:
218
+ base = self._load_texture(
219
+ base_key,
220
+ cache_path=base_path,
221
+ file_path=self._png_path_for(base_path),
222
+ )
223
+ overlay = self._load_texture(
224
+ overlay_key,
225
+ cache_path=overlay_path,
226
+ file_path=self._png_path_for(overlay_path),
227
+ )
228
+ detail = None
229
+ if detail_key is not None and detail_path is not None:
230
+ detail = self._load_texture(
231
+ detail_key,
232
+ cache_path=detail_path,
233
+ file_path=self._png_path_for(detail_path),
234
+ )
235
+ if detail is None:
236
+ detail = overlay or base
237
+ if base is None:
238
+ return
239
+ if self.ground is None:
240
+ self.ground = GroundRenderer(
241
+ texture=base,
242
+ overlay=overlay,
243
+ overlay_detail=detail,
244
+ width=int(self.world_size),
245
+ height=int(self.world_size),
246
+ texture_scale=1.0,
247
+ screen_width=None,
248
+ screen_height=None,
249
+ )
250
+ else:
251
+ self.ground.texture = base
252
+ self.ground.overlay = overlay
253
+ self.ground.overlay_detail = detail
254
+ self._sync_ground_settings()
255
+ terrain_seed = int(self.state.rng.rand() % 10_000)
256
+ self.ground.schedule_generate(seed=terrain_seed, layers=3)
257
+
258
+ def open(self) -> None:
259
+ self.close()
260
+ self.missing_assets.clear()
261
+ self.creature_textures.clear()
262
+
263
+ base = self._load_texture(
264
+ "ter_q1_base",
265
+ cache_path="ter/ter_q1_base.jaz",
266
+ file_path="ter/ter_q1_base.png",
267
+ )
268
+ overlay = self._load_texture(
269
+ "ter_q1_tex1",
270
+ cache_path="ter/ter_q1_tex1.jaz",
271
+ file_path="ter/ter_q1_tex1.png",
272
+ )
273
+ detail = overlay or base
274
+ if base is not None:
275
+ if self.ground is None:
276
+ self.ground = GroundRenderer(
277
+ texture=base,
278
+ overlay=overlay,
279
+ overlay_detail=detail,
280
+ width=int(self.world_size),
281
+ height=int(self.world_size),
282
+ texture_scale=1.0,
283
+ screen_width=None,
284
+ screen_height=None,
285
+ )
286
+ else:
287
+ self.ground.texture = base
288
+ self.ground.overlay = overlay
289
+ self.ground.overlay_detail = detail
290
+ self._sync_ground_settings()
291
+ terrain_seed = int(self.state.rng.rand() % 10_000)
292
+ self.ground.schedule_generate(seed=terrain_seed, layers=3)
293
+
294
+ for asset in sorted(set(CREATURE_ASSET.values())):
295
+ texture = self._load_texture(
296
+ asset,
297
+ cache_path=f"game/{asset}.jaz",
298
+ file_path=f"game/{asset}.png",
299
+ )
300
+ if texture is not None:
301
+ self.creature_textures[asset] = texture
302
+
303
+ self.projs_texture = self._load_texture(
304
+ "projs",
305
+ cache_path="game/projs.jaz",
306
+ file_path="game/projs.png",
307
+ )
308
+ self.particles_texture = self._load_texture(
309
+ "particles",
310
+ cache_path="game/particles.jaz",
311
+ file_path="game/particles.png",
312
+ )
313
+ self.bullet_texture = self._load_texture(
314
+ "bullet_i",
315
+ cache_path="load/bullet16.tga",
316
+ file_path="load/bullet16.png",
317
+ )
318
+ self.bullet_trail_texture = self._load_texture(
319
+ "bulletTrail",
320
+ cache_path="load/bulletTrail.tga",
321
+ file_path="load/bulletTrail.png",
322
+ )
323
+ self.bonuses_texture = self._load_texture(
324
+ "bonuses",
325
+ cache_path="game/bonuses.jaz",
326
+ file_path="game/bonuses.png",
327
+ )
328
+ self.wicons_texture = self._load_texture(
329
+ "ui_wicons",
330
+ cache_path="ui/ui_wicons.jaz",
331
+ file_path="ui/ui_wicons.png",
332
+ )
333
+ self.bodyset_texture = self._load_texture(
334
+ "bodyset",
335
+ cache_path="game/bodyset.jaz",
336
+ file_path="game/bodyset.png",
337
+ )
338
+ self.clock_table_texture = self._load_texture(
339
+ "ui_clockTable",
340
+ cache_path="ui/ui_clockTable.jaz",
341
+ file_path="ui/ui_clockTable.png",
342
+ )
343
+ self.clock_pointer_texture = self._load_texture(
344
+ "ui_clockPointer",
345
+ cache_path="ui/ui_clockPointer.jaz",
346
+ file_path="ui/ui_clockPointer.png",
347
+ )
348
+ self.muzzle_flash_texture = self._load_texture(
349
+ "muzzleFlash",
350
+ cache_path="game/muzzleFlash.jaz",
351
+ file_path="game/muzzleFlash.png",
352
+ )
353
+
354
+ if self.particles_texture is not None and self.bodyset_texture is not None:
355
+ self.fx_textures = FxQueueTextures(
356
+ particles=self.particles_texture,
357
+ bodyset=self.bodyset_texture,
358
+ )
359
+ else:
360
+ self.fx_textures = None
361
+
362
+ def close(self) -> None:
363
+ if self.ground is not None and self.ground.render_target is not None:
364
+ rl.unload_render_texture(self.ground.render_target)
365
+ self.ground.render_target = None
366
+ self.ground = None
367
+
368
+ self._texture_loader = None
369
+
370
+ self.creature_textures.clear()
371
+ self.projs_texture = None
372
+ self.particles_texture = None
373
+ self.bullet_texture = None
374
+ self.bullet_trail_texture = None
375
+ self.bonuses_texture = None
376
+ self.wicons_texture = None
377
+ self.bodyset_texture = None
378
+ self.clock_table_texture = None
379
+ self.clock_pointer_texture = None
380
+ self.muzzle_flash_texture = None
381
+ self.fx_textures = None
382
+ self.fx_queue.clear()
383
+ self.fx_queue_rotated.clear()
384
+
385
+ def update(
386
+ self,
387
+ dt: float,
388
+ *,
389
+ inputs: list[PlayerInput] | None = None,
390
+ auto_pick_perks: bool = False,
391
+ game_mode: int = int(GameMode.SURVIVAL),
392
+ perk_progression_enabled: bool = False,
393
+ ) -> list[ProjectileHit]:
394
+ if inputs is None:
395
+ inputs = [PlayerInput() for _ in self.players]
396
+
397
+ self.state.game_mode = int(game_mode)
398
+ self.state.demo_mode_active = bool(self.demo_mode_active)
399
+ weapon_refresh_available(self.state)
400
+ perks_rebuild_available(self.state)
401
+
402
+ if self.audio_router is not None:
403
+ self.audio_router.audio = self.audio
404
+ self.audio_router.audio_rng = self.audio_rng
405
+ self.audio_router.demo_mode_active = self.demo_mode_active
406
+
407
+ # Time scale (Reflex Boost): gameplay_update_and_render @ 0x0040AAB0.
408
+ # When active, `frame_dt` is scaled by `time_scale_factor`, with a linear
409
+ # ramp from 0.3 -> 1.0 over the final second of the timer.
410
+ time_scale_active = self.state.bonuses.reflex_boost > 0.0
411
+ if time_scale_active:
412
+ time_scale_factor = 0.3
413
+ timer = float(self.state.bonuses.reflex_boost)
414
+ if timer < 1.0:
415
+ time_scale_factor = (1.0 - timer) * 0.7 + 0.3
416
+ dt = float(dt) * float(time_scale_factor)
417
+
418
+ if dt > 0.0:
419
+ self._elapsed_ms += float(dt) * 1000.0
420
+ self._bonus_anim_phase += float(dt) * 1.3
421
+
422
+ detail_preset = 5
423
+ if self.config is not None:
424
+ detail_preset = int(self.config.data.get("detail_preset", 5) or 5)
425
+
426
+ if self.ground is not None:
427
+ self._sync_ground_settings()
428
+ self.ground.process_pending()
429
+
430
+ prev_audio = [(player.shot_seq, player.reload_active, player.reload_timer) for player in self.players]
431
+ prev_perk_pending = int(self.state.perk_selection.pending_count)
432
+
433
+ events = self.world_state.step(
434
+ dt,
435
+ inputs=inputs,
436
+ world_size=float(self.world_size),
437
+ damage_scale_by_type=self._damage_scale_by_type,
438
+ detail_preset=detail_preset,
439
+ fx_queue=self.fx_queue,
440
+ fx_queue_rotated=self.fx_queue_rotated,
441
+ auto_pick_perks=auto_pick_perks,
442
+ game_mode=game_mode,
443
+ perk_progression_enabled=bool(perk_progression_enabled),
444
+ )
445
+
446
+ if perk_progression_enabled and int(self.state.perk_selection.pending_count) > prev_perk_pending:
447
+ self.audio_router.play_sfx("sfx_ui_levelup")
448
+
449
+ if events.hits:
450
+ self._queue_projectile_decals(events.hits)
451
+ self.audio_router.play_hit_sfx(
452
+ events.hits,
453
+ game_mode=game_mode,
454
+ rand=self.state.rng.rand,
455
+ beam_types=BEAM_TYPES,
456
+ )
457
+
458
+ for idx, player in enumerate(self.players):
459
+ if idx < len(prev_audio):
460
+ prev_shot_seq, prev_reload_active, prev_reload_timer = prev_audio[idx]
461
+ self.audio_router.handle_player_audio(
462
+ player,
463
+ prev_shot_seq=prev_shot_seq,
464
+ prev_reload_active=prev_reload_active,
465
+ prev_reload_timer=prev_reload_timer,
466
+ )
467
+
468
+ if events.deaths:
469
+ self.audio_router.play_death_sfx(events.deaths, rand=self.state.rng.rand)
470
+
471
+ if events.pickups:
472
+ for _ in events.pickups:
473
+ self.audio_router.play_sfx("sfx_ui_bonus")
474
+
475
+ if events.sfx:
476
+ for key in events.sfx[:4]:
477
+ self.audio_router.play_sfx(key)
478
+
479
+ self.update_camera(dt)
480
+ return events.hits
481
+
482
+ def _queue_projectile_decals(self, hits: list[ProjectileHit]) -> None:
483
+ rand = self.state.rng.rand
484
+ fx_toggle = 0
485
+ detail_preset = 5
486
+ if self.config is not None:
487
+ fx_toggle = int(self.config.data.get("fx_toggle", 0) or 0)
488
+ detail_preset = int(self.config.data.get("detail_preset", 5) or 5)
489
+
490
+ freeze_active = self.state.bonuses.freeze > 0.0
491
+ bloody = bool(self.players) and perk_active(self.players[0], PerkId.BLOODY_MESS_QUICK_LEARNER)
492
+
493
+ for type_id, origin_x, origin_y, hit_x, hit_y, target_x, target_y in hits:
494
+ type_id = int(type_id)
495
+
496
+ base_angle = math.atan2(float(hit_y) - float(origin_y), float(hit_x) - float(origin_x))
497
+
498
+ # Native: Gauss Gun + Fire Bullets spawn a distinct "streak" of large terrain decals.
499
+ if type_id in (int(ProjectileTypeId.GAUSS_GUN), int(ProjectileTypeId.FIRE_BULLETS)):
500
+ dir_x = math.cos(base_angle)
501
+ dir_y = math.sin(base_angle)
502
+ for _ in range(6):
503
+ dist = float(int(rand()) % 100) * 0.1
504
+ if dist > 4.0:
505
+ dist = float(int(rand()) % 0x5A + 10) * 0.1
506
+ if dist > 7.0:
507
+ dist = float(int(rand()) % 0x50 + 0x14) * 0.1
508
+ self.fx_queue.add_random(
509
+ pos_x=float(target_x) + dir_x * dist * 20.0,
510
+ pos_y=float(target_y) + dir_y * dist * 20.0,
511
+ rand=rand,
512
+ )
513
+ elif type_id in ION_TYPES:
514
+ pass
515
+ elif not freeze_active:
516
+ for _ in range(3):
517
+ spread = float(int(rand()) % 0x14 - 10) * 0.1
518
+ angle = base_angle + spread
519
+ dir_x = math.cos(angle) * 20.0
520
+ dir_y = math.sin(angle) * 20.0
521
+ self.fx_queue.add_random(pos_x=float(target_x), pos_y=float(target_y), rand=rand)
522
+ self.fx_queue.add_random(
523
+ pos_x=float(target_x) + dir_x * 1.5,
524
+ pos_y=float(target_y) + dir_y * 1.5,
525
+ rand=rand,
526
+ )
527
+ self.fx_queue.add_random(
528
+ pos_x=float(target_x) + dir_x * 2.0,
529
+ pos_y=float(target_y) + dir_y * 2.0,
530
+ rand=rand,
531
+ )
532
+ self.fx_queue.add_random(
533
+ pos_x=float(target_x) + dir_x * 2.5,
534
+ pos_y=float(target_y) + dir_y * 2.5,
535
+ rand=rand,
536
+ )
537
+
538
+ if bloody:
539
+ lo = -30
540
+ hi = 30
541
+ while lo > -60:
542
+ span = hi - lo
543
+ for _ in range(2):
544
+ dx = float(int(rand()) % span + lo)
545
+ dy = float(int(rand()) % span + lo)
546
+ self.fx_queue.add_random(
547
+ pos_x=float(target_x) + dx,
548
+ pos_y=float(target_y) + dy,
549
+ rand=rand,
550
+ )
551
+ lo -= 10
552
+ hi += 10
553
+
554
+ # Native hit path: spawn transient blood splatter particles and only
555
+ # bake decals into the terrain once those particles expire.
556
+ if bloody:
557
+ for _ in range(8):
558
+ spread = float((int(rand()) & 0x1F) - 0x10) * 0.0625
559
+ self.state.effects.spawn_blood_splatter(
560
+ pos_x=float(hit_x),
561
+ pos_y=float(hit_y),
562
+ angle=base_angle + spread,
563
+ age=0.0,
564
+ rand=rand,
565
+ detail_preset=detail_preset,
566
+ fx_toggle=fx_toggle,
567
+ )
568
+ self.state.effects.spawn_blood_splatter(
569
+ pos_x=float(hit_x),
570
+ pos_y=float(hit_y),
571
+ angle=base_angle + math.pi,
572
+ age=0.0,
573
+ rand=rand,
574
+ detail_preset=detail_preset,
575
+ fx_toggle=fx_toggle,
576
+ )
577
+ continue
578
+
579
+ if freeze_active:
580
+ continue
581
+
582
+ for _ in range(2):
583
+ self.state.effects.spawn_blood_splatter(
584
+ pos_x=float(hit_x),
585
+ pos_y=float(hit_y),
586
+ angle=base_angle,
587
+ age=0.0,
588
+ rand=rand,
589
+ detail_preset=detail_preset,
590
+ fx_toggle=fx_toggle,
591
+ )
592
+ if (int(rand()) & 7) == 2:
593
+ self.state.effects.spawn_blood_splatter(
594
+ pos_x=float(hit_x),
595
+ pos_y=float(hit_y),
596
+ angle=base_angle + math.pi,
597
+ age=0.0,
598
+ rand=rand,
599
+ detail_preset=detail_preset,
600
+ fx_toggle=fx_toggle,
601
+ )
602
+
603
+ def _bake_fx_queues(self) -> None:
604
+ if self.ground is None or self.fx_textures is None:
605
+ return
606
+ if not (self.fx_queue.count or self.fx_queue_rotated.count):
607
+ return
608
+ bake_fx_queues(
609
+ self.ground,
610
+ fx_queue=self.fx_queue,
611
+ fx_queue_rotated=self.fx_queue_rotated,
612
+ textures=self.fx_textures,
613
+ corpse_frame_for_type=self._corpse_frame_for_type,
614
+ )
615
+
616
+ @staticmethod
617
+ def _corpse_frame_for_type(type_id: int) -> int:
618
+ return creature_corpse_frame_for_type(type_id)
619
+
620
+ def draw(self, *, draw_aim_indicators: bool = True, entity_alpha: float = 1.0) -> None:
621
+ # Bake decals into the ground render target as part of the render pass,
622
+ # matching `fx_queue_render()` placement in `gameplay_render_world`.
623
+ self._bake_fx_queues()
624
+ self.renderer.draw(draw_aim_indicators=draw_aim_indicators, entity_alpha=entity_alpha)
625
+
626
+ def update_camera(self, dt: float) -> None:
627
+ if not self.players:
628
+ return
629
+ camera_shake_update(self.state, dt)
630
+
631
+ screen_w, screen_h = self.renderer._camera_screen_size()
632
+
633
+ alive = [player for player in self.players if player.health > 0.0]
634
+ if alive:
635
+ focus_x = sum(player.pos_x for player in alive) / float(len(alive))
636
+ focus_y = sum(player.pos_y for player in alive) / float(len(alive))
637
+ cam_x = (screen_w * 0.5) - focus_x
638
+ cam_y = (screen_h * 0.5) - focus_y
639
+ else:
640
+ cam_x = self.camera_x
641
+ cam_y = self.camera_y
642
+
643
+ cam_x += self.state.camera_shake_offset_x
644
+ cam_y += self.state.camera_shake_offset_y
645
+
646
+ self.camera_x, self.camera_y = self.renderer._clamp_camera(cam_x, cam_y, screen_w, screen_h)
647
+
648
+ def world_to_screen(self, x: float, y: float) -> tuple[float, float]:
649
+ return self.renderer.world_to_screen(x, y)
650
+
651
+ def screen_to_world(self, x: float, y: float) -> tuple[float, float]:
652
+ return self.renderer.screen_to_world(x, y)