crimsonland 0.1.0.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +652 -0
  34. crimson/gameplay.py +2467 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1133 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
crimson/effects.py ADDED
@@ -0,0 +1,1086 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import math
5
+ from typing import Callable, Protocol
6
+
7
+ __all__ = [
8
+ "FX_QUEUE_CAPACITY",
9
+ "FX_QUEUE_MAX_COUNT",
10
+ "FX_QUEUE_ROTATED_CAPACITY",
11
+ "FX_QUEUE_ROTATED_MAX_COUNT",
12
+ "EFFECT_POOL_SIZE",
13
+ "PARTICLE_POOL_SIZE",
14
+ "SPRITE_EFFECT_POOL_SIZE",
15
+ "FxQueue",
16
+ "FxQueueEntry",
17
+ "FxQueueRotated",
18
+ "FxQueueRotatedEntry",
19
+ "EffectEntry",
20
+ "EffectPool",
21
+ "Particle",
22
+ "ParticlePool",
23
+ "SpriteEffect",
24
+ "SpriteEffectPool",
25
+ ]
26
+
27
+ EFFECT_POOL_SIZE = 0x200
28
+ PARTICLE_POOL_SIZE = 0x80
29
+ SPRITE_EFFECT_POOL_SIZE = 0x180
30
+
31
+ FX_QUEUE_CAPACITY = 0x80
32
+ FX_QUEUE_MAX_COUNT = 0x7F
33
+
34
+ FX_QUEUE_ROTATED_CAPACITY = 0x40
35
+ FX_QUEUE_ROTATED_MAX_COUNT = 0x3F
36
+
37
+
38
+ def _clamp(value: float, lo: float, hi: float) -> float:
39
+ if value < lo:
40
+ return lo
41
+ if value > hi:
42
+ return hi
43
+ return value
44
+
45
+
46
+ def _default_rand() -> int:
47
+ return 0
48
+
49
+
50
+ class _CreatureForParticles(Protocol):
51
+ active: bool
52
+ x: float
53
+ y: float
54
+ hp: float
55
+ size: float
56
+ hitbox_size: float
57
+ tint_r: float
58
+ tint_g: float
59
+ tint_b: float
60
+ tint_a: float
61
+
62
+
63
+ CreatureDamageApplier = Callable[[int, float, int, float, float, int], None]
64
+ CreatureKillHandler = Callable[[int, int], None]
65
+
66
+
67
+ @dataclass(slots=True)
68
+ class Particle:
69
+ active: bool = False
70
+ render_flag: bool = False
71
+ pos_x: float = 0.0
72
+ pos_y: float = 0.0
73
+ vel_x: float = 0.0
74
+ vel_y: float = 0.0
75
+ scale_x: float = 1.0
76
+ scale_y: float = 1.0
77
+ scale_z: float = 1.0
78
+ age: float = 0.0
79
+ intensity: float = 0.0
80
+ angle: float = 0.0
81
+ spin: float = 0.0
82
+ style_id: int = 0
83
+ target_id: int = -1
84
+ owner_id: int = -100
85
+
86
+
87
+ class ParticlePool:
88
+ def __init__(self, *, size: int = PARTICLE_POOL_SIZE, rand: Callable[[], int] | None = None) -> None:
89
+ self._entries = [Particle() for _ in range(int(size))]
90
+ self._rand = rand or _default_rand
91
+
92
+ @property
93
+ def entries(self) -> list[Particle]:
94
+ return self._entries
95
+
96
+ def reset(self) -> None:
97
+ for entry in self._entries:
98
+ entry.active = False
99
+
100
+ def _alloc_slot(self) -> int:
101
+ for i, entry in enumerate(self._entries):
102
+ if not entry.active:
103
+ return i
104
+ if not self._entries:
105
+ raise ValueError("Particle pool has zero entries")
106
+ # Native: `crt_rand() & 0x7f` (pool size is 0x80).
107
+ return int(self._rand()) % len(self._entries)
108
+
109
+ def spawn_particle(
110
+ self,
111
+ *,
112
+ pos_x: float,
113
+ pos_y: float,
114
+ angle: float,
115
+ intensity: float = 1.0,
116
+ owner_id: int = -100,
117
+ ) -> int:
118
+ """Port of `fx_spawn_particle` (0x00420130)."""
119
+
120
+ idx = self._alloc_slot()
121
+ entry = self._entries[idx]
122
+ entry.active = True
123
+ entry.render_flag = True
124
+ entry.pos_x = float(pos_x)
125
+ entry.pos_y = float(pos_y)
126
+ entry.vel_x = math.cos(angle) * 90.0
127
+ entry.vel_y = math.sin(angle) * 90.0
128
+ entry.scale_x = 1.0
129
+ entry.scale_y = 1.0
130
+ entry.scale_z = 1.0
131
+ entry.age = 0.0
132
+ entry.intensity = float(intensity)
133
+ entry.angle = float(angle)
134
+ entry.spin = float(int(self._rand()) % 0x274) * 0.01
135
+ entry.style_id = 0
136
+ entry.target_id = -1
137
+ entry.owner_id = int(owner_id)
138
+ return idx
139
+
140
+ def spawn_particle_slow(
141
+ self,
142
+ *,
143
+ pos_x: float,
144
+ pos_y: float,
145
+ angle: float,
146
+ owner_id: int = -100,
147
+ ) -> int:
148
+ """Port of `fx_spawn_particle_slow` (0x00420240)."""
149
+
150
+ idx = self._alloc_slot()
151
+ entry = self._entries[idx]
152
+ entry.active = True
153
+ entry.render_flag = True
154
+ entry.pos_x = float(pos_x)
155
+ entry.pos_y = float(pos_y)
156
+ entry.vel_x = math.cos(angle) * 30.0
157
+ entry.vel_y = math.sin(angle) * 30.0
158
+ entry.scale_x = 1.0
159
+ entry.scale_y = 1.0
160
+ entry.scale_z = 1.0
161
+ entry.age = 0.0
162
+ entry.intensity = 1.0
163
+ entry.angle = float(angle)
164
+ entry.spin = float(int(self._rand()) % 0x274) * 0.01
165
+ entry.style_id = 8
166
+ entry.target_id = -1
167
+ entry.owner_id = int(owner_id)
168
+ return idx
169
+
170
+ def iter_active(self) -> list[Particle]:
171
+ return [entry for entry in self._entries if entry.active]
172
+
173
+ def update(
174
+ self,
175
+ dt: float,
176
+ *,
177
+ creatures: list[_CreatureForParticles] | None = None,
178
+ apply_creature_damage: CreatureDamageApplier | None = None,
179
+ kill_creature: CreatureKillHandler | None = None,
180
+ ) -> list[int]:
181
+ """Advance particles and deactivate expired entries.
182
+
183
+ This is a minimal port of the particle loop inside `projectile_update`
184
+ (0x00420b90). It captures the per-style decay/movement rules that drive
185
+ visual lifetimes and the weapon-driven collision damage.
186
+
187
+ Returns indices of particles that were deactivated this tick.
188
+ """
189
+
190
+ if dt <= 0.0:
191
+ return []
192
+
193
+ def _creature_find_in_radius(*, pos_x: float, pos_y: float, radius: float) -> int:
194
+ if creatures is None:
195
+ return -1
196
+ max_index = min(len(creatures), 0x180)
197
+ pos_x = float(pos_x)
198
+ pos_y = float(pos_y)
199
+ radius = float(radius)
200
+
201
+ for creature_idx in range(max_index):
202
+ creature = creatures[creature_idx]
203
+ if not bool(getattr(creature, "active", False)):
204
+ continue
205
+ if float(getattr(creature, "hp", 0.0)) <= 0.0:
206
+ continue
207
+ if float(getattr(creature, "hitbox_size", 0.0)) < 5.0:
208
+ continue
209
+
210
+ size = float(getattr(creature, "size", 50.0))
211
+ dist = math.hypot(float(getattr(creature, "x", 0.0)) - pos_x, float(getattr(creature, "y", 0.0)) - pos_y) - radius
212
+ threshold = size * 0.142857149 + 3.0
213
+ if threshold < dist:
214
+ continue
215
+ return int(creature_idx)
216
+
217
+ return -1
218
+
219
+ expired: list[int] = []
220
+ rand = self._rand
221
+
222
+ for idx, entry in enumerate(self._entries):
223
+ if not entry.active:
224
+ continue
225
+
226
+ style = int(entry.style_id) & 0xFF
227
+
228
+ if style == 8:
229
+ entry.intensity -= dt * 0.11
230
+ entry.spin += dt * 5.0
231
+ move_scale = entry.intensity
232
+ if move_scale <= 0.15:
233
+ move_scale *= 0.55
234
+ entry.pos_x += entry.vel_x * dt * move_scale
235
+ entry.pos_y += entry.vel_y * dt * move_scale
236
+ else:
237
+ entry.intensity -= dt * 0.9
238
+ entry.spin += dt
239
+ move_scale = max(entry.intensity, 0.15) * 2.5
240
+ entry.pos_x += entry.vel_x * dt * move_scale
241
+ entry.pos_y += entry.vel_y * dt * move_scale
242
+
243
+ if entry.render_flag:
244
+ # Random walk drift (native adjusts angle based on `crt_rand`).
245
+ jitter = float(int(rand()) % 100 - 50) * 0.06 * max(entry.intensity, 0.0) * dt
246
+ if style == 0:
247
+ jitter *= 1.96
248
+ speed = 82.0
249
+ elif style == 8:
250
+ jitter *= 1.1
251
+ speed = 62.0
252
+ else:
253
+ jitter *= 1.1
254
+ speed = 82.0
255
+ entry.angle -= jitter
256
+ entry.vel_x = math.cos(entry.angle) * speed
257
+ entry.vel_y = math.sin(entry.angle) * speed
258
+
259
+ alpha = _clamp(entry.intensity, 0.0, 1.0)
260
+ shade = 1.0 - max(entry.intensity, 0.0) * 0.95
261
+ entry.age = alpha
262
+ entry.scale_x = shade
263
+ entry.scale_y = shade
264
+ entry.scale_z = shade
265
+
266
+ alive = entry.intensity > (0.0 if style == 0 else 0.8)
267
+ if not alive:
268
+ entry.active = False
269
+ expired.append(idx)
270
+ if style == 8 and entry.target_id != -1:
271
+ target_id = int(entry.target_id)
272
+ entry.target_id = -1
273
+ if kill_creature is not None:
274
+ kill_creature(target_id, int(entry.owner_id))
275
+ elif creatures is not None and 0 <= target_id < len(creatures):
276
+ creatures[target_id].hp = -1.0
277
+ creatures[target_id].active = False
278
+ continue
279
+
280
+ if style == 8 and (not entry.render_flag) and entry.target_id != -1 and creatures is not None:
281
+ target_id = int(entry.target_id)
282
+ if 0 <= target_id < len(creatures) and bool(getattr(creatures[target_id], "active", False)):
283
+ entry.pos_x = float(getattr(creatures[target_id], "x", entry.pos_x))
284
+ entry.pos_y = float(getattr(creatures[target_id], "y", entry.pos_y))
285
+
286
+ if entry.render_flag and creatures is not None:
287
+ hit_idx = _creature_find_in_radius(pos_x=entry.pos_x, pos_y=entry.pos_y, radius=max(entry.intensity, 0.0) * 8.0)
288
+ if hit_idx != -1:
289
+ entry.render_flag = False
290
+ creature = creatures[hit_idx]
291
+ if style == 8:
292
+ entry.target_id = int(hit_idx)
293
+ entry.pos_x = float(getattr(creature, "x", entry.pos_x))
294
+ entry.pos_y = float(getattr(creature, "y", entry.pos_y))
295
+ entry.vel_x = 0.0
296
+ entry.vel_y = 0.0
297
+ else:
298
+ damage = max(0.0, float(entry.intensity) * 10.0)
299
+ if damage > 0.0:
300
+ if apply_creature_damage is not None:
301
+ apply_creature_damage(int(hit_idx), float(damage), 4, 0.0, 0.0, int(entry.owner_id))
302
+ else:
303
+ creature.hp = float(getattr(creature, "hp", 0.0)) - float(damage)
304
+
305
+ tint_sum = float(getattr(creature, "tint_r", 1.0)) + float(getattr(creature, "tint_g", 1.0)) + float(getattr(creature, "tint_b", 1.0))
306
+ if tint_sum > 1.6:
307
+ factor = 1.0 - max(entry.intensity, 0.0) * 0.01
308
+ creature.tint_r = _clamp(float(getattr(creature, "tint_r", 1.0)) * factor, 0.0, 1.0)
309
+ creature.tint_g = _clamp(float(getattr(creature, "tint_g", 1.0)) * factor, 0.0, 1.0)
310
+ creature.tint_b = _clamp(float(getattr(creature, "tint_b", 1.0)) * factor, 0.0, 1.0)
311
+ creature.tint_a = _clamp(float(getattr(creature, "tint_a", 1.0)) * factor, 0.0, 1.0)
312
+
313
+ return expired
314
+
315
+
316
+ @dataclass(slots=True)
317
+ class SpriteEffect:
318
+ active: bool = False
319
+ color_r: float = 1.0
320
+ color_g: float = 1.0
321
+ color_b: float = 1.0
322
+ color_a: float = 0.0
323
+ rotation: float = 0.0
324
+ pos_x: float = 0.0
325
+ pos_y: float = 0.0
326
+ vel_x: float = 0.0
327
+ vel_y: float = 0.0
328
+ scale: float = 1.0
329
+
330
+
331
+ class SpriteEffectPool:
332
+ def __init__(self, *, size: int = SPRITE_EFFECT_POOL_SIZE, rand: Callable[[], int] | None = None) -> None:
333
+ self._entries = [SpriteEffect() for _ in range(int(size))]
334
+ self._rand = rand or _default_rand
335
+
336
+ @property
337
+ def entries(self) -> list[SpriteEffect]:
338
+ return self._entries
339
+
340
+ def reset(self) -> None:
341
+ for entry in self._entries:
342
+ entry.active = False
343
+
344
+ def spawn(self, *, pos_x: float, pos_y: float, vel_x: float, vel_y: float, scale: float = 1.0) -> int:
345
+ """Port of `fx_spawn_sprite` (0x0041fbb0)."""
346
+
347
+ idx = None
348
+ for i, entry in enumerate(self._entries):
349
+ if not entry.active:
350
+ idx = i
351
+ break
352
+ if idx is None:
353
+ if not self._entries:
354
+ raise ValueError("Sprite effect pool has zero entries")
355
+ idx = int(self._rand()) % len(self._entries)
356
+
357
+ entry = self._entries[idx]
358
+ entry.active = True
359
+ entry.color_r = 1.0
360
+ entry.color_g = 1.0
361
+ entry.color_b = 1.0
362
+ entry.color_a = 1.0
363
+ entry.rotation = float(int(self._rand()) % 0x274) * 0.01
364
+ entry.pos_x = float(pos_x)
365
+ entry.pos_y = float(pos_y)
366
+ entry.vel_x = float(vel_x)
367
+ entry.vel_y = float(vel_y)
368
+ entry.scale = float(scale)
369
+ return idx
370
+
371
+ def iter_active(self) -> list[SpriteEffect]:
372
+ return [entry for entry in self._entries if entry.active]
373
+
374
+ def update(self, dt: float) -> list[int]:
375
+ if dt <= 0.0:
376
+ return []
377
+
378
+ expired: list[int] = []
379
+ for idx, entry in enumerate(self._entries):
380
+ if not entry.active:
381
+ continue
382
+ entry.pos_x += dt * entry.vel_x
383
+ entry.pos_y += dt * entry.vel_y
384
+ entry.rotation += dt * 3.0
385
+ entry.color_a -= dt
386
+ entry.scale += dt * 60.0
387
+ if entry.color_a <= 0.0:
388
+ entry.active = False
389
+ expired.append(idx)
390
+ return expired
391
+
392
+
393
+ @dataclass(slots=True)
394
+ class FxQueueEntry:
395
+ effect_id: int = 0
396
+ rotation: float = 0.0
397
+ pos_x: float = 0.0
398
+ pos_y: float = 0.0
399
+ height: float = 0.0
400
+ width: float = 0.0
401
+ color_r: float = 1.0
402
+ color_g: float = 1.0
403
+ color_b: float = 1.0
404
+ color_a: float = 1.0
405
+
406
+
407
+ class FxQueue:
408
+ """Per-frame terrain decal queue (`fx_queue` / `fx_queue_add`)."""
409
+
410
+ def __init__(self, *, capacity: int = FX_QUEUE_CAPACITY, max_count: int = FX_QUEUE_MAX_COUNT) -> None:
411
+ capacity = max(0, int(capacity))
412
+ max_count = max(0, min(int(max_count), capacity))
413
+ self._entries = [FxQueueEntry() for _ in range(capacity)]
414
+ self._count = 0
415
+ self._max_count = max_count
416
+
417
+ @property
418
+ def entries(self) -> list[FxQueueEntry]:
419
+ return self._entries
420
+
421
+ @property
422
+ def count(self) -> int:
423
+ return self._count
424
+
425
+ def clear(self) -> None:
426
+ self._count = 0
427
+
428
+ def iter_active(self) -> list[FxQueueEntry]:
429
+ return self._entries[: self._count]
430
+
431
+ def add(
432
+ self,
433
+ *,
434
+ effect_id: int,
435
+ pos_x: float,
436
+ pos_y: float,
437
+ width: float,
438
+ height: float,
439
+ rotation: float,
440
+ rgba: tuple[float, float, float, float],
441
+ ) -> bool:
442
+ """Port of `fx_queue_add` (0x0041e840)."""
443
+
444
+ if self._count >= self._max_count:
445
+ return False
446
+
447
+ entry = self._entries[self._count]
448
+ entry.effect_id = int(effect_id)
449
+ entry.rotation = float(rotation)
450
+ entry.pos_x = float(pos_x)
451
+ entry.pos_y = float(pos_y)
452
+ entry.height = float(height)
453
+ entry.width = float(width)
454
+ entry.color_r = float(rgba[0])
455
+ entry.color_g = float(rgba[1])
456
+ entry.color_b = float(rgba[2])
457
+ entry.color_a = float(rgba[3])
458
+ self._count += 1
459
+ return True
460
+
461
+ def add_random(self, *, pos_x: float, pos_y: float, rand: Callable[[], int]) -> bool:
462
+ """Port of `fx_queue_add_random` (effect ids 3..7 with grayscale tint)."""
463
+
464
+ if self._count >= self._max_count:
465
+ return False
466
+
467
+ gray = float(int(rand()) & 0xF) * 0.01 + 0.84
468
+ w = float(int(rand()) % 0x18 - 0x0C) + 30.0
469
+ rotation = float(int(rand()) % 0x274) * 0.01
470
+ effect_id = int(rand()) % 5 + 3
471
+ return self.add(
472
+ effect_id=effect_id,
473
+ pos_x=pos_x,
474
+ pos_y=pos_y,
475
+ width=w,
476
+ height=w,
477
+ rotation=rotation,
478
+ rgba=(gray, gray, gray, 1.0),
479
+ )
480
+
481
+
482
+ @dataclass(slots=True)
483
+ class FxQueueRotatedEntry:
484
+ top_left_x: float = 0.0
485
+ top_left_y: float = 0.0
486
+ color_r: float = 1.0
487
+ color_g: float = 1.0
488
+ color_b: float = 1.0
489
+ color_a: float = 1.0
490
+ rotation: float = 0.0
491
+ scale: float = 1.0
492
+ creature_type_id: int = 0
493
+
494
+
495
+ class FxQueueRotated:
496
+ """Rotated corpse queue (`fx_queue_rotated` / `fx_queue_add_rotated`)."""
497
+
498
+ def __init__(self, *, capacity: int = FX_QUEUE_ROTATED_CAPACITY, max_count: int = FX_QUEUE_ROTATED_MAX_COUNT) -> None:
499
+ capacity = max(0, int(capacity))
500
+ max_count = max(0, min(int(max_count), capacity))
501
+ self._entries = [FxQueueRotatedEntry() for _ in range(capacity)]
502
+ self._count = 0
503
+ self._max_count = max_count
504
+
505
+ @property
506
+ def entries(self) -> list[FxQueueRotatedEntry]:
507
+ return self._entries
508
+
509
+ @property
510
+ def count(self) -> int:
511
+ return self._count
512
+
513
+ def clear(self) -> None:
514
+ self._count = 0
515
+
516
+ def iter_active(self) -> list[FxQueueRotatedEntry]:
517
+ return self._entries[: self._count]
518
+
519
+ def add(
520
+ self,
521
+ *,
522
+ top_left_x: float,
523
+ top_left_y: float,
524
+ rgba: tuple[float, float, float, float],
525
+ rotation: float,
526
+ scale: float,
527
+ creature_type_id: int,
528
+ terrain_bodies_transparency: float = 0.0,
529
+ terrain_texture_failed: bool = False,
530
+ ) -> bool:
531
+ """Port of `fx_queue_add_rotated` (0x00427840)."""
532
+
533
+ if terrain_texture_failed:
534
+ return False
535
+ if self._count >= self._max_count:
536
+ return False
537
+
538
+ r, g, b, a = rgba
539
+ if terrain_bodies_transparency != 0.0:
540
+ a = a / float(terrain_bodies_transparency)
541
+ else:
542
+ a = a * 0.8
543
+
544
+ entry = self._entries[self._count]
545
+ entry.top_left_x = float(top_left_x)
546
+ entry.top_left_y = float(top_left_y)
547
+ entry.color_r = float(r)
548
+ entry.color_g = float(g)
549
+ entry.color_b = float(b)
550
+ entry.color_a = float(a)
551
+ entry.rotation = float(rotation)
552
+ entry.scale = float(scale)
553
+ entry.creature_type_id = int(creature_type_id)
554
+
555
+ self._count += 1
556
+ return True
557
+
558
+
559
+ @dataclass(slots=True)
560
+ class EffectEntry:
561
+ pos_x: float = 0.0
562
+ pos_y: float = 0.0
563
+ effect_id: int = 0
564
+ vel_x: float = 0.0
565
+ vel_y: float = 0.0
566
+ rotation: float = 0.0
567
+ scale: float = 1.0
568
+ half_width: float = 0.0
569
+ half_height: float = 0.0
570
+ age: float = 0.0
571
+ lifetime: float = 0.0
572
+ flags: int = 0
573
+ color_r: float = 1.0
574
+ color_g: float = 1.0
575
+ color_b: float = 1.0
576
+ color_a: float = 1.0
577
+ rotation_step: float = 0.0
578
+ scale_step: float = 0.0
579
+
580
+
581
+ class EffectPool:
582
+ """Effect pool (`effect_spawn`, `effects_update`).
583
+
584
+ This pool renders transient particle quads and can optionally enqueue decals
585
+ into `FxQueue` on expiry (flags bit `0x80`).
586
+ """
587
+
588
+ def __init__(self, *, size: int = EFFECT_POOL_SIZE) -> None:
589
+ size = max(0, int(size))
590
+ self._entries = [EffectEntry() for _ in range(size)]
591
+ self._free = list(range(size - 1, -1, -1))
592
+ self._detail_toggle = 0
593
+ self._overwrite_cursor = 0
594
+
595
+ @property
596
+ def entries(self) -> list[EffectEntry]:
597
+ return self._entries
598
+
599
+ def reset(self) -> None:
600
+ for entry in self._entries:
601
+ entry.flags = 0
602
+ self._free = list(range(len(self._entries) - 1, -1, -1))
603
+ self._detail_toggle = 0
604
+ self._overwrite_cursor = 0
605
+
606
+ def iter_active(self) -> list[EffectEntry]:
607
+ return [entry for entry in self._entries if entry.flags]
608
+
609
+ def _alloc_slot(self, *, detail_preset: int) -> int | None:
610
+ # Native: if detail_preset < 3, skip every other spawn attempt.
611
+ if int(detail_preset) < 3:
612
+ skip = self._detail_toggle & 1
613
+ self._detail_toggle += 1
614
+ if skip:
615
+ return None
616
+
617
+ if self._free:
618
+ return self._free.pop()
619
+
620
+ if not self._entries:
621
+ return None
622
+
623
+ idx = self._overwrite_cursor % len(self._entries)
624
+ self._overwrite_cursor = idx + 1
625
+ return idx
626
+
627
+ def spawn(
628
+ self,
629
+ *,
630
+ effect_id: int,
631
+ pos_x: float,
632
+ pos_y: float,
633
+ vel_x: float,
634
+ vel_y: float,
635
+ rotation: float,
636
+ scale: float,
637
+ half_width: float,
638
+ half_height: float,
639
+ age: float,
640
+ lifetime: float,
641
+ flags: int,
642
+ color_r: float,
643
+ color_g: float,
644
+ color_b: float,
645
+ color_a: float,
646
+ rotation_step: float,
647
+ scale_step: float,
648
+ detail_preset: int,
649
+ ) -> int | None:
650
+ idx = self._alloc_slot(detail_preset=int(detail_preset))
651
+ if idx is None:
652
+ return None
653
+
654
+ entry = self._entries[idx]
655
+ entry.pos_x = float(pos_x)
656
+ entry.pos_y = float(pos_y)
657
+ entry.effect_id = int(effect_id)
658
+ entry.vel_x = float(vel_x)
659
+ entry.vel_y = float(vel_y)
660
+ entry.rotation = float(rotation)
661
+ entry.scale = float(scale)
662
+ entry.half_width = float(half_width)
663
+ entry.half_height = float(half_height)
664
+ entry.age = float(age)
665
+ entry.lifetime = float(lifetime)
666
+ entry.flags = int(flags)
667
+ entry.color_r = float(color_r)
668
+ entry.color_g = float(color_g)
669
+ entry.color_b = float(color_b)
670
+ entry.color_a = float(color_a)
671
+ entry.rotation_step = float(rotation_step)
672
+ entry.scale_step = float(scale_step)
673
+ return idx
674
+
675
+ def free(self, idx: int) -> None:
676
+ if not (0 <= idx < len(self._entries)):
677
+ return
678
+ entry = self._entries[idx]
679
+ entry.flags = 0
680
+ self._free.append(idx)
681
+
682
+ def update(self, dt: float, *, fx_queue: FxQueue | None = None) -> None:
683
+ """Advance active effects and enqueue terrain decals on expiry."""
684
+
685
+ if dt <= 0.0:
686
+ return
687
+
688
+ for idx, entry in enumerate(self._entries):
689
+ flags = int(entry.flags)
690
+ if not flags:
691
+ continue
692
+
693
+ age = float(entry.age) + float(dt)
694
+ entry.age = age
695
+ lifetime = float(entry.lifetime)
696
+
697
+ if age < lifetime:
698
+ if age >= 0.0:
699
+ entry.pos_x += float(entry.vel_x) * float(dt)
700
+ entry.pos_y += float(entry.vel_y) * float(dt)
701
+ if flags & 0x4:
702
+ entry.rotation += float(entry.rotation_step) * float(dt)
703
+ if flags & 0x8:
704
+ entry.scale += float(entry.scale_step) * float(dt)
705
+ if flags & 0x10:
706
+ entry.color_a = 1.0 - age / lifetime if lifetime > 1e-9 else 0.0
707
+ continue
708
+
709
+ if fx_queue is not None and (flags & 0x80):
710
+ # On expiry, the native code overrides alpha before queuing.
711
+ alpha = 0.35 if (flags & 0x100) else 0.8
712
+ fx_queue.add(
713
+ effect_id=int(entry.effect_id),
714
+ pos_x=float(entry.pos_x),
715
+ pos_y=float(entry.pos_y),
716
+ width=float(entry.half_width) * 2.0,
717
+ height=float(entry.half_height) * 2.0,
718
+ rotation=float(entry.rotation),
719
+ rgba=(float(entry.color_r), float(entry.color_g), float(entry.color_b), float(alpha)),
720
+ )
721
+
722
+ self.free(idx)
723
+
724
+ def spawn_blood_splatter(
725
+ self,
726
+ *,
727
+ pos_x: float,
728
+ pos_y: float,
729
+ angle: float,
730
+ age: float,
731
+ rand: Callable[[], int],
732
+ detail_preset: int,
733
+ fx_toggle: int,
734
+ ) -> None:
735
+ """Port of `effect_spawn_blood_splatter` (0x0042eb10)."""
736
+
737
+ if int(fx_toggle) != 0:
738
+ return
739
+
740
+ lifetime = 0.25 - float(age)
741
+ base = float(angle) + math.pi
742
+ dir_x = math.cos(base)
743
+ dir_y = math.sin(base)
744
+
745
+ for _ in range(2):
746
+ r0 = int(rand())
747
+ rotation = float((r0 & 0x3F) - 0x20) * 0.1 + base
748
+ r1 = int(rand())
749
+ half = float((r1 & 7) + 1)
750
+ r2 = int(rand())
751
+ vel_x = float((r2 & 0x3F) + 100) * dir_x
752
+ r3 = int(rand())
753
+ vel_y = float((r3 & 0x3F) + 100) * dir_y
754
+ r4 = int(rand())
755
+ scale_step = float(r4 & 0x7F) * 0.03 + 0.1
756
+
757
+ self.spawn(
758
+ effect_id=7,
759
+ pos_x=pos_x,
760
+ pos_y=pos_y,
761
+ vel_x=vel_x,
762
+ vel_y=vel_y,
763
+ rotation=rotation,
764
+ scale=1.0,
765
+ half_width=half,
766
+ half_height=half,
767
+ age=float(age),
768
+ lifetime=lifetime,
769
+ flags=0xC9,
770
+ color_r=1.0,
771
+ color_g=1.0,
772
+ color_b=1.0,
773
+ color_a=0.5,
774
+ rotation_step=0.0,
775
+ scale_step=scale_step,
776
+ detail_preset=int(detail_preset),
777
+ )
778
+
779
+ def spawn_burst(
780
+ self,
781
+ *,
782
+ pos_x: float,
783
+ pos_y: float,
784
+ count: int,
785
+ rand: Callable[[], int],
786
+ detail_preset: int,
787
+ lifetime: float = 0.5,
788
+ scale_step: float | None = None,
789
+ color_r: float = 0.4,
790
+ color_g: float = 0.5,
791
+ color_b: float = 1.0,
792
+ color_a: float = 0.5,
793
+ ) -> None:
794
+ """Port of `effect_spawn_burst` (0x0042ef60)."""
795
+
796
+ count = max(0, int(count))
797
+ for _ in range(count):
798
+ r0 = int(rand())
799
+ rotation = float(r0 & 0x7F) * 0.049087387
800
+ r1 = int(rand())
801
+ vel_x = float((r1 & 0x7F) - 0x40)
802
+ r2 = int(rand())
803
+ vel_y = float((r2 & 0x7F) - 0x40)
804
+ if scale_step is None:
805
+ r3 = int(rand())
806
+ step = float(r3 % 100) * 0.01 + 0.1
807
+ else:
808
+ step = float(scale_step)
809
+
810
+ self.spawn(
811
+ effect_id=0,
812
+ pos_x=pos_x,
813
+ pos_y=pos_y,
814
+ vel_x=vel_x,
815
+ vel_y=vel_y,
816
+ rotation=rotation,
817
+ scale=1.0,
818
+ half_width=32.0,
819
+ half_height=32.0,
820
+ age=0.0,
821
+ lifetime=float(lifetime),
822
+ flags=0x1D,
823
+ color_r=float(color_r),
824
+ color_g=float(color_g),
825
+ color_b=float(color_b),
826
+ color_a=float(color_a),
827
+ rotation_step=0.0,
828
+ scale_step=step,
829
+ detail_preset=int(detail_preset),
830
+ )
831
+
832
+ def spawn_ring(
833
+ self,
834
+ *,
835
+ pos_x: float,
836
+ pos_y: float,
837
+ detail_preset: int,
838
+ color_r: float,
839
+ color_g: float,
840
+ color_b: float,
841
+ color_a: float,
842
+ lifetime: float = 0.25,
843
+ scale_step: float = 50.0,
844
+ ) -> None:
845
+ """Ring/halo burst used by bonus pickup effects (`bonus_apply`)."""
846
+
847
+ self.spawn(
848
+ effect_id=1,
849
+ pos_x=pos_x,
850
+ pos_y=pos_y,
851
+ vel_x=0.0,
852
+ vel_y=0.0,
853
+ rotation=0.0,
854
+ scale=1.0,
855
+ half_width=32.0,
856
+ half_height=32.0,
857
+ age=0.0,
858
+ lifetime=float(lifetime),
859
+ flags=0x19,
860
+ color_r=float(color_r),
861
+ color_g=float(color_g),
862
+ color_b=float(color_b),
863
+ color_a=float(color_a),
864
+ rotation_step=0.0,
865
+ scale_step=float(scale_step),
866
+ detail_preset=int(detail_preset),
867
+ )
868
+
869
+ def spawn_freeze_shard(
870
+ self,
871
+ *,
872
+ pos_x: float,
873
+ pos_y: float,
874
+ angle: float,
875
+ rand: Callable[[], int],
876
+ detail_preset: int,
877
+ ) -> None:
878
+ """Port of `effect_spawn_freeze_shard` (0x0042ec80)."""
879
+
880
+ lifetime = float(int(rand()) & 0xF) * 0.01 + 0.2
881
+ base = float(angle) + math.pi
882
+
883
+ rotation = float(int(rand()) % 100) * 0.01 + base
884
+ half = float(int(rand()) % 5 + 7)
885
+
886
+ vel_x = math.cos(base) * 114.0
887
+ vel_y = math.sin(base) * 114.0
888
+
889
+ rotation_step = (float(int(rand()) % 0x14) * 0.1 - 1.0) * 4.0
890
+ scale_step = -float(int(rand()) & 0xF) * 0.1
891
+
892
+ effect_id = int(rand()) % 3 + 8
893
+ self.spawn(
894
+ effect_id=int(effect_id),
895
+ pos_x=float(pos_x),
896
+ pos_y=float(pos_y),
897
+ vel_x=float(vel_x),
898
+ vel_y=float(vel_y),
899
+ rotation=float(rotation),
900
+ scale=1.0,
901
+ half_width=float(half),
902
+ half_height=float(half),
903
+ age=0.0,
904
+ lifetime=float(lifetime),
905
+ flags=0x1CD,
906
+ color_r=1.0,
907
+ color_g=1.0,
908
+ color_b=1.0,
909
+ color_a=0.5,
910
+ rotation_step=float(rotation_step),
911
+ scale_step=float(scale_step),
912
+ detail_preset=int(detail_preset),
913
+ )
914
+
915
+ def spawn_freeze_shatter(
916
+ self,
917
+ *,
918
+ pos_x: float,
919
+ pos_y: float,
920
+ angle: float,
921
+ rand: Callable[[], int],
922
+ detail_preset: int,
923
+ ) -> None:
924
+ """Port of `effect_spawn_freeze_shatter` (0x0042ee00)."""
925
+
926
+ lifetime = 1.1
927
+ for idx in range(4):
928
+ rotation = float(idx) * (math.pi / 2.0) + float(angle)
929
+ vel_x = math.cos(rotation) * 42.0
930
+ vel_y = math.sin(rotation) * 42.0
931
+ half = float(int(rand()) % 10 + 0x12)
932
+ rotation_step = (float(int(rand()) % 0x14) * 0.1 - 1.0) * 1.9
933
+
934
+ self.spawn(
935
+ effect_id=0x0E,
936
+ pos_x=float(pos_x),
937
+ pos_y=float(pos_y),
938
+ vel_x=float(vel_x),
939
+ vel_y=float(vel_y),
940
+ rotation=float(rotation),
941
+ scale=1.0,
942
+ half_width=float(half),
943
+ half_height=float(half),
944
+ age=0.0,
945
+ lifetime=float(lifetime),
946
+ flags=0x5D,
947
+ color_r=1.0,
948
+ color_g=1.0,
949
+ color_b=1.0,
950
+ color_a=0.5,
951
+ rotation_step=float(rotation_step),
952
+ scale_step=0.0,
953
+ detail_preset=int(detail_preset),
954
+ )
955
+
956
+ for _ in range(4):
957
+ shard_angle = float(int(rand()) % 0x264) * 0.01
958
+ self.spawn_freeze_shard(
959
+ pos_x=float(pos_x),
960
+ pos_y=float(pos_y),
961
+ angle=float(shard_angle),
962
+ rand=rand,
963
+ detail_preset=int(detail_preset),
964
+ )
965
+
966
+ def spawn_explosion_burst(
967
+ self,
968
+ *,
969
+ pos_x: float,
970
+ pos_y: float,
971
+ scale: float,
972
+ rand: Callable[[], int],
973
+ detail_preset: int,
974
+ ) -> None:
975
+ """Port of `effect_spawn_explosion_burst` (0x0042f6c0)."""
976
+
977
+ detail_preset = int(detail_preset)
978
+ scale = float(scale)
979
+
980
+ # Shockwave ring.
981
+ self.spawn(
982
+ effect_id=1,
983
+ pos_x=float(pos_x),
984
+ pos_y=float(pos_y),
985
+ vel_x=0.0,
986
+ vel_y=0.0,
987
+ rotation=0.0,
988
+ scale=1.0,
989
+ half_width=32.0,
990
+ half_height=32.0,
991
+ age=-0.1,
992
+ lifetime=0.35,
993
+ flags=0x19,
994
+ color_r=0.6,
995
+ color_g=0.6,
996
+ color_b=0.6,
997
+ color_a=1.0,
998
+ rotation_step=0.0,
999
+ scale_step=scale * 25.0,
1000
+ detail_preset=detail_preset,
1001
+ )
1002
+
1003
+ # Dark explosion puffs (high detail only).
1004
+ if detail_preset > 3:
1005
+ for idx in range(2):
1006
+ age = float(idx) * 0.2 - 0.5
1007
+ lifetime = float(idx) * 0.2 + 0.6
1008
+ rotation = float(int(rand()) % 0x266) * 0.02
1009
+ self.spawn(
1010
+ effect_id=0x11,
1011
+ pos_x=float(pos_x),
1012
+ pos_y=float(pos_y),
1013
+ vel_x=0.0,
1014
+ vel_y=0.0,
1015
+ rotation=float(rotation),
1016
+ scale=1.0,
1017
+ half_width=32.0,
1018
+ half_height=32.0,
1019
+ age=float(age),
1020
+ lifetime=float(lifetime),
1021
+ flags=0x5D,
1022
+ color_r=0.1,
1023
+ color_g=0.1,
1024
+ color_b=0.1,
1025
+ color_a=1.0,
1026
+ rotation_step=1.4,
1027
+ scale_step=scale * 5.0,
1028
+ detail_preset=detail_preset,
1029
+ )
1030
+
1031
+ # Bright flash.
1032
+ self.spawn(
1033
+ effect_id=0,
1034
+ pos_x=float(pos_x),
1035
+ pos_y=float(pos_y),
1036
+ vel_x=0.0,
1037
+ vel_y=0.0,
1038
+ rotation=0.0,
1039
+ scale=1.0,
1040
+ half_width=32.0,
1041
+ half_height=32.0,
1042
+ age=0.0,
1043
+ lifetime=0.3,
1044
+ flags=0x19,
1045
+ color_r=1.0,
1046
+ color_g=1.0,
1047
+ color_b=1.0,
1048
+ color_a=1.0,
1049
+ rotation_step=0.0,
1050
+ scale_step=scale * 45.0,
1051
+ detail_preset=detail_preset,
1052
+ )
1053
+
1054
+ if detail_preset < 2:
1055
+ count = 1
1056
+ else:
1057
+ count = 3 + (1 if detail_preset > 3 else 0)
1058
+
1059
+ # Extra shockwave particles.
1060
+ for _ in range(count):
1061
+ rotation = float(int(rand()) % 0x13A) * 0.02
1062
+ vel_x = float((int(rand()) & 0x3F) * 2 - 0x40)
1063
+ vel_y = float((int(rand()) & 0x3F) * 2 - 0x40)
1064
+ scale_step = float((int(rand()) - 3) & 7) * scale
1065
+ rotation_step = float((int(rand()) + 3) & 7)
1066
+ self.spawn(
1067
+ effect_id=0x0C,
1068
+ pos_x=float(pos_x),
1069
+ pos_y=float(pos_y),
1070
+ vel_x=float(vel_x),
1071
+ vel_y=float(vel_y),
1072
+ rotation=float(rotation),
1073
+ scale=1.0,
1074
+ half_width=32.0,
1075
+ half_height=32.0,
1076
+ age=0.0,
1077
+ lifetime=0.7,
1078
+ flags=0x1D,
1079
+ color_r=1.0,
1080
+ color_g=1.0,
1081
+ color_b=1.0,
1082
+ color_a=1.0,
1083
+ rotation_step=float(rotation_step),
1084
+ scale_step=float(scale_step),
1085
+ detail_preset=detail_preset,
1086
+ )