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
@@ -0,0 +1,1941 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+ from typing import TYPE_CHECKING
6
+
7
+ import pyray as rl
8
+
9
+ from grim.math import clamp
10
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
11
+ from grim.terrain_render import _maybe_alpha_test
12
+
13
+ from ..bonuses import BONUS_BY_ID, BonusId
14
+ from ..creatures.anim import creature_anim_select_frame
15
+ from ..creatures.spawn import CreatureFlags, CreatureTypeId
16
+ from ..effects_atlas import EFFECT_ID_ATLAS_TABLE_BY_ID, SIZE_CODE_GRID
17
+ from ..gameplay import bonus_find_aim_hover_entry, perk_active
18
+ from ..perks import PerkId
19
+ from ..projectiles import ProjectileTypeId
20
+ from ..sim.world_defs import (
21
+ BEAM_TYPES,
22
+ CREATURE_ANIM,
23
+ CREATURE_ASSET,
24
+ ION_TYPES,
25
+ KNOWN_PROJ_FRAMES,
26
+ PLASMA_PARTICLE_TYPES,
27
+ )
28
+ from ..weapons import WEAPON_BY_ID
29
+
30
+ if TYPE_CHECKING:
31
+ from ..game_world import GameWorld
32
+
33
+ _RAD_TO_DEG = 57.29577951308232
34
+
35
+
36
+ def monster_vision_fade_alpha(hitbox_size: float) -> float:
37
+ if float(hitbox_size) >= 0.0:
38
+ return 1.0
39
+ return clamp((float(hitbox_size) + 10.0) * 0.1, 0.0, 1.0)
40
+
41
+
42
+ @dataclass(slots=True)
43
+ class WorldRenderer:
44
+ _world: GameWorld
45
+ _small_font: SmallFontData | None = None
46
+
47
+ def __getattr__(self, name: str) -> object:
48
+ return getattr(self._world, name)
49
+
50
+ def _ensure_small_font(self) -> SmallFontData | None:
51
+ if self._small_font is not None:
52
+ return self._small_font
53
+ try:
54
+ # Keep UI text consistent with the HUD/menu font when available.
55
+ self._small_font = load_small_font(self.assets_dir, self.missing_assets)
56
+ except Exception:
57
+ self._small_font = None
58
+ return self._small_font
59
+
60
+ def _camera_screen_size(self) -> tuple[float, float]:
61
+ if self.config is not None:
62
+ screen_w = float(self.config.screen_width)
63
+ screen_h = float(self.config.screen_height)
64
+ else:
65
+ screen_w = float(rl.get_screen_width())
66
+ screen_h = float(rl.get_screen_height())
67
+ if screen_w > self.world_size:
68
+ screen_w = float(self.world_size)
69
+ if screen_h > self.world_size:
70
+ screen_h = float(self.world_size)
71
+ return screen_w, screen_h
72
+
73
+ def _clamp_camera(self, cam_x: float, cam_y: float, screen_w: float, screen_h: float) -> tuple[float, float]:
74
+ min_x = screen_w - float(self.world_size)
75
+ min_y = screen_h - float(self.world_size)
76
+ if cam_x > -1.0:
77
+ cam_x = -1.0
78
+ if cam_x < min_x:
79
+ cam_x = min_x
80
+ if cam_y > -1.0:
81
+ cam_y = -1.0
82
+ if cam_y < min_y:
83
+ cam_y = min_y
84
+ return cam_x, cam_y
85
+
86
+ def _world_params(self) -> tuple[float, float, float, float]:
87
+ out_w = float(rl.get_screen_width())
88
+ out_h = float(rl.get_screen_height())
89
+ screen_w, screen_h = self._camera_screen_size()
90
+ cam_x, cam_y = self._clamp_camera(self.camera_x, self.camera_y, screen_w, screen_h)
91
+ scale_x = out_w / screen_w if screen_w > 0 else 1.0
92
+ scale_y = out_h / screen_h if screen_h > 0 else 1.0
93
+ return cam_x, cam_y, scale_x, scale_y
94
+
95
+ def _color_from_rgba(self, rgba: tuple[float, float, float, float]) -> rl.Color:
96
+ r = int(clamp(rgba[0], 0.0, 1.0) * 255.0 + 0.5)
97
+ g = int(clamp(rgba[1], 0.0, 1.0) * 255.0 + 0.5)
98
+ b = int(clamp(rgba[2], 0.0, 1.0) * 255.0 + 0.5)
99
+ a = int(clamp(rgba[3], 0.0, 1.0) * 255.0 + 0.5)
100
+ return rl.Color(r, g, b, a)
101
+
102
+ def _bonus_icon_src(self, texture: rl.Texture, icon_id: int) -> rl.Rectangle:
103
+ grid = 4
104
+ cell_w = float(texture.width) / grid
105
+ cell_h = float(texture.height) / grid
106
+ col = int(icon_id) % grid
107
+ row = int(icon_id) // grid
108
+ return rl.Rectangle(float(col * cell_w), float(row * cell_h), float(cell_w), float(cell_h))
109
+
110
+ def _weapon_icon_src(self, texture: rl.Texture, icon_index: int) -> rl.Rectangle:
111
+ grid = 8
112
+ cell_w = float(texture.width) / float(grid)
113
+ cell_h = float(texture.height) / float(grid)
114
+ frame = int(icon_index) * 2
115
+ col = frame % grid
116
+ row = frame // grid
117
+ return rl.Rectangle(float(col * cell_w), float(row * cell_h), float(cell_w * 2), float(cell_h))
118
+
119
+ @staticmethod
120
+ def _bonus_fade(time_left: float, time_max: float) -> float:
121
+ time_left = float(time_left)
122
+ time_max = float(time_max)
123
+ if time_left <= 0.0 or time_max <= 0.0:
124
+ return 0.0
125
+ if time_left < 0.5:
126
+ return clamp(time_left * 2.0, 0.0, 1.0)
127
+ age = time_max - time_left
128
+ if age < 0.5:
129
+ return clamp(age * 2.0, 0.0, 1.0)
130
+ return 1.0
131
+
132
+ def _draw_bonus_pickups(
133
+ self,
134
+ *,
135
+ cam_x: float,
136
+ cam_y: float,
137
+ scale_x: float,
138
+ scale_y: float,
139
+ scale: float,
140
+ alpha: float = 1.0,
141
+ ) -> None:
142
+ alpha = clamp(float(alpha), 0.0, 1.0)
143
+ if alpha <= 1e-3:
144
+ return
145
+ if self.bonuses_texture is None:
146
+ for bonus in self.state.bonus_pool.entries:
147
+ if bonus.bonus_id == 0:
148
+ continue
149
+ sx = (bonus.pos_x + cam_x) * scale_x
150
+ sy = (bonus.pos_y + cam_y) * scale_y
151
+ tint = rl.Color(220, 220, 90, int(255 * alpha + 0.5))
152
+ rl.draw_circle(int(sx), int(sy), max(1.0, 10.0 * scale), tint)
153
+ return
154
+
155
+ bubble_src = self._bonus_icon_src(self.bonuses_texture, 0)
156
+ bubble_size = 32.0 * scale
157
+
158
+ for idx, bonus in enumerate(self.state.bonus_pool.entries):
159
+ if bonus.bonus_id == 0:
160
+ continue
161
+
162
+ fade = self._bonus_fade(float(bonus.time_left), float(bonus.time_max))
163
+ bubble_alpha = clamp(fade * 0.9, 0.0, 1.0) * alpha
164
+
165
+ sx = (bonus.pos_x + cam_x) * scale_x
166
+ sy = (bonus.pos_y + cam_y) * scale_y
167
+ bubble_dst = rl.Rectangle(float(sx), float(sy), float(bubble_size), float(bubble_size))
168
+ bubble_origin = rl.Vector2(bubble_size * 0.5, bubble_size * 0.5)
169
+ tint = rl.Color(255, 255, 255, int(bubble_alpha * 255.0 + 0.5))
170
+ rl.draw_texture_pro(self.bonuses_texture, bubble_src, bubble_dst, bubble_origin, 0.0, tint)
171
+
172
+ bonus_id = int(bonus.bonus_id)
173
+ if bonus_id == int(BonusId.WEAPON):
174
+ weapon = WEAPON_BY_ID.get(int(bonus.amount))
175
+ icon_index = int(weapon.icon_index) if weapon is not None and weapon.icon_index is not None else None
176
+ if icon_index is None or not (0 <= icon_index <= 31) or self.wicons_texture is None:
177
+ continue
178
+
179
+ pulse = math.sin(float(self._bonus_anim_phase)) ** 4 * 0.25 + 0.75
180
+ icon_scale = fade * pulse
181
+ if icon_scale <= 1e-3:
182
+ continue
183
+
184
+ src = self._weapon_icon_src(self.wicons_texture, icon_index)
185
+ w = 60.0 * icon_scale * scale
186
+ h = 30.0 * icon_scale * scale
187
+ dst = rl.Rectangle(float(sx), float(sy), float(w), float(h))
188
+ origin = rl.Vector2(w * 0.5, h * 0.5)
189
+ rl.draw_texture_pro(self.wicons_texture, src, dst, origin, 0.0, tint)
190
+ continue
191
+
192
+ meta = BONUS_BY_ID.get(bonus_id)
193
+ icon_id = int(meta.icon_id) if meta is not None and meta.icon_id is not None else None
194
+ if icon_id is None or icon_id < 0:
195
+ continue
196
+ if bonus_id == int(BonusId.POINTS) and int(bonus.amount) == 1000:
197
+ icon_id += 1
198
+
199
+ pulse = math.sin(float(idx) + float(self._bonus_anim_phase)) ** 4 * 0.25 + 0.75
200
+ icon_scale = fade * pulse
201
+ if icon_scale <= 1e-3:
202
+ continue
203
+
204
+ src = self._bonus_icon_src(self.bonuses_texture, icon_id)
205
+ size = 32.0 * icon_scale * scale
206
+ rotation_rad = math.sin(float(idx) - float(self._elapsed_ms) * 0.003) * 0.2
207
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
208
+ origin = rl.Vector2(size * 0.5, size * 0.5)
209
+ rl.draw_texture_pro(self.bonuses_texture, src, dst, origin, float(rotation_rad * _RAD_TO_DEG), tint)
210
+
211
+ def _bonus_hover_label(self, bonus_id: int, amount: int) -> str:
212
+ bonus_id = int(bonus_id)
213
+ if bonus_id == int(BonusId.WEAPON):
214
+ weapon = WEAPON_BY_ID.get(int(amount))
215
+ if weapon is not None and weapon.name is not None:
216
+ return str(weapon.name)
217
+ return "Weapon"
218
+ if bonus_id == int(BonusId.POINTS):
219
+ return f"Score: {int(amount)}"
220
+ meta = BONUS_BY_ID.get(int(bonus_id))
221
+ if meta is not None:
222
+ return str(meta.name)
223
+ return "Bonus"
224
+
225
+ def _draw_bonus_hover_labels(
226
+ self,
227
+ *,
228
+ cam_x: float,
229
+ cam_y: float,
230
+ scale_x: float,
231
+ scale_y: float,
232
+ alpha: float = 1.0,
233
+ ) -> None:
234
+ alpha = clamp(float(alpha), 0.0, 1.0)
235
+ if alpha <= 1e-3:
236
+ return
237
+
238
+ font = self._ensure_small_font()
239
+ text_scale = 1.0
240
+ screen_w = float(rl.get_screen_width())
241
+
242
+ shadow = rl.Color(0, 0, 0, int(180 * alpha + 0.5))
243
+ color = rl.Color(230, 230, 230, int(255 * alpha + 0.5))
244
+
245
+ for player in self.players:
246
+ if player.health <= 0.0:
247
+ continue
248
+ hovered = bonus_find_aim_hover_entry(player, self.state.bonus_pool)
249
+ if hovered is None:
250
+ continue
251
+ _idx, entry = hovered
252
+ label = self._bonus_hover_label(int(entry.bonus_id), int(entry.amount))
253
+ if not label:
254
+ continue
255
+
256
+ aim_x = float(getattr(player, "aim_x", player.pos_x))
257
+ aim_y = float(getattr(player, "aim_y", player.pos_y))
258
+ x = (aim_x + cam_x) * scale_x + 16.0
259
+ y = (aim_y + cam_y) * scale_y - 7.0
260
+
261
+ if font is not None:
262
+ text_w = measure_small_text_width(font, label, text_scale)
263
+ else:
264
+ text_w = float(rl.measure_text(label, int(18 * text_scale)))
265
+ if x + text_w > screen_w:
266
+ x = max(0.0, screen_w - text_w)
267
+
268
+ if font is not None:
269
+ draw_small_text(font, label, x + 1.0, y + 1.0, text_scale, shadow)
270
+ draw_small_text(font, label, x, y, text_scale, color)
271
+ else:
272
+ rl.draw_text(label, int(x) + 1, int(y) + 1, int(18 * text_scale), shadow)
273
+ rl.draw_text(label, int(x), int(y), int(18 * text_scale), color)
274
+
275
+ def _draw_atlas_sprite(
276
+ self,
277
+ texture: rl.Texture,
278
+ *,
279
+ grid: int,
280
+ frame: int,
281
+ x: float,
282
+ y: float,
283
+ scale: float,
284
+ rotation_rad: float = 0.0,
285
+ tint: rl.Color = rl.WHITE,
286
+ ) -> None:
287
+ grid = max(1, int(grid))
288
+ frame = max(0, int(frame))
289
+ cell_w = float(texture.width) / float(grid)
290
+ cell_h = float(texture.height) / float(grid)
291
+ col = frame % grid
292
+ row = frame // grid
293
+ src = rl.Rectangle(cell_w * float(col), cell_h * float(row), cell_w, cell_h)
294
+ w = cell_w * float(scale)
295
+ h = cell_h * float(scale)
296
+ dst = rl.Rectangle(float(x), float(y), w, h)
297
+ origin = rl.Vector2(w * 0.5, h * 0.5)
298
+ rl.draw_texture_pro(texture, src, dst, origin, float(rotation_rad * _RAD_TO_DEG), tint)
299
+
300
+ @staticmethod
301
+ def _grim2d_circle_segments_filled(radius: float) -> int:
302
+ # grim_draw_circle_filled (grim.dll): segments = trunc(radius * 0.125 + 12.0)
303
+ return max(3, int(radius * 0.125 + 12.0))
304
+
305
+ @staticmethod
306
+ def _grim2d_circle_segments_outline(radius: float) -> int:
307
+ # grim_draw_circle_outline (grim.dll): segments = trunc(radius * 0.2 + 14.0)
308
+ return max(3, int(radius * 0.2 + 14.0))
309
+
310
+ def _draw_aim_circle(self, *, x: float, y: float, radius: float, alpha: float = 1.0) -> None:
311
+ if radius <= 1e-3:
312
+ return
313
+ alpha = clamp(float(alpha), 0.0, 1.0)
314
+ if alpha <= 1e-3:
315
+ return
316
+
317
+ fill_a = int(77 * alpha + 0.5) # ui_render_aim_indicators: rgba(0,0,0.1,0.3)
318
+ outline_a = int(255 * 0.55 * alpha + 0.5)
319
+ fill = rl.Color(0, 0, 26, fill_a)
320
+ outline = rl.Color(255, 255, 255, outline_a)
321
+
322
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
323
+
324
+ # The original uses a triangle fan (polygons). Raylib provides circle
325
+ # primitives that still use triangles internally, but allow higher
326
+ # segment counts for a smoother result when scaled.
327
+ seg_count = max(self._grim2d_circle_segments_filled(radius), 64, int(radius))
328
+ rl.draw_circle_sector(rl.Vector2(x, y), float(radius), 0.0, 360.0, int(seg_count), fill)
329
+
330
+ seg_count = max(self._grim2d_circle_segments_outline(radius), int(seg_count))
331
+ # grim_draw_circle_outline draws a 2px-thick ring (outer radius = r + 2).
332
+ # The exe binds bulletTrail, but that texture is white; the visual intent is
333
+ # a subtle white outline around the filled spread circle.
334
+ rl.draw_ring(rl.Vector2(x, y), float(radius), float(radius + 2.0), 0.0, 360.0, int(seg_count), outline)
335
+
336
+ rl.rl_set_texture(0)
337
+ rl.end_blend_mode()
338
+
339
+ def _draw_clock_gauge(self, *, x: float, y: float, ms: int, scale: float, alpha: float = 1.0) -> None:
340
+ if self.clock_table_texture is None or self.clock_pointer_texture is None:
341
+ return
342
+ size = 32.0 * scale
343
+ if size <= 1e-3:
344
+ return
345
+ tint = rl.Color(255, 255, 255, int(clamp(float(alpha), 0.0, 1.0) * 255.0 + 0.5))
346
+ half = size * 0.5
347
+
348
+ table_src = rl.Rectangle(0.0, 0.0, float(self.clock_table_texture.width), float(self.clock_table_texture.height))
349
+ table_dst = rl.Rectangle(float(x), float(y), size, size)
350
+ rl.draw_texture_pro(self.clock_table_texture, table_src, table_dst, rl.Vector2(0.0, 0.0), 0.0, tint)
351
+
352
+ seconds = int(ms) // 1000
353
+ pointer_src = rl.Rectangle(
354
+ 0.0,
355
+ 0.0,
356
+ float(self.clock_pointer_texture.width),
357
+ float(self.clock_pointer_texture.height),
358
+ )
359
+ pointer_dst = rl.Rectangle(float(x) + half, float(y) + half, size, size)
360
+ origin = rl.Vector2(half, half)
361
+ rotation_deg = float(seconds) * 6.0
362
+ rl.draw_texture_pro(self.clock_pointer_texture, pointer_src, pointer_dst, origin, rotation_deg, tint)
363
+
364
+ def _draw_creature_sprite(
365
+ self,
366
+ texture: rl.Texture,
367
+ *,
368
+ type_id: CreatureTypeId,
369
+ flags: CreatureFlags,
370
+ phase: float,
371
+ mirror_long: bool | None = None,
372
+ shadow_alpha: int | None = None,
373
+ world_x: float,
374
+ world_y: float,
375
+ rotation_rad: float,
376
+ scale: float,
377
+ size_scale: float,
378
+ tint: rl.Color,
379
+ shadow: bool = False,
380
+ ) -> None:
381
+ info = CREATURE_ANIM.get(type_id)
382
+ if info is None:
383
+ return
384
+ mirror_flag = info.mirror if mirror_long is None else mirror_long
385
+ # Long-strip mirroring is handled by frame index selection, not texture flips.
386
+ index, _, _ = creature_anim_select_frame(
387
+ phase,
388
+ base_frame=info.base,
389
+ mirror_long=mirror_flag,
390
+ flags=flags,
391
+ )
392
+ if index < 0:
393
+ return
394
+
395
+ sx, sy = self.world_to_screen(world_x, world_y)
396
+ width = float(texture.width) / 8.0 * size_scale * scale
397
+ height = float(texture.height) / 8.0 * size_scale * scale
398
+ src_x = float((index % 8) * (texture.width // 8))
399
+ src_y = float((index // 8) * (texture.height // 8))
400
+ src = rl.Rectangle(src_x, src_y, float(texture.width) / 8.0, float(texture.height) / 8.0)
401
+
402
+ rotation_deg = float(rotation_rad * _RAD_TO_DEG)
403
+
404
+ if shadow:
405
+ # In the original exe this is a "darken" blend pass gated by fx_detail_0
406
+ # (creature_render_type). We approximate it with a black silhouette draw.
407
+ # The observed pass is slightly bigger than the main sprite and offset
408
+ # down-right by ~1px at default sizes.
409
+ alpha = int(shadow_alpha) if shadow_alpha is not None else int(clamp(float(tint.a) * 0.4, 0.0, 255.0) + 0.5)
410
+ shadow_tint = rl.Color(0, 0, 0, alpha)
411
+ shadow_scale = 1.07
412
+ shadow_w = width * shadow_scale
413
+ shadow_h = height * shadow_scale
414
+ offset = width * 0.035 - 0.7 * scale
415
+ shadow_dst = rl.Rectangle(sx + offset, sy + offset, shadow_w, shadow_h)
416
+ shadow_origin = rl.Vector2(shadow_w * 0.5, shadow_h * 0.5)
417
+ rl.draw_texture_pro(texture, src, shadow_dst, shadow_origin, rotation_deg, shadow_tint)
418
+
419
+ dst = rl.Rectangle(sx, sy, width, height)
420
+ origin = rl.Vector2(width * 0.5, height * 0.5)
421
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, tint)
422
+
423
+ def _draw_player_trooper_sprite(
424
+ self,
425
+ texture: rl.Texture,
426
+ player: object,
427
+ *,
428
+ cam_x: float,
429
+ cam_y: float,
430
+ scale_x: float,
431
+ scale_y: float,
432
+ scale: float,
433
+ alpha: float = 1.0,
434
+ ) -> None:
435
+ alpha = clamp(float(alpha), 0.0, 1.0)
436
+ if alpha <= 1e-3:
437
+ return
438
+ grid = 8
439
+ cell = float(texture.width) / float(grid) if grid > 0 else float(texture.width)
440
+ if cell <= 0.0:
441
+ return
442
+
443
+ sx = (player.pos_x + cam_x) * scale_x
444
+ sy = (player.pos_y + cam_y) * scale_y
445
+ base_size = float(player.size) * scale
446
+ base_scale = base_size / cell
447
+
448
+ if (
449
+ self.particles_texture is not None
450
+ and perk_active(player, PerkId.RADIOACTIVE)
451
+ and alpha > 1e-3
452
+ ):
453
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x10)
454
+ if atlas is not None:
455
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
456
+ if grid:
457
+ frame = int(atlas.frame)
458
+ col = frame % grid
459
+ row = frame // grid
460
+ cell_w = float(self.particles_texture.width) / float(grid)
461
+ cell_h = float(self.particles_texture.height) / float(grid)
462
+ src = rl.Rectangle(
463
+ cell_w * float(col),
464
+ cell_h * float(row),
465
+ max(0.0, cell_w - 2.0),
466
+ max(0.0, cell_h - 2.0),
467
+ )
468
+ t = float(self._elapsed_ms) * 0.001
469
+ aura_alpha = ((math.sin(t) + 1.0) * 0.1875 + 0.25) * alpha
470
+ if aura_alpha > 1e-3:
471
+ size = 100.0 * scale
472
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
473
+ origin = rl.Vector2(size * 0.5, size * 0.5)
474
+ tint = rl.Color(77, 153, 77, int(clamp(aura_alpha, 0.0, 1.0) * 255.0 + 0.5))
475
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
476
+ rl.draw_texture_pro(self.particles_texture, src, dst, origin, 0.0, tint)
477
+ rl.end_blend_mode()
478
+
479
+ tint = rl.Color(240, 240, 255, int(255 * alpha + 0.5))
480
+ shadow_tint = rl.Color(0, 0, 0, int(90 * alpha + 0.5))
481
+ overlay_tint = tint
482
+ if len(self.players) > 1:
483
+ index = int(getattr(player, "index", 0))
484
+ if index == 0:
485
+ overlay_tint = rl.Color(77, 77, 255, tint.a)
486
+ else:
487
+ overlay_tint = rl.Color(255, 140, 89, tint.a)
488
+
489
+ def draw(frame: int, *, x: float, y: float, scale_mul: float, rotation: float, color: rl.Color) -> None:
490
+ self._draw_atlas_sprite(
491
+ texture,
492
+ grid=grid,
493
+ frame=max(0, min(63, int(frame))),
494
+ x=x,
495
+ y=y,
496
+ scale=base_scale * float(scale_mul),
497
+ rotation_rad=float(rotation),
498
+ tint=color,
499
+ )
500
+
501
+ if player.health > 0.0:
502
+ leg_frame = max(0, min(14, int(player.move_phase + 0.5)))
503
+ torso_frame = leg_frame + 16
504
+
505
+ recoil_dir = float(player.aim_heading) + math.pi / 2.0
506
+ recoil = float(player.muzzle_flash_alpha) * 12.0 * scale
507
+ recoil_x = math.cos(recoil_dir) * recoil
508
+ recoil_y = math.sin(recoil_dir) * recoil
509
+
510
+ leg_shadow_scale = 1.02
511
+ torso_shadow_scale = 1.03
512
+ leg_shadow_off = 3.0 * scale + base_size * (leg_shadow_scale - 1.0) * 0.5
513
+ torso_shadow_off = 1.0 * scale + base_size * (torso_shadow_scale - 1.0) * 0.5
514
+
515
+ draw(
516
+ leg_frame,
517
+ x=sx + leg_shadow_off,
518
+ y=sy + leg_shadow_off,
519
+ scale_mul=leg_shadow_scale,
520
+ rotation=float(player.heading),
521
+ color=shadow_tint,
522
+ )
523
+ draw(
524
+ torso_frame,
525
+ x=sx + recoil_x + torso_shadow_off,
526
+ y=sy + recoil_y + torso_shadow_off,
527
+ scale_mul=torso_shadow_scale,
528
+ rotation=float(player.aim_heading),
529
+ color=shadow_tint,
530
+ )
531
+
532
+ draw(
533
+ leg_frame,
534
+ x=sx,
535
+ y=sy,
536
+ scale_mul=1.0,
537
+ rotation=float(player.heading),
538
+ color=tint,
539
+ )
540
+ draw(
541
+ torso_frame,
542
+ x=sx + recoil_x,
543
+ y=sy + recoil_y,
544
+ scale_mul=1.0,
545
+ rotation=float(player.aim_heading),
546
+ color=overlay_tint,
547
+ )
548
+
549
+ if self.particles_texture is not None and float(player.shield_timer) > 1e-3 and alpha > 1e-3:
550
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x02)
551
+ if atlas is not None:
552
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
553
+ if grid:
554
+ frame = int(atlas.frame)
555
+ col = frame % grid
556
+ row = frame // grid
557
+ cell_w = float(self.particles_texture.width) / float(grid)
558
+ cell_h = float(self.particles_texture.height) / float(grid)
559
+ src = rl.Rectangle(
560
+ cell_w * float(col),
561
+ cell_h * float(row),
562
+ max(0.0, cell_w - 2.0),
563
+ max(0.0, cell_h - 2.0),
564
+ )
565
+ t = float(self._elapsed_ms) * 0.001
566
+ timer = float(player.shield_timer)
567
+ strength = (math.sin(t) + 1.0) * 0.25 + timer
568
+ if timer < 1.0:
569
+ strength *= timer
570
+ strength = min(1.0, strength) * alpha
571
+ if strength > 1e-3:
572
+ offset_dir = float(player.aim_heading) - math.pi / 2.0
573
+ ox = math.cos(offset_dir) * 3.0 * scale
574
+ oy = math.sin(offset_dir) * 3.0 * scale
575
+ cx = sx + ox
576
+ cy = sy + oy
577
+
578
+ half = math.sin(t * 3.0) + 17.5
579
+ size = half * 2.0 * scale
580
+ a = int(clamp(strength * 0.4, 0.0, 1.0) * 255.0 + 0.5)
581
+ tint = rl.Color(91, 180, 255, a)
582
+ dst = rl.Rectangle(float(cx), float(cy), float(size), float(size))
583
+ origin = rl.Vector2(size * 0.5, size * 0.5)
584
+ rotation_deg = float((t + t) * _RAD_TO_DEG)
585
+
586
+ half = math.sin(t * 3.0) * 4.0 + 24.0
587
+ size2 = half * 2.0 * scale
588
+ a2 = int(clamp(strength * 0.3, 0.0, 1.0) * 255.0 + 0.5)
589
+ tint2 = rl.Color(91, 180, 255, a2)
590
+ dst2 = rl.Rectangle(float(cx), float(cy), float(size2), float(size2))
591
+ origin2 = rl.Vector2(size2 * 0.5, size2 * 0.5)
592
+ rotation2_deg = float((t * -2.0) * _RAD_TO_DEG)
593
+
594
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
595
+ rl.draw_texture_pro(self.particles_texture, src, dst, origin, rotation_deg, tint)
596
+ rl.draw_texture_pro(self.particles_texture, src, dst2, origin2, rotation2_deg, tint2)
597
+ rl.end_blend_mode()
598
+
599
+ if self.muzzle_flash_texture is not None and float(player.muzzle_flash_alpha) > 1e-3 and alpha > 1e-3:
600
+ weapon = WEAPON_BY_ID.get(int(player.weapon_id))
601
+ flags = int(weapon.flags) if weapon is not None and weapon.flags is not None else 0
602
+ if (flags & 0x8) == 0:
603
+ flash_alpha = clamp(float(player.muzzle_flash_alpha) * 0.8, 0.0, 1.0) * alpha
604
+ if flash_alpha > 1e-3:
605
+ size = base_size * (0.5 if (flags & 0x4) else 1.0)
606
+ heading = float(player.aim_heading) + math.pi / 2.0
607
+ offset = (float(player.muzzle_flash_alpha) * 12.0 - 21.0) * scale
608
+ pos_x = sx + math.cos(heading) * offset
609
+ pos_y = sy + math.sin(heading) * offset
610
+ src = rl.Rectangle(
611
+ 0.0,
612
+ 0.0,
613
+ float(self.muzzle_flash_texture.width),
614
+ float(self.muzzle_flash_texture.height),
615
+ )
616
+ dst = rl.Rectangle(pos_x, pos_y, size, size)
617
+ origin = rl.Vector2(size * 0.5, size * 0.5)
618
+ tint_flash = rl.Color(255, 255, 255, int(flash_alpha * 255.0 + 0.5))
619
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
620
+ rl.draw_texture_pro(
621
+ self.muzzle_flash_texture,
622
+ src,
623
+ dst,
624
+ origin,
625
+ float(player.aim_heading * _RAD_TO_DEG),
626
+ tint_flash,
627
+ )
628
+ rl.end_blend_mode()
629
+ return
630
+
631
+ if player.death_timer >= 0.0:
632
+ # Matches the observed frame ramp (32..52) in player_sprite_trace.jsonl.
633
+ frame = 32 + int((16.0 - float(player.death_timer)) * 1.25)
634
+ if frame > 52:
635
+ frame = 52
636
+ if frame < 32:
637
+ frame = 32
638
+ else:
639
+ frame = 52
640
+
641
+ dead_shadow_scale = 1.03
642
+ dead_shadow_off = 1.0 * scale + base_size * (dead_shadow_scale - 1.0) * 0.5
643
+ draw(
644
+ frame,
645
+ x=sx + dead_shadow_off,
646
+ y=sy + dead_shadow_off,
647
+ scale_mul=dead_shadow_scale,
648
+ rotation=float(player.aim_heading),
649
+ color=shadow_tint,
650
+ )
651
+ draw(frame, x=sx, y=sy, scale_mul=1.0, rotation=float(player.aim_heading), color=overlay_tint)
652
+
653
+ def _draw_projectile(self, proj: object, *, proj_index: int = 0, scale: float, alpha: float = 1.0) -> None:
654
+ alpha = clamp(float(alpha), 0.0, 1.0)
655
+ if alpha <= 1e-3:
656
+ return
657
+ texture = self.projs_texture
658
+ type_id = int(getattr(proj, "type_id", 0))
659
+ pos_x = float(getattr(proj, "pos_x", 0.0))
660
+ pos_y = float(getattr(proj, "pos_y", 0.0))
661
+ sx, sy = self.world_to_screen(pos_x, pos_y)
662
+ life = float(getattr(proj, "life_timer", 0.0))
663
+ angle = float(getattr(proj, "angle", 0.0))
664
+
665
+ if self._is_bullet_trail_type(type_id):
666
+ life_alpha = int(clamp(life, 0.0, 1.0) * 255)
667
+ alpha_byte = int(clamp(float(life_alpha) * alpha, 0.0, 255.0) + 0.5)
668
+ drawn = False
669
+ if self.bullet_trail_texture is not None:
670
+ ox = float(getattr(proj, "origin_x", pos_x))
671
+ oy = float(getattr(proj, "origin_y", pos_y))
672
+ sx0, sy0 = self.world_to_screen(ox, oy)
673
+ sx1, sy1 = sx, sy
674
+ drawn = self._draw_bullet_trail(sx0, sy0, sx1, sy1, type_id=type_id, alpha=alpha_byte, scale=scale)
675
+
676
+ if self.bullet_texture is not None and life >= 0.39:
677
+ size = self._bullet_sprite_size(type_id, scale=scale)
678
+ src = rl.Rectangle(
679
+ 0.0,
680
+ 0.0,
681
+ float(self.bullet_texture.width),
682
+ float(self.bullet_texture.height),
683
+ )
684
+ dst = rl.Rectangle(float(sx), float(sy), size, size)
685
+ origin = rl.Vector2(size * 0.5, size * 0.5)
686
+ tint = rl.Color(220, 220, 220, alpha_byte)
687
+ rl.draw_texture_pro(self.bullet_texture, src, dst, origin, float(angle * _RAD_TO_DEG), tint)
688
+ drawn = True
689
+
690
+ if drawn:
691
+ return
692
+
693
+ if type_id in PLASMA_PARTICLE_TYPES and self.particles_texture is not None:
694
+ particles_texture = self.particles_texture
695
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x0D)
696
+ if atlas is not None:
697
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
698
+ if grid:
699
+ cell_w = float(particles_texture.width) / float(grid)
700
+ cell_h = float(particles_texture.height) / float(grid)
701
+ frame = int(atlas.frame)
702
+ col = frame % grid
703
+ row = frame // grid
704
+ src = rl.Rectangle(
705
+ cell_w * float(col),
706
+ cell_h * float(row),
707
+ max(0.0, cell_w - 2.0),
708
+ max(0.0, cell_h - 2.0),
709
+ )
710
+
711
+ speed_scale = float(getattr(proj, "speed_scale", 1.0))
712
+ fx_detail_1 = bool(self.config.data.get("fx_detail_1", 0)) if self.config is not None else True
713
+
714
+ rgb = (1.0, 1.0, 1.0)
715
+ spacing = 2.1
716
+ seg_limit = 3
717
+ tail_size = 12.0
718
+ head_size = 16.0
719
+ head_alpha_mul = 0.45
720
+ aura_rgb = rgb
721
+ aura_size = 120.0
722
+ aura_alpha_mul = 0.15
723
+
724
+ if type_id == int(ProjectileTypeId.PLASMA_RIFLE):
725
+ spacing = 2.5
726
+ seg_limit = 8
727
+ tail_size = 22.0
728
+ head_size = 56.0
729
+ aura_size = 256.0
730
+ aura_alpha_mul = 0.3
731
+ elif type_id == int(ProjectileTypeId.PLASMA_MINIGUN):
732
+ spacing = 2.1
733
+ seg_limit = 3
734
+ tail_size = 12.0
735
+ head_size = 16.0
736
+ aura_size = 120.0
737
+ aura_alpha_mul = 0.15
738
+ elif type_id == int(ProjectileTypeId.PLASMA_CANNON):
739
+ spacing = 2.6
740
+ seg_limit = 18
741
+ tail_size = 44.0
742
+ head_size = 84.0
743
+ aura_size = 256.0
744
+ # In the decompile, cannon reuses the tail alpha for the aura (0.4).
745
+ aura_alpha_mul = 0.4
746
+ elif type_id == int(ProjectileTypeId.SPIDER_PLASMA):
747
+ rgb = (0.3, 1.0, 0.3)
748
+ aura_rgb = rgb
749
+ elif type_id == int(ProjectileTypeId.SHRINKIFIER):
750
+ rgb = (0.3, 0.3, 1.0)
751
+ aura_rgb = rgb
752
+
753
+ if life >= 0.4:
754
+ # Reconstruct the tail length heuristic used by the native render path.
755
+ seg_count = int(float(getattr(proj, "base_damage", 0.0)))
756
+ if seg_count < 0:
757
+ seg_count = 0
758
+ seg_count //= 5
759
+ if seg_count > seg_limit:
760
+ seg_count = seg_limit
761
+
762
+ # The stored projectile angle is rotated by +pi/2 vs travel direction.
763
+ dir_x = math.cos(angle + math.pi / 2.0) * speed_scale
764
+ dir_y = math.sin(angle + math.pi / 2.0) * speed_scale
765
+
766
+ tail_tint = self._color_from_rgba((rgb[0], rgb[1], rgb[2], alpha * 0.4))
767
+ head_tint = self._color_from_rgba((rgb[0], rgb[1], rgb[2], alpha * head_alpha_mul))
768
+ aura_tint = self._color_from_rgba((aura_rgb[0], aura_rgb[1], aura_rgb[2], alpha * aura_alpha_mul))
769
+
770
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
771
+
772
+ if seg_count > 0:
773
+ size = tail_size * scale
774
+ origin = rl.Vector2(size * 0.5, size * 0.5)
775
+ step_x = dir_x * spacing
776
+ step_y = dir_y * spacing
777
+ for idx in range(seg_count):
778
+ px = pos_x + float(idx) * step_x
779
+ py = pos_y + float(idx) * step_y
780
+ psx, psy = self.world_to_screen(px, py)
781
+ dst = rl.Rectangle(float(psx), float(psy), float(size), float(size))
782
+ rl.draw_texture_pro(particles_texture, src, dst, origin, 0.0, tail_tint)
783
+
784
+ size = head_size * scale
785
+ origin = rl.Vector2(size * 0.5, size * 0.5)
786
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
787
+ rl.draw_texture_pro(particles_texture, src, dst, origin, 0.0, head_tint)
788
+
789
+ if fx_detail_1:
790
+ size = aura_size * scale
791
+ origin = rl.Vector2(size * 0.5, size * 0.5)
792
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
793
+ rl.draw_texture_pro(particles_texture, src, dst, origin, 0.0, aura_tint)
794
+
795
+ rl.end_blend_mode()
796
+ return
797
+
798
+ fade = clamp(life * 2.5, 0.0, 1.0)
799
+ fade_alpha = fade * alpha
800
+ if fade_alpha > 1e-3:
801
+ tint = self._color_from_rgba((1.0, 1.0, 1.0, fade_alpha))
802
+ size = 56.0 * scale
803
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
804
+ origin = rl.Vector2(size * 0.5, size * 0.5)
805
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
806
+ rl.draw_texture_pro(particles_texture, src, dst, origin, 0.0, tint)
807
+ rl.end_blend_mode()
808
+ return
809
+
810
+ if type_id in BEAM_TYPES and texture is not None:
811
+ # Ion weapons and Fire Bullets use the projs.png streak effect (and Ion adds chain arcs on impact).
812
+ grid = 4
813
+ frame = 2
814
+
815
+ is_fire_bullets = type_id == int(ProjectileTypeId.FIRE_BULLETS)
816
+ is_ion = type_id in ION_TYPES
817
+
818
+ ox = float(getattr(proj, "origin_x", pos_x))
819
+ oy = float(getattr(proj, "origin_y", pos_y))
820
+ dx = pos_x - ox
821
+ dy = pos_y - oy
822
+ dist = math.hypot(dx, dy)
823
+ if dist <= 1e-6:
824
+ return
825
+
826
+ dir_x = dx / dist
827
+ dir_y = dy / dist
828
+
829
+ # In the native renderer, Ion Gun Master increases the chain effect thickness and reach.
830
+ perk_scale = 1.0
831
+ if any(perk_active(player, PerkId.ION_GUN_MASTER) for player in self.players):
832
+ perk_scale = 1.2
833
+
834
+ if type_id == int(ProjectileTypeId.ION_MINIGUN):
835
+ effect_scale = 1.05
836
+ elif type_id == int(ProjectileTypeId.ION_RIFLE):
837
+ effect_scale = 2.2
838
+ elif type_id == int(ProjectileTypeId.ION_CANNON):
839
+ effect_scale = 3.5
840
+ else:
841
+ effect_scale = 0.8
842
+
843
+ if life >= 0.4:
844
+ base_alpha = alpha
845
+ else:
846
+ fade = clamp(life * 2.5, 0.0, 1.0)
847
+ base_alpha = fade * alpha
848
+
849
+ if base_alpha <= 1e-3:
850
+ return
851
+
852
+ streak_rgb = (1.0, 0.6, 0.1) if is_fire_bullets else (0.5, 0.6, 1.0)
853
+ head_rgb = (1.0, 1.0, 0.7)
854
+
855
+ # Only draw the last 256 units of the path.
856
+ start = 0.0
857
+ span = dist
858
+ if dist > 256.0:
859
+ start = dist - 256.0
860
+ span = 256.0
861
+
862
+ step = min(effect_scale * 3.1, 9.0)
863
+ sprite_scale = effect_scale * scale
864
+
865
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
866
+
867
+ s = start
868
+ while s < dist:
869
+ t = (s - start) / span if span > 1e-6 else 1.0
870
+ seg_alpha = t * base_alpha
871
+ if seg_alpha > 1e-3:
872
+ px = ox + dir_x * s
873
+ py = oy + dir_y * s
874
+ psx, psy = self.world_to_screen(px, py)
875
+ tint = self._color_from_rgba((streak_rgb[0], streak_rgb[1], streak_rgb[2], seg_alpha))
876
+ self._draw_atlas_sprite(
877
+ texture,
878
+ grid=grid,
879
+ frame=frame,
880
+ x=psx,
881
+ y=psy,
882
+ scale=sprite_scale,
883
+ rotation_rad=angle,
884
+ tint=tint,
885
+ )
886
+ s += step
887
+
888
+ if life >= 0.4:
889
+ head_tint = self._color_from_rgba((head_rgb[0], head_rgb[1], head_rgb[2], base_alpha))
890
+ self._draw_atlas_sprite(
891
+ texture,
892
+ grid=grid,
893
+ frame=frame,
894
+ x=sx,
895
+ y=sy,
896
+ scale=sprite_scale,
897
+ rotation_rad=angle,
898
+ tint=head_tint,
899
+ )
900
+
901
+ # Fire Bullets renders an extra particles.png overlay in a later pass.
902
+ if is_fire_bullets and self.particles_texture is not None:
903
+ particles_texture = self.particles_texture
904
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x0D)
905
+ if atlas is not None:
906
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
907
+ if grid:
908
+ cell_w = float(particles_texture.width) / float(grid)
909
+ cell_h = float(particles_texture.height) / float(grid)
910
+ frame = int(atlas.frame)
911
+ col = frame % grid
912
+ row = frame // grid
913
+ src = rl.Rectangle(
914
+ cell_w * float(col),
915
+ cell_h * float(row),
916
+ max(0.0, cell_w - 2.0),
917
+ max(0.0, cell_h - 2.0),
918
+ )
919
+ tint = self._color_from_rgba((1.0, 1.0, 1.0, alpha))
920
+ size = 64.0 * scale
921
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
922
+ origin = rl.Vector2(size * 0.5, size * 0.5)
923
+ rl.draw_texture_pro(particles_texture, src, dst, origin, float(angle * _RAD_TO_DEG), tint)
924
+ else:
925
+ core_tint = self._color_from_rgba((0.5, 0.6, 1.0, base_alpha))
926
+ self._draw_atlas_sprite(
927
+ texture,
928
+ grid=grid,
929
+ frame=frame,
930
+ x=sx,
931
+ y=sy,
932
+ scale=1.0 * scale,
933
+ rotation_rad=angle,
934
+ tint=core_tint,
935
+ )
936
+
937
+ if is_ion:
938
+ if type_id == int(ProjectileTypeId.ION_RIFLE):
939
+ radius = 88.0
940
+ elif type_id == int(ProjectileTypeId.ION_MINIGUN):
941
+ radius = 60.0
942
+ else:
943
+ radius = 128.0
944
+ radius *= perk_scale
945
+
946
+ # Pick a stable set of targets so the arc visuals don't flicker.
947
+ candidates: list[tuple[float, object]] = []
948
+ for creature in self.creatures.entries:
949
+ if not creature.active or float(creature.hp) <= 0.0:
950
+ continue
951
+ if float(getattr(creature, "hitbox_size", 0.0)) < 5.0:
952
+ continue
953
+ d = math.hypot(float(creature.x) - pos_x, float(creature.y) - pos_y)
954
+ threshold = float(creature.size) * 0.142857149 + 3.0
955
+ if d > radius + threshold:
956
+ continue
957
+ candidates.append((d, creature))
958
+
959
+ candidates.sort(key=lambda item: item[0])
960
+ targets = [creature for _d, creature in candidates[:8]]
961
+
962
+ inner = 10.0 * perk_scale * scale
963
+ outer = 14.0 * perk_scale * scale
964
+ u = 0.625
965
+ v0 = 0.0
966
+ v1 = 0.25
967
+
968
+ glow_targets: list[object] = []
969
+ rl.rl_set_texture(texture.id)
970
+ rl.rl_begin(rl.RL_QUADS)
971
+
972
+ for creature in targets:
973
+ tx, ty = self.world_to_screen(float(creature.x), float(creature.y))
974
+ ddx = tx - sx
975
+ ddy = ty - sy
976
+ dlen = math.hypot(ddx, ddy)
977
+ if dlen <= 1e-3:
978
+ continue
979
+ glow_targets.append(creature)
980
+ inv = 1.0 / dlen
981
+ nx = ddx * inv
982
+ ny = ddy * inv
983
+ px = -ny
984
+ py = nx
985
+
986
+ # Outer strip (softer).
987
+ half = outer * 0.5
988
+ off_x = px * half
989
+ off_y = py * half
990
+ x0 = sx - off_x
991
+ y0 = sy - off_y
992
+ x1 = sx + off_x
993
+ y1 = sy + off_y
994
+ x2 = tx + off_x
995
+ y2 = ty + off_y
996
+ x3 = tx - off_x
997
+ y3 = ty - off_y
998
+
999
+ outer_tint = self._color_from_rgba((0.5, 0.6, 1.0, base_alpha * 0.5))
1000
+ rl.rl_color4ub(outer_tint.r, outer_tint.g, outer_tint.b, outer_tint.a)
1001
+ rl.rl_tex_coord2f(u, v0)
1002
+ rl.rl_vertex2f(x0, y0)
1003
+ rl.rl_tex_coord2f(u, v1)
1004
+ rl.rl_vertex2f(x1, y1)
1005
+ rl.rl_tex_coord2f(u, v1)
1006
+ rl.rl_vertex2f(x2, y2)
1007
+ rl.rl_tex_coord2f(u, v0)
1008
+ rl.rl_vertex2f(x3, y3)
1009
+
1010
+ # Inner strip (brighter).
1011
+ half = inner * 0.5
1012
+ off_x = px * half
1013
+ off_y = py * half
1014
+ x0 = sx - off_x
1015
+ y0 = sy - off_y
1016
+ x1 = sx + off_x
1017
+ y1 = sy + off_y
1018
+ x2 = tx + off_x
1019
+ y2 = ty + off_y
1020
+ x3 = tx - off_x
1021
+ y3 = ty - off_y
1022
+
1023
+ inner_tint = self._color_from_rgba((0.5, 0.6, 1.0, base_alpha))
1024
+ rl.rl_color4ub(inner_tint.r, inner_tint.g, inner_tint.b, inner_tint.a)
1025
+ rl.rl_tex_coord2f(u, v0)
1026
+ rl.rl_vertex2f(x0, y0)
1027
+ rl.rl_tex_coord2f(u, v1)
1028
+ rl.rl_vertex2f(x1, y1)
1029
+ rl.rl_tex_coord2f(u, v1)
1030
+ rl.rl_vertex2f(x2, y2)
1031
+ rl.rl_tex_coord2f(u, v0)
1032
+ rl.rl_vertex2f(x3, y3)
1033
+
1034
+ rl.rl_end()
1035
+ rl.rl_set_texture(0)
1036
+
1037
+ for creature in glow_targets:
1038
+ tx, ty = self.world_to_screen(float(creature.x), float(creature.y))
1039
+ target_tint = self._color_from_rgba((0.5, 0.6, 1.0, base_alpha))
1040
+ self._draw_atlas_sprite(
1041
+ texture,
1042
+ grid=grid,
1043
+ frame=frame,
1044
+ x=tx,
1045
+ y=ty,
1046
+ scale=sprite_scale,
1047
+ rotation_rad=0.0,
1048
+ tint=target_tint,
1049
+ )
1050
+
1051
+ rl.end_blend_mode()
1052
+ return
1053
+
1054
+ if type_id == int(ProjectileTypeId.PULSE_GUN) and texture is not None:
1055
+ mapping = KNOWN_PROJ_FRAMES.get(type_id)
1056
+ if mapping is None:
1057
+ return
1058
+ grid, frame = mapping
1059
+ cell_w = float(texture.width) / float(grid)
1060
+
1061
+ if life >= 0.4:
1062
+ ox = float(getattr(proj, "origin_x", pos_x))
1063
+ oy = float(getattr(proj, "origin_y", pos_y))
1064
+ dist = math.hypot(pos_x - ox, pos_y - oy)
1065
+
1066
+ desired_size = dist * 0.16 * scale
1067
+ if desired_size <= 1e-3:
1068
+ return
1069
+ sprite_scale = desired_size / cell_w if cell_w > 1e-6 else 0.0
1070
+ if sprite_scale <= 1e-6:
1071
+ return
1072
+
1073
+ tint = self._color_from_rgba((0.1, 0.6, 0.2, alpha * 0.7))
1074
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1075
+ self._draw_atlas_sprite(
1076
+ texture,
1077
+ grid=grid,
1078
+ frame=frame,
1079
+ x=sx,
1080
+ y=sy,
1081
+ scale=sprite_scale,
1082
+ rotation_rad=angle,
1083
+ tint=tint,
1084
+ )
1085
+ rl.end_blend_mode()
1086
+ return
1087
+
1088
+ fade = clamp(life * 2.5, 0.0, 1.0)
1089
+ fade_alpha = fade * alpha
1090
+ if fade_alpha <= 1e-3:
1091
+ return
1092
+
1093
+ desired_size = 56.0 * scale
1094
+ sprite_scale = desired_size / cell_w if cell_w > 1e-6 else 0.0
1095
+ if sprite_scale <= 1e-6:
1096
+ return
1097
+
1098
+ tint = self._color_from_rgba((1.0, 1.0, 1.0, fade_alpha))
1099
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1100
+ self._draw_atlas_sprite(
1101
+ texture,
1102
+ grid=grid,
1103
+ frame=frame,
1104
+ x=sx,
1105
+ y=sy,
1106
+ scale=sprite_scale,
1107
+ rotation_rad=angle,
1108
+ tint=tint,
1109
+ )
1110
+ rl.end_blend_mode()
1111
+ return
1112
+
1113
+ if type_id in (int(ProjectileTypeId.SPLITTER_GUN), int(ProjectileTypeId.BLADE_GUN)) and texture is not None:
1114
+ mapping = KNOWN_PROJ_FRAMES.get(type_id)
1115
+ if mapping is None:
1116
+ return
1117
+ grid, frame = mapping
1118
+ cell_w = float(texture.width) / float(grid)
1119
+
1120
+ if life < 0.4:
1121
+ return
1122
+
1123
+ ox = float(getattr(proj, "origin_x", pos_x))
1124
+ oy = float(getattr(proj, "origin_y", pos_y))
1125
+ dist = math.hypot(pos_x - ox, pos_y - oy)
1126
+
1127
+ desired_size = min(dist, 20.0) * scale
1128
+ if desired_size <= 1e-3:
1129
+ return
1130
+
1131
+ sprite_scale = desired_size / cell_w if cell_w > 1e-6 else 0.0
1132
+ if sprite_scale <= 1e-6:
1133
+ return
1134
+
1135
+ rotation_rad = angle
1136
+ rgb = (1.0, 1.0, 1.0)
1137
+ if type_id == int(ProjectileTypeId.BLADE_GUN):
1138
+ rotation_rad = float(int(proj_index)) * 0.1 - float(self._elapsed_ms) * 0.1
1139
+ rgb = (0.8, 0.8, 0.8)
1140
+
1141
+ tint = self._color_from_rgba((rgb[0], rgb[1], rgb[2], alpha))
1142
+ self._draw_atlas_sprite(
1143
+ texture,
1144
+ grid=grid,
1145
+ frame=frame,
1146
+ x=sx,
1147
+ y=sy,
1148
+ scale=sprite_scale,
1149
+ rotation_rad=rotation_rad,
1150
+ tint=tint,
1151
+ )
1152
+ return
1153
+
1154
+ if type_id == int(ProjectileTypeId.PLAGUE_SPREADER) and texture is not None:
1155
+ grid = 4
1156
+ frame = 2
1157
+ cell_w = float(texture.width) / float(grid)
1158
+
1159
+ if life >= 0.4:
1160
+ tint = self._color_from_rgba((1.0, 1.0, 1.0, alpha))
1161
+
1162
+ def draw_plague_quad(*, px: float, py: float, size: float) -> None:
1163
+ size = float(size)
1164
+ if size <= 1e-3:
1165
+ return
1166
+ desired_size = size * scale
1167
+ sprite_scale = desired_size / cell_w if cell_w > 1e-6 else 0.0
1168
+ if sprite_scale <= 1e-6:
1169
+ return
1170
+ psx, psy = self.world_to_screen(px, py)
1171
+ self._draw_atlas_sprite(
1172
+ texture,
1173
+ grid=grid,
1174
+ frame=frame,
1175
+ x=psx,
1176
+ y=psy,
1177
+ scale=sprite_scale,
1178
+ rotation_rad=0.0,
1179
+ tint=tint,
1180
+ )
1181
+
1182
+ draw_plague_quad(px=pos_x, py=pos_y, size=60.0)
1183
+
1184
+ offset_angle = angle + math.pi / 2.0
1185
+ draw_plague_quad(
1186
+ px=pos_x + math.cos(offset_angle) * 15.0,
1187
+ py=pos_y + math.sin(offset_angle) * 15.0,
1188
+ size=60.0,
1189
+ )
1190
+
1191
+ phase = float(int(proj_index)) + float(self._elapsed_ms) * 0.01
1192
+ cos_phase = math.cos(phase)
1193
+ sin_phase = math.sin(phase)
1194
+ draw_plague_quad(
1195
+ px=pos_x + cos_phase * cos_phase - 5.0,
1196
+ py=pos_y + sin_phase * 11.0 - 5.0,
1197
+ size=52.0,
1198
+ )
1199
+
1200
+ phase_120 = phase + 2.0943952
1201
+ sin_phase_120 = math.sin(phase_120)
1202
+ draw_plague_quad(
1203
+ px=pos_x + math.cos(phase_120) * 10.0,
1204
+ py=pos_y + sin_phase_120 * 10.0,
1205
+ size=62.0,
1206
+ )
1207
+
1208
+ phase_240 = phase + 4.1887903
1209
+ draw_plague_quad(
1210
+ px=pos_x + math.cos(phase_240) * 10.0,
1211
+ py=pos_y + math.sin(phase_240) * sin_phase_120,
1212
+ size=62.0,
1213
+ )
1214
+ return
1215
+
1216
+ fade = clamp(life * 2.5, 0.0, 1.0)
1217
+ fade_alpha = fade * alpha
1218
+ if fade_alpha <= 1e-3:
1219
+ return
1220
+
1221
+ desired_size = (fade * 40.0 + 32.0) * scale
1222
+ sprite_scale = desired_size / cell_w if cell_w > 1e-6 else 0.0
1223
+ if sprite_scale <= 1e-6:
1224
+ return
1225
+
1226
+ tint = self._color_from_rgba((1.0, 1.0, 1.0, fade_alpha))
1227
+ self._draw_atlas_sprite(
1228
+ texture,
1229
+ grid=grid,
1230
+ frame=frame,
1231
+ x=sx,
1232
+ y=sy,
1233
+ scale=sprite_scale,
1234
+ rotation_rad=0.0,
1235
+ tint=tint,
1236
+ )
1237
+ return
1238
+
1239
+ mapping = KNOWN_PROJ_FRAMES.get(type_id)
1240
+ if texture is None or mapping is None:
1241
+ rl.draw_circle(int(sx), int(sy), max(1.0, 3.0 * scale), rl.Color(240, 220, 160, int(255 * alpha + 0.5)))
1242
+ return
1243
+ grid, frame = mapping
1244
+
1245
+ color = rl.Color(240, 220, 160, 255)
1246
+ if type_id in (ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_MINIGUN, ProjectileTypeId.ION_CANNON):
1247
+ color = rl.Color(120, 200, 255, 255)
1248
+ elif type_id == ProjectileTypeId.FIRE_BULLETS:
1249
+ color = rl.Color(255, 170, 90, 255)
1250
+ elif type_id == ProjectileTypeId.SHRINKIFIER:
1251
+ color = rl.Color(160, 255, 170, 255)
1252
+ elif type_id == ProjectileTypeId.BLADE_GUN:
1253
+ color = rl.Color(240, 120, 255, 255)
1254
+
1255
+ alpha_byte = int(clamp(clamp(life / 0.4, 0.0, 1.0) * 255.0 * alpha, 0.0, 255.0) + 0.5)
1256
+ tint = rl.Color(color.r, color.g, color.b, alpha_byte)
1257
+ self._draw_atlas_sprite(
1258
+ texture,
1259
+ grid=grid,
1260
+ frame=frame,
1261
+ x=sx,
1262
+ y=sy,
1263
+ scale=0.6 * scale,
1264
+ rotation_rad=angle,
1265
+ tint=tint,
1266
+ )
1267
+
1268
+ @staticmethod
1269
+ def _is_bullet_trail_type(type_id: int) -> bool:
1270
+ return 0 <= type_id < 8 or type_id == int(ProjectileTypeId.SPLITTER_GUN)
1271
+
1272
+ @staticmethod
1273
+ def _bullet_sprite_size(type_id: int, *, scale: float) -> float:
1274
+ base = 4.0
1275
+ if type_id == int(ProjectileTypeId.ASSAULT_RIFLE):
1276
+ base = 6.0
1277
+ elif type_id == int(ProjectileTypeId.SUBMACHINE_GUN):
1278
+ base = 8.0
1279
+ return max(2.0, base * scale)
1280
+
1281
+ def _draw_bullet_trail(
1282
+ self,
1283
+ sx0: float,
1284
+ sy0: float,
1285
+ sx1: float,
1286
+ sy1: float,
1287
+ *,
1288
+ type_id: int,
1289
+ alpha: int,
1290
+ scale: float,
1291
+ ) -> bool:
1292
+ if self.bullet_trail_texture is None:
1293
+ return False
1294
+ if alpha <= 0:
1295
+ return False
1296
+ dx = sx1 - sx0
1297
+ dy = sy1 - sy0
1298
+ dist = math.hypot(dx, dy)
1299
+ if dist <= 1e-3:
1300
+ return False
1301
+ thickness = max(1.0, 2.1 * scale)
1302
+ half = thickness * 0.5
1303
+ inv = 1.0 / dist
1304
+ nx = dx * inv
1305
+ ny = dy * inv
1306
+ px = -ny
1307
+ py = nx
1308
+ ox = px * half
1309
+ oy = py * half
1310
+ x0 = sx0 - ox
1311
+ y0 = sy0 - oy
1312
+ x1 = sx0 + ox
1313
+ y1 = sy0 + oy
1314
+ x2 = sx1 + ox
1315
+ y2 = sy1 + oy
1316
+ x3 = sx1 - ox
1317
+ y3 = sy1 - oy
1318
+
1319
+ # Native uses additive blending for bullet trails and sets color slots per projectile type.
1320
+ # Gauss has a distinct blue tint; most other bullet trails are neutral gray.
1321
+ if type_id == int(ProjectileTypeId.GAUSS_GUN):
1322
+ head_rgb = (51, 128, 255) # (0.2, 0.5, 1.0)
1323
+ else:
1324
+ head_rgb = (128, 128, 128) # (0.5, 0.5, 0.5)
1325
+
1326
+ tail_rgb = (128, 128, 128)
1327
+ head = rl.Color(head_rgb[0], head_rgb[1], head_rgb[2], alpha)
1328
+ tail = rl.Color(tail_rgb[0], tail_rgb[1], tail_rgb[2], 0)
1329
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1330
+ rl.rl_set_texture(self.bullet_trail_texture.id)
1331
+ rl.rl_begin(rl.RL_QUADS)
1332
+ rl.rl_color4ub(tail.r, tail.g, tail.b, tail.a)
1333
+ rl.rl_tex_coord2f(0.0, 0.0)
1334
+ rl.rl_vertex2f(x0, y0)
1335
+ rl.rl_color4ub(tail.r, tail.g, tail.b, tail.a)
1336
+ rl.rl_tex_coord2f(1.0, 0.0)
1337
+ rl.rl_vertex2f(x1, y1)
1338
+ rl.rl_color4ub(head.r, head.g, head.b, head.a)
1339
+ rl.rl_tex_coord2f(1.0, 0.5)
1340
+ rl.rl_vertex2f(x2, y2)
1341
+ rl.rl_color4ub(head.r, head.g, head.b, head.a)
1342
+ rl.rl_tex_coord2f(0.0, 0.5)
1343
+ rl.rl_vertex2f(x3, y3)
1344
+ rl.rl_end()
1345
+ rl.rl_set_texture(0)
1346
+ rl.end_blend_mode()
1347
+ return True
1348
+
1349
+ def _draw_secondary_projectile(self, proj: object, *, scale: float, alpha: float = 1.0) -> None:
1350
+ alpha = clamp(float(alpha), 0.0, 1.0)
1351
+ if alpha <= 1e-3:
1352
+ return
1353
+ sx, sy = self.world_to_screen(float(getattr(proj, "pos_x", 0.0)), float(getattr(proj, "pos_y", 0.0)))
1354
+ proj_type = int(getattr(proj, "type_id", 0))
1355
+ angle = float(getattr(proj, "angle", 0.0))
1356
+
1357
+ if proj_type in (1, 2, 4) and self.projs_texture is not None:
1358
+ texture = self.projs_texture
1359
+ cell_w = float(texture.width) / 4.0
1360
+ if cell_w <= 1e-6:
1361
+ return
1362
+
1363
+ base_alpha = clamp(alpha * 0.9, 0.0, 1.0)
1364
+ base_tint = self._color_from_rgba((0.8, 0.8, 0.8, base_alpha))
1365
+ base_size = 14.0
1366
+ if proj_type == 2:
1367
+ base_size = 10.0
1368
+ elif proj_type == 4:
1369
+ base_size = 8.0
1370
+ sprite_scale = (base_size * scale) / cell_w
1371
+
1372
+ fx_detail_1 = bool(self.config.data.get("fx_detail_1", 0)) if self.config is not None else True
1373
+ if fx_detail_1 and self.particles_texture is not None:
1374
+ particles_texture = self.particles_texture
1375
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x0D)
1376
+ if atlas is not None:
1377
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1378
+ if grid:
1379
+ particle_cell_w = float(particles_texture.width) / float(grid)
1380
+ particle_cell_h = float(particles_texture.height) / float(grid)
1381
+ frame = int(atlas.frame)
1382
+ col = frame % grid
1383
+ row = frame // grid
1384
+ src = rl.Rectangle(
1385
+ particle_cell_w * float(col),
1386
+ particle_cell_h * float(row),
1387
+ max(0.0, particle_cell_w - 2.0),
1388
+ max(0.0, particle_cell_h - 2.0),
1389
+ )
1390
+
1391
+ dir_x = math.cos(angle - math.pi / 2.0)
1392
+ dir_y = math.sin(angle - math.pi / 2.0)
1393
+
1394
+ def _draw_rocket_fx(
1395
+ *,
1396
+ size: float,
1397
+ offset: float,
1398
+ rgba: tuple[float, float, float, float],
1399
+ ) -> None:
1400
+ fx_alpha = rgba[3]
1401
+ if fx_alpha <= 1e-3:
1402
+ return
1403
+ tint = self._color_from_rgba(rgba)
1404
+ fx_sx = sx - dir_x * offset * scale
1405
+ fx_sy = sy - dir_y * offset * scale
1406
+ dst_size = size * scale
1407
+ dst = rl.Rectangle(float(fx_sx), float(fx_sy), float(dst_size), float(dst_size))
1408
+ origin = rl.Vector2(dst_size * 0.5, dst_size * 0.5)
1409
+ rl.draw_texture_pro(particles_texture, src, dst, origin, 0.0, tint)
1410
+
1411
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1412
+ # Large bloom around the rocket (effect_id=0x0D).
1413
+ _draw_rocket_fx(size=140.0, offset=5.0, rgba=(1.0, 1.0, 1.0, alpha * 0.48))
1414
+
1415
+ if proj_type == 4:
1416
+ _draw_rocket_fx(size=30.0, offset=9.0, rgba=(0.7, 0.7, 1.0, alpha * 0.158))
1417
+ elif proj_type == 2:
1418
+ _draw_rocket_fx(size=40.0, offset=9.0, rgba=(1.0, 1.0, 1.0, alpha * 0.58))
1419
+ else:
1420
+ _draw_rocket_fx(size=60.0, offset=9.0, rgba=(1.0, 1.0, 1.0, alpha * 0.68))
1421
+
1422
+ rl.end_blend_mode()
1423
+ self._draw_atlas_sprite(
1424
+ texture,
1425
+ grid=4,
1426
+ frame=3,
1427
+ x=sx,
1428
+ y=sy,
1429
+ scale=sprite_scale,
1430
+ rotation_rad=angle,
1431
+ tint=base_tint,
1432
+ )
1433
+ return
1434
+
1435
+ if proj_type == 4:
1436
+ rl.draw_circle(int(sx), int(sy), max(1.0, 12.0 * scale), rl.Color(200, 120, 255, int(255 * alpha + 0.5)))
1437
+ return
1438
+ if proj_type == 3:
1439
+ t = clamp(float(getattr(proj, "lifetime", 0.0)), 0.0, 1.0)
1440
+ radius = float(getattr(proj, "speed", 1.0)) * t * 80.0
1441
+ alpha_byte = int(clamp((1.0 - t) * 180.0 * alpha, 0.0, 255.0) + 0.5)
1442
+ color = rl.Color(200, 120, 255, alpha_byte)
1443
+ rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
1444
+ return
1445
+ rl.draw_circle(int(sx), int(sy), max(1.0, 4.0 * scale), rl.Color(200, 200, 220, int(200 * alpha + 0.5)))
1446
+
1447
+ def _draw_particle_pool(self, *, cam_x: float, cam_y: float, scale_x: float, scale_y: float, alpha: float = 1.0) -> None:
1448
+ alpha = clamp(float(alpha), 0.0, 1.0)
1449
+ if alpha <= 1e-3:
1450
+ return
1451
+ texture = self.particles_texture
1452
+ if texture is None:
1453
+ return
1454
+
1455
+ particles = self.state.particles.entries
1456
+ if not any(entry.active for entry in particles):
1457
+ return
1458
+
1459
+ scale = (scale_x + scale_y) * 0.5
1460
+
1461
+ def src_rect(effect_id: int) -> rl.Rectangle | None:
1462
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(int(effect_id))
1463
+ if atlas is None:
1464
+ return None
1465
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1466
+ if not grid:
1467
+ return None
1468
+ frame = int(atlas.frame)
1469
+ col = frame % grid
1470
+ row = frame // grid
1471
+ cell_w = float(texture.width) / float(grid)
1472
+ cell_h = float(texture.height) / float(grid)
1473
+ return rl.Rectangle(
1474
+ cell_w * float(col),
1475
+ cell_h * float(row),
1476
+ max(0.0, cell_w - 2.0),
1477
+ max(0.0, cell_h - 2.0),
1478
+ )
1479
+
1480
+ src_large = src_rect(13)
1481
+ src_normal = src_rect(12)
1482
+ src_style_8 = src_rect(2)
1483
+ if src_normal is None or src_style_8 is None:
1484
+ return
1485
+
1486
+ fx_detail_1 = bool(self.config.data.get("fx_detail_1", 0)) if self.config is not None else True
1487
+
1488
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1489
+
1490
+ if fx_detail_1 and src_large is not None:
1491
+ alpha_byte = int(clamp(alpha * 0.04, 0.0, 1.0) * 255.0 + 0.5)
1492
+ tint = rl.Color(255, 255, 255, alpha_byte)
1493
+ for idx, entry in enumerate(particles):
1494
+ if not entry.active or (idx % 2) or int(entry.style_id) == 8:
1495
+ continue
1496
+ radius = (math.sin((1.0 - float(entry.intensity)) * 1.5707964) + 0.1) * 55.0 + 4.0
1497
+ radius = max(radius, 16.0)
1498
+ size = max(0.0, radius * 2.0 * scale)
1499
+ if size <= 0.0:
1500
+ continue
1501
+ sx = (float(entry.pos_x) + cam_x) * scale_x
1502
+ sy = (float(entry.pos_y) + cam_y) * scale_y
1503
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
1504
+ origin = rl.Vector2(float(size) * 0.5, float(size) * 0.5)
1505
+ rl.draw_texture_pro(texture, src_large, dst, origin, 0.0, tint)
1506
+
1507
+ for entry in particles:
1508
+ if not entry.active or int(entry.style_id) == 8:
1509
+ continue
1510
+ radius = math.sin((1.0 - float(entry.intensity)) * 1.5707964) * 24.0
1511
+ if int(entry.style_id) == 1:
1512
+ radius *= 0.8
1513
+ radius = max(radius, 2.0)
1514
+ size = max(0.0, radius * 2.0 * scale)
1515
+ if size <= 0.0:
1516
+ continue
1517
+ sx = (float(entry.pos_x) + cam_x) * scale_x
1518
+ sy = (float(entry.pos_y) + cam_y) * scale_y
1519
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
1520
+ origin = rl.Vector2(float(size) * 0.5, float(size) * 0.5)
1521
+ rotation_deg = float(entry.spin) * _RAD_TO_DEG
1522
+ tint = self._color_from_rgba((entry.scale_x, entry.scale_y, entry.scale_z, float(entry.age) * alpha))
1523
+ rl.draw_texture_pro(texture, src_normal, dst, origin, rotation_deg, tint)
1524
+
1525
+ alpha_byte = int(clamp(alpha, 0.0, 1.0) * 255.0 + 0.5)
1526
+ for entry in particles:
1527
+ if not entry.active or int(entry.style_id) != 8:
1528
+ continue
1529
+ wobble = math.sin(float(entry.spin)) * 3.0
1530
+ half_h = (wobble + 15.0) * float(entry.scale_x) * 7.0
1531
+ half_w = (15.0 - wobble) * float(entry.scale_x) * 7.0
1532
+ w = max(0.0, half_w * 2.0 * scale)
1533
+ h = max(0.0, half_h * 2.0 * scale)
1534
+ if w <= 0.0 or h <= 0.0:
1535
+ continue
1536
+ sx = (float(entry.pos_x) + cam_x) * scale_x
1537
+ sy = (float(entry.pos_y) + cam_y) * scale_y
1538
+ dst = rl.Rectangle(float(sx), float(sy), float(w), float(h))
1539
+ origin = rl.Vector2(float(w) * 0.5, float(h) * 0.5)
1540
+ tint = rl.Color(255, 255, 255, int(float(entry.age) * alpha_byte + 0.5))
1541
+ rl.draw_texture_pro(texture, src_style_8, dst, origin, 0.0, tint)
1542
+
1543
+ rl.end_blend_mode()
1544
+
1545
+ def _draw_sprite_effect_pool(
1546
+ self,
1547
+ *,
1548
+ cam_x: float,
1549
+ cam_y: float,
1550
+ scale_x: float,
1551
+ scale_y: float,
1552
+ alpha: float = 1.0,
1553
+ ) -> None:
1554
+ alpha = clamp(float(alpha), 0.0, 1.0)
1555
+ if alpha <= 1e-3:
1556
+ return
1557
+ if self.config is not None and not bool(self.config.data.get("fx_detail_2", 0)):
1558
+ return
1559
+ texture = self.particles_texture
1560
+ if texture is None:
1561
+ return
1562
+
1563
+ effects = self.state.sprite_effects.entries
1564
+ if not any(entry.active for entry in effects):
1565
+ return
1566
+
1567
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x11)
1568
+ if atlas is None:
1569
+ return
1570
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1571
+ if not grid:
1572
+ return
1573
+ frame = int(atlas.frame)
1574
+ col = frame % grid
1575
+ row = frame // grid
1576
+ cell_w = float(texture.width) / float(grid)
1577
+ cell_h = float(texture.height) / float(grid)
1578
+ src = rl.Rectangle(cell_w * float(col), cell_h * float(row), cell_w, cell_h)
1579
+ scale = (scale_x + scale_y) * 0.5
1580
+
1581
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
1582
+ for entry in effects:
1583
+ if not entry.active:
1584
+ continue
1585
+ size = float(entry.scale) * scale
1586
+ if size <= 0.0:
1587
+ continue
1588
+ sx = (float(entry.pos_x) + cam_x) * scale_x
1589
+ sy = (float(entry.pos_y) + cam_y) * scale_y
1590
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
1591
+ origin = rl.Vector2(float(size) * 0.5, float(size) * 0.5)
1592
+ rotation_deg = float(entry.rotation) * _RAD_TO_DEG
1593
+ tint = self._color_from_rgba((entry.color_r, entry.color_g, entry.color_b, float(entry.color_a) * alpha))
1594
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, tint)
1595
+ rl.end_blend_mode()
1596
+
1597
+ def _draw_effect_pool(self, *, cam_x: float, cam_y: float, scale_x: float, scale_y: float, alpha: float = 1.0) -> None:
1598
+ alpha = clamp(float(alpha), 0.0, 1.0)
1599
+ if alpha <= 1e-3:
1600
+ return
1601
+ texture = self.particles_texture
1602
+ if texture is None:
1603
+ return
1604
+
1605
+ effects = self.state.effects.entries
1606
+ if not any(entry.flags and entry.age >= 0.0 for entry in effects):
1607
+ return
1608
+
1609
+ scale = (scale_x + scale_y) * 0.5
1610
+
1611
+ src_cache: dict[int, rl.Rectangle] = {}
1612
+
1613
+ def src_rect(effect_id: int) -> rl.Rectangle | None:
1614
+ cached = src_cache.get(effect_id)
1615
+ if cached is not None:
1616
+ return cached
1617
+
1618
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(int(effect_id))
1619
+ if atlas is None:
1620
+ return None
1621
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1622
+ if not grid:
1623
+ return None
1624
+ frame = int(atlas.frame)
1625
+ col = frame % grid
1626
+ row = frame // grid
1627
+ cell_w = float(texture.width) / float(grid)
1628
+ cell_h = float(texture.height) / float(grid)
1629
+ # Native effect pool clamps UVs to (cell_size - 2px) to avoid bleeding.
1630
+ src = rl.Rectangle(
1631
+ cell_w * float(col),
1632
+ cell_h * float(row),
1633
+ max(0.0, cell_w - 2.0),
1634
+ max(0.0, cell_h - 2.0),
1635
+ )
1636
+ src_cache[effect_id] = src
1637
+ return src
1638
+
1639
+ def draw_entry(entry: object) -> None:
1640
+ effect_id = int(getattr(entry, "effect_id", 0))
1641
+ src = src_rect(effect_id)
1642
+ if src is None:
1643
+ return
1644
+
1645
+ pos_x = float(getattr(entry, "pos_x", 0.0))
1646
+ pos_y = float(getattr(entry, "pos_y", 0.0))
1647
+ sx = (pos_x + cam_x) * scale_x
1648
+ sy = (pos_y + cam_y) * scale_y
1649
+
1650
+ half_w = float(getattr(entry, "half_width", 0.0))
1651
+ half_h = float(getattr(entry, "half_height", 0.0))
1652
+ local_scale = float(getattr(entry, "scale", 1.0))
1653
+ w = max(0.0, half_w * 2.0 * local_scale * scale)
1654
+ h = max(0.0, half_h * 2.0 * local_scale * scale)
1655
+ if w <= 0.0 or h <= 0.0:
1656
+ return
1657
+
1658
+ rotation_deg = float(getattr(entry, "rotation", 0.0)) * _RAD_TO_DEG
1659
+ tint = self._color_from_rgba(
1660
+ (
1661
+ float(getattr(entry, "color_r", 1.0)),
1662
+ float(getattr(entry, "color_g", 1.0)),
1663
+ float(getattr(entry, "color_b", 1.0)),
1664
+ float(getattr(entry, "color_a", 1.0)),
1665
+ )
1666
+ )
1667
+ tint = rl.Color(tint.r, tint.g, tint.b, int(tint.a * alpha + 0.5))
1668
+
1669
+ dst = rl.Rectangle(float(sx), float(sy), float(w), float(h))
1670
+ origin = rl.Vector2(float(w) * 0.5, float(h) * 0.5)
1671
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, tint)
1672
+
1673
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
1674
+ for entry in effects:
1675
+ if not entry.flags or entry.age < 0.0:
1676
+ continue
1677
+ if int(entry.flags) & 0x40:
1678
+ draw_entry(entry)
1679
+ rl.end_blend_mode()
1680
+
1681
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1682
+ for entry in effects:
1683
+ if not entry.flags or entry.age < 0.0:
1684
+ continue
1685
+ if not (int(entry.flags) & 0x40):
1686
+ draw_entry(entry)
1687
+ rl.end_blend_mode()
1688
+
1689
+ def draw(self, *, draw_aim_indicators: bool = True, entity_alpha: float = 1.0) -> None:
1690
+ entity_alpha = clamp(float(entity_alpha), 0.0, 1.0)
1691
+ clear_color = rl.Color(10, 10, 12, 255)
1692
+ screen_w, screen_h = self._camera_screen_size()
1693
+ cam_x, cam_y = self._clamp_camera(self.camera_x, self.camera_y, screen_w, screen_h)
1694
+ out_w = float(rl.get_screen_width())
1695
+ out_h = float(rl.get_screen_height())
1696
+ scale_x = out_w / screen_w if screen_w > 0 else 1.0
1697
+ scale_y = out_h / screen_h if screen_h > 0 else 1.0
1698
+ if self.ground is None:
1699
+ rl.clear_background(clear_color)
1700
+ else:
1701
+ rl.clear_background(clear_color)
1702
+ self.ground.draw(cam_x, cam_y, screen_w=screen_w, screen_h=screen_h)
1703
+ scale = (scale_x + scale_y) * 0.5
1704
+
1705
+ # World bounds for debug if terrain is missing.
1706
+ if self.ground is None:
1707
+ x0 = (0.0 + cam_x) * scale_x
1708
+ y0 = (0.0 + cam_y) * scale_y
1709
+ x1 = (float(self.world_size) + cam_x) * scale_x
1710
+ y1 = (float(self.world_size) + cam_y) * scale_y
1711
+ rl.draw_rectangle_lines(int(x0), int(y0), int(x1 - x0), int(y1 - y0), rl.Color(40, 40, 55, 255))
1712
+
1713
+ if entity_alpha <= 1e-3:
1714
+ return
1715
+
1716
+ alpha_test = True
1717
+ if self.ground is not None:
1718
+ alpha_test = bool(getattr(self.ground, "alpha_test", True))
1719
+
1720
+ with _maybe_alpha_test(bool(alpha_test)):
1721
+ trooper_texture = self.creature_textures.get(CREATURE_ASSET.get(CreatureTypeId.TROOPER))
1722
+ particles_texture = self.particles_texture
1723
+ monster_vision = bool(self.players) and perk_active(self.players[0], PerkId.MONSTER_VISION)
1724
+ monster_vision_src: rl.Rectangle | None = None
1725
+ if monster_vision and particles_texture is not None:
1726
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x10)
1727
+ if atlas is not None:
1728
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1729
+ if grid:
1730
+ frame = int(atlas.frame)
1731
+ col = frame % grid
1732
+ row = frame // grid
1733
+ cell_w = float(particles_texture.width) / float(grid)
1734
+ cell_h = float(particles_texture.height) / float(grid)
1735
+ monster_vision_src = rl.Rectangle(
1736
+ cell_w * float(col),
1737
+ cell_h * float(row),
1738
+ max(0.0, cell_w - 2.0),
1739
+ max(0.0, cell_h - 2.0),
1740
+ )
1741
+
1742
+ def draw_player(player: object) -> None:
1743
+ if trooper_texture is not None:
1744
+ self._draw_player_trooper_sprite(
1745
+ trooper_texture,
1746
+ player,
1747
+ cam_x=cam_x,
1748
+ cam_y=cam_y,
1749
+ scale_x=scale_x,
1750
+ scale_y=scale_y,
1751
+ scale=scale,
1752
+ alpha=entity_alpha,
1753
+ )
1754
+ return
1755
+
1756
+ sx = (player.pos_x + cam_x) * scale_x
1757
+ sy = (player.pos_y + cam_y) * scale_y
1758
+ tint = rl.Color(90, 190, 120, int(255 * entity_alpha + 0.5))
1759
+ rl.draw_circle(int(sx), int(sy), max(1.0, 14.0 * scale), tint)
1760
+
1761
+ for player in self.players:
1762
+ if player.health <= 0.0:
1763
+ draw_player(player)
1764
+
1765
+ creature_type_order = {
1766
+ int(CreatureTypeId.ZOMBIE): 0,
1767
+ int(CreatureTypeId.SPIDER_SP1): 1,
1768
+ int(CreatureTypeId.SPIDER_SP2): 2,
1769
+ int(CreatureTypeId.ALIEN): 3,
1770
+ int(CreatureTypeId.LIZARD): 4,
1771
+ }
1772
+ creatures = [
1773
+ (idx, creature)
1774
+ for idx, creature in enumerate(self.creatures.entries)
1775
+ if creature.active
1776
+ ]
1777
+ creatures.sort(key=lambda item: (creature_type_order.get(int(getattr(item[1], "type_id", -1)), 999), item[0]))
1778
+ for _idx, creature in creatures:
1779
+ sx = (creature.x + cam_x) * scale_x
1780
+ sy = (creature.y + cam_y) * scale_y
1781
+ hitbox_size = float(creature.hitbox_size)
1782
+ try:
1783
+ type_id = CreatureTypeId(int(creature.type_id))
1784
+ except ValueError:
1785
+ type_id = None
1786
+ asset = CREATURE_ASSET.get(type_id) if type_id is not None else None
1787
+ texture = self.creature_textures.get(asset) if asset is not None else None
1788
+ if monster_vision and particles_texture is not None and monster_vision_src is not None:
1789
+ fade = monster_vision_fade_alpha(hitbox_size)
1790
+ mv_alpha = fade * entity_alpha
1791
+ if mv_alpha > 1e-3:
1792
+ size = 90.0 * scale
1793
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
1794
+ origin = rl.Vector2(size * 0.5, size * 0.5)
1795
+ tint = rl.Color(255, 255, 0, int(clamp(mv_alpha, 0.0, 1.0) * 255.0 + 0.5))
1796
+ rl.draw_texture_pro(particles_texture, monster_vision_src, dst, origin, 0.0, tint)
1797
+ if texture is None:
1798
+ tint = rl.Color(220, 90, 90, int(255 * entity_alpha + 0.5))
1799
+ rl.draw_circle(int(sx), int(sy), max(1.0, creature.size * 0.5 * scale), tint)
1800
+ continue
1801
+
1802
+ info = CREATURE_ANIM.get(type_id) if type_id is not None else None
1803
+ if info is None:
1804
+ continue
1805
+
1806
+ tint_alpha = float(creature.tint_a)
1807
+ if hitbox_size < 0.0:
1808
+ # Mirrors the main-pass alpha fade when hitbox_size ramps negative.
1809
+ tint_alpha = max(0.0, tint_alpha + hitbox_size * 0.1)
1810
+ tint_alpha = clamp(tint_alpha * entity_alpha, 0.0, 1.0)
1811
+ tint = self._color_from_rgba((creature.tint_r, creature.tint_g, creature.tint_b, tint_alpha))
1812
+
1813
+ size_scale = clamp(float(creature.size) / 64.0, 0.25, 2.0)
1814
+ fx_detail = bool(self.config.data.get("fx_detail_0", 0)) if self.config is not None else True
1815
+ # Mirrors `creature_render_type`: the "shadow-ish" pass is gated by fx_detail_0
1816
+ # and is disabled when the Monster Vision perk is active.
1817
+ shadow = fx_detail and (not self.players or not perk_active(self.players[0], PerkId.MONSTER_VISION))
1818
+ long_strip = (creature.flags & CreatureFlags.ANIM_PING_PONG) == 0 or (
1819
+ creature.flags & CreatureFlags.ANIM_LONG_STRIP
1820
+ ) != 0
1821
+ phase = float(creature.anim_phase)
1822
+ if long_strip:
1823
+ if hitbox_size < 0.0:
1824
+ # Negative phase selects the fallback "corpse" frame in creature_render_type.
1825
+ phase = -1.0
1826
+ elif hitbox_size < 16.0:
1827
+ # Death staging: while hitbox_size ramps down (16..0), creature_render_type
1828
+ # selects frames via `__ftol((base_frame + 15) - hitbox_size)`.
1829
+ phase = float(info.base + 0x0F) - hitbox_size - 0.5
1830
+
1831
+ shadow_alpha = None
1832
+ if shadow:
1833
+ # Shadow pass uses tint_a * 0.4 and fades much faster for corpses (hitbox_size < 0).
1834
+ shadow_a = float(creature.tint_a) * 0.4
1835
+ if hitbox_size < 0.0:
1836
+ shadow_a += hitbox_size * (0.5 if long_strip else 0.1)
1837
+ shadow_a = max(0.0, shadow_a)
1838
+ shadow_alpha = int(clamp(shadow_a * entity_alpha * 255.0, 0.0, 255.0) + 0.5)
1839
+ self._draw_creature_sprite(
1840
+ texture,
1841
+ type_id=type_id or CreatureTypeId.ZOMBIE,
1842
+ flags=creature.flags,
1843
+ phase=phase,
1844
+ mirror_long=bool(info.mirror) and hitbox_size >= 16.0,
1845
+ shadow_alpha=shadow_alpha,
1846
+ world_x=float(creature.x),
1847
+ world_y=float(creature.y),
1848
+ rotation_rad=float(creature.heading) - math.pi / 2.0,
1849
+ scale=scale,
1850
+ size_scale=size_scale,
1851
+ tint=tint,
1852
+ shadow=shadow,
1853
+ )
1854
+
1855
+ freeze_timer = float(self.state.bonuses.freeze)
1856
+ if particles_texture is not None and freeze_timer > 0.0:
1857
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x0E)
1858
+ if atlas is not None:
1859
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1860
+ if grid:
1861
+ cell_w = float(particles_texture.width) / float(grid)
1862
+ cell_h = float(particles_texture.height) / float(grid)
1863
+ frame = int(atlas.frame)
1864
+ col = frame % grid
1865
+ row = frame // grid
1866
+ src = rl.Rectangle(
1867
+ cell_w * float(col),
1868
+ cell_h * float(row),
1869
+ max(0.0, cell_w - 2.0),
1870
+ max(0.0, cell_h - 2.0),
1871
+ )
1872
+
1873
+ fade = 1.0 if freeze_timer >= 1.0 else clamp(freeze_timer, 0.0, 1.0)
1874
+ freeze_alpha = clamp(fade * entity_alpha * 0.7, 0.0, 1.0)
1875
+ if freeze_alpha > 1e-3:
1876
+ tint = rl.Color(255, 255, 255, int(freeze_alpha * 255.0 + 0.5))
1877
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
1878
+ for idx, creature in enumerate(self.creatures.entries):
1879
+ if not creature.active:
1880
+ continue
1881
+ size = float(creature.size) * scale
1882
+ if size <= 1e-3:
1883
+ continue
1884
+ sx = (creature.x + cam_x) * scale_x
1885
+ sy = (creature.y + cam_y) * scale_y
1886
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
1887
+ origin = rl.Vector2(size * 0.5, size * 0.5)
1888
+ rotation_deg = (float(idx) * 0.01 + float(creature.heading)) * _RAD_TO_DEG
1889
+ rl.draw_texture_pro(particles_texture, src, dst, origin, rotation_deg, tint)
1890
+ rl.end_blend_mode()
1891
+
1892
+ for player in self.players:
1893
+ if player.health > 0.0:
1894
+ draw_player(player)
1895
+
1896
+ for proj_index, proj in enumerate(self.state.projectiles.entries):
1897
+ if not proj.active:
1898
+ continue
1899
+ self._draw_projectile(proj, proj_index=proj_index, scale=scale, alpha=entity_alpha)
1900
+
1901
+ self._draw_particle_pool(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1902
+
1903
+ for proj in self.state.secondary_projectiles.entries:
1904
+ if not proj.active:
1905
+ continue
1906
+ self._draw_secondary_projectile(proj, scale=scale, alpha=entity_alpha)
1907
+
1908
+ self._draw_sprite_effect_pool(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1909
+ self._draw_effect_pool(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1910
+ self._draw_bonus_pickups(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, scale=scale, alpha=entity_alpha)
1911
+ self._draw_bonus_hover_labels(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1912
+
1913
+ if draw_aim_indicators and (not self.demo_mode_active):
1914
+ for player in self.players:
1915
+ if player.health <= 0.0:
1916
+ continue
1917
+ aim_x = float(getattr(player, "aim_x", player.pos_x))
1918
+ aim_y = float(getattr(player, "aim_y", player.pos_y))
1919
+ dist = math.hypot(aim_x - float(player.pos_x), aim_y - float(player.pos_y))
1920
+ radius = max(6.0, dist * float(getattr(player, "spread_heat", 0.0)) * 0.5)
1921
+ sx = (aim_x + cam_x) * scale_x
1922
+ sy = (aim_y + cam_y) * scale_y
1923
+ screen_radius = max(1.0, radius * scale)
1924
+ self._draw_aim_circle(x=sx, y=sy, radius=screen_radius, alpha=entity_alpha)
1925
+ reload_timer = float(getattr(player, "reload_timer", 0.0))
1926
+ reload_max = float(getattr(player, "reload_timer_max", 0.0))
1927
+ if reload_max > 1e-6 and reload_timer > 1e-6:
1928
+ progress = reload_timer / reload_max
1929
+ if progress > 0.0:
1930
+ ms = int(progress * 60000.0)
1931
+ self._draw_clock_gauge(x=float(int(sx)), y=float(int(sy)), ms=ms, scale=scale, alpha=entity_alpha)
1932
+
1933
+ def world_to_screen(self, x: float, y: float) -> tuple[float, float]:
1934
+ cam_x, cam_y, scale_x, scale_y = self._world_params()
1935
+ return (x + cam_x) * scale_x, (y + cam_y) * scale_y
1936
+
1937
+ def screen_to_world(self, x: float, y: float) -> tuple[float, float]:
1938
+ cam_x, cam_y, scale_x, scale_y = self._world_params()
1939
+ inv_x = 1.0 / scale_x if scale_x > 0 else 1.0
1940
+ inv_y = 1.0 / scale_y if scale_y > 0 else 1.0
1941
+ return x * inv_x - cam_x, y * inv_y - cam_y