crimsonland 0.1.0.dev1__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 (138) 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 +153 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +377 -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 +663 -0
  34. crimson/gameplay.py +2450 -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 +1039 -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 +1338 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +56 -0
  67. crimson/sim/world_state.py +421 -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 +414 -0
  86. crimson/views/bonuses.py +201 -0
  87. crimson/views/camera_debug.py +359 -0
  88. crimson/views/camera_shake.py +229 -0
  89. crimson/views/corpse_stamp_debug.py +324 -0
  90. crimson/views/decals_debug.py +739 -0
  91. crimson/views/empty.py +19 -0
  92. crimson/views/fonts.py +114 -0
  93. crimson/views/game_over.py +117 -0
  94. crimson/views/ground.py +259 -0
  95. crimson/views/lighting_debug.py +1166 -0
  96. crimson/views/particles.py +293 -0
  97. crimson/views/perk_menu_debug.py +430 -0
  98. crimson/views/perks.py +398 -0
  99. crimson/views/player.py +433 -0
  100. crimson/views/player_sprite_debug.py +314 -0
  101. crimson/views/projectile_fx.py +608 -0
  102. crimson/views/projectile_render_debug.py +407 -0
  103. crimson/views/projectiles.py +221 -0
  104. crimson/views/quest_title_overlay.py +108 -0
  105. crimson/views/registry.py +34 -0
  106. crimson/views/rush.py +16 -0
  107. crimson/views/small_font_debug.py +204 -0
  108. crimson/views/spawn_plan.py +363 -0
  109. crimson/views/sprites.py +214 -0
  110. crimson/views/survival.py +15 -0
  111. crimson/views/terrain.py +132 -0
  112. crimson/views/ui.py +123 -0
  113. crimson/views/wicons.py +166 -0
  114. crimson/weapon_sfx.py +63 -0
  115. crimson/weapons.py +860 -0
  116. crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
  117. crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
  118. crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
  119. crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
  120. grim/__init__.py +20 -0
  121. grim/app.py +92 -0
  122. grim/assets.py +231 -0
  123. grim/audio.py +106 -0
  124. grim/config.py +294 -0
  125. grim/console.py +737 -0
  126. grim/fonts/__init__.py +7 -0
  127. grim/fonts/grim_mono.py +111 -0
  128. grim/fonts/small.py +120 -0
  129. grim/input.py +44 -0
  130. grim/jaz.py +103 -0
  131. grim/math.py +17 -0
  132. grim/music.py +403 -0
  133. grim/paq.py +76 -0
  134. grim/rand.py +37 -0
  135. grim/sfx.py +276 -0
  136. grim/sfx_map.py +103 -0
  137. grim/terrain_render.py +840 -0
  138. grim/view.py +16 -0
@@ -0,0 +1,1338 @@
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 BEAM_TYPES, CREATURE_ANIM, CREATURE_ASSET, KNOWN_PROJ_FRAMES
21
+ from ..weapons import WEAPON_BY_ID
22
+
23
+ if TYPE_CHECKING:
24
+ from ..game_world import GameWorld
25
+
26
+ _RAD_TO_DEG = 57.29577951308232
27
+
28
+
29
+ def monster_vision_fade_alpha(hitbox_size: float) -> float:
30
+ if float(hitbox_size) >= 0.0:
31
+ return 1.0
32
+ return clamp((float(hitbox_size) + 10.0) * 0.1, 0.0, 1.0)
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class WorldRenderer:
37
+ _world: GameWorld
38
+ _small_font: SmallFontData | None = None
39
+
40
+ def __getattr__(self, name: str) -> object:
41
+ return getattr(self._world, name)
42
+
43
+ def _ensure_small_font(self) -> SmallFontData | None:
44
+ if self._small_font is not None:
45
+ return self._small_font
46
+ try:
47
+ # Keep UI text consistent with the HUD/menu font when available.
48
+ self._small_font = load_small_font(self.assets_dir, self.missing_assets)
49
+ except Exception:
50
+ self._small_font = None
51
+ return self._small_font
52
+
53
+ def _camera_screen_size(self) -> tuple[float, float]:
54
+ if self.config is not None:
55
+ screen_w = float(self.config.screen_width)
56
+ screen_h = float(self.config.screen_height)
57
+ else:
58
+ screen_w = float(rl.get_screen_width())
59
+ screen_h = float(rl.get_screen_height())
60
+ if screen_w > self.world_size:
61
+ screen_w = float(self.world_size)
62
+ if screen_h > self.world_size:
63
+ screen_h = float(self.world_size)
64
+ return screen_w, screen_h
65
+
66
+ def _clamp_camera(self, cam_x: float, cam_y: float, screen_w: float, screen_h: float) -> tuple[float, float]:
67
+ min_x = screen_w - float(self.world_size)
68
+ min_y = screen_h - float(self.world_size)
69
+ if cam_x > -1.0:
70
+ cam_x = -1.0
71
+ if cam_x < min_x:
72
+ cam_x = min_x
73
+ if cam_y > -1.0:
74
+ cam_y = -1.0
75
+ if cam_y < min_y:
76
+ cam_y = min_y
77
+ return cam_x, cam_y
78
+
79
+ def _world_params(self) -> tuple[float, float, float, float]:
80
+ out_w = float(rl.get_screen_width())
81
+ out_h = float(rl.get_screen_height())
82
+ screen_w, screen_h = self._camera_screen_size()
83
+ cam_x, cam_y = self._clamp_camera(self.camera_x, self.camera_y, screen_w, screen_h)
84
+ scale_x = out_w / screen_w if screen_w > 0 else 1.0
85
+ scale_y = out_h / screen_h if screen_h > 0 else 1.0
86
+ return cam_x, cam_y, scale_x, scale_y
87
+
88
+ def _color_from_rgba(self, rgba: tuple[float, float, float, float]) -> rl.Color:
89
+ r = int(clamp(rgba[0], 0.0, 1.0) * 255.0 + 0.5)
90
+ g = int(clamp(rgba[1], 0.0, 1.0) * 255.0 + 0.5)
91
+ b = int(clamp(rgba[2], 0.0, 1.0) * 255.0 + 0.5)
92
+ a = int(clamp(rgba[3], 0.0, 1.0) * 255.0 + 0.5)
93
+ return rl.Color(r, g, b, a)
94
+
95
+ def _bonus_icon_src(self, texture: rl.Texture, icon_id: int) -> rl.Rectangle:
96
+ grid = 4
97
+ cell_w = float(texture.width) / grid
98
+ cell_h = float(texture.height) / grid
99
+ col = int(icon_id) % grid
100
+ row = int(icon_id) // grid
101
+ return rl.Rectangle(float(col * cell_w), float(row * cell_h), float(cell_w), float(cell_h))
102
+
103
+ def _weapon_icon_src(self, texture: rl.Texture, icon_index: int) -> rl.Rectangle:
104
+ grid = 8
105
+ cell_w = float(texture.width) / float(grid)
106
+ cell_h = float(texture.height) / float(grid)
107
+ frame = int(icon_index) * 2
108
+ col = frame % grid
109
+ row = frame // grid
110
+ return rl.Rectangle(float(col * cell_w), float(row * cell_h), float(cell_w * 2), float(cell_h))
111
+
112
+ @staticmethod
113
+ def _bonus_fade(time_left: float, time_max: float) -> float:
114
+ time_left = float(time_left)
115
+ time_max = float(time_max)
116
+ if time_left <= 0.0 or time_max <= 0.0:
117
+ return 0.0
118
+ if time_left < 0.5:
119
+ return clamp(time_left * 2.0, 0.0, 1.0)
120
+ age = time_max - time_left
121
+ if age < 0.5:
122
+ return clamp(age * 2.0, 0.0, 1.0)
123
+ return 1.0
124
+
125
+ def _draw_bonus_pickups(
126
+ self,
127
+ *,
128
+ cam_x: float,
129
+ cam_y: float,
130
+ scale_x: float,
131
+ scale_y: float,
132
+ scale: float,
133
+ alpha: float = 1.0,
134
+ ) -> None:
135
+ alpha = clamp(float(alpha), 0.0, 1.0)
136
+ if alpha <= 1e-3:
137
+ return
138
+ if self.bonuses_texture is None:
139
+ for bonus in self.state.bonus_pool.entries:
140
+ if bonus.bonus_id == 0:
141
+ continue
142
+ sx = (bonus.pos_x + cam_x) * scale_x
143
+ sy = (bonus.pos_y + cam_y) * scale_y
144
+ tint = rl.Color(220, 220, 90, int(255 * alpha + 0.5))
145
+ rl.draw_circle(int(sx), int(sy), max(1.0, 10.0 * scale), tint)
146
+ return
147
+
148
+ bubble_src = self._bonus_icon_src(self.bonuses_texture, 0)
149
+ bubble_size = 32.0 * scale
150
+
151
+ for idx, bonus in enumerate(self.state.bonus_pool.entries):
152
+ if bonus.bonus_id == 0:
153
+ continue
154
+
155
+ fade = self._bonus_fade(float(bonus.time_left), float(bonus.time_max))
156
+ bubble_alpha = clamp(fade * 0.9, 0.0, 1.0) * alpha
157
+
158
+ sx = (bonus.pos_x + cam_x) * scale_x
159
+ sy = (bonus.pos_y + cam_y) * scale_y
160
+ bubble_dst = rl.Rectangle(float(sx), float(sy), float(bubble_size), float(bubble_size))
161
+ bubble_origin = rl.Vector2(bubble_size * 0.5, bubble_size * 0.5)
162
+ tint = rl.Color(255, 255, 255, int(bubble_alpha * 255.0 + 0.5))
163
+ rl.draw_texture_pro(self.bonuses_texture, bubble_src, bubble_dst, bubble_origin, 0.0, tint)
164
+
165
+ bonus_id = int(bonus.bonus_id)
166
+ if bonus_id == int(BonusId.WEAPON):
167
+ weapon = WEAPON_BY_ID.get(int(bonus.amount))
168
+ icon_index = int(weapon.icon_index) if weapon is not None and weapon.icon_index is not None else None
169
+ if icon_index is None or not (0 <= icon_index <= 31) or self.wicons_texture is None:
170
+ continue
171
+
172
+ pulse = math.sin(float(self._bonus_anim_phase)) ** 4 * 0.25 + 0.75
173
+ icon_scale = fade * pulse
174
+ if icon_scale <= 1e-3:
175
+ continue
176
+
177
+ src = self._weapon_icon_src(self.wicons_texture, icon_index)
178
+ w = 60.0 * icon_scale * scale
179
+ h = 30.0 * icon_scale * scale
180
+ dst = rl.Rectangle(float(sx), float(sy), float(w), float(h))
181
+ origin = rl.Vector2(w * 0.5, h * 0.5)
182
+ rl.draw_texture_pro(self.wicons_texture, src, dst, origin, 0.0, tint)
183
+ continue
184
+
185
+ meta = BONUS_BY_ID.get(bonus_id)
186
+ icon_id = int(meta.icon_id) if meta is not None and meta.icon_id is not None else None
187
+ if icon_id is None or icon_id < 0:
188
+ continue
189
+ if bonus_id == int(BonusId.POINTS) and int(bonus.amount) == 1000:
190
+ icon_id += 1
191
+
192
+ pulse = math.sin(float(idx) + float(self._bonus_anim_phase)) ** 4 * 0.25 + 0.75
193
+ icon_scale = fade * pulse
194
+ if icon_scale <= 1e-3:
195
+ continue
196
+
197
+ src = self._bonus_icon_src(self.bonuses_texture, icon_id)
198
+ size = 32.0 * icon_scale * scale
199
+ rotation_rad = math.sin(float(idx) - float(self._elapsed_ms) * 0.003) * 0.2
200
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
201
+ origin = rl.Vector2(size * 0.5, size * 0.5)
202
+ rl.draw_texture_pro(self.bonuses_texture, src, dst, origin, float(rotation_rad * _RAD_TO_DEG), tint)
203
+
204
+ def _bonus_hover_label(self, bonus_id: int, amount: int) -> str:
205
+ bonus_id = int(bonus_id)
206
+ if bonus_id == int(BonusId.WEAPON):
207
+ weapon = WEAPON_BY_ID.get(int(amount))
208
+ if weapon is not None and weapon.name is not None:
209
+ return str(weapon.name)
210
+ return "Weapon"
211
+ if bonus_id == int(BonusId.POINTS):
212
+ return f"Score: {int(amount)}"
213
+ meta = BONUS_BY_ID.get(int(bonus_id))
214
+ if meta is not None:
215
+ return str(meta.name)
216
+ return "Bonus"
217
+
218
+ def _draw_bonus_hover_labels(
219
+ self,
220
+ *,
221
+ cam_x: float,
222
+ cam_y: float,
223
+ scale_x: float,
224
+ scale_y: float,
225
+ alpha: float = 1.0,
226
+ ) -> None:
227
+ alpha = clamp(float(alpha), 0.0, 1.0)
228
+ if alpha <= 1e-3:
229
+ return
230
+
231
+ font = self._ensure_small_font()
232
+ text_scale = 1.0
233
+ screen_w = float(rl.get_screen_width())
234
+
235
+ shadow = rl.Color(0, 0, 0, int(180 * alpha + 0.5))
236
+ color = rl.Color(230, 230, 230, int(255 * alpha + 0.5))
237
+
238
+ for player in self.players:
239
+ if player.health <= 0.0:
240
+ continue
241
+ hovered = bonus_find_aim_hover_entry(player, self.state.bonus_pool)
242
+ if hovered is None:
243
+ continue
244
+ _idx, entry = hovered
245
+ label = self._bonus_hover_label(int(entry.bonus_id), int(entry.amount))
246
+ if not label:
247
+ continue
248
+
249
+ aim_x = float(getattr(player, "aim_x", player.pos_x))
250
+ aim_y = float(getattr(player, "aim_y", player.pos_y))
251
+ x = (aim_x + cam_x) * scale_x + 16.0
252
+ y = (aim_y + cam_y) * scale_y - 7.0
253
+
254
+ if font is not None:
255
+ text_w = measure_small_text_width(font, label, text_scale)
256
+ else:
257
+ text_w = float(rl.measure_text(label, int(18 * text_scale)))
258
+ if x + text_w > screen_w:
259
+ x = max(0.0, screen_w - text_w)
260
+
261
+ if font is not None:
262
+ draw_small_text(font, label, x + 1.0, y + 1.0, text_scale, shadow)
263
+ draw_small_text(font, label, x, y, text_scale, color)
264
+ else:
265
+ rl.draw_text(label, int(x) + 1, int(y) + 1, int(18 * text_scale), shadow)
266
+ rl.draw_text(label, int(x), int(y), int(18 * text_scale), color)
267
+
268
+ def _draw_atlas_sprite(
269
+ self,
270
+ texture: rl.Texture,
271
+ *,
272
+ grid: int,
273
+ frame: int,
274
+ x: float,
275
+ y: float,
276
+ scale: float,
277
+ rotation_rad: float = 0.0,
278
+ tint: rl.Color = rl.WHITE,
279
+ ) -> None:
280
+ grid = max(1, int(grid))
281
+ frame = max(0, int(frame))
282
+ cell_w = float(texture.width) / float(grid)
283
+ cell_h = float(texture.height) / float(grid)
284
+ col = frame % grid
285
+ row = frame // grid
286
+ src = rl.Rectangle(cell_w * float(col), cell_h * float(row), cell_w, cell_h)
287
+ w = cell_w * float(scale)
288
+ h = cell_h * float(scale)
289
+ dst = rl.Rectangle(float(x), float(y), w, h)
290
+ origin = rl.Vector2(w * 0.5, h * 0.5)
291
+ rl.draw_texture_pro(texture, src, dst, origin, float(rotation_rad * _RAD_TO_DEG), tint)
292
+
293
+ @staticmethod
294
+ def _grim2d_circle_segments_filled(radius: float) -> int:
295
+ # grim_draw_circle_filled (grim.dll): segments = trunc(radius * 0.125 + 12.0)
296
+ return max(3, int(radius * 0.125 + 12.0))
297
+
298
+ @staticmethod
299
+ def _grim2d_circle_segments_outline(radius: float) -> int:
300
+ # grim_draw_circle_outline (grim.dll): segments = trunc(radius * 0.2 + 14.0)
301
+ return max(3, int(radius * 0.2 + 14.0))
302
+
303
+ def _draw_aim_circle(self, *, x: float, y: float, radius: float, alpha: float = 1.0) -> None:
304
+ if radius <= 1e-3:
305
+ return
306
+ alpha = clamp(float(alpha), 0.0, 1.0)
307
+ if alpha <= 1e-3:
308
+ return
309
+
310
+ fill_a = int(77 * alpha + 0.5) # ui_render_aim_indicators: rgba(0,0,0.1,0.3)
311
+ outline_a = int(255 * 0.55 * alpha + 0.5)
312
+ fill = rl.Color(0, 0, 26, fill_a)
313
+ outline = rl.Color(255, 255, 255, outline_a)
314
+
315
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
316
+
317
+ # The original uses a triangle fan (polygons). Raylib provides circle
318
+ # primitives that still use triangles internally, but allow higher
319
+ # segment counts for a smoother result when scaled.
320
+ seg_count = max(self._grim2d_circle_segments_filled(radius), 64, int(radius))
321
+ rl.draw_circle_sector(rl.Vector2(x, y), float(radius), 0.0, 360.0, int(seg_count), fill)
322
+
323
+ seg_count = max(self._grim2d_circle_segments_outline(radius), int(seg_count))
324
+ # grim_draw_circle_outline draws a 2px-thick ring (outer radius = r + 2).
325
+ # The exe binds bulletTrail, but that texture is white; the visual intent is
326
+ # a subtle white outline around the filled spread circle.
327
+ rl.draw_ring(rl.Vector2(x, y), float(radius), float(radius + 2.0), 0.0, 360.0, int(seg_count), outline)
328
+
329
+ rl.rl_set_texture(0)
330
+ rl.end_blend_mode()
331
+
332
+ def _draw_clock_gauge(self, *, x: float, y: float, ms: int, scale: float, alpha: float = 1.0) -> None:
333
+ if self.clock_table_texture is None or self.clock_pointer_texture is None:
334
+ return
335
+ size = 32.0 * scale
336
+ if size <= 1e-3:
337
+ return
338
+ tint = rl.Color(255, 255, 255, int(clamp(float(alpha), 0.0, 1.0) * 255.0 + 0.5))
339
+ half = size * 0.5
340
+
341
+ table_src = rl.Rectangle(0.0, 0.0, float(self.clock_table_texture.width), float(self.clock_table_texture.height))
342
+ table_dst = rl.Rectangle(float(x), float(y), size, size)
343
+ rl.draw_texture_pro(self.clock_table_texture, table_src, table_dst, rl.Vector2(0.0, 0.0), 0.0, tint)
344
+
345
+ seconds = int(ms) // 1000
346
+ pointer_src = rl.Rectangle(
347
+ 0.0,
348
+ 0.0,
349
+ float(self.clock_pointer_texture.width),
350
+ float(self.clock_pointer_texture.height),
351
+ )
352
+ pointer_dst = rl.Rectangle(float(x) + half, float(y) + half, size, size)
353
+ origin = rl.Vector2(half, half)
354
+ rotation_deg = float(seconds) * 6.0
355
+ rl.draw_texture_pro(self.clock_pointer_texture, pointer_src, pointer_dst, origin, rotation_deg, tint)
356
+
357
+ def _draw_creature_sprite(
358
+ self,
359
+ texture: rl.Texture,
360
+ *,
361
+ type_id: CreatureTypeId,
362
+ flags: CreatureFlags,
363
+ phase: float,
364
+ mirror_long: bool | None = None,
365
+ shadow_alpha: int | None = None,
366
+ world_x: float,
367
+ world_y: float,
368
+ rotation_rad: float,
369
+ scale: float,
370
+ size_scale: float,
371
+ tint: rl.Color,
372
+ shadow: bool = False,
373
+ ) -> None:
374
+ info = CREATURE_ANIM.get(type_id)
375
+ if info is None:
376
+ return
377
+ mirror_flag = info.mirror if mirror_long is None else mirror_long
378
+ # Long-strip mirroring is handled by frame index selection, not texture flips.
379
+ index, _, _ = creature_anim_select_frame(
380
+ phase,
381
+ base_frame=info.base,
382
+ mirror_long=mirror_flag,
383
+ flags=flags,
384
+ )
385
+ if index < 0:
386
+ return
387
+
388
+ sx, sy = self.world_to_screen(world_x, world_y)
389
+ width = float(texture.width) / 8.0 * size_scale * scale
390
+ height = float(texture.height) / 8.0 * size_scale * scale
391
+ src_x = float((index % 8) * (texture.width // 8))
392
+ src_y = float((index // 8) * (texture.height // 8))
393
+ src = rl.Rectangle(src_x, src_y, float(texture.width) / 8.0, float(texture.height) / 8.0)
394
+
395
+ rotation_deg = float(rotation_rad * _RAD_TO_DEG)
396
+
397
+ if shadow:
398
+ # In the original exe this is a "darken" blend pass gated by fx_detail_0
399
+ # (creature_render_type). We approximate it with a black silhouette draw.
400
+ # The observed pass is slightly bigger than the main sprite and offset
401
+ # down-right by ~1px at default sizes.
402
+ alpha = int(shadow_alpha) if shadow_alpha is not None else int(clamp(float(tint.a) * 0.4, 0.0, 255.0) + 0.5)
403
+ shadow_tint = rl.Color(0, 0, 0, alpha)
404
+ shadow_scale = 1.07
405
+ shadow_w = width * shadow_scale
406
+ shadow_h = height * shadow_scale
407
+ offset = width * 0.035 - 0.7 * scale
408
+ shadow_dst = rl.Rectangle(sx + offset, sy + offset, shadow_w, shadow_h)
409
+ shadow_origin = rl.Vector2(shadow_w * 0.5, shadow_h * 0.5)
410
+ rl.draw_texture_pro(texture, src, shadow_dst, shadow_origin, rotation_deg, shadow_tint)
411
+
412
+ dst = rl.Rectangle(sx, sy, width, height)
413
+ origin = rl.Vector2(width * 0.5, height * 0.5)
414
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, tint)
415
+
416
+ def _draw_player_trooper_sprite(
417
+ self,
418
+ texture: rl.Texture,
419
+ player: object,
420
+ *,
421
+ cam_x: float,
422
+ cam_y: float,
423
+ scale_x: float,
424
+ scale_y: float,
425
+ scale: float,
426
+ alpha: float = 1.0,
427
+ ) -> None:
428
+ alpha = clamp(float(alpha), 0.0, 1.0)
429
+ if alpha <= 1e-3:
430
+ return
431
+ grid = 8
432
+ cell = float(texture.width) / float(grid) if grid > 0 else float(texture.width)
433
+ if cell <= 0.0:
434
+ return
435
+
436
+ sx = (player.pos_x + cam_x) * scale_x
437
+ sy = (player.pos_y + cam_y) * scale_y
438
+ base_size = float(player.size) * scale
439
+ base_scale = base_size / cell
440
+
441
+ if (
442
+ self.particles_texture is not None
443
+ and perk_active(player, PerkId.RADIOACTIVE)
444
+ and alpha > 1e-3
445
+ ):
446
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x10)
447
+ if atlas is not None:
448
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
449
+ if grid:
450
+ frame = int(atlas.frame)
451
+ col = frame % grid
452
+ row = frame // grid
453
+ cell_w = float(self.particles_texture.width) / float(grid)
454
+ cell_h = float(self.particles_texture.height) / float(grid)
455
+ src = rl.Rectangle(
456
+ cell_w * float(col),
457
+ cell_h * float(row),
458
+ max(0.0, cell_w - 2.0),
459
+ max(0.0, cell_h - 2.0),
460
+ )
461
+ t = float(self._elapsed_ms) * 0.001
462
+ aura_alpha = ((math.sin(t) + 1.0) * 0.1875 + 0.25) * alpha
463
+ if aura_alpha > 1e-3:
464
+ size = 100.0 * scale
465
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
466
+ origin = rl.Vector2(size * 0.5, size * 0.5)
467
+ tint = rl.Color(77, 153, 77, int(clamp(aura_alpha, 0.0, 1.0) * 255.0 + 0.5))
468
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
469
+ rl.draw_texture_pro(self.particles_texture, src, dst, origin, 0.0, tint)
470
+ rl.end_blend_mode()
471
+
472
+ tint = rl.Color(240, 240, 255, int(255 * alpha + 0.5))
473
+ shadow_tint = rl.Color(0, 0, 0, int(90 * alpha + 0.5))
474
+ overlay_tint = tint
475
+ if len(self.players) > 1:
476
+ index = int(getattr(player, "index", 0))
477
+ if index == 0:
478
+ overlay_tint = rl.Color(77, 77, 255, tint.a)
479
+ else:
480
+ overlay_tint = rl.Color(255, 140, 89, tint.a)
481
+
482
+ def draw(frame: int, *, x: float, y: float, scale_mul: float, rotation: float, color: rl.Color) -> None:
483
+ self._draw_atlas_sprite(
484
+ texture,
485
+ grid=grid,
486
+ frame=max(0, min(63, int(frame))),
487
+ x=x,
488
+ y=y,
489
+ scale=base_scale * float(scale_mul),
490
+ rotation_rad=float(rotation),
491
+ tint=color,
492
+ )
493
+
494
+ if player.health > 0.0:
495
+ leg_frame = max(0, min(14, int(player.move_phase + 0.5)))
496
+ torso_frame = leg_frame + 16
497
+
498
+ recoil_dir = float(player.aim_heading) + math.pi / 2.0
499
+ recoil = float(player.muzzle_flash_alpha) * 12.0 * scale
500
+ recoil_x = math.cos(recoil_dir) * recoil
501
+ recoil_y = math.sin(recoil_dir) * recoil
502
+
503
+ leg_shadow_scale = 1.02
504
+ torso_shadow_scale = 1.03
505
+ leg_shadow_off = 3.0 * scale + base_size * (leg_shadow_scale - 1.0) * 0.5
506
+ torso_shadow_off = 1.0 * scale + base_size * (torso_shadow_scale - 1.0) * 0.5
507
+
508
+ draw(
509
+ leg_frame,
510
+ x=sx + leg_shadow_off,
511
+ y=sy + leg_shadow_off,
512
+ scale_mul=leg_shadow_scale,
513
+ rotation=float(player.heading),
514
+ color=shadow_tint,
515
+ )
516
+ draw(
517
+ torso_frame,
518
+ x=sx + recoil_x + torso_shadow_off,
519
+ y=sy + recoil_y + torso_shadow_off,
520
+ scale_mul=torso_shadow_scale,
521
+ rotation=float(player.aim_heading),
522
+ color=shadow_tint,
523
+ )
524
+
525
+ draw(
526
+ leg_frame,
527
+ x=sx,
528
+ y=sy,
529
+ scale_mul=1.0,
530
+ rotation=float(player.heading),
531
+ color=tint,
532
+ )
533
+ draw(
534
+ torso_frame,
535
+ x=sx + recoil_x,
536
+ y=sy + recoil_y,
537
+ scale_mul=1.0,
538
+ rotation=float(player.aim_heading),
539
+ color=overlay_tint,
540
+ )
541
+
542
+ if self.particles_texture is not None and float(player.shield_timer) > 1e-3 and alpha > 1e-3:
543
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x02)
544
+ if atlas is not None:
545
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
546
+ if grid:
547
+ frame = int(atlas.frame)
548
+ col = frame % grid
549
+ row = frame // grid
550
+ cell_w = float(self.particles_texture.width) / float(grid)
551
+ cell_h = float(self.particles_texture.height) / float(grid)
552
+ src = rl.Rectangle(
553
+ cell_w * float(col),
554
+ cell_h * float(row),
555
+ max(0.0, cell_w - 2.0),
556
+ max(0.0, cell_h - 2.0),
557
+ )
558
+ t = float(self._elapsed_ms) * 0.001
559
+ timer = float(player.shield_timer)
560
+ strength = (math.sin(t) + 1.0) * 0.25 + timer
561
+ if timer < 1.0:
562
+ strength *= timer
563
+ strength = min(1.0, strength) * alpha
564
+ if strength > 1e-3:
565
+ offset_dir = float(player.aim_heading) - math.pi / 2.0
566
+ ox = math.cos(offset_dir) * 3.0 * scale
567
+ oy = math.sin(offset_dir) * 3.0 * scale
568
+ cx = sx + ox
569
+ cy = sy + oy
570
+
571
+ half = math.sin(t * 3.0) + 17.5
572
+ size = half * 2.0 * scale
573
+ a = int(clamp(strength * 0.4, 0.0, 1.0) * 255.0 + 0.5)
574
+ tint = rl.Color(91, 180, 255, a)
575
+ dst = rl.Rectangle(float(cx), float(cy), float(size), float(size))
576
+ origin = rl.Vector2(size * 0.5, size * 0.5)
577
+ rotation_deg = float((t + t) * _RAD_TO_DEG)
578
+
579
+ half = math.sin(t * 3.0) * 4.0 + 24.0
580
+ size2 = half * 2.0 * scale
581
+ a2 = int(clamp(strength * 0.3, 0.0, 1.0) * 255.0 + 0.5)
582
+ tint2 = rl.Color(91, 180, 255, a2)
583
+ dst2 = rl.Rectangle(float(cx), float(cy), float(size2), float(size2))
584
+ origin2 = rl.Vector2(size2 * 0.5, size2 * 0.5)
585
+ rotation2_deg = float((t * -2.0) * _RAD_TO_DEG)
586
+
587
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
588
+ rl.draw_texture_pro(self.particles_texture, src, dst, origin, rotation_deg, tint)
589
+ rl.draw_texture_pro(self.particles_texture, src, dst2, origin2, rotation2_deg, tint2)
590
+ rl.end_blend_mode()
591
+
592
+ if self.muzzle_flash_texture is not None and float(player.muzzle_flash_alpha) > 1e-3 and alpha > 1e-3:
593
+ weapon = WEAPON_BY_ID.get(int(player.weapon_id))
594
+ flags = int(weapon.flags) if weapon is not None and weapon.flags is not None else 0
595
+ if (flags & 0x8) == 0:
596
+ flash_alpha = clamp(float(player.muzzle_flash_alpha) * 0.8, 0.0, 1.0) * alpha
597
+ if flash_alpha > 1e-3:
598
+ size = base_size * (0.5 if (flags & 0x4) else 1.0)
599
+ heading = float(player.aim_heading) + math.pi / 2.0
600
+ offset = (float(player.muzzle_flash_alpha) * 12.0 - 21.0) * scale
601
+ pos_x = sx + math.cos(heading) * offset
602
+ pos_y = sy + math.sin(heading) * offset
603
+ src = rl.Rectangle(
604
+ 0.0,
605
+ 0.0,
606
+ float(self.muzzle_flash_texture.width),
607
+ float(self.muzzle_flash_texture.height),
608
+ )
609
+ dst = rl.Rectangle(pos_x, pos_y, size, size)
610
+ origin = rl.Vector2(size * 0.5, size * 0.5)
611
+ tint_flash = rl.Color(255, 255, 255, int(flash_alpha * 255.0 + 0.5))
612
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
613
+ rl.draw_texture_pro(
614
+ self.muzzle_flash_texture,
615
+ src,
616
+ dst,
617
+ origin,
618
+ float(player.aim_heading * _RAD_TO_DEG),
619
+ tint_flash,
620
+ )
621
+ rl.end_blend_mode()
622
+ return
623
+
624
+ if player.death_timer >= 0.0:
625
+ # Matches the observed frame ramp (32..52) in player_sprite_trace.jsonl.
626
+ frame = 32 + int((16.0 - float(player.death_timer)) * 1.25)
627
+ if frame > 52:
628
+ frame = 52
629
+ if frame < 32:
630
+ frame = 32
631
+ else:
632
+ frame = 52
633
+
634
+ dead_shadow_scale = 1.03
635
+ dead_shadow_off = 1.0 * scale + base_size * (dead_shadow_scale - 1.0) * 0.5
636
+ draw(
637
+ frame,
638
+ x=sx + dead_shadow_off,
639
+ y=sy + dead_shadow_off,
640
+ scale_mul=dead_shadow_scale,
641
+ rotation=float(player.aim_heading),
642
+ color=shadow_tint,
643
+ )
644
+ draw(frame, x=sx, y=sy, scale_mul=1.0, rotation=float(player.aim_heading), color=overlay_tint)
645
+
646
+ def _draw_projectile(self, proj: object, *, scale: float, alpha: float = 1.0) -> None:
647
+ alpha = clamp(float(alpha), 0.0, 1.0)
648
+ if alpha <= 1e-3:
649
+ return
650
+ texture = self.projs_texture
651
+ type_id = int(getattr(proj, "type_id", 0))
652
+ pos_x = float(getattr(proj, "pos_x", 0.0))
653
+ pos_y = float(getattr(proj, "pos_y", 0.0))
654
+ sx, sy = self.world_to_screen(pos_x, pos_y)
655
+ life = float(getattr(proj, "life_timer", 0.0))
656
+ angle = float(getattr(proj, "angle", 0.0))
657
+
658
+ if self._is_bullet_trail_type(type_id):
659
+ life_alpha = int(clamp(life, 0.0, 1.0) * 255)
660
+ alpha_byte = int(clamp(float(life_alpha) * alpha, 0.0, 255.0) + 0.5)
661
+ drawn = False
662
+ if self.bullet_trail_texture is not None:
663
+ ox = float(getattr(proj, "origin_x", pos_x))
664
+ oy = float(getattr(proj, "origin_y", pos_y))
665
+ sx0, sy0 = self.world_to_screen(ox, oy)
666
+ sx1, sy1 = sx, sy
667
+ drawn = self._draw_bullet_trail(sx0, sy0, sx1, sy1, type_id=type_id, alpha=alpha_byte, scale=scale)
668
+
669
+ if self.bullet_texture is not None and life >= 0.39:
670
+ size = self._bullet_sprite_size(type_id, scale=scale)
671
+ src = rl.Rectangle(
672
+ 0.0,
673
+ 0.0,
674
+ float(self.bullet_texture.width),
675
+ float(self.bullet_texture.height),
676
+ )
677
+ dst = rl.Rectangle(float(sx), float(sy), size, size)
678
+ origin = rl.Vector2(size * 0.5, size * 0.5)
679
+ tint = rl.Color(220, 220, 220, alpha_byte)
680
+ rl.draw_texture_pro(self.bullet_texture, src, dst, origin, float(angle * _RAD_TO_DEG), tint)
681
+ drawn = True
682
+
683
+ if drawn:
684
+ return
685
+
686
+ mapping = KNOWN_PROJ_FRAMES.get(type_id)
687
+ if texture is None or mapping is None:
688
+ rl.draw_circle(int(sx), int(sy), max(1.0, 3.0 * scale), rl.Color(240, 220, 160, int(255 * alpha + 0.5)))
689
+ return
690
+ grid, frame = mapping
691
+
692
+ color = rl.Color(240, 220, 160, 255)
693
+ if type_id in (ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_MINIGUN, ProjectileTypeId.ION_CANNON):
694
+ color = rl.Color(120, 200, 255, 255)
695
+ elif type_id == ProjectileTypeId.FIRE_BULLETS:
696
+ color = rl.Color(255, 170, 90, 255)
697
+ elif type_id == ProjectileTypeId.SHRINKIFIER:
698
+ color = rl.Color(160, 255, 170, 255)
699
+ elif type_id == ProjectileTypeId.BLADE_GUN:
700
+ color = rl.Color(240, 120, 255, 255)
701
+
702
+ if type_id in BEAM_TYPES and life >= 0.4:
703
+ ox = float(getattr(proj, "origin_x", 0.0))
704
+ oy = float(getattr(proj, "origin_y", 0.0))
705
+ dx = float(getattr(proj, "pos_x", 0.0)) - ox
706
+ dy = float(getattr(proj, "pos_y", 0.0)) - oy
707
+ dist = math.hypot(dx, dy)
708
+ if dist > 1e-6:
709
+ step = 14.0
710
+ seg_count = max(1, int(dist // step) + 1)
711
+ dir_x = dx / dist
712
+ dir_y = dy / dist
713
+ for idx in range(seg_count):
714
+ t = float(idx) / float(max(1, seg_count - 1))
715
+ px = ox + dir_x * dist * t
716
+ py = oy + dir_y * dist * t
717
+ seg_alpha = int(clamp(220.0 * (1.0 - t * 0.75) * alpha, 0.0, 255.0) + 0.5)
718
+ tint = rl.Color(color.r, color.g, color.b, seg_alpha)
719
+ psx, psy = self.world_to_screen(px, py)
720
+ self._draw_atlas_sprite(
721
+ texture,
722
+ grid=grid,
723
+ frame=frame,
724
+ x=psx,
725
+ y=psy,
726
+ scale=0.55 * scale,
727
+ rotation_rad=angle,
728
+ tint=tint,
729
+ )
730
+ return
731
+
732
+ alpha_byte = int(clamp(clamp(life / 0.4, 0.0, 1.0) * 255.0 * alpha, 0.0, 255.0) + 0.5)
733
+ tint = rl.Color(color.r, color.g, color.b, alpha_byte)
734
+ self._draw_atlas_sprite(
735
+ texture,
736
+ grid=grid,
737
+ frame=frame,
738
+ x=sx,
739
+ y=sy,
740
+ scale=0.6 * scale,
741
+ rotation_rad=angle,
742
+ tint=tint,
743
+ )
744
+
745
+ @staticmethod
746
+ def _is_bullet_trail_type(type_id: int) -> bool:
747
+ return 0 <= type_id < 8 or type_id == int(ProjectileTypeId.SPLITTER_GUN)
748
+
749
+ @staticmethod
750
+ def _bullet_sprite_size(type_id: int, *, scale: float) -> float:
751
+ base = 4.0
752
+ if type_id == int(ProjectileTypeId.ASSAULT_RIFLE):
753
+ base = 6.0
754
+ elif type_id == int(ProjectileTypeId.SUBMACHINE_GUN):
755
+ base = 8.0
756
+ return max(2.0, base * scale)
757
+
758
+ def _draw_bullet_trail(
759
+ self,
760
+ sx0: float,
761
+ sy0: float,
762
+ sx1: float,
763
+ sy1: float,
764
+ *,
765
+ type_id: int,
766
+ alpha: int,
767
+ scale: float,
768
+ ) -> bool:
769
+ if self.bullet_trail_texture is None:
770
+ return False
771
+ if alpha <= 0:
772
+ return False
773
+ dx = sx1 - sx0
774
+ dy = sy1 - sy0
775
+ dist = math.hypot(dx, dy)
776
+ if dist <= 1e-3:
777
+ return False
778
+ thickness = max(1.0, 2.1 * scale)
779
+ half = thickness * 0.5
780
+ inv = 1.0 / dist
781
+ nx = dx * inv
782
+ ny = dy * inv
783
+ px = -ny
784
+ py = nx
785
+ ox = px * half
786
+ oy = py * half
787
+ x0 = sx0 - ox
788
+ y0 = sy0 - oy
789
+ x1 = sx0 + ox
790
+ y1 = sy0 + oy
791
+ x2 = sx1 + ox
792
+ y2 = sy1 + oy
793
+ x3 = sx1 - ox
794
+ y3 = sy1 - oy
795
+
796
+ # Native uses additive blending for bullet trails and sets color slots per projectile type.
797
+ # Gauss has a distinct blue tint; most other bullet trails are neutral gray.
798
+ if type_id == int(ProjectileTypeId.GAUSS_GUN):
799
+ head_rgb = (51, 128, 255) # (0.2, 0.5, 1.0)
800
+ else:
801
+ head_rgb = (128, 128, 128) # (0.5, 0.5, 0.5)
802
+
803
+ tail_rgb = (128, 128, 128)
804
+ head = rl.Color(head_rgb[0], head_rgb[1], head_rgb[2], alpha)
805
+ tail = rl.Color(tail_rgb[0], tail_rgb[1], tail_rgb[2], 0)
806
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
807
+ rl.rl_set_texture(self.bullet_trail_texture.id)
808
+ rl.rl_begin(rl.RL_QUADS)
809
+ rl.rl_color4ub(tail.r, tail.g, tail.b, tail.a)
810
+ rl.rl_tex_coord2f(0.0, 0.0)
811
+ rl.rl_vertex2f(x0, y0)
812
+ rl.rl_color4ub(tail.r, tail.g, tail.b, tail.a)
813
+ rl.rl_tex_coord2f(1.0, 0.0)
814
+ rl.rl_vertex2f(x1, y1)
815
+ rl.rl_color4ub(head.r, head.g, head.b, head.a)
816
+ rl.rl_tex_coord2f(1.0, 0.5)
817
+ rl.rl_vertex2f(x2, y2)
818
+ rl.rl_color4ub(head.r, head.g, head.b, head.a)
819
+ rl.rl_tex_coord2f(0.0, 0.5)
820
+ rl.rl_vertex2f(x3, y3)
821
+ rl.rl_end()
822
+ rl.rl_set_texture(0)
823
+ rl.end_blend_mode()
824
+ return True
825
+
826
+ def _draw_secondary_projectile(self, proj: object, *, scale: float, alpha: float = 1.0) -> None:
827
+ alpha = clamp(float(alpha), 0.0, 1.0)
828
+ if alpha <= 1e-3:
829
+ return
830
+ sx, sy = self.world_to_screen(float(getattr(proj, "pos_x", 0.0)), float(getattr(proj, "pos_y", 0.0)))
831
+ proj_type = int(getattr(proj, "type_id", 0))
832
+ if proj_type == 4:
833
+ rl.draw_circle(int(sx), int(sy), max(1.0, 12.0 * scale), rl.Color(200, 120, 255, int(255 * alpha + 0.5)))
834
+ return
835
+ if proj_type == 3:
836
+ t = clamp(float(getattr(proj, "lifetime", 0.0)), 0.0, 1.0)
837
+ radius = float(getattr(proj, "speed", 1.0)) * t * 80.0
838
+ alpha_byte = int(clamp((1.0 - t) * 180.0 * alpha, 0.0, 255.0) + 0.5)
839
+ color = rl.Color(200, 120, 255, alpha_byte)
840
+ rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
841
+ return
842
+ rl.draw_circle(int(sx), int(sy), max(1.0, 4.0 * scale), rl.Color(200, 200, 220, int(200 * alpha + 0.5)))
843
+
844
+ def _draw_particle_pool(self, *, cam_x: float, cam_y: float, scale_x: float, scale_y: float, alpha: float = 1.0) -> None:
845
+ alpha = clamp(float(alpha), 0.0, 1.0)
846
+ if alpha <= 1e-3:
847
+ return
848
+ texture = self.particles_texture
849
+ if texture is None:
850
+ return
851
+
852
+ particles = self.state.particles.entries
853
+ if not any(entry.active for entry in particles):
854
+ return
855
+
856
+ scale = (scale_x + scale_y) * 0.5
857
+
858
+ def src_rect(effect_id: int) -> rl.Rectangle | None:
859
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(int(effect_id))
860
+ if atlas is None:
861
+ return None
862
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
863
+ if not grid:
864
+ return None
865
+ frame = int(atlas.frame)
866
+ col = frame % grid
867
+ row = frame // grid
868
+ cell_w = float(texture.width) / float(grid)
869
+ cell_h = float(texture.height) / float(grid)
870
+ return rl.Rectangle(
871
+ cell_w * float(col),
872
+ cell_h * float(row),
873
+ max(0.0, cell_w - 2.0),
874
+ max(0.0, cell_h - 2.0),
875
+ )
876
+
877
+ src_large = src_rect(13)
878
+ src_normal = src_rect(12)
879
+ src_style_8 = src_rect(2)
880
+ if src_normal is None or src_style_8 is None:
881
+ return
882
+
883
+ fx_detail_1 = bool(self.config.data.get("fx_detail_1", 0)) if self.config is not None else True
884
+
885
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
886
+
887
+ if fx_detail_1 and src_large is not None:
888
+ alpha_byte = int(clamp(alpha * 0.04, 0.0, 1.0) * 255.0 + 0.5)
889
+ tint = rl.Color(255, 255, 255, alpha_byte)
890
+ for idx, entry in enumerate(particles):
891
+ if not entry.active or (idx % 2) or int(entry.style_id) == 8:
892
+ continue
893
+ radius = (math.sin((1.0 - float(entry.intensity)) * 1.5707964) + 0.1) * 55.0 + 4.0
894
+ radius = max(radius, 16.0)
895
+ size = max(0.0, radius * 2.0 * scale)
896
+ if size <= 0.0:
897
+ continue
898
+ sx = (float(entry.pos_x) + cam_x) * scale_x
899
+ sy = (float(entry.pos_y) + cam_y) * scale_y
900
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
901
+ origin = rl.Vector2(float(size) * 0.5, float(size) * 0.5)
902
+ rl.draw_texture_pro(texture, src_large, dst, origin, 0.0, tint)
903
+
904
+ for entry in particles:
905
+ if not entry.active or int(entry.style_id) == 8:
906
+ continue
907
+ radius = math.sin((1.0 - float(entry.intensity)) * 1.5707964) * 24.0
908
+ if int(entry.style_id) == 1:
909
+ radius *= 0.8
910
+ radius = max(radius, 2.0)
911
+ size = max(0.0, radius * 2.0 * scale)
912
+ if size <= 0.0:
913
+ continue
914
+ sx = (float(entry.pos_x) + cam_x) * scale_x
915
+ sy = (float(entry.pos_y) + cam_y) * scale_y
916
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
917
+ origin = rl.Vector2(float(size) * 0.5, float(size) * 0.5)
918
+ rotation_deg = float(entry.spin) * _RAD_TO_DEG
919
+ tint = self._color_from_rgba((entry.scale_x, entry.scale_y, entry.scale_z, float(entry.age) * alpha))
920
+ rl.draw_texture_pro(texture, src_normal, dst, origin, rotation_deg, tint)
921
+
922
+ alpha_byte = int(clamp(alpha, 0.0, 1.0) * 255.0 + 0.5)
923
+ for entry in particles:
924
+ if not entry.active or int(entry.style_id) != 8:
925
+ continue
926
+ wobble = math.sin(float(entry.spin)) * 3.0
927
+ half_h = (wobble + 15.0) * float(entry.scale_x) * 7.0
928
+ half_w = (15.0 - wobble) * float(entry.scale_x) * 7.0
929
+ w = max(0.0, half_w * 2.0 * scale)
930
+ h = max(0.0, half_h * 2.0 * scale)
931
+ if w <= 0.0 or h <= 0.0:
932
+ continue
933
+ sx = (float(entry.pos_x) + cam_x) * scale_x
934
+ sy = (float(entry.pos_y) + cam_y) * scale_y
935
+ dst = rl.Rectangle(float(sx), float(sy), float(w), float(h))
936
+ origin = rl.Vector2(float(w) * 0.5, float(h) * 0.5)
937
+ tint = rl.Color(255, 255, 255, int(float(entry.age) * alpha_byte + 0.5))
938
+ rl.draw_texture_pro(texture, src_style_8, dst, origin, 0.0, tint)
939
+
940
+ rl.end_blend_mode()
941
+
942
+ def _draw_sprite_effect_pool(
943
+ self,
944
+ *,
945
+ cam_x: float,
946
+ cam_y: float,
947
+ scale_x: float,
948
+ scale_y: float,
949
+ alpha: float = 1.0,
950
+ ) -> None:
951
+ alpha = clamp(float(alpha), 0.0, 1.0)
952
+ if alpha <= 1e-3:
953
+ return
954
+ if self.config is not None and not bool(self.config.data.get("fx_detail_2", 0)):
955
+ return
956
+ texture = self.particles_texture
957
+ if texture is None:
958
+ return
959
+
960
+ effects = self.state.sprite_effects.entries
961
+ if not any(entry.active for entry in effects):
962
+ return
963
+
964
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x11)
965
+ if atlas is None:
966
+ return
967
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
968
+ if not grid:
969
+ return
970
+ frame = int(atlas.frame)
971
+ col = frame % grid
972
+ row = frame // grid
973
+ cell_w = float(texture.width) / float(grid)
974
+ cell_h = float(texture.height) / float(grid)
975
+ src = rl.Rectangle(cell_w * float(col), cell_h * float(row), cell_w, cell_h)
976
+ scale = (scale_x + scale_y) * 0.5
977
+
978
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
979
+ for entry in effects:
980
+ if not entry.active:
981
+ continue
982
+ size = float(entry.scale) * scale
983
+ if size <= 0.0:
984
+ continue
985
+ sx = (float(entry.pos_x) + cam_x) * scale_x
986
+ sy = (float(entry.pos_y) + cam_y) * scale_y
987
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
988
+ origin = rl.Vector2(float(size) * 0.5, float(size) * 0.5)
989
+ rotation_deg = float(entry.rotation) * _RAD_TO_DEG
990
+ tint = self._color_from_rgba((entry.color_r, entry.color_g, entry.color_b, float(entry.color_a) * alpha))
991
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, tint)
992
+ rl.end_blend_mode()
993
+
994
+ def _draw_effect_pool(self, *, cam_x: float, cam_y: float, scale_x: float, scale_y: float, alpha: float = 1.0) -> None:
995
+ alpha = clamp(float(alpha), 0.0, 1.0)
996
+ if alpha <= 1e-3:
997
+ return
998
+ texture = self.particles_texture
999
+ if texture is None:
1000
+ return
1001
+
1002
+ effects = self.state.effects.entries
1003
+ if not any(entry.flags and entry.age >= 0.0 for entry in effects):
1004
+ return
1005
+
1006
+ scale = (scale_x + scale_y) * 0.5
1007
+
1008
+ src_cache: dict[int, rl.Rectangle] = {}
1009
+
1010
+ def src_rect(effect_id: int) -> rl.Rectangle | None:
1011
+ cached = src_cache.get(effect_id)
1012
+ if cached is not None:
1013
+ return cached
1014
+
1015
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(int(effect_id))
1016
+ if atlas is None:
1017
+ return None
1018
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1019
+ if not grid:
1020
+ return None
1021
+ frame = int(atlas.frame)
1022
+ col = frame % grid
1023
+ row = frame // grid
1024
+ cell_w = float(texture.width) / float(grid)
1025
+ cell_h = float(texture.height) / float(grid)
1026
+ # Native effect pool clamps UVs to (cell_size - 2px) to avoid bleeding.
1027
+ src = rl.Rectangle(
1028
+ cell_w * float(col),
1029
+ cell_h * float(row),
1030
+ max(0.0, cell_w - 2.0),
1031
+ max(0.0, cell_h - 2.0),
1032
+ )
1033
+ src_cache[effect_id] = src
1034
+ return src
1035
+
1036
+ def draw_entry(entry: object) -> None:
1037
+ effect_id = int(getattr(entry, "effect_id", 0))
1038
+ src = src_rect(effect_id)
1039
+ if src is None:
1040
+ return
1041
+
1042
+ pos_x = float(getattr(entry, "pos_x", 0.0))
1043
+ pos_y = float(getattr(entry, "pos_y", 0.0))
1044
+ sx = (pos_x + cam_x) * scale_x
1045
+ sy = (pos_y + cam_y) * scale_y
1046
+
1047
+ half_w = float(getattr(entry, "half_width", 0.0))
1048
+ half_h = float(getattr(entry, "half_height", 0.0))
1049
+ local_scale = float(getattr(entry, "scale", 1.0))
1050
+ w = max(0.0, half_w * 2.0 * local_scale * scale)
1051
+ h = max(0.0, half_h * 2.0 * local_scale * scale)
1052
+ if w <= 0.0 or h <= 0.0:
1053
+ return
1054
+
1055
+ rotation_deg = float(getattr(entry, "rotation", 0.0)) * _RAD_TO_DEG
1056
+ tint = self._color_from_rgba(
1057
+ (
1058
+ float(getattr(entry, "color_r", 1.0)),
1059
+ float(getattr(entry, "color_g", 1.0)),
1060
+ float(getattr(entry, "color_b", 1.0)),
1061
+ float(getattr(entry, "color_a", 1.0)),
1062
+ )
1063
+ )
1064
+ tint = rl.Color(tint.r, tint.g, tint.b, int(tint.a * alpha + 0.5))
1065
+
1066
+ dst = rl.Rectangle(float(sx), float(sy), float(w), float(h))
1067
+ origin = rl.Vector2(float(w) * 0.5, float(h) * 0.5)
1068
+ rl.draw_texture_pro(texture, src, dst, origin, rotation_deg, tint)
1069
+
1070
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
1071
+ for entry in effects:
1072
+ if not entry.flags or entry.age < 0.0:
1073
+ continue
1074
+ if int(entry.flags) & 0x40:
1075
+ draw_entry(entry)
1076
+ rl.end_blend_mode()
1077
+
1078
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
1079
+ for entry in effects:
1080
+ if not entry.flags or entry.age < 0.0:
1081
+ continue
1082
+ if not (int(entry.flags) & 0x40):
1083
+ draw_entry(entry)
1084
+ rl.end_blend_mode()
1085
+
1086
+ def draw(self, *, draw_aim_indicators: bool = True, entity_alpha: float = 1.0) -> None:
1087
+ entity_alpha = clamp(float(entity_alpha), 0.0, 1.0)
1088
+ clear_color = rl.Color(10, 10, 12, 255)
1089
+ screen_w, screen_h = self._camera_screen_size()
1090
+ cam_x, cam_y = self._clamp_camera(self.camera_x, self.camera_y, screen_w, screen_h)
1091
+ out_w = float(rl.get_screen_width())
1092
+ out_h = float(rl.get_screen_height())
1093
+ scale_x = out_w / screen_w if screen_w > 0 else 1.0
1094
+ scale_y = out_h / screen_h if screen_h > 0 else 1.0
1095
+ if self.ground is None:
1096
+ rl.clear_background(clear_color)
1097
+ else:
1098
+ rl.clear_background(clear_color)
1099
+ self.ground.draw(cam_x, cam_y, screen_w=screen_w, screen_h=screen_h)
1100
+ scale = (scale_x + scale_y) * 0.5
1101
+
1102
+ # World bounds for debug if terrain is missing.
1103
+ if self.ground is None:
1104
+ x0 = (0.0 + cam_x) * scale_x
1105
+ y0 = (0.0 + cam_y) * scale_y
1106
+ x1 = (float(self.world_size) + cam_x) * scale_x
1107
+ y1 = (float(self.world_size) + cam_y) * scale_y
1108
+ rl.draw_rectangle_lines(int(x0), int(y0), int(x1 - x0), int(y1 - y0), rl.Color(40, 40, 55, 255))
1109
+
1110
+ if entity_alpha <= 1e-3:
1111
+ return
1112
+
1113
+ alpha_test = True
1114
+ if self.ground is not None:
1115
+ alpha_test = bool(getattr(self.ground, "alpha_test", True))
1116
+
1117
+ with _maybe_alpha_test(bool(alpha_test)):
1118
+ trooper_texture = self.creature_textures.get(CREATURE_ASSET.get(CreatureTypeId.TROOPER))
1119
+ particles_texture = self.particles_texture
1120
+ monster_vision = bool(self.players) and perk_active(self.players[0], PerkId.MONSTER_VISION)
1121
+ monster_vision_src: rl.Rectangle | None = None
1122
+ if monster_vision and particles_texture is not None:
1123
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x10)
1124
+ if atlas is not None:
1125
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1126
+ if grid:
1127
+ frame = int(atlas.frame)
1128
+ col = frame % grid
1129
+ row = frame // grid
1130
+ cell_w = float(particles_texture.width) / float(grid)
1131
+ cell_h = float(particles_texture.height) / float(grid)
1132
+ monster_vision_src = rl.Rectangle(
1133
+ cell_w * float(col),
1134
+ cell_h * float(row),
1135
+ max(0.0, cell_w - 2.0),
1136
+ max(0.0, cell_h - 2.0),
1137
+ )
1138
+
1139
+ def draw_player(player: object) -> None:
1140
+ if trooper_texture is not None:
1141
+ self._draw_player_trooper_sprite(
1142
+ trooper_texture,
1143
+ player,
1144
+ cam_x=cam_x,
1145
+ cam_y=cam_y,
1146
+ scale_x=scale_x,
1147
+ scale_y=scale_y,
1148
+ scale=scale,
1149
+ alpha=entity_alpha,
1150
+ )
1151
+ return
1152
+
1153
+ sx = (player.pos_x + cam_x) * scale_x
1154
+ sy = (player.pos_y + cam_y) * scale_y
1155
+ tint = rl.Color(90, 190, 120, int(255 * entity_alpha + 0.5))
1156
+ rl.draw_circle(int(sx), int(sy), max(1.0, 14.0 * scale), tint)
1157
+
1158
+ for player in self.players:
1159
+ if player.health <= 0.0:
1160
+ draw_player(player)
1161
+
1162
+ creature_type_order = {
1163
+ int(CreatureTypeId.ZOMBIE): 0,
1164
+ int(CreatureTypeId.SPIDER_SP1): 1,
1165
+ int(CreatureTypeId.SPIDER_SP2): 2,
1166
+ int(CreatureTypeId.ALIEN): 3,
1167
+ int(CreatureTypeId.LIZARD): 4,
1168
+ }
1169
+ creatures = [
1170
+ (idx, creature)
1171
+ for idx, creature in enumerate(self.creatures.entries)
1172
+ if creature.active
1173
+ ]
1174
+ creatures.sort(key=lambda item: (creature_type_order.get(int(getattr(item[1], "type_id", -1)), 999), item[0]))
1175
+ for _idx, creature in creatures:
1176
+ sx = (creature.x + cam_x) * scale_x
1177
+ sy = (creature.y + cam_y) * scale_y
1178
+ hitbox_size = float(creature.hitbox_size)
1179
+ try:
1180
+ type_id = CreatureTypeId(int(creature.type_id))
1181
+ except ValueError:
1182
+ type_id = None
1183
+ asset = CREATURE_ASSET.get(type_id) if type_id is not None else None
1184
+ texture = self.creature_textures.get(asset) if asset is not None else None
1185
+ if monster_vision and particles_texture is not None and monster_vision_src is not None:
1186
+ fade = monster_vision_fade_alpha(hitbox_size)
1187
+ mv_alpha = fade * entity_alpha
1188
+ if mv_alpha > 1e-3:
1189
+ size = 90.0 * scale
1190
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
1191
+ origin = rl.Vector2(size * 0.5, size * 0.5)
1192
+ tint = rl.Color(255, 255, 0, int(clamp(mv_alpha, 0.0, 1.0) * 255.0 + 0.5))
1193
+ rl.draw_texture_pro(particles_texture, monster_vision_src, dst, origin, 0.0, tint)
1194
+ if texture is None:
1195
+ tint = rl.Color(220, 90, 90, int(255 * entity_alpha + 0.5))
1196
+ rl.draw_circle(int(sx), int(sy), max(1.0, creature.size * 0.5 * scale), tint)
1197
+ continue
1198
+
1199
+ info = CREATURE_ANIM.get(type_id) if type_id is not None else None
1200
+ if info is None:
1201
+ continue
1202
+
1203
+ tint_alpha = float(creature.tint_a)
1204
+ if hitbox_size < 0.0:
1205
+ # Mirrors the main-pass alpha fade when hitbox_size ramps negative.
1206
+ tint_alpha = max(0.0, tint_alpha + hitbox_size * 0.1)
1207
+ tint_alpha = clamp(tint_alpha * entity_alpha, 0.0, 1.0)
1208
+ tint = self._color_from_rgba((creature.tint_r, creature.tint_g, creature.tint_b, tint_alpha))
1209
+
1210
+ size_scale = clamp(float(creature.size) / 64.0, 0.25, 2.0)
1211
+ fx_detail = bool(self.config.data.get("fx_detail_0", 0)) if self.config is not None else True
1212
+ # Mirrors `creature_render_type`: the "shadow-ish" pass is gated by fx_detail_0
1213
+ # and is disabled when the Monster Vision perk is active.
1214
+ shadow = fx_detail and (not self.players or not perk_active(self.players[0], PerkId.MONSTER_VISION))
1215
+ long_strip = (creature.flags & CreatureFlags.ANIM_PING_PONG) == 0 or (
1216
+ creature.flags & CreatureFlags.ANIM_LONG_STRIP
1217
+ ) != 0
1218
+ phase = float(creature.anim_phase)
1219
+ if long_strip:
1220
+ if hitbox_size < 0.0:
1221
+ # Negative phase selects the fallback "corpse" frame in creature_render_type.
1222
+ phase = -1.0
1223
+ elif hitbox_size < 16.0:
1224
+ # Death staging: while hitbox_size ramps down (16..0), creature_render_type
1225
+ # selects frames via `__ftol((base_frame + 15) - hitbox_size)`.
1226
+ phase = float(info.base + 0x0F) - hitbox_size - 0.5
1227
+
1228
+ shadow_alpha = None
1229
+ if shadow:
1230
+ # Shadow pass uses tint_a * 0.4 and fades much faster for corpses (hitbox_size < 0).
1231
+ shadow_a = float(creature.tint_a) * 0.4
1232
+ if hitbox_size < 0.0:
1233
+ shadow_a += hitbox_size * (0.5 if long_strip else 0.1)
1234
+ shadow_a = max(0.0, shadow_a)
1235
+ shadow_alpha = int(clamp(shadow_a * entity_alpha * 255.0, 0.0, 255.0) + 0.5)
1236
+ self._draw_creature_sprite(
1237
+ texture,
1238
+ type_id=type_id or CreatureTypeId.ZOMBIE,
1239
+ flags=creature.flags,
1240
+ phase=phase,
1241
+ mirror_long=bool(info.mirror) and hitbox_size >= 16.0,
1242
+ shadow_alpha=shadow_alpha,
1243
+ world_x=float(creature.x),
1244
+ world_y=float(creature.y),
1245
+ rotation_rad=float(creature.heading) - math.pi / 2.0,
1246
+ scale=scale,
1247
+ size_scale=size_scale,
1248
+ tint=tint,
1249
+ shadow=shadow,
1250
+ )
1251
+
1252
+ freeze_timer = float(self.state.bonuses.freeze)
1253
+ if particles_texture is not None and freeze_timer > 0.0:
1254
+ atlas = EFFECT_ID_ATLAS_TABLE_BY_ID.get(0x0E)
1255
+ if atlas is not None:
1256
+ grid = SIZE_CODE_GRID.get(int(atlas.size_code))
1257
+ if grid:
1258
+ cell_w = float(particles_texture.width) / float(grid)
1259
+ cell_h = float(particles_texture.height) / float(grid)
1260
+ frame = int(atlas.frame)
1261
+ col = frame % grid
1262
+ row = frame // grid
1263
+ src = rl.Rectangle(
1264
+ cell_w * float(col),
1265
+ cell_h * float(row),
1266
+ max(0.0, cell_w - 2.0),
1267
+ max(0.0, cell_h - 2.0),
1268
+ )
1269
+
1270
+ fade = 1.0 if freeze_timer >= 1.0 else clamp(freeze_timer, 0.0, 1.0)
1271
+ freeze_alpha = clamp(fade * entity_alpha * 0.7, 0.0, 1.0)
1272
+ if freeze_alpha > 1e-3:
1273
+ tint = rl.Color(255, 255, 255, int(freeze_alpha * 255.0 + 0.5))
1274
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
1275
+ for idx, creature in enumerate(self.creatures.entries):
1276
+ if not creature.active:
1277
+ continue
1278
+ size = float(creature.size) * scale
1279
+ if size <= 1e-3:
1280
+ continue
1281
+ sx = (creature.x + cam_x) * scale_x
1282
+ sy = (creature.y + cam_y) * scale_y
1283
+ dst = rl.Rectangle(float(sx), float(sy), float(size), float(size))
1284
+ origin = rl.Vector2(size * 0.5, size * 0.5)
1285
+ rotation_deg = (float(idx) * 0.01 + float(creature.heading)) * _RAD_TO_DEG
1286
+ rl.draw_texture_pro(particles_texture, src, dst, origin, rotation_deg, tint)
1287
+ rl.end_blend_mode()
1288
+
1289
+ for player in self.players:
1290
+ if player.health > 0.0:
1291
+ draw_player(player)
1292
+
1293
+ for proj in self.state.projectiles.entries:
1294
+ if not proj.active:
1295
+ continue
1296
+ self._draw_projectile(proj, scale=scale, alpha=entity_alpha)
1297
+
1298
+ self._draw_particle_pool(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1299
+
1300
+ for proj in self.state.secondary_projectiles.entries:
1301
+ if not proj.active:
1302
+ continue
1303
+ self._draw_secondary_projectile(proj, scale=scale, alpha=entity_alpha)
1304
+
1305
+ self._draw_sprite_effect_pool(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1306
+ self._draw_effect_pool(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1307
+ 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)
1308
+ self._draw_bonus_hover_labels(cam_x=cam_x, cam_y=cam_y, scale_x=scale_x, scale_y=scale_y, alpha=entity_alpha)
1309
+
1310
+ if draw_aim_indicators and (not self.demo_mode_active):
1311
+ for player in self.players:
1312
+ if player.health <= 0.0:
1313
+ continue
1314
+ aim_x = float(getattr(player, "aim_x", player.pos_x))
1315
+ aim_y = float(getattr(player, "aim_y", player.pos_y))
1316
+ dist = math.hypot(aim_x - float(player.pos_x), aim_y - float(player.pos_y))
1317
+ radius = max(6.0, dist * float(getattr(player, "spread_heat", 0.0)) * 0.5)
1318
+ sx = (aim_x + cam_x) * scale_x
1319
+ sy = (aim_y + cam_y) * scale_y
1320
+ screen_radius = max(1.0, radius * scale)
1321
+ self._draw_aim_circle(x=sx, y=sy, radius=screen_radius, alpha=entity_alpha)
1322
+ reload_timer = float(getattr(player, "reload_timer", 0.0))
1323
+ reload_max = float(getattr(player, "reload_timer_max", 0.0))
1324
+ if reload_max > 1e-6 and reload_timer > 1e-6:
1325
+ progress = reload_timer / reload_max
1326
+ if progress > 0.0:
1327
+ ms = int(progress * 60000.0)
1328
+ self._draw_clock_gauge(x=float(int(sx)), y=float(int(sy)), ms=ms, scale=scale, alpha=entity_alpha)
1329
+
1330
+ def world_to_screen(self, x: float, y: float) -> tuple[float, float]:
1331
+ cam_x, cam_y, scale_x, scale_y = self._world_params()
1332
+ return (x + cam_x) * scale_x, (y + cam_y) * scale_y
1333
+
1334
+ def screen_to_world(self, x: float, y: float) -> tuple[float, float]:
1335
+ cam_x, cam_y, scale_x, scale_y = self._world_params()
1336
+ inv_x = 1.0 / scale_x if scale_x > 0 else 1.0
1337
+ inv_y = 1.0 / scale_y if scale_y > 0 else 1.0
1338
+ return x * inv_x - cam_x, y * inv_y - cam_y