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
crimson/projectiles.py ADDED
@@ -0,0 +1,1039 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+ import math
6
+ from typing import Callable, Protocol
7
+
8
+ from .creatures.spawn import CreatureFlags
9
+ from .perks import PerkId
10
+ from .weapons import weapon_entry_for_projectile_type_id
11
+
12
+
13
+ class Damageable(Protocol):
14
+ x: float
15
+ y: float
16
+ hp: float
17
+
18
+
19
+ class PlayerDamageable(Protocol):
20
+ pos_x: float
21
+ pos_y: float
22
+ health: float
23
+ size: float
24
+
25
+
26
+ class FxQueueLike(Protocol):
27
+ def add(
28
+ self,
29
+ *,
30
+ effect_id: int,
31
+ pos_x: float,
32
+ pos_y: float,
33
+ width: float,
34
+ height: float,
35
+ rotation: float,
36
+ rgba: tuple[float, float, float, float],
37
+ ) -> bool: ...
38
+
39
+ def add_random(self, *, pos_x: float, pos_y: float, rand: Callable[[], int]) -> bool: ...
40
+
41
+
42
+ MAIN_PROJECTILE_POOL_SIZE = 0x60
43
+ SECONDARY_PROJECTILE_POOL_SIZE = 0x40
44
+
45
+
46
+ class ProjectileTypeId(IntEnum):
47
+ # Values are projectile type ids (not weapon ids). Based on the decompile
48
+ # for `player_fire_weapon` and `projectile_update`.
49
+ PISTOL = 0x01
50
+ MEAN_MINIGUN = 0x01
51
+ ASSAULT_RIFLE = 0x02
52
+ SHOTGUN = 0x03
53
+ SAWED_OFF_SHOTGUN = 0x03
54
+ JACKHAMMER = 0x03
55
+ SUBMACHINE_GUN = 0x05
56
+ GAUSS_GUN = 0x06
57
+ GAUSS_SHOTGUN = 0x06
58
+ PLASMA_RIFLE = 0x09
59
+ MULTI_PLASMA = 0x09
60
+ PLASMA_MINIGUN = 0x0B
61
+ PLASMA_SHOTGUN = 0x0B
62
+ PULSE_GUN = 0x13
63
+ ION_RIFLE = 0x15
64
+ ION_MINIGUN = 0x16
65
+ ION_CANNON = 0x17
66
+ SHRINKIFIER = 0x18
67
+ BLADE_GUN = 0x19
68
+ PLASMA_CANNON = 0x1C
69
+ SPLITTER_GUN = 0x1D
70
+ PLAGUE_SPREADER = 0x29
71
+ RAINBOW_GUN = 0x2B
72
+ FIRE_BULLETS = 0x2D
73
+
74
+
75
+ def _rng_zero() -> int:
76
+ return 0
77
+
78
+
79
+ CreatureDamageApplier = Callable[[int, float, int, float, float, int], None]
80
+
81
+
82
+ @dataclass(slots=True)
83
+ class Projectile:
84
+ active: bool = False
85
+ angle: float = 0.0
86
+ pos_x: float = 0.0
87
+ pos_y: float = 0.0
88
+ origin_x: float = 0.0
89
+ origin_y: float = 0.0
90
+ vel_x: float = 0.0
91
+ vel_y: float = 0.0
92
+ type_id: int = 0
93
+ life_timer: float = 0.0
94
+ reserved: float = 0.0
95
+ speed_scale: float = 1.0
96
+ damage_pool: float = 1.0
97
+ hit_radius: float = 1.0
98
+ base_damage: float = 0.0
99
+ owner_id: int = 0
100
+ hits_players: bool = False
101
+
102
+
103
+ @dataclass(slots=True)
104
+ class SecondaryProjectile:
105
+ active: bool = False
106
+ angle: float = 0.0
107
+ speed: float = 0.0
108
+ pos_x: float = 0.0
109
+ pos_y: float = 0.0
110
+ vel_x: float = 0.0
111
+ vel_y: float = 0.0
112
+ type_id: int = 0
113
+ owner_id: int = -100
114
+ lifetime: float = 0.0
115
+ target_id: int = -1
116
+
117
+
118
+ def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
119
+ dx = x1 - x0
120
+ dy = y1 - y0
121
+ return dx * dx + dy * dy
122
+
123
+
124
+ def _hit_radius_for(creature: Damageable) -> float:
125
+ """Approximate `creature_find_in_radius`/`creatures_apply_radius_damage` sizing.
126
+
127
+ The native code compares `distance - radius < creature.size * 0.14285715 + 3.0`.
128
+ """
129
+
130
+ raw = getattr(creature, "size", None)
131
+ if raw is None:
132
+ size = 50.0
133
+ else:
134
+ size = float(raw)
135
+ return max(0.0, size * 0.14285715 + 3.0)
136
+
137
+
138
+ class ProjectilePool:
139
+ def __init__(self, *, size: int = MAIN_PROJECTILE_POOL_SIZE) -> None:
140
+ self._entries = [Projectile() for _ in range(size)]
141
+
142
+ @property
143
+ def entries(self) -> list[Projectile]:
144
+ return self._entries
145
+
146
+ def reset(self) -> None:
147
+ for entry in self._entries:
148
+ entry.active = False
149
+
150
+ def spawn(
151
+ self,
152
+ *,
153
+ pos_x: float,
154
+ pos_y: float,
155
+ angle: float,
156
+ type_id: int,
157
+ owner_id: int,
158
+ base_damage: float = 0.0,
159
+ hits_players: bool = False,
160
+ ) -> int:
161
+ index = None
162
+ for i, entry in enumerate(self._entries):
163
+ if not entry.active:
164
+ index = i
165
+ break
166
+ if index is None:
167
+ index = len(self._entries) - 1
168
+ entry = self._entries[index]
169
+
170
+ entry.active = True
171
+ entry.angle = angle
172
+ entry.pos_x = pos_x
173
+ entry.pos_y = pos_y
174
+ entry.origin_x = pos_x
175
+ entry.origin_y = pos_y
176
+ entry.vel_x = math.cos(angle) * 1.5
177
+ entry.vel_y = math.sin(angle) * 1.5
178
+ entry.type_id = int(type_id)
179
+ entry.life_timer = 0.4
180
+ entry.reserved = 0.0
181
+ entry.speed_scale = 1.0
182
+ entry.base_damage = float(base_damage)
183
+ entry.owner_id = int(owner_id)
184
+ entry.hits_players = bool(hits_players)
185
+
186
+ if type_id == ProjectileTypeId.ION_MINIGUN:
187
+ entry.hit_radius = 3.0
188
+ entry.damage_pool = 1.0
189
+ return index
190
+ if type_id == ProjectileTypeId.ION_RIFLE:
191
+ entry.hit_radius = 5.0
192
+ entry.damage_pool = 1.0
193
+ return index
194
+ if type_id in (ProjectileTypeId.ION_CANNON, ProjectileTypeId.PLASMA_CANNON):
195
+ entry.hit_radius = 10.0
196
+ else:
197
+ entry.hit_radius = 1.0
198
+ if type_id == ProjectileTypeId.GAUSS_GUN:
199
+ entry.damage_pool = 300.0
200
+ return index
201
+ if type_id == ProjectileTypeId.FIRE_BULLETS:
202
+ entry.damage_pool = 240.0
203
+ return index
204
+ if type_id == ProjectileTypeId.BLADE_GUN:
205
+ entry.damage_pool = 50.0
206
+ return index
207
+ entry.damage_pool = 1.0
208
+ return index
209
+
210
+ def iter_active(self) -> list[Projectile]:
211
+ return [entry for entry in self._entries if entry.active]
212
+
213
+ def update(
214
+ self,
215
+ dt: float,
216
+ creatures: list[Damageable],
217
+ *,
218
+ world_size: float,
219
+ damage_scale_by_type: dict[int, float] | None = None,
220
+ damage_scale_default: float = 1.0,
221
+ ion_aoe_scale: float = 1.0,
222
+ rng: Callable[[], int] | None = None,
223
+ runtime_state: object | None = None,
224
+ players: list[PlayerDamageable] | None = None,
225
+ apply_player_damage: Callable[[int, float], None] | None = None,
226
+ apply_creature_damage: CreatureDamageApplier | None = None,
227
+ ) -> list[tuple[int, float, float, float, float, float, float]]:
228
+ """Update the main projectile pool.
229
+
230
+ Modeled after `projectile_update` (0x00420b90) for the subset used by demo/state-9 work.
231
+
232
+ Returns a list of hit tuples: (type_id, origin_x, origin_y, hit_x, hit_y, target_x, target_y).
233
+ """
234
+
235
+ if dt <= 0.0:
236
+ return []
237
+
238
+ barrel_greaser_active = False
239
+ ion_gun_master_active = False
240
+ ion_scale = float(ion_aoe_scale)
241
+ poison_idx = int(PerkId.POISON_BULLETS)
242
+ if players is not None:
243
+ barrel_idx = int(PerkId.BARREL_GREASER)
244
+ ion_idx = int(PerkId.ION_GUN_MASTER)
245
+ for player in players:
246
+ perk_counts = getattr(player, "perk_counts", None)
247
+ if not isinstance(perk_counts, list):
248
+ continue
249
+
250
+ if 0 <= barrel_idx < len(perk_counts) and int(perk_counts[barrel_idx]) > 0:
251
+ barrel_greaser_active = True
252
+ if 0 <= ion_idx < len(perk_counts) and int(perk_counts[ion_idx]) > 0:
253
+ ion_gun_master_active = True
254
+ if barrel_greaser_active and ion_gun_master_active:
255
+ break
256
+
257
+ if ion_scale == 1.0 and ion_gun_master_active:
258
+ ion_scale = 1.2
259
+
260
+ def _owner_perk_active(owner_id: int, perk_idx: int) -> bool:
261
+ if players is None:
262
+ return False
263
+ if owner_id == -100:
264
+ player_index = 0
265
+ elif owner_id < 0:
266
+ player_index = -1 - int(owner_id)
267
+ else:
268
+ return False
269
+ if not (0 <= player_index < len(players)):
270
+ return False
271
+ perk_counts = getattr(players[player_index], "perk_counts", None)
272
+ if not isinstance(perk_counts, list):
273
+ return False
274
+ return 0 <= perk_idx < len(perk_counts) and int(perk_counts[perk_idx]) > 0
275
+
276
+ if damage_scale_by_type is None:
277
+ damage_scale_by_type = {}
278
+
279
+ if rng is None:
280
+ rng = _rng_zero
281
+
282
+ hits: list[tuple[int, float, float, float, float, float, float]] = []
283
+ margin = 64.0
284
+
285
+ def _damage_scale(type_id: int) -> float:
286
+ value = damage_scale_by_type.get(type_id)
287
+ if value is None:
288
+ return float(damage_scale_default)
289
+ return float(value)
290
+
291
+ def _damage_type_for() -> int:
292
+ return 1
293
+
294
+ def _apply_damage_to_creature(
295
+ creature_index: int,
296
+ damage: float,
297
+ *,
298
+ damage_type: int,
299
+ impulse_x: float,
300
+ impulse_y: float,
301
+ owner_id: int,
302
+ ) -> None:
303
+ if damage <= 0.0:
304
+ return
305
+ idx = int(creature_index)
306
+ if not (0 <= idx < len(creatures)):
307
+ return
308
+ if apply_creature_damage is not None:
309
+ apply_creature_damage(
310
+ idx,
311
+ float(damage),
312
+ int(damage_type),
313
+ float(impulse_x),
314
+ float(impulse_y),
315
+ int(owner_id),
316
+ )
317
+ else:
318
+ creatures[idx].hp -= float(damage)
319
+
320
+ def _reset_shock_chain_if_owner(index: int) -> None:
321
+ if runtime_state is None:
322
+ return
323
+ if getattr(runtime_state, "shock_chain_projectile_id", -1) != index:
324
+ return
325
+ setattr(runtime_state, "shock_chain_projectile_id", -1)
326
+ setattr(runtime_state, "shock_chain_links_left", 0)
327
+
328
+ def _try_spawn_shock_chain_link(index: int, hit_creature: int) -> None:
329
+ if runtime_state is None:
330
+ return
331
+ if getattr(runtime_state, "shock_chain_projectile_id", -1) != index:
332
+ return
333
+ links_left = int(getattr(runtime_state, "shock_chain_links_left", 0) or 0)
334
+ if links_left <= 0:
335
+ return
336
+ if not (0 <= hit_creature < len(creatures)):
337
+ return
338
+
339
+ origin = creatures[hit_creature]
340
+ best_idx = -1
341
+ best_dist = 0.0
342
+ max_dist = 100.0
343
+ for creature_id, creature in enumerate(creatures):
344
+ if creature_id == hit_creature:
345
+ continue
346
+ if creature.hp <= 0.0:
347
+ continue
348
+ d = _distance_sq(origin.x, origin.y, creature.x, creature.y)
349
+ if d > max_dist * max_dist:
350
+ continue
351
+ if best_idx == -1 or d < best_dist:
352
+ best_idx = creature_id
353
+ best_dist = d
354
+
355
+ setattr(runtime_state, "shock_chain_links_left", links_left - 1)
356
+ if best_idx == -1:
357
+ return
358
+
359
+ target = creatures[best_idx]
360
+ angle = math.atan2(target.y - origin.y, target.x - origin.x) + math.pi / 2.0
361
+
362
+ set_guard = hasattr(runtime_state, "bonus_spawn_guard")
363
+ if set_guard:
364
+ setattr(runtime_state, "bonus_spawn_guard", True)
365
+ proj_id = self.spawn(
366
+ pos_x=proj.pos_x,
367
+ pos_y=proj.pos_y,
368
+ angle=angle,
369
+ type_id=int(proj.type_id),
370
+ owner_id=hit_creature,
371
+ base_damage=proj.base_damage,
372
+ )
373
+ if set_guard:
374
+ setattr(runtime_state, "bonus_spawn_guard", False)
375
+ setattr(runtime_state, "shock_chain_projectile_id", proj_id)
376
+
377
+ for proj_index, proj in enumerate(self._entries):
378
+ if not proj.active:
379
+ continue
380
+
381
+ if proj.life_timer <= 0.0:
382
+ _reset_shock_chain_if_owner(proj_index)
383
+ proj.active = False
384
+ continue
385
+
386
+ if runtime_state is not None and getattr(runtime_state, "shock_chain_projectile_id", -1) == proj_index:
387
+ pending_hit = int(getattr(proj, "reserved", 0.0) or 0.0)
388
+ if pending_hit > 0:
389
+ proj.reserved = 0.0
390
+ _try_spawn_shock_chain_link(proj_index, pending_hit - 1)
391
+
392
+ if proj.life_timer < 0.4:
393
+ type_id = proj.type_id
394
+ if type_id in (ProjectileTypeId.ION_RIFLE, ProjectileTypeId.ION_MINIGUN):
395
+ proj.life_timer -= dt
396
+ if type_id == ProjectileTypeId.ION_RIFLE:
397
+ damage = dt * 100.0
398
+ radius = ion_scale * 88.0
399
+ else:
400
+ damage = dt * 40.0
401
+ radius = ion_scale * 60.0
402
+ for creature_idx, creature in enumerate(creatures):
403
+ if creature.hp <= 0.0:
404
+ continue
405
+ creature_radius = _hit_radius_for(creature)
406
+ hit_r = radius + creature_radius
407
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
408
+ _apply_damage_to_creature(
409
+ creature_idx,
410
+ damage,
411
+ damage_type=7,
412
+ impulse_x=0.0,
413
+ impulse_y=0.0,
414
+ owner_id=int(proj.owner_id),
415
+ )
416
+ elif type_id == ProjectileTypeId.ION_CANNON:
417
+ proj.life_timer -= dt * 0.7
418
+ damage = dt * 300.0
419
+ radius = ion_scale * 128.0
420
+ for creature_idx, creature in enumerate(creatures):
421
+ if creature.hp <= 0.0:
422
+ continue
423
+ creature_radius = _hit_radius_for(creature)
424
+ hit_r = radius + creature_radius
425
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
426
+ _apply_damage_to_creature(
427
+ creature_idx,
428
+ damage,
429
+ damage_type=7,
430
+ impulse_x=0.0,
431
+ impulse_y=0.0,
432
+ owner_id=int(proj.owner_id),
433
+ )
434
+ elif type_id == ProjectileTypeId.GAUSS_GUN:
435
+ proj.life_timer -= dt * 0.1
436
+ else:
437
+ proj.life_timer -= dt
438
+
439
+ if proj.life_timer <= 0.0:
440
+ proj.active = False
441
+ continue
442
+
443
+ if (
444
+ proj.pos_x < -margin
445
+ or proj.pos_y < -margin
446
+ or proj.pos_x > world_size + margin
447
+ or proj.pos_y > world_size + margin
448
+ ):
449
+ proj.life_timer -= dt
450
+ if proj.life_timer <= 0.0:
451
+ proj.active = False
452
+ continue
453
+
454
+ steps = int(proj.base_damage)
455
+ if steps <= 0:
456
+ steps = 1
457
+ if barrel_greaser_active and int(proj.owner_id) < 0:
458
+ steps *= 2
459
+
460
+ dir_x = math.cos(proj.angle - math.pi / 2.0)
461
+ dir_y = math.sin(proj.angle - math.pi / 2.0)
462
+
463
+ acc_x = 0.0
464
+ acc_y = 0.0
465
+ step = 0
466
+ while step < steps:
467
+ acc_x += dir_x * dt * 20.0 * proj.speed_scale * 3.0
468
+ acc_y += dir_y * dt * 20.0 * proj.speed_scale * 3.0
469
+
470
+ if math.hypot(acc_x, acc_y) >= 4.0 or steps <= step + 3:
471
+ move_dx = acc_x
472
+ move_dy = acc_y
473
+ proj.pos_x += move_dx
474
+ proj.pos_y += move_dy
475
+ acc_x = 0.0
476
+ acc_y = 0.0
477
+
478
+ if proj.hits_players:
479
+ hit_player_idx = None
480
+ if players is not None:
481
+ for idx, player in enumerate(players):
482
+ if float(player.health) <= 0.0:
483
+ continue
484
+ player_radius = _hit_radius_for(player)
485
+ hit_r = proj.hit_radius + player_radius
486
+ if _distance_sq(proj.pos_x, proj.pos_y, player.pos_x, player.pos_y) <= hit_r * hit_r:
487
+ hit_player_idx = idx
488
+ break
489
+
490
+ if hit_player_idx is None:
491
+ step += 3
492
+ continue
493
+
494
+ type_id = proj.type_id
495
+ hit_x = float(proj.pos_x)
496
+ hit_y = float(proj.pos_y)
497
+ player = players[int(hit_player_idx)] if players is not None else None
498
+ target_x = float(getattr(player, "pos_x", hit_x) if player is not None else hit_x)
499
+ target_y = float(getattr(player, "pos_y", hit_y) if player is not None else hit_y)
500
+ hits.append((type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, target_x, target_y))
501
+
502
+ if proj.life_timer != 0.25 and type_id not in (
503
+ ProjectileTypeId.FIRE_BULLETS,
504
+ ProjectileTypeId.GAUSS_GUN,
505
+ ProjectileTypeId.BLADE_GUN,
506
+ ):
507
+ proj.life_timer = 0.25
508
+ jitter = rng() & 3
509
+ proj.pos_x += dir_x * float(jitter)
510
+ proj.pos_y += dir_y * float(jitter)
511
+
512
+ dist = math.hypot(proj.origin_x - proj.pos_x, proj.origin_y - proj.pos_y)
513
+ if dist < 50.0:
514
+ dist = 50.0
515
+
516
+ damage_scale = _damage_scale(type_id)
517
+ damage_amount = ((100.0 / dist) * damage_scale * 30.0 + 10.0) * 0.95
518
+ if damage_amount > 0.0:
519
+ if apply_player_damage is not None:
520
+ apply_player_damage(int(hit_player_idx), float(damage_amount))
521
+ elif players is not None:
522
+ players[int(hit_player_idx)].health -= float(damage_amount)
523
+
524
+ break
525
+
526
+ hit_idx = None
527
+ for idx, creature in enumerate(creatures):
528
+ if creature.hp <= 0.0:
529
+ continue
530
+ if idx == proj.owner_id:
531
+ continue
532
+ creature_radius = _hit_radius_for(creature)
533
+ hit_r = proj.hit_radius + creature_radius
534
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
535
+ hit_idx = idx
536
+ break
537
+
538
+ if hit_idx is None:
539
+ step += 3
540
+ continue
541
+
542
+ type_id = proj.type_id
543
+ creature = creatures[hit_idx]
544
+
545
+ if _owner_perk_active(int(proj.owner_id), poison_idx) and (int(rng()) & 7) == 1:
546
+ if hasattr(creature, "flags"):
547
+ creature.flags |= CreatureFlags.SELF_DAMAGE_TICK
548
+
549
+ if type_id == ProjectileTypeId.SPLITTER_GUN:
550
+ self.spawn(
551
+ pos_x=proj.pos_x,
552
+ pos_y=proj.pos_y,
553
+ angle=proj.angle - 1.0471976,
554
+ type_id=ProjectileTypeId.SPLITTER_GUN,
555
+ owner_id=hit_idx,
556
+ base_damage=proj.base_damage,
557
+ hits_players=proj.hits_players,
558
+ )
559
+ self.spawn(
560
+ pos_x=proj.pos_x,
561
+ pos_y=proj.pos_y,
562
+ angle=proj.angle + 1.0471976,
563
+ type_id=ProjectileTypeId.SPLITTER_GUN,
564
+ owner_id=hit_idx,
565
+ base_damage=proj.base_damage,
566
+ hits_players=proj.hits_players,
567
+ )
568
+
569
+ shots_hit = getattr(runtime_state, "shots_hit", None) if runtime_state is not None else None
570
+ if isinstance(shots_hit, list):
571
+ owner_id = int(proj.owner_id)
572
+ if owner_id < 0 and owner_id != -100:
573
+ player_index = -1 - owner_id
574
+ if 0 <= player_index < len(shots_hit):
575
+ shots_hit[player_index] += 1
576
+
577
+ hit_x = float(proj.pos_x)
578
+ hit_y = float(proj.pos_y)
579
+ target_x = float(creature.x)
580
+ target_y = float(creature.y)
581
+ hits.append((type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, target_x, target_y))
582
+
583
+ if proj.life_timer != 0.25 and type_id not in (
584
+ ProjectileTypeId.FIRE_BULLETS,
585
+ ProjectileTypeId.GAUSS_GUN,
586
+ ProjectileTypeId.BLADE_GUN,
587
+ ):
588
+ proj.life_timer = 0.25
589
+ jitter = rng() & 3
590
+ proj.pos_x += dir_x * float(jitter)
591
+ proj.pos_y += dir_y * float(jitter)
592
+
593
+ dist = math.hypot(proj.origin_x - proj.pos_x, proj.origin_y - proj.pos_y)
594
+ if dist < 50.0:
595
+ dist = 50.0
596
+
597
+ if type_id == ProjectileTypeId.ION_RIFLE:
598
+ if runtime_state is not None and getattr(runtime_state, "shock_chain_projectile_id", -1) == proj_index:
599
+ proj.reserved = float(int(hit_idx) + 1)
600
+ elif type_id == ProjectileTypeId.PLASMA_CANNON:
601
+ size = float(getattr(creature, "size", 50.0) or 50.0)
602
+ ring_radius = size * 0.5 + 1.0
603
+ plasma_entry = weapon_entry_for_projectile_type_id(int(ProjectileTypeId.PLASMA_RIFLE))
604
+ plasma_meta = (
605
+ float(plasma_entry.projectile_meta)
606
+ if plasma_entry and plasma_entry.projectile_meta is not None
607
+ else proj.base_damage
608
+ )
609
+ for ring_idx in range(12):
610
+ ring_angle = float(ring_idx) * (math.pi / 6.0)
611
+ self.spawn(
612
+ pos_x=proj.pos_x + math.cos(ring_angle) * ring_radius,
613
+ pos_y=proj.pos_y + math.sin(ring_angle) * ring_radius,
614
+ angle=ring_angle,
615
+ type_id=ProjectileTypeId.PLASMA_RIFLE,
616
+ owner_id=-100,
617
+ base_damage=plasma_meta,
618
+ )
619
+ elif type_id == ProjectileTypeId.SHRINKIFIER:
620
+ if hasattr(creature, "size"):
621
+ new_size = float(getattr(creature, "size", 50.0) or 50.0) * 0.65
622
+ setattr(creature, "size", new_size)
623
+ if new_size < 16.0:
624
+ _apply_damage_to_creature(
625
+ hit_idx,
626
+ float(creature.hp) + 1.0,
627
+ damage_type=_damage_type_for(),
628
+ impulse_x=0.0,
629
+ impulse_y=0.0,
630
+ owner_id=int(proj.owner_id),
631
+ )
632
+ proj.life_timer = 0.25
633
+ elif type_id == ProjectileTypeId.PULSE_GUN:
634
+ creature.x += move_dx * 3.0
635
+ creature.y += move_dy * 3.0
636
+ elif type_id == ProjectileTypeId.PLAGUE_SPREADER and hasattr(creature, "collision_flag"):
637
+ setattr(creature, "collision_flag", 1)
638
+
639
+ damage_scale = _damage_scale(type_id)
640
+ damage_amount = ((100.0 / dist) * damage_scale * 30.0 + 10.0) * 0.95
641
+
642
+ if damage_amount > 0.0 and creature.hp > 0.0:
643
+ remaining = proj.damage_pool - 1.0
644
+ proj.damage_pool = remaining
645
+ impulse_x = dir_x * float(proj.speed_scale)
646
+ impulse_y = dir_y * float(proj.speed_scale)
647
+ damage_type = _damage_type_for()
648
+ if remaining <= 0.0:
649
+ _apply_damage_to_creature(
650
+ hit_idx,
651
+ damage_amount,
652
+ damage_type=damage_type,
653
+ impulse_x=impulse_x,
654
+ impulse_y=impulse_y,
655
+ owner_id=int(proj.owner_id),
656
+ )
657
+ if proj.life_timer != 0.25:
658
+ proj.life_timer = 0.25
659
+ else:
660
+ hp_before = float(creature.hp)
661
+ _apply_damage_to_creature(
662
+ hit_idx,
663
+ remaining,
664
+ damage_type=damage_type,
665
+ impulse_x=impulse_x,
666
+ impulse_y=impulse_y,
667
+ owner_id=int(proj.owner_id),
668
+ )
669
+ proj.damage_pool -= hp_before
670
+
671
+ if proj.damage_pool == 1.0 and proj.life_timer != 0.25:
672
+ proj.damage_pool = 0.0
673
+ proj.life_timer = 0.25
674
+
675
+ if proj.life_timer == 0.25 and type_id not in (
676
+ ProjectileTypeId.FIRE_BULLETS,
677
+ ProjectileTypeId.GAUSS_GUN,
678
+ ProjectileTypeId.BLADE_GUN,
679
+ ):
680
+ break
681
+
682
+ if proj.damage_pool <= 0.0:
683
+ break
684
+
685
+ step += 3
686
+
687
+ return hits
688
+
689
+ def update_demo(
690
+ self,
691
+ dt: float,
692
+ creatures: list[Damageable],
693
+ *,
694
+ world_size: float,
695
+ speed_by_type: dict[int, float],
696
+ damage_by_type: dict[int, float],
697
+ ) -> list[tuple[int, float, float, float, float, float, float]]:
698
+ """Update a small projectile subset for the demo view.
699
+
700
+ Returns a list of hit tuples: (type_id, origin_x, origin_y, hit_x, hit_y, target_x, target_y).
701
+ """
702
+
703
+ if dt <= 0.0:
704
+ return []
705
+
706
+ hits: list[tuple[int, float, float, float, float, float, float]] = []
707
+ margin = 64.0
708
+
709
+ for proj in self._entries:
710
+ if not proj.active:
711
+ continue
712
+
713
+ if proj.life_timer <= 0.0:
714
+ proj.active = False
715
+ continue
716
+
717
+ if proj.life_timer < 0.4:
718
+ if proj.type_id == ProjectileTypeId.ION_RIFLE:
719
+ damage = dt * 100.0
720
+ radius = 88.0
721
+ for creature in creatures:
722
+ if creature.hp <= 0.0:
723
+ continue
724
+ creature_radius = _hit_radius_for(creature)
725
+ hit_r = radius + creature_radius
726
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
727
+ creature.hp -= damage
728
+ elif proj.type_id == ProjectileTypeId.ION_MINIGUN:
729
+ damage = dt * 40.0
730
+ radius = 60.0
731
+ for creature in creatures:
732
+ if creature.hp <= 0.0:
733
+ continue
734
+ creature_radius = _hit_radius_for(creature)
735
+ hit_r = radius + creature_radius
736
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
737
+ creature.hp -= damage
738
+ proj.life_timer -= dt
739
+ if proj.life_timer <= 0.0:
740
+ proj.active = False
741
+ continue
742
+
743
+ if (
744
+ proj.pos_x < -margin
745
+ or proj.pos_y < -margin
746
+ or proj.pos_x > world_size + margin
747
+ or proj.pos_y > world_size + margin
748
+ ):
749
+ proj.life_timer -= dt
750
+ if proj.life_timer <= 0.0:
751
+ proj.active = False
752
+ continue
753
+
754
+ speed = speed_by_type.get(proj.type_id, 650.0) * proj.speed_scale
755
+ direction_x = math.cos(proj.angle - math.pi / 2.0)
756
+ direction_y = math.sin(proj.angle - math.pi / 2.0)
757
+ proj.pos_x += direction_x * speed * dt
758
+ proj.pos_y += direction_y * speed * dt
759
+
760
+ hit_idx = None
761
+ for idx, creature in enumerate(creatures):
762
+ if creature.hp <= 0.0:
763
+ continue
764
+ creature_radius = _hit_radius_for(creature)
765
+ hit_r = proj.hit_radius + creature_radius
766
+ if _distance_sq(proj.pos_x, proj.pos_y, creature.x, creature.y) <= hit_r * hit_r:
767
+ hit_idx = idx
768
+ break
769
+ if hit_idx is None:
770
+ continue
771
+
772
+ hit_x = float(proj.pos_x)
773
+ hit_y = float(proj.pos_y)
774
+ creature = creatures[hit_idx]
775
+ hits.append((proj.type_id, proj.origin_x, proj.origin_y, hit_x, hit_y, float(creature.x), float(creature.y)))
776
+
777
+ creature = creatures[hit_idx]
778
+ creature.hp -= damage_by_type.get(proj.type_id, 10.0)
779
+
780
+ proj.life_timer = 0.25
781
+
782
+ return hits
783
+
784
+
785
+ class SecondaryProjectilePool:
786
+ def __init__(self, *, size: int = SECONDARY_PROJECTILE_POOL_SIZE) -> None:
787
+ self._entries = [SecondaryProjectile() for _ in range(size)]
788
+
789
+ @property
790
+ def entries(self) -> list[SecondaryProjectile]:
791
+ return self._entries
792
+
793
+ def reset(self) -> None:
794
+ for entry in self._entries:
795
+ entry.active = False
796
+
797
+ def spawn(
798
+ self,
799
+ *,
800
+ pos_x: float,
801
+ pos_y: float,
802
+ angle: float,
803
+ type_id: int,
804
+ owner_id: int = -100,
805
+ time_to_live: float = 2.0,
806
+ ) -> int:
807
+ index = None
808
+ for i, entry in enumerate(self._entries):
809
+ if not entry.active:
810
+ index = i
811
+ break
812
+ if index is None:
813
+ index = len(self._entries) - 1
814
+
815
+ entry = self._entries[index]
816
+ entry.active = True
817
+ entry.angle = float(angle)
818
+ entry.type_id = int(type_id)
819
+ entry.pos_x = float(pos_x)
820
+ entry.pos_y = float(pos_y)
821
+ entry.owner_id = int(owner_id)
822
+ entry.target_id = -1
823
+
824
+ if entry.type_id == 3:
825
+ entry.vel_x = 0.0
826
+ entry.vel_y = 0.0
827
+ entry.speed = float(time_to_live)
828
+ entry.lifetime = 0.0
829
+ return index
830
+
831
+ # Effects.md: vel = cos/sin(angle - PI/2) * 90 (190 for type 2).
832
+ base_speed = 90.0
833
+ if entry.type_id == 2:
834
+ base_speed = 190.0
835
+ vx = math.cos(angle - math.pi / 2.0) * base_speed
836
+ vy = math.sin(angle - math.pi / 2.0) * base_speed
837
+ entry.vel_x = vx
838
+ entry.vel_y = vy
839
+ entry.speed = float(time_to_live)
840
+ entry.lifetime = 0.0
841
+ return index
842
+
843
+ def iter_active(self) -> list[SecondaryProjectile]:
844
+ return [entry for entry in self._entries if entry.active]
845
+
846
+ def update_pulse_gun(
847
+ self,
848
+ dt: float,
849
+ creatures: list[Damageable],
850
+ *,
851
+ apply_creature_damage: CreatureDamageApplier | None = None,
852
+ runtime_state: object | None = None,
853
+ fx_queue: FxQueueLike | None = None,
854
+ detail_preset: int = 5,
855
+ ) -> None:
856
+ """Update the secondary projectile pool subset (types 1/2/4 + detonation type 3)."""
857
+
858
+ if dt <= 0.0:
859
+ return
860
+
861
+ def _apply_damage_to_creature(creature_index: int, damage: float, *, owner_id: int) -> None:
862
+ if damage <= 0.0:
863
+ return
864
+ idx = int(creature_index)
865
+ if not (0 <= idx < len(creatures)):
866
+ return
867
+ if apply_creature_damage is not None:
868
+ apply_creature_damage(idx, float(damage), 3, 0.0, 0.0, int(owner_id))
869
+ else:
870
+ creatures[idx].hp -= float(damage)
871
+
872
+ rand = _rng_zero
873
+ freeze_active = False
874
+ effects = None
875
+ sfx_queue = None
876
+ if runtime_state is not None:
877
+ rng = getattr(runtime_state, "rng", None)
878
+ if rng is not None:
879
+ rand = getattr(rng, "rand", _rng_zero)
880
+
881
+ bonuses = getattr(runtime_state, "bonuses", None)
882
+ if bonuses is not None and float(getattr(bonuses, "freeze", 0.0)) > 0.0:
883
+ freeze_active = True
884
+
885
+ effects = getattr(runtime_state, "effects", None)
886
+ sfx_queue = getattr(runtime_state, "sfx_queue", None)
887
+
888
+ for entry in self._entries:
889
+ if not entry.active:
890
+ continue
891
+
892
+ if entry.type_id == 3:
893
+ entry.lifetime += dt * 3.0
894
+ t = entry.lifetime
895
+ scale = entry.speed
896
+ if t > 1.0:
897
+ if fx_queue is not None:
898
+ fx_queue.add(
899
+ effect_id=0x10,
900
+ pos_x=float(entry.pos_x),
901
+ pos_y=float(entry.pos_y),
902
+ width=float(scale) * 256.0,
903
+ height=float(scale) * 256.0,
904
+ rotation=0.0,
905
+ rgba=(0.0, 0.0, 0.0, 0.25),
906
+ )
907
+ entry.active = False
908
+
909
+ radius = scale * t * 80.0
910
+ damage = dt * scale * 700.0
911
+ for creature_idx, creature in enumerate(creatures):
912
+ if creature.hp <= 0.0:
913
+ continue
914
+ creature_radius = _hit_radius_for(creature)
915
+ hit_r = radius + creature_radius
916
+ if _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
917
+ _apply_damage_to_creature(creature_idx, damage, owner_id=int(entry.owner_id))
918
+ continue
919
+
920
+ if entry.type_id not in (1, 2, 4):
921
+ continue
922
+
923
+ # Move.
924
+ entry.pos_x += entry.vel_x * dt
925
+ entry.pos_y += entry.vel_y * dt
926
+
927
+ # Update velocity + countdown.
928
+ speed_mag = math.hypot(entry.vel_x, entry.vel_y)
929
+ if entry.type_id == 1:
930
+ if speed_mag < 500.0:
931
+ factor = 1.0 + dt * 3.0
932
+ entry.vel_x *= factor
933
+ entry.vel_y *= factor
934
+ entry.speed -= dt
935
+ elif entry.type_id == 4:
936
+ if speed_mag < 600.0:
937
+ factor = 1.0 + dt * 4.0
938
+ entry.vel_x *= factor
939
+ entry.vel_y *= factor
940
+ entry.speed -= dt
941
+ else:
942
+ # Type 2: homing projectile.
943
+ target_id = entry.target_id
944
+ if not (0 <= target_id < len(creatures)) or creatures[target_id].hp <= 0.0:
945
+ best_idx = -1
946
+ best_dist = 0.0
947
+ for idx, creature in enumerate(creatures):
948
+ if creature.hp <= 0.0:
949
+ continue
950
+ d = _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y)
951
+ if best_idx == -1 or d < best_dist:
952
+ best_idx = idx
953
+ best_dist = d
954
+ entry.target_id = best_idx
955
+ target_id = best_idx
956
+
957
+ if 0 <= target_id < len(creatures):
958
+ target = creatures[target_id]
959
+ dx = target.x - entry.pos_x
960
+ dy = target.y - entry.pos_y
961
+ dist = math.hypot(dx, dy)
962
+ if dist > 1e-6:
963
+ angle = math.atan2(dy, dx) + math.pi / 2.0
964
+ entry.angle = angle
965
+ dir_x = math.cos(angle - math.pi / 2.0)
966
+ dir_y = math.sin(angle - math.pi / 2.0)
967
+ entry.vel_x += dir_x * dt * 800.0
968
+ entry.vel_y += dir_y * dt * 800.0
969
+ if 350.0 < math.hypot(entry.vel_x, entry.vel_y):
970
+ entry.vel_x -= dir_x * dt * 800.0
971
+ entry.vel_y -= dir_y * dt * 800.0
972
+
973
+ entry.speed -= dt * 0.5
974
+
975
+ # projectile_update uses creature_find_in_radius(..., 8.0, ...)
976
+ hit_idx: int | None = None
977
+ for idx, creature in enumerate(creatures):
978
+ if creature.hp <= 0.0:
979
+ continue
980
+ creature_radius = _hit_radius_for(creature)
981
+ hit_r = 8.0 + creature_radius
982
+ if _distance_sq(entry.pos_x, entry.pos_y, creature.x, creature.y) <= hit_r * hit_r:
983
+ hit_idx = idx
984
+ break
985
+ if hit_idx is not None:
986
+ if isinstance(sfx_queue, list):
987
+ sfx_queue.append("sfx_explosion_medium")
988
+
989
+ damage = 150.0
990
+ if entry.type_id == 1:
991
+ damage = entry.speed * 50.0 + 500.0
992
+ elif entry.type_id == 2:
993
+ damage = entry.speed * 20.0 + 80.0
994
+ elif entry.type_id == 4:
995
+ damage = entry.speed * 20.0 + 40.0
996
+ _apply_damage_to_creature(hit_idx, damage, owner_id=int(entry.owner_id))
997
+
998
+ det_scale = 0.5
999
+ if entry.type_id == 1:
1000
+ det_scale = 1.0
1001
+ elif entry.type_id == 2:
1002
+ det_scale = 0.35
1003
+ elif entry.type_id == 4:
1004
+ det_scale = 0.25
1005
+
1006
+ if freeze_active:
1007
+ if effects is not None and hasattr(effects, "spawn_freeze_shard"):
1008
+ for _ in range(4):
1009
+ shard_angle = float(int(rand()) % 0x264) * 0.01
1010
+ effects.spawn_freeze_shard(
1011
+ pos_x=float(entry.pos_x),
1012
+ pos_y=float(entry.pos_y),
1013
+ angle=shard_angle,
1014
+ rand=rand,
1015
+ detail_preset=int(detail_preset),
1016
+ )
1017
+ elif fx_queue is not None:
1018
+ for _ in range(3):
1019
+ off_x = float(int(rand()) % 0x14 - 10)
1020
+ off_y = float(int(rand()) % 0x14 - 10)
1021
+ fx_queue.add_random(
1022
+ pos_x=float(creatures[hit_idx].x) + off_x,
1023
+ pos_y=float(creatures[hit_idx].y) + off_y,
1024
+ rand=rand,
1025
+ )
1026
+
1027
+ entry.type_id = 3
1028
+ entry.vel_x = 0.0
1029
+ entry.vel_y = 0.0
1030
+ entry.speed = det_scale
1031
+ entry.lifetime = 0.0
1032
+ continue
1033
+
1034
+ if entry.speed <= 0.0:
1035
+ entry.type_id = 3
1036
+ entry.vel_x = 0.0
1037
+ entry.vel_y = 0.0
1038
+ entry.speed = 0.5
1039
+ entry.lifetime = 0.0