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,1019 @@
1
+ from __future__ import annotations
2
+
3
+ """Creature realtime simulation glue.
4
+
5
+ This module materializes pure spawn plans (`creatures.spawn`) into a fixed-size
6
+ runtime pool and advances creatures each frame using the AI helpers.
7
+
8
+ It is intentionally minimal: the goal is to unblock a playable Survival loop,
9
+ not to perfectly match every edge case in `creature_update_all`.
10
+ See: `docs/creatures/update.md`.
11
+ """
12
+
13
+ from dataclasses import dataclass, replace
14
+ import math
15
+ from typing import Callable, Sequence
16
+
17
+ from grim.rand import Crand
18
+ from ..effects import FxQueue, FxQueueRotated
19
+ from ..gameplay import GameplayState, PlayerState, award_experience, perk_active
20
+ from ..perks import PerkId
21
+ from ..player_damage import player_take_damage
22
+ from .ai import creature_ai7_tick_link_timer, creature_ai_update_target
23
+ from .spawn import (
24
+ CreatureFlags,
25
+ CreatureInit,
26
+ SpawnEnv,
27
+ SpawnPlan,
28
+ SpawnSlotInit,
29
+ build_spawn_plan,
30
+ resolve_tint,
31
+ tick_spawn_slot,
32
+ )
33
+
34
+ __all__ = [
35
+ "CONTACT_DAMAGE_PERIOD",
36
+ "CREATURE_POOL_SIZE",
37
+ "CreatureDeath",
38
+ "CreaturePool",
39
+ "CreatureState",
40
+ "CreatureUpdateResult",
41
+ ]
42
+
43
+
44
+ CREATURE_POOL_SIZE = 0x180
45
+
46
+ CONTACT_DAMAGE_PERIOD = 0.5
47
+
48
+ # The native uses per-type speed scaling; until we port the exact table, keep a
49
+ # single global factor (native multiplies `move_speed * 30.0` in creature_update_all).
50
+ CREATURE_SPEED_SCALE = 30.0
51
+
52
+ # Base heading turn rate multiplier (angle_approach clamps by frame_dt internally).
53
+ CREATURE_TURN_RATE_SCALE = 4.0 / 3.0
54
+
55
+ # Native uses hitbox_size as a lifecycle sentinel:
56
+ # - 16.0 means "alive" (normal AI/movement/anim update)
57
+ # - once HP <= 0 it ramps down quickly and drives the death slide + corpse decal timing.
58
+ CREATURE_HITBOX_ALIVE = 16.0
59
+ CREATURE_DEATH_TIMER_DECAY = 28.0
60
+ CREATURE_CORPSE_FADE_DECAY = 20.0
61
+ CREATURE_CORPSE_DESPAWN_HITBOX = -10.0
62
+ CREATURE_DEATH_SLIDE_SCALE = 9.0
63
+
64
+
65
+ def _clamp(value: float, lo: float, hi: float) -> float:
66
+ if value < lo:
67
+ return lo
68
+ if value > hi:
69
+ return hi
70
+ return value
71
+
72
+
73
+ def _wrap_angle(angle: float) -> float:
74
+ return (angle + math.pi) % math.tau - math.pi
75
+
76
+
77
+ def _angle_approach(current: float, target: float, rate: float, dt: float) -> float:
78
+ delta = _wrap_angle(target - current)
79
+ step_scale = min(1.0, abs(delta))
80
+ step = float(dt) * step_scale * float(rate)
81
+ if delta >= 0.0:
82
+ current += step
83
+ else:
84
+ current -= step
85
+ return _wrap_angle(current)
86
+
87
+
88
+ def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
89
+ dx = x1 - x0
90
+ dy = y1 - y0
91
+ return dx * dx + dy * dy
92
+
93
+
94
+ def _owner_id_to_player_index(owner_id: int) -> int | None:
95
+ # Native uses `-1/-2/-3/-4` for player indices and `-100` as a player-owned sentinel.
96
+ if owner_id == -100:
97
+ return 0
98
+ if owner_id < 0:
99
+ return -1 - owner_id
100
+ return None
101
+
102
+
103
+ @dataclass(slots=True)
104
+ class CreatureState:
105
+ # Core identity/alive flags.
106
+ active: bool = False
107
+ type_id: int = 0
108
+
109
+ # Movement / AI.
110
+ x: float = 0.0
111
+ y: float = 0.0
112
+ vel_x: float = 0.0
113
+ vel_y: float = 0.0
114
+ heading: float = 0.0
115
+ target_heading: float = 0.0
116
+ force_target: int = 0
117
+ target_x: float = 0.0
118
+ target_y: float = 0.0
119
+ target_player: int = 0
120
+ ai_mode: int = 0
121
+ flags: CreatureFlags = CreatureFlags(0)
122
+
123
+ link_index: int = 0
124
+ target_offset_x: float | None = None
125
+ target_offset_y: float | None = None
126
+ orbit_angle: float = 0.0
127
+ orbit_radius: float = 0.0
128
+ phase_seed: float = 0.0
129
+ move_scale: float = 1.0
130
+
131
+ # Combat / timers.
132
+ hp: float = 0.0
133
+ max_hp: float = 0.0
134
+ move_speed: float = 1.0
135
+ contact_damage: float = 0.0
136
+ attack_cooldown: float = 0.0
137
+ reward_value: float = 0.0
138
+
139
+ # Contact damage gate.
140
+ collision_flag: int = 0
141
+ collision_timer: float = CONTACT_DAMAGE_PERIOD
142
+ hitbox_size: float = CREATURE_HITBOX_ALIVE
143
+
144
+ # Presentation.
145
+ size: float = 50.0
146
+ anim_phase: float = 0.0
147
+ hit_flash_timer: float = 0.0
148
+ last_hit_owner_id: int = -100
149
+ tint_r: float = 1.0
150
+ tint_g: float = 1.0
151
+ tint_b: float = 1.0
152
+ tint_a: float = 1.0
153
+
154
+ # Rewrite-only helpers (not in native struct, but derived from spawn plans).
155
+ spawn_slot_index: int | None = None
156
+ bonus_id: int | None = None
157
+ bonus_duration_override: int | None = None
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class CreatureDeath:
162
+ index: int
163
+ x: float
164
+ y: float
165
+ type_id: int
166
+ reward_value: float
167
+ xp_awarded: int
168
+
169
+
170
+ @dataclass(frozen=True, slots=True)
171
+ class CreatureUpdateResult:
172
+ deaths: tuple[CreatureDeath, ...] = ()
173
+ spawned: tuple[int, ...] = ()
174
+ sfx: tuple[str, ...] = ()
175
+
176
+
177
+ class CreaturePool:
178
+ def __init__(self, *, size: int = CREATURE_POOL_SIZE, env: SpawnEnv | None = None) -> None:
179
+ self._entries = [CreatureState() for _ in range(int(size))]
180
+ self.spawn_slots: list[SpawnSlotInit] = []
181
+ self.env = env
182
+ self.kill_count = 0
183
+ self.spawned_count = 0
184
+
185
+ @property
186
+ def entries(self) -> list[CreatureState]:
187
+ return self._entries
188
+
189
+ def reset(self) -> None:
190
+ for i in range(len(self._entries)):
191
+ self._entries[i] = CreatureState()
192
+ self.spawn_slots.clear()
193
+ self.kill_count = 0
194
+ self.spawned_count = 0
195
+
196
+ def iter_active(self) -> list[CreatureState]:
197
+ return [entry for entry in self._entries if entry.active and entry.hp > 0.0]
198
+
199
+ def _plaguebearer_spread_infection(self, origin_index: int) -> None:
200
+ """Port of `FUN_00425d80` (infects nearby creatures when Plaguebearer is active)."""
201
+
202
+ origin_index = int(origin_index)
203
+ if not (0 <= origin_index < len(self._entries)):
204
+ return
205
+ origin = self._entries[origin_index]
206
+ if not origin.active:
207
+ return
208
+
209
+ for idx, creature in enumerate(self._entries):
210
+ if not creature.active:
211
+ continue
212
+
213
+ if math.hypot(float(creature.x) - float(origin.x), float(creature.y) - float(origin.y)) < 45.0:
214
+ if creature.collision_flag != 0 and float(origin.hp) < 150.0:
215
+ origin.collision_flag = 1
216
+ if origin.collision_flag != 0 and float(creature.hp) < 150.0:
217
+ creature.collision_flag = 1
218
+ return
219
+
220
+ def _alloc_slot(self, *, rand: Callable[[], int] | None = None) -> int:
221
+ for i, entry in enumerate(self._entries):
222
+ if not entry.active:
223
+ return i
224
+ if not self._entries:
225
+ raise ValueError("Creature pool has zero entries")
226
+ if rand is not None:
227
+ return int(rand()) % len(self._entries)
228
+ return len(self._entries) - 1
229
+
230
+ def spawn_init(self, init: CreatureInit, *, rand: Callable[[], int] | None = None) -> int:
231
+ """Materialize a single `CreatureInit` into the runtime pool."""
232
+
233
+ idx = self._alloc_slot(rand=rand)
234
+ entry = CreatureState()
235
+ self._apply_init(entry, init)
236
+
237
+ # Direct init does not have plan-local indices; preserve any raw linkage.
238
+ if init.ai_timer is not None:
239
+ entry.link_index = int(init.ai_timer)
240
+ elif init.ai_link_parent is not None:
241
+ entry.link_index = int(init.ai_link_parent)
242
+ if init.spawn_slot is not None:
243
+ # Plan-local slot ids must be remapped by `spawn_plan`; keep explicit.
244
+ entry.spawn_slot_index = int(init.spawn_slot)
245
+ entry.link_index = int(init.spawn_slot)
246
+
247
+ self._entries[idx] = entry
248
+ self.spawned_count += 1
249
+ return idx
250
+
251
+ def spawn_inits(self, inits: Sequence[CreatureInit], *, rand: Callable[[], int] | None = None) -> list[int]:
252
+ return [self.spawn_init(init, rand=rand) for init in inits]
253
+
254
+ def spawn_plan(
255
+ self,
256
+ plan: SpawnPlan,
257
+ *,
258
+ rand: Callable[[], int] | None = None,
259
+ ) -> tuple[list[int], int | None]:
260
+ """Materialize a pure `SpawnPlan` into the runtime pool.
261
+
262
+ Returns:
263
+ (plan_index_to_pool_index, primary_pool_index_or_none)
264
+ """
265
+
266
+ mapping: list[int] = []
267
+ pending_ai_links: list[int | None] = []
268
+ pending_ai_timers: list[int | None] = []
269
+ pending_spawn_slots: list[int | None] = []
270
+
271
+ # 1) Allocate pool slots for every creature.
272
+ for init in plan.creatures:
273
+ pool_idx = self._alloc_slot(rand=rand)
274
+ entry = CreatureState()
275
+ self._apply_init(entry, init)
276
+ self._entries[pool_idx] = entry
277
+ self.spawned_count += 1
278
+
279
+ mapping.append(pool_idx)
280
+ pending_ai_links.append(init.ai_link_parent)
281
+ pending_ai_timers.append(init.ai_timer)
282
+ pending_spawn_slots.append(init.spawn_slot)
283
+
284
+ # 2) Allocate and remap spawn slots.
285
+ slot_mapping: list[int] = []
286
+ for slot in plan.spawn_slots:
287
+ owner_plan = int(slot.owner_creature)
288
+ owner_pool = mapping[owner_plan] if 0 <= owner_plan < len(mapping) else -1
289
+ self.spawn_slots.append(
290
+ SpawnSlotInit(
291
+ owner_creature=int(owner_pool),
292
+ timer=float(slot.timer),
293
+ count=int(slot.count),
294
+ limit=int(slot.limit),
295
+ interval=float(slot.interval),
296
+ child_template_id=int(slot.child_template_id),
297
+ )
298
+ )
299
+ slot_mapping.append(len(self.spawn_slots) - 1)
300
+
301
+ # 3) Patch link indices now that we have global indices.
302
+ for plan_idx, pool_idx in enumerate(mapping):
303
+ entry = self._entries[pool_idx]
304
+
305
+ slot_plan = pending_spawn_slots[plan_idx]
306
+ if slot_plan is not None:
307
+ global_slot = slot_mapping[int(slot_plan)]
308
+ entry.spawn_slot_index = int(global_slot)
309
+ entry.link_index = int(global_slot)
310
+ continue
311
+
312
+ timer = pending_ai_timers[plan_idx]
313
+ if timer is not None:
314
+ entry.link_index = int(timer)
315
+ continue
316
+
317
+ link_plan = pending_ai_links[plan_idx]
318
+ if link_plan is not None:
319
+ entry.link_index = mapping[int(link_plan)]
320
+
321
+ primary_pool = None
322
+ if 0 <= int(plan.primary) < len(mapping):
323
+ primary_pool = mapping[int(plan.primary)]
324
+ return mapping, primary_pool
325
+
326
+ def spawn_template(
327
+ self,
328
+ template_id: int,
329
+ pos: tuple[float, float],
330
+ heading: float,
331
+ rng: Crand,
332
+ *,
333
+ rand: Callable[[], int] | None = None,
334
+ env: SpawnEnv | None = None,
335
+ ) -> tuple[list[int], int | None]:
336
+ """Build a spawn plan and materialize it into the pool."""
337
+
338
+ spawn_env = env or self.env
339
+ if spawn_env is None:
340
+ raise ValueError("CreaturePool.spawn_template requires SpawnEnv (set CreaturePool.env or pass env=...)")
341
+ plan = build_spawn_plan(template_id, pos, heading, rng, spawn_env)
342
+ return self.spawn_plan(plan, rand=rand)
343
+
344
+ def update(
345
+ self,
346
+ dt: float,
347
+ *,
348
+ state: GameplayState,
349
+ players: list[PlayerState],
350
+ rand: Callable[[], int] | None = None,
351
+ detail_preset: int = 5,
352
+ env: SpawnEnv | None = None,
353
+ world_width: float = 1024.0,
354
+ world_height: float = 1024.0,
355
+ fx_queue: FxQueue | None = None,
356
+ fx_queue_rotated: FxQueueRotated | None = None,
357
+ ) -> CreatureUpdateResult:
358
+ """Advance the creature runtime pool by `dt` seconds.
359
+
360
+ Notes:
361
+ - Death side effects should be initiated by damage call sites.
362
+ - This is not a full port of `creature_update_all`; it targets the Survival subset.
363
+ """
364
+
365
+ if rand is None:
366
+ rand = state.rng.rand
367
+ spawn_env = env or self.env
368
+
369
+ deaths: list[CreatureDeath] = []
370
+ spawned: list[int] = []
371
+ sfx: list[str] = []
372
+
373
+ evil_target = -1
374
+ if players and perk_active(players[0], PerkId.EVIL_EYES):
375
+ evil_target = int(players[0].evil_eyes_target_creature)
376
+
377
+ # Movement + AI. Dead creatures keep updating (death slide + corpse decals)
378
+ # even when `players` is empty so debug views remain deterministic.
379
+ dt_ms = int(dt * 1000.0) if dt > 0.0 else 0
380
+ for idx, creature in enumerate(self._entries):
381
+ if not creature.active:
382
+ continue
383
+
384
+ if creature.hitbox_size != CREATURE_HITBOX_ALIVE or creature.hp <= 0.0:
385
+ if creature.hitbox_size == CREATURE_HITBOX_ALIVE:
386
+ creature.hitbox_size = CREATURE_HITBOX_ALIVE - 0.001
387
+ if dt > 0.0:
388
+ self._tick_dead(
389
+ creature,
390
+ dt=dt,
391
+ world_width=world_width,
392
+ world_height=world_height,
393
+ fx_queue_rotated=fx_queue_rotated,
394
+ )
395
+ continue
396
+
397
+ if dt <= 0.0 or not players:
398
+ continue
399
+
400
+ if float(state.bonuses.freeze) > 0.0:
401
+ creature.move_scale = 0.0
402
+ creature.vel_x = 0.0
403
+ creature.vel_y = 0.0
404
+ continue
405
+
406
+ if creature.flags & CreatureFlags.SELF_DAMAGE_TICK_STRONG:
407
+ creature.hp -= dt * 180.0
408
+ elif creature.flags & CreatureFlags.SELF_DAMAGE_TICK:
409
+ creature.hp -= dt * 60.0
410
+ if creature.hp <= 0.0:
411
+ deaths.append(
412
+ self.handle_death(
413
+ idx,
414
+ state=state,
415
+ players=players,
416
+ rand=rand,
417
+ detail_preset=int(detail_preset),
418
+ world_width=world_width,
419
+ world_height=world_height,
420
+ fx_queue=fx_queue,
421
+ )
422
+ )
423
+ if creature.active:
424
+ self._tick_dead(
425
+ creature,
426
+ dt=dt,
427
+ world_width=world_width,
428
+ world_height=world_height,
429
+ fx_queue_rotated=fx_queue_rotated,
430
+ )
431
+ continue
432
+
433
+ if creature.collision_flag != 0:
434
+ creature.collision_timer -= float(dt)
435
+ if creature.collision_timer < 0.0:
436
+ creature.collision_timer += CONTACT_DAMAGE_PERIOD
437
+ creature.hp -= 15.0
438
+ if fx_queue is not None:
439
+ fx_queue.add_random(pos_x=creature.x, pos_y=creature.y, rand=rand)
440
+
441
+ if creature.hp < 0.0:
442
+ state.plaguebearer_infection_count += 1
443
+ deaths.append(
444
+ self.handle_death(
445
+ idx,
446
+ state=state,
447
+ players=players,
448
+ rand=rand,
449
+ detail_preset=int(detail_preset),
450
+ world_width=world_width,
451
+ world_height=world_height,
452
+ fx_queue=fx_queue,
453
+ )
454
+ )
455
+ if creature.active:
456
+ self._tick_dead(
457
+ creature,
458
+ dt=dt,
459
+ world_width=world_width,
460
+ world_height=world_height,
461
+ fx_queue_rotated=fx_queue_rotated,
462
+ )
463
+ continue
464
+
465
+ target_player = int(creature.target_player)
466
+ if not (0 <= target_player < len(players)):
467
+ target_player = 0
468
+ creature.target_player = 0
469
+ player = players[target_player]
470
+
471
+ if players and perk_active(players[0], PerkId.RADIOACTIVE):
472
+ radioactive_player = players[0]
473
+ dist = math.hypot(
474
+ float(creature.x) - float(radioactive_player.pos_x),
475
+ float(creature.y) - float(radioactive_player.pos_y),
476
+ )
477
+ if dist < 100.0:
478
+ creature.collision_timer -= float(dt) * 1.5
479
+ if creature.collision_timer < 0.0:
480
+ creature.collision_timer = CONTACT_DAMAGE_PERIOD
481
+ creature.hp -= (100.0 - dist) * 0.3
482
+ if fx_queue is not None:
483
+ fx_queue.add_random(pos_x=creature.x, pos_y=creature.y, rand=rand)
484
+
485
+ if creature.hp < 0.0:
486
+ if creature.type_id == 1:
487
+ creature.hp = 1.0
488
+ else:
489
+ radioactive_player.experience = int(
490
+ float(radioactive_player.experience) + float(creature.reward_value)
491
+ )
492
+ creature.hitbox_size -= float(dt)
493
+ continue
494
+
495
+ frozen_by_evil_eyes = idx == evil_target
496
+ if frozen_by_evil_eyes:
497
+ creature.move_scale = 0.0
498
+ creature.vel_x = 0.0
499
+ creature.vel_y = 0.0
500
+ else:
501
+ creature_ai7_tick_link_timer(creature, dt_ms=dt_ms, rand=rand)
502
+ ai = creature_ai_update_target(
503
+ creature,
504
+ player_x=player.pos_x,
505
+ player_y=player.pos_y,
506
+ creatures=self._entries,
507
+ dt=dt,
508
+ )
509
+ creature.move_scale = float(ai.move_scale)
510
+ if ai.self_damage is not None and ai.self_damage > 0.0:
511
+ creature.hp -= float(ai.self_damage)
512
+ if creature.hp <= 0.0:
513
+ deaths.append(
514
+ self.handle_death(
515
+ idx,
516
+ state=state,
517
+ players=players,
518
+ rand=rand,
519
+ world_width=world_width,
520
+ world_height=world_height,
521
+ fx_queue=fx_queue,
522
+ )
523
+ )
524
+ if creature.active:
525
+ self._tick_dead(
526
+ creature,
527
+ dt=dt,
528
+ world_width=world_width,
529
+ world_height=world_height,
530
+ fx_queue_rotated=fx_queue_rotated,
531
+ )
532
+ continue
533
+
534
+ if (float(state.bonuses.energizer) > 0.0 and float(creature.max_hp) < 500.0) or creature.collision_flag != 0:
535
+ creature.target_heading = _wrap_angle(float(creature.target_heading) + math.pi)
536
+
537
+ turn_rate = float(creature.move_speed) * CREATURE_TURN_RATE_SCALE
538
+ speed = float(creature.move_speed) * CREATURE_SPEED_SCALE * creature.move_scale
539
+
540
+ if (creature.flags & CreatureFlags.ANIM_PING_PONG) == 0:
541
+ if creature.ai_mode == 7:
542
+ creature.vel_x = 0.0
543
+ creature.vel_y = 0.0
544
+ else:
545
+ creature.heading = _angle_approach(creature.heading, creature.target_heading, turn_rate, dt)
546
+ dir_x = math.cos(creature.heading - math.pi / 2.0)
547
+ dir_y = math.sin(creature.heading - math.pi / 2.0)
548
+ creature.vel_x = dir_x * speed
549
+ creature.vel_y = dir_y * speed
550
+ creature.x = _clamp(creature.x + creature.vel_x * dt, 0.0, float(world_width))
551
+ creature.y = _clamp(creature.y + creature.vel_y * dt, 0.0, float(world_height))
552
+ else:
553
+ # Spawner/short-strip creatures clamp to bounds using `size` as a radius; most are stationary
554
+ # unless ANIM_LONG_STRIP is set (see creature_update_all).
555
+ radius = max(0.0, float(creature.size))
556
+ max_x = max(radius, float(world_width) - radius)
557
+ max_y = max(radius, float(world_height) - radius)
558
+ creature.x = _clamp(creature.x, radius, max_x)
559
+ creature.y = _clamp(creature.y, radius, max_y)
560
+ if (creature.flags & CreatureFlags.ANIM_LONG_STRIP) == 0:
561
+ creature.vel_x = 0.0
562
+ creature.vel_y = 0.0
563
+ else:
564
+ creature.heading = _angle_approach(creature.heading, creature.target_heading, turn_rate, dt)
565
+ dir_x = math.cos(creature.heading - math.pi / 2.0)
566
+ dir_y = math.sin(creature.heading - math.pi / 2.0)
567
+ creature.vel_x = dir_x * speed
568
+ creature.vel_y = dir_y * speed
569
+ creature.x = _clamp(creature.x + creature.vel_x * dt, radius, max_x)
570
+ creature.y = _clamp(creature.y + creature.vel_y * dt, radius, max_y)
571
+
572
+ if (
573
+ players
574
+ and perk_active(players[0], PerkId.PLAGUEBEARER)
575
+ and int(state.plaguebearer_infection_count) < 0x3C
576
+ ):
577
+ self._plaguebearer_spread_infection(idx)
578
+
579
+ if float(state.bonuses.energizer) > 0.0 and float(creature.max_hp) < 380.0 and float(player.health) > 0.0:
580
+ eat_dist_sq = _distance_sq(creature.x, creature.y, player.pos_x, player.pos_y)
581
+ if eat_dist_sq < 20.0 * 20.0:
582
+ creature.x = _clamp(creature.x - creature.vel_x * dt, 0.0, float(world_width))
583
+ creature.y = _clamp(creature.y - creature.vel_y * dt, 0.0, float(world_height))
584
+
585
+ state.effects.spawn_burst(
586
+ pos_x=float(creature.x),
587
+ pos_y=float(creature.y),
588
+ count=6,
589
+ rand=rand,
590
+ detail_preset=int(detail_preset),
591
+ )
592
+ sfx.append("sfx_ui_bonus")
593
+
594
+ prev_guard = bool(state.bonus_spawn_guard)
595
+ state.bonus_spawn_guard = True
596
+ creature.last_hit_owner_id = -1 - int(player.index)
597
+ deaths.append(
598
+ self.handle_death(
599
+ idx,
600
+ state=state,
601
+ players=players,
602
+ rand=rand,
603
+ detail_preset=int(detail_preset),
604
+ world_width=world_width,
605
+ world_height=world_height,
606
+ fx_queue=fx_queue,
607
+ keep_corpse=False,
608
+ )
609
+ )
610
+ state.bonus_spawn_guard = prev_guard
611
+ continue
612
+
613
+ # Contact damage throttle. While Energizer is active, the native suppresses
614
+ # contact/melee interactions for most creatures (and instead allows "eat" kills).
615
+ if float(state.bonuses.energizer) <= 0.0:
616
+ dist_sq = _distance_sq(creature.x, creature.y, player.pos_x, player.pos_y)
617
+ contact_r = (float(creature.size) + float(player.size)) * 0.25 + 20.0
618
+ in_contact = dist_sq <= contact_r * contact_r
619
+ if in_contact:
620
+ creature.collision_timer -= dt
621
+ if creature.collision_timer < 0.0:
622
+ creature.collision_timer += CONTACT_DAMAGE_PERIOD
623
+ if perk_active(player, PerkId.MR_MELEE):
624
+ death_start_needed = creature.hp > 0.0 and creature.hitbox_size == CREATURE_HITBOX_ALIVE
625
+
626
+ from .damage import creature_apply_damage
627
+
628
+ killed = creature_apply_damage(
629
+ creature,
630
+ damage_amount=25.0,
631
+ damage_type=2,
632
+ impulse_x=0.0,
633
+ impulse_y=0.0,
634
+ owner_id=-1 - int(player.index),
635
+ dt=dt,
636
+ players=players,
637
+ rand=rand,
638
+ )
639
+ if killed and death_start_needed:
640
+ deaths.append(
641
+ self.handle_death(
642
+ idx,
643
+ state=state,
644
+ players=players,
645
+ rand=rand,
646
+ detail_preset=int(detail_preset),
647
+ world_width=world_width,
648
+ world_height=world_height,
649
+ fx_queue=fx_queue,
650
+ )
651
+ )
652
+ if creature.active:
653
+ self._tick_dead(
654
+ creature,
655
+ dt=dt,
656
+ world_width=world_width,
657
+ world_height=world_height,
658
+ fx_queue_rotated=fx_queue_rotated,
659
+ )
660
+ continue
661
+
662
+ if float(player.shield_timer) <= 0.0:
663
+ if perk_active(player, PerkId.TOXIC_AVENGER):
664
+ creature.flags |= (
665
+ CreatureFlags.SELF_DAMAGE_TICK | CreatureFlags.SELF_DAMAGE_TICK_STRONG
666
+ )
667
+ elif perk_active(player, PerkId.VEINS_OF_POISON):
668
+ creature.flags |= CreatureFlags.SELF_DAMAGE_TICK
669
+ player_take_damage(state, player, float(creature.contact_damage), dt=dt, rand=rand)
670
+
671
+ if (
672
+ bool(player.plaguebearer_active)
673
+ and float(creature.hp) < 150.0
674
+ and int(state.plaguebearer_infection_count) < 0x32
675
+ and dist_sq < 30.0 * 30.0
676
+ ):
677
+ creature.collision_flag = 1
678
+
679
+ if (not frozen_by_evil_eyes) and (creature.flags & (CreatureFlags.RANGED_ATTACK_SHOCK | CreatureFlags.RANGED_ATTACK_VARIANT)):
680
+ # Ported from creature_update_all (see `analysis/ghidra/raw/crimsonland.exe_decompiled.c`
681
+ # around the 0x004276xx ranged-fire branch).
682
+ if creature.attack_cooldown <= 0.0:
683
+ creature.attack_cooldown = 0.0
684
+ else:
685
+ creature.attack_cooldown -= dt
686
+
687
+ dist = math.hypot(creature.x - player.pos_x, creature.y - player.pos_y)
688
+ if dist > 64.0 and creature.attack_cooldown <= 0.0:
689
+ if creature.flags & CreatureFlags.RANGED_ATTACK_SHOCK:
690
+ state.projectiles.spawn(
691
+ pos_x=creature.x,
692
+ pos_y=creature.y,
693
+ angle=float(creature.heading),
694
+ type_id=9,
695
+ owner_id=idx,
696
+ base_damage=45.0,
697
+ hits_players=True,
698
+ )
699
+ sfx.append("sfx_shock_fire")
700
+ creature.attack_cooldown += 1.0
701
+
702
+ if (creature.flags & CreatureFlags.RANGED_ATTACK_VARIANT) and creature.attack_cooldown <= 0.0:
703
+ projectile_type = int(creature.orbit_radius)
704
+ state.projectiles.spawn(
705
+ pos_x=creature.x,
706
+ pos_y=creature.y,
707
+ angle=float(creature.heading),
708
+ type_id=projectile_type,
709
+ owner_id=idx,
710
+ base_damage=45.0,
711
+ hits_players=True,
712
+ )
713
+ sfx.append("sfx_plasmaminigun_fire")
714
+ creature.attack_cooldown = (
715
+ float(rand() & 3) * 0.1 + float(creature.orbit_angle) + float(creature.attack_cooldown)
716
+ )
717
+
718
+ # Spawn-slot ticking (spawns child templates while owner stays alive).
719
+ if dt > 0.0 and float(state.bonuses.freeze) <= 0.0 and spawn_env is not None and self.spawn_slots:
720
+ for slot in self.spawn_slots:
721
+ owner_idx = int(slot.owner_creature)
722
+ if not (0 <= owner_idx < len(self._entries)):
723
+ continue
724
+ owner = self._entries[owner_idx]
725
+ if not (owner.active and owner.hp > 0.0):
726
+ continue
727
+ child_template_id = tick_spawn_slot(slot, dt)
728
+ if child_template_id is None:
729
+ continue
730
+
731
+ plan = build_spawn_plan(
732
+ int(child_template_id),
733
+ (owner.x, owner.y),
734
+ float(owner.heading),
735
+ state.rng,
736
+ spawn_env,
737
+ )
738
+ mapping, _ = self.spawn_plan(plan, rand=rand)
739
+ spawned.extend(mapping)
740
+
741
+ return CreatureUpdateResult(deaths=tuple(deaths), spawned=tuple(spawned), sfx=tuple(sfx))
742
+
743
+ def handle_death(
744
+ self,
745
+ idx: int,
746
+ *,
747
+ state: GameplayState,
748
+ players: list[PlayerState],
749
+ rand: Callable[[], int],
750
+ detail_preset: int = 5,
751
+ world_width: float,
752
+ world_height: float,
753
+ fx_queue: FxQueue | None,
754
+ keep_corpse: bool = True, # noqa: FBT001, FBT002
755
+ ) -> CreatureDeath:
756
+ """Run one-shot death side effects and return the `CreatureDeath` event."""
757
+
758
+ creature = self._entries[int(idx)]
759
+ death = self._start_death(
760
+ int(idx),
761
+ creature,
762
+ state=state,
763
+ players=players,
764
+ rand=rand,
765
+ detail_preset=int(detail_preset),
766
+ world_width=world_width,
767
+ world_height=world_height,
768
+ fx_queue=fx_queue,
769
+ )
770
+
771
+ if keep_corpse:
772
+ if creature.hitbox_size == CREATURE_HITBOX_ALIVE:
773
+ creature.hitbox_size = CREATURE_HITBOX_ALIVE - 0.001
774
+ else:
775
+ creature.active = False
776
+
777
+ if float(state.bonuses.freeze) > 0.0:
778
+ pos_x = float(creature.x)
779
+ pos_y = float(creature.y)
780
+ for _ in range(8):
781
+ angle = float(int(rand()) % 0x264) * 0.01
782
+ state.effects.spawn_freeze_shard(
783
+ pos_x=pos_x,
784
+ pos_y=pos_y,
785
+ angle=angle,
786
+ rand=rand,
787
+ detail_preset=int(detail_preset),
788
+ )
789
+ angle = float(int(rand()) % 0x264) * 0.01
790
+ state.effects.spawn_freeze_shatter(
791
+ pos_x=pos_x,
792
+ pos_y=pos_y,
793
+ angle=angle,
794
+ rand=rand,
795
+ detail_preset=int(detail_preset),
796
+ )
797
+ self.kill_count += 1
798
+ creature.active = False
799
+
800
+ return death
801
+
802
+ def _apply_init(self, entry: CreatureState, init: CreatureInit) -> None:
803
+ entry.active = True
804
+ entry.type_id = int(init.type_id.value) if init.type_id is not None else 0
805
+ entry.x = float(init.pos_x)
806
+ entry.y = float(init.pos_y)
807
+ entry.heading = float(init.heading)
808
+ entry.target_heading = float(init.heading)
809
+ entry.target_x = float(init.pos_x)
810
+ entry.target_y = float(init.pos_y)
811
+ entry.phase_seed = float(init.phase_seed)
812
+
813
+ entry.flags = init.flags or CreatureFlags(0)
814
+ entry.ai_mode = int(init.ai_mode)
815
+
816
+ hp = float(init.health or 0.0)
817
+ if hp <= 0.0:
818
+ hp = 1.0
819
+ entry.hp = hp
820
+ entry.max_hp = float(init.max_health or hp)
821
+
822
+ entry.move_speed = float(init.move_speed or 1.0)
823
+ entry.reward_value = float(init.reward_value or 0.0)
824
+ entry.size = float(init.size or 50.0)
825
+ entry.contact_damage = float(init.contact_damage or 0.0)
826
+
827
+ entry.target_offset_x = init.target_offset_x
828
+ entry.target_offset_y = init.target_offset_y
829
+ entry.orbit_angle = float(init.orbit_angle or 0.0)
830
+ if init.orbit_radius is not None:
831
+ orbit_radius = float(init.orbit_radius)
832
+ elif init.ranged_projectile_type is not None:
833
+ orbit_radius = float(init.ranged_projectile_type)
834
+ else:
835
+ orbit_radius = 0.0
836
+ entry.orbit_radius = orbit_radius
837
+
838
+ entry.spawn_slot_index = None
839
+ entry.link_index = 0
840
+
841
+ entry.bonus_id = int(init.bonus_id) if init.bonus_id is not None else None
842
+ entry.bonus_duration_override = int(init.bonus_duration_override) if init.bonus_duration_override is not None else None
843
+
844
+ tint = resolve_tint(init.tint)
845
+ entry.tint_r = float(tint[0])
846
+ entry.tint_g = float(tint[1])
847
+ entry.tint_b = float(tint[2])
848
+ entry.tint_a = float(tint[3])
849
+
850
+ entry.collision_flag = 0
851
+ entry.collision_timer = CONTACT_DAMAGE_PERIOD
852
+ entry.hitbox_size = CREATURE_HITBOX_ALIVE
853
+
854
+ def _disable_spawn_slot(self, slot_index: int) -> None:
855
+ if not (0 <= slot_index < len(self.spawn_slots)):
856
+ return
857
+ slot = self.spawn_slots[slot_index]
858
+ slot.owner_creature = -1
859
+ slot.limit = 0
860
+
861
+ def _tick_dead(
862
+ self,
863
+ creature: CreatureState,
864
+ *,
865
+ dt: float,
866
+ world_width: float,
867
+ world_height: float,
868
+ fx_queue_rotated: FxQueueRotated | None,
869
+ ) -> None:
870
+ """Advance the post-death hitbox_size ramp and queue corpse decals.
871
+
872
+ This matches the `hitbox_size` death staging inside `creature_update_all`:
873
+ - while hitbox_size > 0: decrement quickly and slide backwards
874
+ - once hitbox_size <= 0: queue a corpse decal and fade out until < -10, then deactivate.
875
+ """
876
+
877
+ if dt <= 0.0:
878
+ return
879
+
880
+ hitbox = float(creature.hitbox_size)
881
+ if hitbox <= 0.0:
882
+ creature.hitbox_size = hitbox - float(dt) * CREATURE_CORPSE_FADE_DECAY
883
+ if creature.hitbox_size < CREATURE_CORPSE_DESPAWN_HITBOX:
884
+ creature.active = False
885
+ return
886
+
887
+ long_strip = (creature.flags & CreatureFlags.ANIM_PING_PONG) == 0 or (creature.flags & CreatureFlags.ANIM_LONG_STRIP) != 0
888
+
889
+ new_hitbox = hitbox - float(dt) * CREATURE_DEATH_TIMER_DECAY
890
+ creature.hitbox_size = new_hitbox
891
+ if new_hitbox > 0.0:
892
+ if long_strip:
893
+ dir_x = math.cos(creature.heading - math.pi / 2.0)
894
+ dir_y = math.sin(creature.heading - math.pi / 2.0)
895
+ creature.vel_x = dir_x * new_hitbox * float(dt) * CREATURE_DEATH_SLIDE_SCALE
896
+ creature.vel_y = dir_y * new_hitbox * float(dt) * CREATURE_DEATH_SLIDE_SCALE
897
+ creature.x = _clamp(creature.x - creature.vel_x, 0.0, float(world_width))
898
+ creature.y = _clamp(creature.y - creature.vel_y, 0.0, float(world_height))
899
+ else:
900
+ creature.vel_x = 0.0
901
+ creature.vel_y = 0.0
902
+ return
903
+
904
+ # hitbox_size just crossed <= 0: bake a persistent corpse decal into the ground.
905
+ if fx_queue_rotated is not None:
906
+ corpse_size = max(1.0, float(creature.size))
907
+ # Native uses a special fallback corpse id for ping-pong strip creatures.
908
+ corpse_type_id = int(creature.type_id) if long_strip else 7
909
+ ok = fx_queue_rotated.add(
910
+ top_left_x=creature.x - corpse_size * 0.5,
911
+ top_left_y=creature.y - corpse_size * 0.5,
912
+ rgba=(creature.tint_r, creature.tint_g, creature.tint_b, creature.tint_a),
913
+ rotation=float(creature.heading),
914
+ scale=corpse_size,
915
+ creature_type_id=corpse_type_id,
916
+ )
917
+ if not ok:
918
+ creature.hitbox_size = 0.001
919
+ return
920
+
921
+ self.kill_count += 1
922
+
923
+ def _start_death(
924
+ self,
925
+ idx: int,
926
+ creature: CreatureState,
927
+ *,
928
+ state: GameplayState,
929
+ players: list[PlayerState],
930
+ rand: Callable[[], int],
931
+ detail_preset: int = 5,
932
+ world_width: float,
933
+ world_height: float,
934
+ fx_queue: FxQueue | None,
935
+ ) -> CreatureDeath:
936
+ creature.hp = 0.0
937
+
938
+ if creature.spawn_slot_index is not None:
939
+ self._disable_spawn_slot(int(creature.spawn_slot_index))
940
+
941
+ if (creature.flags & CreatureFlags.SPLIT_ON_DEATH) and float(creature.size) > 35.0:
942
+ for heading_offset in (-math.pi / 2.0, math.pi / 2.0):
943
+ child_idx = self._alloc_slot(rand=rand)
944
+ child = replace(creature)
945
+ child.phase_seed = float(int(rand()) & 0xFF)
946
+ child.heading = _wrap_angle(float(creature.heading) + float(heading_offset))
947
+ child.target_heading = float(child.heading)
948
+ child.hp = float(creature.max_hp) * 0.25
949
+ child.reward_value = float(child.reward_value) * (2.0 / 3.0)
950
+ child.size = float(child.size) - 8.0
951
+ child.move_speed = float(child.move_speed) + 0.1
952
+ child.contact_damage = float(child.contact_damage) * 0.7
953
+ child.hitbox_size = CREATURE_HITBOX_ALIVE
954
+ self._entries[child_idx] = child
955
+ self.spawned_count += 1
956
+
957
+ state.effects.spawn_burst(
958
+ pos_x=float(creature.x),
959
+ pos_y=float(creature.y),
960
+ count=8,
961
+ rand=rand,
962
+ detail_preset=int(detail_preset),
963
+ )
964
+
965
+ xp_base = int(creature.reward_value)
966
+ killer: PlayerState | None = None
967
+ if players:
968
+ player_index = _owner_id_to_player_index(int(creature.last_hit_owner_id))
969
+ if player_index is None or not (0 <= player_index < len(players)):
970
+ player_index = 0
971
+ killer = players[player_index]
972
+
973
+ if killer is not None and perk_active(killer, PerkId.BLOODY_MESS_QUICK_LEARNER):
974
+ xp_base = int(float(creature.reward_value) * 1.3)
975
+
976
+ xp_awarded = 0
977
+ if killer is not None:
978
+ xp_awarded = award_experience(state, killer, xp_base)
979
+
980
+ if players:
981
+ spawned_bonus = None
982
+ if (creature.flags & CreatureFlags.BONUS_ON_DEATH) and creature.bonus_id is not None:
983
+ spawned_bonus = state.bonus_pool.spawn_at(
984
+ creature.x,
985
+ creature.y,
986
+ int(creature.bonus_id),
987
+ int(creature.bonus_duration_override) if creature.bonus_duration_override is not None else -1,
988
+ world_width=world_width,
989
+ world_height=world_height,
990
+ )
991
+ else:
992
+ spawned_bonus = state.bonus_pool.try_spawn_on_kill(
993
+ creature.x,
994
+ creature.y,
995
+ state=state,
996
+ players=players,
997
+ world_width=world_width,
998
+ world_height=world_height,
999
+ )
1000
+ if spawned_bonus is not None:
1001
+ state.effects.spawn_burst(
1002
+ pos_x=float(spawned_bonus.pos_x),
1003
+ pos_y=float(spawned_bonus.pos_y),
1004
+ count=16,
1005
+ rand=rand,
1006
+ detail_preset=int(detail_preset),
1007
+ )
1008
+
1009
+ if fx_queue is not None:
1010
+ fx_queue.add_random(pos_x=creature.x, pos_y=creature.y, rand=rand)
1011
+
1012
+ return CreatureDeath(
1013
+ index=int(idx),
1014
+ x=float(creature.x),
1015
+ y=float(creature.y),
1016
+ type_id=int(creature.type_id),
1017
+ reward_value=float(creature.reward_value),
1018
+ xp_awarded=int(xp_awarded),
1019
+ )