crimsonland 0.1.0.dev5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +652 -0
  34. crimson/gameplay.py +2467 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1133 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
@@ -0,0 +1,2871 @@
1
+ from __future__ import annotations
2
+
3
+ """Creature spawning helpers.
4
+
5
+ This module combines:
6
+ - a spawn-id labeling index (direct `type_id`/`flags` assignments extracted from
7
+ `creature_spawn_template`, `FUN_00430af0`)
8
+ - a partial 1:1 rewrite of `creature_spawn_template` as a pure plan builder
9
+
10
+ Note: in the original game, `creature_spawn_template` is an algorithm (formations,
11
+ spawn slots, tail modifiers), so the spawn-id index here is only used for labeling
12
+ and debug UIs.
13
+
14
+ See also: `docs/creatures/spawn_plan.md` (porting model / invariants).
15
+ """
16
+
17
+ from dataclasses import dataclass
18
+ from enum import IntEnum, IntFlag
19
+ import math
20
+ from typing import Callable, SupportsInt
21
+
22
+ from ..bonuses import BonusId
23
+ from grim.rand import Crand
24
+
25
+ __all__ = [
26
+ "BurstEffect",
27
+ "CreatureFlags",
28
+ "CreatureInit",
29
+ "CreatureTypeId",
30
+ "SpawnId",
31
+ "SPAWN_ID_TO_TEMPLATE",
32
+ "SPAWN_TEMPLATES",
33
+ "SpawnEnv",
34
+ "SpawnPlan",
35
+ "SpawnSlotInit",
36
+ "SpawnTemplate",
37
+ "SpawnTemplateCall",
38
+ "TYPE_ID_TO_NAME",
39
+ "advance_survival_spawn_stage",
40
+ "build_rush_mode_spawn_creature",
41
+ "build_spawn_plan",
42
+ "build_survival_spawn_creature",
43
+ "build_tutorial_stage3_fire_spawns",
44
+ "build_tutorial_stage4_clear_spawns",
45
+ "build_tutorial_stage5_repeat_spawns",
46
+ "build_tutorial_stage6_perks_done_spawns",
47
+ "resolve_tint",
48
+ "spawn_id_label",
49
+ "tick_rush_mode_spawns",
50
+ "tick_spawn_slot",
51
+ "tick_survival_wave_spawns",
52
+ ]
53
+
54
+ Tint = tuple[float | None, float | None, float | None, float | None]
55
+ TintRGBA = tuple[float, float, float, float]
56
+
57
+
58
+ class CreatureTypeId(IntEnum):
59
+ ZOMBIE = 0
60
+ LIZARD = 1
61
+ ALIEN = 2
62
+ SPIDER_SP1 = 3
63
+ SPIDER_SP2 = 4
64
+ TROOPER = 5
65
+
66
+
67
+ class CreatureFlags(IntFlag):
68
+ SELF_DAMAGE_TICK = 0x01 # periodic self-damage tick (dt * 60)
69
+ SELF_DAMAGE_TICK_STRONG = 0x02 # stronger periodic self-damage tick (dt * 180)
70
+ ANIM_PING_PONG = 0x04 # short ping-pong strip
71
+ HAS_SPAWN_SLOT = 0x04 # alias: link_index interpreted as spawn slot index
72
+ SPLIT_ON_DEATH = 0x08 # split-on-death behavior
73
+ RANGED_ATTACK_SHOCK = 0x10 # ranged attack using projectile type 9
74
+ ANIM_LONG_STRIP = 0x40 # force long animation strip
75
+ AI7_LINK_TIMER = 0x80 # uses link index as timer for AI mode 7
76
+ RANGED_ATTACK_VARIANT = 0x100 # ranged attack using orbit_radius as projectile type
77
+ BONUS_ON_DEATH = 0x400 # spawns bonus on death
78
+
79
+
80
+ class SpawnId(IntEnum):
81
+ ZOMBIE_BOSS_SPAWNER_00 = 0x00
82
+ SPIDER_SP2_SPLITTER_01 = 0x01
83
+ UNUSED_02 = 0x02
84
+ SPIDER_SP1_RANDOM_03 = 0x03
85
+ LIZARD_RANDOM_04 = 0x04
86
+ SPIDER_SP2_RANDOM_05 = 0x05
87
+ ALIEN_RANDOM_06 = 0x06
88
+
89
+ ALIEN_SPAWNER_CHILD_1D_FAST_07 = 0x07
90
+ ALIEN_SPAWNER_CHILD_1D_SLOW_08 = 0x08
91
+ ALIEN_SPAWNER_CHILD_1D_LIMITED_09 = 0x09
92
+ ALIEN_SPAWNER_CHILD_32_SLOW_0A = 0x0A
93
+ ALIEN_SPAWNER_CHILD_3C_SLOW_0B = 0x0B
94
+ ALIEN_SPAWNER_CHILD_31_FAST_0C = 0x0C
95
+ ALIEN_SPAWNER_CHILD_31_SLOW_0D = 0x0D
96
+ ALIEN_SPAWNER_RING_24_0E = 0x0E
97
+ ALIEN_CONST_BROWN_TRANSPARENT_0F = 0x0F
98
+ ALIEN_SPAWNER_CHILD_32_FAST_10 = 0x10
99
+
100
+ FORMATION_CHAIN_LIZARD_4_11 = 0x11
101
+ FORMATION_RING_ALIEN_8_12 = 0x12
102
+ FORMATION_CHAIN_ALIEN_10_13 = 0x13
103
+ FORMATION_GRID_ALIEN_GREEN_14 = 0x14
104
+ FORMATION_GRID_ALIEN_WHITE_15 = 0x15
105
+ FORMATION_GRID_LIZARD_WHITE_16 = 0x16
106
+ FORMATION_GRID_SPIDER_SP1_WHITE_17 = 0x17
107
+ FORMATION_GRID_ALIEN_BRONZE_18 = 0x18
108
+ FORMATION_RING_ALIEN_5_19 = 0x19
109
+
110
+ AI1_ALIEN_BLUE_TINT_1A = 0x1A
111
+ AI1_SPIDER_SP1_BLUE_TINT_1B = 0x1B
112
+ AI1_LIZARD_BLUE_TINT_1C = 0x1C
113
+
114
+ ALIEN_RANDOM_1D = 0x1D
115
+ ALIEN_RANDOM_1E = 0x1E
116
+ ALIEN_RANDOM_1F = 0x1F
117
+ ALIEN_RANDOM_GREEN_20 = 0x20
118
+
119
+ ALIEN_CONST_PURPLE_GHOST_21 = 0x21
120
+ ALIEN_CONST_GREEN_GHOST_22 = 0x22
121
+ ALIEN_CONST_GREEN_GHOST_SMALL_23 = 0x23
122
+ ALIEN_CONST_GREEN_24 = 0x24
123
+ ALIEN_CONST_GREEN_SMALL_25 = 0x25
124
+ ALIEN_CONST_PALE_GREEN_26 = 0x26
125
+ ALIEN_CONST_WEAPON_BONUS_27 = 0x27
126
+ ALIEN_CONST_PURPLE_28 = 0x28
127
+ ALIEN_CONST_GREY_BRUTE_29 = 0x29
128
+ ALIEN_CONST_GREY_FAST_2A = 0x2A
129
+ ALIEN_CONST_RED_FAST_2B = 0x2B
130
+ ALIEN_CONST_RED_BOSS_2C = 0x2C
131
+ ALIEN_CONST_CYAN_AI2_2D = 0x2D
132
+
133
+ LIZARD_RANDOM_2E = 0x2E
134
+ LIZARD_CONST_GREY_2F = 0x2F
135
+ LIZARD_CONST_YELLOW_BOSS_30 = 0x30
136
+ LIZARD_RANDOM_31 = 0x31
137
+
138
+ SPIDER_SP1_RANDOM_32 = 0x32
139
+ SPIDER_SP1_RANDOM_RED_33 = 0x33
140
+ SPIDER_SP1_RANDOM_GREEN_34 = 0x34
141
+ SPIDER_SP2_RANDOM_35 = 0x35
142
+
143
+ ALIEN_AI7_ORBITER_36 = 0x36
144
+ SPIDER_SP2_RANGED_VARIANT_37 = 0x37
145
+ SPIDER_SP1_AI7_TIMER_38 = 0x38
146
+ SPIDER_SP1_AI7_TIMER_WEAK_39 = 0x39
147
+
148
+ SPIDER_SP1_CONST_SHOCK_BOSS_3A = 0x3A
149
+ SPIDER_SP1_CONST_RED_BOSS_3B = 0x3B
150
+ SPIDER_SP1_CONST_RANGED_VARIANT_3C = 0x3C
151
+ SPIDER_SP1_RANDOM_3D = 0x3D
152
+ SPIDER_SP1_CONST_WHITE_FAST_3E = 0x3E
153
+ SPIDER_SP1_CONST_BROWN_SMALL_3F = 0x3F
154
+ SPIDER_SP1_CONST_BLUE_40 = 0x40
155
+
156
+ ZOMBIE_RANDOM_41 = 0x41
157
+ ZOMBIE_CONST_GREY_42 = 0x42
158
+ ZOMBIE_CONST_GREEN_BRUTE_43 = 0x43
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class SpawnTemplate:
163
+ spawn_id: SpawnId
164
+ type_id: CreatureTypeId | None
165
+ flags: CreatureFlags | None
166
+ creature: str | None
167
+ anim_note: str | None
168
+ tint: Tint | None = None
169
+ size: float | None = None
170
+ move_speed: float | None = None
171
+
172
+
173
+ TYPE_ID_TO_NAME = {type_id.value: type_id.name.lower() for type_id in CreatureTypeId}
174
+
175
+ # For many template ids, tint/size/move_speed are randomized or derived from other fields.
176
+ # We only fill them in when the game uses fixed constants (and keep the rest as `None`).
177
+ SPAWN_TEMPLATES = [
178
+ SpawnTemplate(
179
+ spawn_id=SpawnId.ZOMBIE_BOSS_SPAWNER_00,
180
+ type_id=CreatureTypeId.ZOMBIE,
181
+ flags=CreatureFlags.ANIM_PING_PONG | CreatureFlags.ANIM_LONG_STRIP,
182
+ creature="zombie",
183
+ anim_note="long strip (0x40 overrides 0x4)",
184
+ tint=(0.6, 0.6, 1.0, 0.8),
185
+ size=64.0,
186
+ move_speed=1.3,
187
+ ),
188
+ SpawnTemplate(
189
+ spawn_id=SpawnId.SPIDER_SP2_SPLITTER_01,
190
+ type_id=CreatureTypeId.SPIDER_SP2,
191
+ flags=CreatureFlags.SPLIT_ON_DEATH,
192
+ creature="spider_sp2",
193
+ anim_note=None,
194
+ tint=(0.8, 0.7, 0.4, 1.0),
195
+ size=80.0,
196
+ move_speed=2.0,
197
+ ),
198
+ SpawnTemplate(
199
+ spawn_id=SpawnId.SPIDER_SP1_RANDOM_03,
200
+ type_id=CreatureTypeId.SPIDER_SP1,
201
+ flags=None,
202
+ creature="spider_sp1",
203
+ anim_note=None,
204
+ ),
205
+ SpawnTemplate(
206
+ spawn_id=SpawnId.LIZARD_RANDOM_04,
207
+ type_id=CreatureTypeId.LIZARD,
208
+ flags=None,
209
+ creature="lizard",
210
+ anim_note=None,
211
+ ),
212
+ SpawnTemplate(
213
+ spawn_id=SpawnId.SPIDER_SP2_RANDOM_05,
214
+ type_id=CreatureTypeId.SPIDER_SP2,
215
+ flags=None,
216
+ creature="spider_sp2",
217
+ anim_note=None,
218
+ ),
219
+ SpawnTemplate(
220
+ spawn_id=SpawnId.ALIEN_RANDOM_06,
221
+ type_id=CreatureTypeId.ALIEN,
222
+ flags=None,
223
+ creature="alien",
224
+ anim_note=None,
225
+ ),
226
+ SpawnTemplate(
227
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_1D_FAST_07,
228
+ type_id=CreatureTypeId.ALIEN,
229
+ flags=CreatureFlags.ANIM_PING_PONG,
230
+ creature="alien",
231
+ anim_note="short strip (ping-pong)",
232
+ tint=(1.0, 1.0, 1.0, 1.0),
233
+ size=50.0,
234
+ move_speed=2.0,
235
+ ),
236
+ SpawnTemplate(
237
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_1D_SLOW_08,
238
+ type_id=CreatureTypeId.ALIEN,
239
+ flags=CreatureFlags.ANIM_PING_PONG,
240
+ creature="alien",
241
+ anim_note="short strip (ping-pong)",
242
+ tint=(1.0, 1.0, 1.0, 1.0),
243
+ size=50.0,
244
+ move_speed=2.0,
245
+ ),
246
+ SpawnTemplate(
247
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_1D_LIMITED_09,
248
+ type_id=CreatureTypeId.ALIEN,
249
+ flags=CreatureFlags.ANIM_PING_PONG,
250
+ creature="alien",
251
+ anim_note="short strip (ping-pong)",
252
+ tint=(1.0, 1.0, 1.0, 1.0),
253
+ size=40.0,
254
+ move_speed=2.0,
255
+ ),
256
+ SpawnTemplate(
257
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_32_SLOW_0A,
258
+ type_id=CreatureTypeId.ALIEN,
259
+ flags=CreatureFlags.ANIM_PING_PONG,
260
+ creature="alien",
261
+ anim_note="short strip (ping-pong)",
262
+ tint=(0.8, 0.7, 0.4, 1.0),
263
+ size=55.0,
264
+ move_speed=1.5,
265
+ ),
266
+ SpawnTemplate(
267
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_3C_SLOW_0B,
268
+ type_id=CreatureTypeId.ALIEN,
269
+ flags=CreatureFlags.ANIM_PING_PONG,
270
+ creature="alien",
271
+ anim_note="short strip (ping-pong)",
272
+ tint=(0.9, 0.1, 0.1, 1.0),
273
+ size=65.0,
274
+ move_speed=1.5,
275
+ ),
276
+ SpawnTemplate(
277
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_31_FAST_0C,
278
+ type_id=CreatureTypeId.ALIEN,
279
+ flags=CreatureFlags.ANIM_PING_PONG,
280
+ creature="alien",
281
+ anim_note="short strip (ping-pong)",
282
+ tint=(0.9, 0.8, 0.4, 1.0),
283
+ size=32.0,
284
+ move_speed=2.8,
285
+ ),
286
+ SpawnTemplate(
287
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_31_SLOW_0D,
288
+ type_id=CreatureTypeId.ALIEN,
289
+ flags=CreatureFlags.ANIM_PING_PONG,
290
+ creature="alien",
291
+ anim_note="short strip (ping-pong)",
292
+ tint=(0.9, 0.8, 0.4, 1.0),
293
+ size=32.0,
294
+ move_speed=1.3,
295
+ ),
296
+ SpawnTemplate(
297
+ spawn_id=SpawnId.ALIEN_SPAWNER_RING_24_0E,
298
+ type_id=CreatureTypeId.ALIEN,
299
+ flags=CreatureFlags.ANIM_PING_PONG,
300
+ creature="alien",
301
+ anim_note="short strip (ping-pong)",
302
+ tint=(0.9, 0.8, 0.4, 1.0),
303
+ size=32.0,
304
+ move_speed=2.8,
305
+ ),
306
+ SpawnTemplate(
307
+ spawn_id=SpawnId.ALIEN_CONST_BROWN_TRANSPARENT_0F,
308
+ type_id=CreatureTypeId.ALIEN,
309
+ flags=None,
310
+ creature="alien",
311
+ anim_note=None,
312
+ tint=(0.665, 0.385, 0.259, 0.56),
313
+ size=50.0,
314
+ move_speed=2.9,
315
+ ),
316
+ SpawnTemplate(
317
+ spawn_id=SpawnId.ALIEN_SPAWNER_CHILD_32_FAST_10,
318
+ type_id=CreatureTypeId.ALIEN,
319
+ flags=CreatureFlags.ANIM_PING_PONG,
320
+ creature="alien",
321
+ anim_note="short strip (ping-pong)",
322
+ tint=(0.9, 0.8, 0.4, 1.0),
323
+ size=32.0,
324
+ move_speed=2.8,
325
+ ),
326
+ SpawnTemplate(
327
+ spawn_id=SpawnId.FORMATION_CHAIN_LIZARD_4_11,
328
+ type_id=CreatureTypeId.LIZARD,
329
+ flags=None,
330
+ creature="lizard",
331
+ anim_note=None,
332
+ ),
333
+ SpawnTemplate(
334
+ spawn_id=SpawnId.FORMATION_RING_ALIEN_8_12,
335
+ type_id=CreatureTypeId.ALIEN,
336
+ flags=None,
337
+ creature="alien",
338
+ anim_note=None,
339
+ ),
340
+ SpawnTemplate(
341
+ spawn_id=SpawnId.FORMATION_CHAIN_ALIEN_10_13,
342
+ type_id=CreatureTypeId.ALIEN,
343
+ flags=None,
344
+ creature="alien",
345
+ anim_note=None,
346
+ ),
347
+ SpawnTemplate(
348
+ spawn_id=SpawnId.FORMATION_GRID_ALIEN_GREEN_14,
349
+ type_id=CreatureTypeId.ALIEN,
350
+ flags=None,
351
+ creature="alien",
352
+ anim_note=None,
353
+ ),
354
+ SpawnTemplate(
355
+ spawn_id=SpawnId.FORMATION_GRID_ALIEN_WHITE_15,
356
+ type_id=CreatureTypeId.ALIEN,
357
+ flags=None,
358
+ creature="alien",
359
+ anim_note=None,
360
+ ),
361
+ SpawnTemplate(
362
+ spawn_id=SpawnId.FORMATION_GRID_LIZARD_WHITE_16,
363
+ type_id=CreatureTypeId.LIZARD,
364
+ flags=None,
365
+ creature="lizard",
366
+ anim_note=None,
367
+ ),
368
+ SpawnTemplate(
369
+ spawn_id=SpawnId.FORMATION_GRID_SPIDER_SP1_WHITE_17,
370
+ type_id=CreatureTypeId.SPIDER_SP1,
371
+ flags=None,
372
+ creature="spider_sp1",
373
+ anim_note=None,
374
+ ),
375
+ SpawnTemplate(
376
+ spawn_id=SpawnId.FORMATION_GRID_ALIEN_BRONZE_18,
377
+ type_id=CreatureTypeId.ALIEN,
378
+ flags=None,
379
+ creature="alien",
380
+ anim_note=None,
381
+ ),
382
+ SpawnTemplate(
383
+ spawn_id=SpawnId.FORMATION_RING_ALIEN_5_19,
384
+ type_id=CreatureTypeId.ALIEN,
385
+ flags=None,
386
+ creature="alien",
387
+ anim_note=None,
388
+ ),
389
+ SpawnTemplate(
390
+ spawn_id=SpawnId.AI1_ALIEN_BLUE_TINT_1A,
391
+ type_id=CreatureTypeId.ALIEN,
392
+ flags=None,
393
+ creature="alien",
394
+ anim_note=None,
395
+ ),
396
+ SpawnTemplate(
397
+ spawn_id=SpawnId.AI1_SPIDER_SP1_BLUE_TINT_1B,
398
+ type_id=CreatureTypeId.SPIDER_SP1,
399
+ flags=None,
400
+ creature="spider_sp1",
401
+ anim_note=None,
402
+ ),
403
+ SpawnTemplate(
404
+ spawn_id=SpawnId.AI1_LIZARD_BLUE_TINT_1C,
405
+ type_id=CreatureTypeId.LIZARD,
406
+ flags=None,
407
+ creature="lizard",
408
+ anim_note=None,
409
+ ),
410
+ SpawnTemplate(
411
+ spawn_id=SpawnId.ALIEN_RANDOM_1D,
412
+ type_id=CreatureTypeId.ALIEN,
413
+ flags=None,
414
+ creature="alien",
415
+ anim_note=None,
416
+ ),
417
+ SpawnTemplate(
418
+ spawn_id=SpawnId.ALIEN_RANDOM_1E,
419
+ type_id=CreatureTypeId.ALIEN,
420
+ flags=None,
421
+ creature="alien",
422
+ anim_note=None,
423
+ ),
424
+ SpawnTemplate(
425
+ spawn_id=SpawnId.ALIEN_RANDOM_1F,
426
+ type_id=CreatureTypeId.ALIEN,
427
+ flags=None,
428
+ creature="alien",
429
+ anim_note=None,
430
+ ),
431
+ SpawnTemplate(
432
+ spawn_id=SpawnId.ALIEN_RANDOM_GREEN_20,
433
+ type_id=CreatureTypeId.ALIEN,
434
+ flags=None,
435
+ creature="alien",
436
+ anim_note=None,
437
+ ),
438
+ SpawnTemplate(
439
+ spawn_id=SpawnId.ALIEN_CONST_PURPLE_GHOST_21,
440
+ type_id=CreatureTypeId.ALIEN,
441
+ flags=None,
442
+ creature="alien",
443
+ anim_note=None,
444
+ ),
445
+ SpawnTemplate(
446
+ spawn_id=SpawnId.ALIEN_CONST_GREEN_GHOST_22,
447
+ type_id=CreatureTypeId.ALIEN,
448
+ flags=None,
449
+ creature="alien",
450
+ anim_note=None,
451
+ ),
452
+ SpawnTemplate(
453
+ spawn_id=SpawnId.ALIEN_CONST_GREEN_GHOST_SMALL_23,
454
+ type_id=CreatureTypeId.ALIEN,
455
+ flags=None,
456
+ creature="alien",
457
+ anim_note=None,
458
+ ),
459
+ SpawnTemplate(
460
+ spawn_id=SpawnId.ALIEN_CONST_GREEN_24,
461
+ type_id=CreatureTypeId.ALIEN,
462
+ flags=None,
463
+ creature="alien",
464
+ anim_note=None,
465
+ tint=(0.1, 0.7, 0.11, 1.0),
466
+ size=50.0,
467
+ move_speed=2.0,
468
+ ),
469
+ SpawnTemplate(
470
+ spawn_id=SpawnId.ALIEN_CONST_GREEN_SMALL_25,
471
+ type_id=CreatureTypeId.ALIEN,
472
+ flags=None,
473
+ creature="alien",
474
+ anim_note=None,
475
+ tint=(0.1, 0.8, 0.11, 1.0),
476
+ size=30.0,
477
+ move_speed=2.5,
478
+ ),
479
+ SpawnTemplate(
480
+ spawn_id=SpawnId.ALIEN_CONST_PALE_GREEN_26,
481
+ type_id=CreatureTypeId.ALIEN,
482
+ flags=None,
483
+ creature="alien",
484
+ anim_note=None,
485
+ tint=(0.6, 0.8, 0.6, 1.0),
486
+ size=45.0,
487
+ move_speed=2.2,
488
+ ),
489
+ SpawnTemplate(
490
+ spawn_id=SpawnId.ALIEN_CONST_WEAPON_BONUS_27,
491
+ type_id=CreatureTypeId.ALIEN,
492
+ flags=CreatureFlags.BONUS_ON_DEATH,
493
+ creature="alien",
494
+ anim_note="bonus_id=WEAPON (3), duration_override=5 (packed in link_index)",
495
+ tint=(1.0, 0.8, 0.1, 1.0),
496
+ size=45.0,
497
+ move_speed=2.1,
498
+ ),
499
+ SpawnTemplate(
500
+ spawn_id=SpawnId.ALIEN_CONST_PURPLE_28,
501
+ type_id=CreatureTypeId.ALIEN,
502
+ flags=None,
503
+ creature="alien",
504
+ anim_note=None,
505
+ tint=(0.7, 0.1, 0.51, 1.0),
506
+ size=55.0,
507
+ move_speed=1.7,
508
+ ),
509
+ SpawnTemplate(
510
+ spawn_id=SpawnId.ALIEN_CONST_GREY_BRUTE_29,
511
+ type_id=CreatureTypeId.ALIEN,
512
+ flags=None,
513
+ creature="alien",
514
+ anim_note=None,
515
+ tint=(0.8, 0.8, 0.8, 1.0),
516
+ size=70.0,
517
+ move_speed=2.5,
518
+ ),
519
+ SpawnTemplate(
520
+ spawn_id=SpawnId.ALIEN_CONST_GREY_FAST_2A,
521
+ type_id=CreatureTypeId.ALIEN,
522
+ flags=None,
523
+ creature="alien",
524
+ anim_note=None,
525
+ tint=(0.3, 0.3, 0.3, 1.0),
526
+ size=60.0,
527
+ move_speed=3.1,
528
+ ),
529
+ SpawnTemplate(
530
+ spawn_id=SpawnId.ALIEN_CONST_RED_FAST_2B,
531
+ type_id=CreatureTypeId.ALIEN,
532
+ flags=None,
533
+ creature="alien",
534
+ anim_note=None,
535
+ tint=(1.0, 0.3, 0.3, 1.0),
536
+ size=35.0,
537
+ move_speed=3.6,
538
+ ),
539
+ SpawnTemplate(
540
+ spawn_id=SpawnId.ALIEN_CONST_RED_BOSS_2C,
541
+ type_id=CreatureTypeId.ALIEN,
542
+ flags=None,
543
+ creature="alien",
544
+ anim_note=None,
545
+ tint=(0.85, 0.2, 0.2, 1.0),
546
+ size=80.0,
547
+ move_speed=2.0,
548
+ ),
549
+ SpawnTemplate(
550
+ spawn_id=SpawnId.ALIEN_CONST_CYAN_AI2_2D,
551
+ type_id=CreatureTypeId.ALIEN,
552
+ flags=None,
553
+ creature="alien",
554
+ anim_note=None,
555
+ tint=(0.0, 0.9, 0.8, 1.0),
556
+ size=38.0,
557
+ move_speed=3.1,
558
+ ),
559
+ SpawnTemplate(
560
+ spawn_id=SpawnId.LIZARD_RANDOM_2E,
561
+ type_id=CreatureTypeId.LIZARD,
562
+ flags=None,
563
+ creature="lizard",
564
+ anim_note=None,
565
+ ),
566
+ SpawnTemplate(
567
+ spawn_id=SpawnId.LIZARD_CONST_GREY_2F,
568
+ type_id=CreatureTypeId.LIZARD,
569
+ flags=None,
570
+ creature="lizard",
571
+ anim_note=None,
572
+ ),
573
+ SpawnTemplate(
574
+ spawn_id=SpawnId.LIZARD_CONST_YELLOW_BOSS_30,
575
+ type_id=CreatureTypeId.LIZARD,
576
+ flags=None,
577
+ creature="lizard",
578
+ anim_note=None,
579
+ ),
580
+ SpawnTemplate(
581
+ spawn_id=SpawnId.LIZARD_RANDOM_31,
582
+ type_id=CreatureTypeId.LIZARD,
583
+ flags=None,
584
+ creature="lizard",
585
+ anim_note=None,
586
+ ),
587
+ SpawnTemplate(
588
+ spawn_id=SpawnId.SPIDER_SP1_RANDOM_32,
589
+ type_id=CreatureTypeId.SPIDER_SP1,
590
+ flags=None,
591
+ creature="spider_sp1",
592
+ anim_note=None,
593
+ ),
594
+ SpawnTemplate(
595
+ spawn_id=SpawnId.SPIDER_SP1_RANDOM_RED_33,
596
+ type_id=CreatureTypeId.SPIDER_SP1,
597
+ flags=None,
598
+ creature="spider_sp1",
599
+ anim_note=None,
600
+ ),
601
+ SpawnTemplate(
602
+ spawn_id=SpawnId.SPIDER_SP1_RANDOM_GREEN_34,
603
+ type_id=CreatureTypeId.SPIDER_SP1,
604
+ flags=None,
605
+ creature="spider_sp1",
606
+ anim_note=None,
607
+ ),
608
+ SpawnTemplate(
609
+ spawn_id=SpawnId.SPIDER_SP2_RANDOM_35,
610
+ type_id=CreatureTypeId.SPIDER_SP2,
611
+ flags=None,
612
+ creature="spider_sp2",
613
+ anim_note=None,
614
+ ),
615
+ SpawnTemplate(
616
+ spawn_id=SpawnId.ALIEN_AI7_ORBITER_36,
617
+ type_id=CreatureTypeId.ALIEN,
618
+ flags=None,
619
+ creature="alien",
620
+ anim_note=None,
621
+ tint=(0.65, None, 0.95, 1.0),
622
+ size=50.0,
623
+ move_speed=1.8,
624
+ ),
625
+ SpawnTemplate(
626
+ spawn_id=SpawnId.SPIDER_SP2_RANGED_VARIANT_37,
627
+ type_id=CreatureTypeId.SPIDER_SP2,
628
+ flags=CreatureFlags.RANGED_ATTACK_VARIANT,
629
+ creature="spider_sp2",
630
+ anim_note=None,
631
+ tint=(1.0, 0.75, 0.1, 1.0),
632
+ move_speed=3.2,
633
+ ),
634
+ SpawnTemplate(
635
+ spawn_id=SpawnId.SPIDER_SP1_AI7_TIMER_38,
636
+ type_id=CreatureTypeId.SPIDER_SP1,
637
+ flags=CreatureFlags.AI7_LINK_TIMER,
638
+ creature="spider_sp1",
639
+ anim_note=None,
640
+ tint=(1.0, 0.75, 0.1, 1.0),
641
+ move_speed=4.8,
642
+ ),
643
+ SpawnTemplate(
644
+ spawn_id=SpawnId.SPIDER_SP1_AI7_TIMER_WEAK_39,
645
+ type_id=CreatureTypeId.SPIDER_SP1,
646
+ flags=CreatureFlags.AI7_LINK_TIMER,
647
+ creature="spider_sp1",
648
+ anim_note=None,
649
+ tint=(0.8, 0.65, 0.1, 1.0),
650
+ move_speed=4.8,
651
+ ),
652
+ SpawnTemplate(
653
+ spawn_id=SpawnId.SPIDER_SP1_CONST_SHOCK_BOSS_3A,
654
+ type_id=CreatureTypeId.SPIDER_SP1,
655
+ flags=CreatureFlags.RANGED_ATTACK_SHOCK,
656
+ creature="spider_sp1",
657
+ anim_note="projectile_type=9",
658
+ tint=(1.0, 1.0, 1.0, 1.0),
659
+ size=64.0,
660
+ move_speed=2.0,
661
+ ),
662
+ SpawnTemplate(
663
+ spawn_id=SpawnId.SPIDER_SP1_CONST_RED_BOSS_3B,
664
+ type_id=CreatureTypeId.SPIDER_SP1,
665
+ flags=None,
666
+ creature="spider_sp1",
667
+ anim_note=None,
668
+ tint=(0.9, 0.0, 0.0, 1.0),
669
+ size=70.0,
670
+ move_speed=2.0,
671
+ ),
672
+ SpawnTemplate(
673
+ spawn_id=SpawnId.SPIDER_SP1_CONST_RANGED_VARIANT_3C,
674
+ type_id=CreatureTypeId.SPIDER_SP1,
675
+ flags=CreatureFlags.RANGED_ATTACK_VARIANT,
676
+ creature="spider_sp1",
677
+ anim_note="projectile_type=26 (packed in orbit_radius)",
678
+ tint=(0.9, 0.1, 0.1, 1.0),
679
+ size=40.0,
680
+ move_speed=2.0,
681
+ ),
682
+ SpawnTemplate(
683
+ spawn_id=SpawnId.SPIDER_SP1_RANDOM_3D,
684
+ type_id=CreatureTypeId.SPIDER_SP1,
685
+ flags=None,
686
+ creature="spider_sp1",
687
+ anim_note=None,
688
+ tint=(None, None, None, 1.0),
689
+ move_speed=2.6,
690
+ ),
691
+ SpawnTemplate(
692
+ spawn_id=SpawnId.SPIDER_SP1_CONST_WHITE_FAST_3E,
693
+ type_id=CreatureTypeId.SPIDER_SP1,
694
+ flags=None,
695
+ creature="spider_sp1",
696
+ anim_note=None,
697
+ tint=(1.0, 1.0, 1.0, 1.0),
698
+ size=64.0,
699
+ move_speed=2.8,
700
+ ),
701
+ SpawnTemplate(
702
+ spawn_id=SpawnId.SPIDER_SP1_CONST_BROWN_SMALL_3F,
703
+ type_id=CreatureTypeId.SPIDER_SP1,
704
+ flags=None,
705
+ creature="spider_sp1",
706
+ anim_note=None,
707
+ tint=(0.7, 0.4, 0.1, 1.0),
708
+ size=35.0,
709
+ move_speed=2.3,
710
+ ),
711
+ SpawnTemplate(
712
+ spawn_id=SpawnId.SPIDER_SP1_CONST_BLUE_40,
713
+ type_id=CreatureTypeId.SPIDER_SP1,
714
+ flags=None,
715
+ creature="spider_sp1",
716
+ anim_note=None,
717
+ tint=(0.5, 0.6, 0.9, 1.0),
718
+ size=45.0,
719
+ move_speed=2.2,
720
+ ),
721
+ SpawnTemplate(
722
+ spawn_id=SpawnId.ZOMBIE_RANDOM_41,
723
+ type_id=CreatureTypeId.ZOMBIE,
724
+ flags=None,
725
+ creature="zombie",
726
+ anim_note=None,
727
+ tint=(None, None, None, 1.0),
728
+ ),
729
+ SpawnTemplate(
730
+ spawn_id=SpawnId.ZOMBIE_CONST_GREY_42,
731
+ type_id=CreatureTypeId.ZOMBIE,
732
+ flags=None,
733
+ creature="zombie",
734
+ anim_note=None,
735
+ tint=(0.9, 0.9, 0.9, 1.0),
736
+ size=45.0,
737
+ move_speed=1.7,
738
+ ),
739
+ SpawnTemplate(
740
+ spawn_id=SpawnId.ZOMBIE_CONST_GREEN_BRUTE_43,
741
+ type_id=CreatureTypeId.ZOMBIE,
742
+ flags=None,
743
+ creature="zombie",
744
+ anim_note=None,
745
+ tint=(0.2, 0.6, 0.1, 1.0),
746
+ size=70.0,
747
+ move_speed=2.1,
748
+ ),
749
+ ]
750
+
751
+ SPAWN_ID_TO_TEMPLATE = {entry.spawn_id: entry for entry in SPAWN_TEMPLATES}
752
+
753
+
754
+ @dataclass(frozen=True, slots=True)
755
+ class AlienSpawnerSpec:
756
+ timer: float
757
+ limit: int
758
+ interval: float
759
+ child_template_id: int
760
+ size: float
761
+ health: float
762
+ move_speed: float
763
+ reward_value: float
764
+ tint: TintRGBA
765
+
766
+
767
+ ALIEN_SPAWNER_TEMPLATES: dict[int, AlienSpawnerSpec] = {
768
+ SpawnId.ALIEN_SPAWNER_CHILD_1D_FAST_07: AlienSpawnerSpec(
769
+ timer=1.0,
770
+ limit=100,
771
+ interval=2.2,
772
+ child_template_id=SpawnId.ALIEN_RANDOM_1D,
773
+ size=50.0,
774
+ health=1000.0,
775
+ move_speed=2.0,
776
+ reward_value=3000.0,
777
+ tint=(1.0, 1.0, 1.0, 1.0),
778
+ ),
779
+ SpawnId.ALIEN_SPAWNER_CHILD_1D_SLOW_08: AlienSpawnerSpec(
780
+ timer=1.0,
781
+ limit=100,
782
+ interval=2.8,
783
+ child_template_id=SpawnId.ALIEN_RANDOM_1D,
784
+ size=50.0,
785
+ health=1000.0,
786
+ move_speed=2.0,
787
+ reward_value=3000.0,
788
+ tint=(1.0, 1.0, 1.0, 1.0),
789
+ ),
790
+ SpawnId.ALIEN_SPAWNER_CHILD_1D_LIMITED_09: AlienSpawnerSpec(
791
+ timer=1.0,
792
+ limit=16,
793
+ interval=2.0,
794
+ child_template_id=SpawnId.ALIEN_RANDOM_1D,
795
+ size=40.0,
796
+ health=450.0,
797
+ move_speed=2.0,
798
+ reward_value=1000.0,
799
+ tint=(1.0, 1.0, 1.0, 1.0),
800
+ ),
801
+ SpawnId.ALIEN_SPAWNER_CHILD_32_SLOW_0A: AlienSpawnerSpec(
802
+ timer=2.0,
803
+ limit=100,
804
+ interval=5.0,
805
+ child_template_id=SpawnId.SPIDER_SP1_RANDOM_32,
806
+ size=55.0,
807
+ health=1000.0,
808
+ move_speed=1.5,
809
+ reward_value=3000.0,
810
+ tint=(0.8, 0.7, 0.4, 1.0),
811
+ ),
812
+ SpawnId.ALIEN_SPAWNER_CHILD_3C_SLOW_0B: AlienSpawnerSpec(
813
+ timer=2.0,
814
+ limit=100,
815
+ interval=6.0,
816
+ child_template_id=SpawnId.SPIDER_SP1_CONST_RANGED_VARIANT_3C,
817
+ size=65.0,
818
+ health=3500.0,
819
+ move_speed=1.5,
820
+ reward_value=5000.0,
821
+ tint=(0.9, 0.1, 0.1, 1.0),
822
+ ),
823
+ SpawnId.ALIEN_SPAWNER_CHILD_31_FAST_0C: AlienSpawnerSpec(
824
+ timer=1.5,
825
+ limit=100,
826
+ interval=2.0,
827
+ child_template_id=SpawnId.LIZARD_RANDOM_31,
828
+ size=32.0,
829
+ health=50.0,
830
+ move_speed=2.8,
831
+ reward_value=1000.0,
832
+ tint=(0.9, 0.8, 0.4, 1.0),
833
+ ),
834
+ SpawnId.ALIEN_SPAWNER_CHILD_31_SLOW_0D: AlienSpawnerSpec(
835
+ timer=2.0,
836
+ limit=100,
837
+ interval=6.0,
838
+ child_template_id=SpawnId.LIZARD_RANDOM_31,
839
+ size=32.0,
840
+ health=50.0,
841
+ move_speed=1.3,
842
+ reward_value=1000.0,
843
+ tint=(0.9, 0.8, 0.4, 1.0),
844
+ ),
845
+ SpawnId.ALIEN_SPAWNER_CHILD_32_FAST_10: AlienSpawnerSpec(
846
+ timer=1.5,
847
+ limit=100,
848
+ interval=2.3,
849
+ child_template_id=SpawnId.SPIDER_SP1_RANDOM_32,
850
+ size=32.0,
851
+ health=50.0,
852
+ move_speed=2.8,
853
+ reward_value=800.0,
854
+ tint=(0.9, 0.8, 0.4, 1.0),
855
+ ),
856
+ }
857
+
858
+
859
+ @dataclass(frozen=True, slots=True)
860
+ class ConstantSpawnSpec:
861
+ type_id: CreatureTypeId
862
+ health: float
863
+ move_speed: float
864
+ reward_value: float
865
+ tint: TintRGBA
866
+ size: float
867
+ contact_damage: float
868
+ flags: CreatureFlags = CreatureFlags(0)
869
+ ai_mode: int = 0
870
+ orbit_angle: float | None = None
871
+ orbit_radius: float | None = None
872
+ ranged_projectile_type: int | None = None
873
+ bonus_id: BonusId | None = None
874
+ bonus_duration_override: int | None = None
875
+
876
+
877
+ @dataclass(frozen=True, slots=True)
878
+ class FormationChildSpec:
879
+ type_id: CreatureTypeId
880
+ health: float
881
+ move_speed: float
882
+ reward_value: float
883
+ size: float
884
+ contact_damage: float
885
+ tint: TintRGBA
886
+ max_health: float | None = None
887
+ orbit_angle: float | None = None
888
+ orbit_radius: float | None = None
889
+
890
+
891
+ @dataclass(frozen=True, slots=True)
892
+ class GridFormationSpec:
893
+ parent: ConstantSpawnSpec
894
+ child_ai_mode: int
895
+ child_spec: FormationChildSpec
896
+ x_range: range
897
+ y_range: range
898
+ apply_fallback: bool = False
899
+ set_parent_max_health: bool = True
900
+
901
+
902
+ @dataclass(frozen=True, slots=True)
903
+ class RingFormationSpec:
904
+ parent: ConstantSpawnSpec
905
+ child_ai_mode: int
906
+ child_spec: FormationChildSpec
907
+ count: int
908
+ angle_step: float
909
+ radius: float
910
+ apply_fallback: bool = False
911
+ set_position: bool = False
912
+ set_parent_max_health: bool = True
913
+
914
+
915
+ CONSTANT_SPAWN_TEMPLATES: dict[int, ConstantSpawnSpec] = {
916
+ SpawnId.SPIDER_SP2_SPLITTER_01: ConstantSpawnSpec(
917
+ type_id=CreatureTypeId.SPIDER_SP2,
918
+ health=400.0,
919
+ move_speed=2.0,
920
+ reward_value=1000.0,
921
+ tint=(0.8, 0.7, 0.4, 1.0),
922
+ size=80.0,
923
+ contact_damage=17.0,
924
+ flags=CreatureFlags.SPLIT_ON_DEATH,
925
+ ),
926
+ SpawnId.ALIEN_CONST_BROWN_TRANSPARENT_0F: ConstantSpawnSpec(
927
+ type_id=CreatureTypeId.ALIEN,
928
+ health=20.0,
929
+ move_speed=2.9,
930
+ reward_value=60.0,
931
+ tint=(0.665, 0.385, 0.259, 0.56),
932
+ size=50.0,
933
+ contact_damage=35.0,
934
+ ),
935
+ SpawnId.ALIEN_CONST_PURPLE_GHOST_21: ConstantSpawnSpec(
936
+ type_id=CreatureTypeId.ALIEN,
937
+ health=53.0,
938
+ move_speed=1.7,
939
+ reward_value=120.0,
940
+ tint=(0.7, 0.1, 0.51, 0.5),
941
+ size=55.0,
942
+ contact_damage=8.0,
943
+ ),
944
+ SpawnId.ALIEN_CONST_GREEN_GHOST_22: ConstantSpawnSpec(
945
+ type_id=CreatureTypeId.ALIEN,
946
+ health=25.0,
947
+ move_speed=1.7,
948
+ reward_value=150.0,
949
+ tint=(0.1, 0.7, 0.51, 0.05),
950
+ size=50.0,
951
+ contact_damage=8.0,
952
+ ),
953
+ SpawnId.ALIEN_CONST_GREEN_GHOST_SMALL_23: ConstantSpawnSpec(
954
+ type_id=CreatureTypeId.ALIEN,
955
+ health=5.0,
956
+ move_speed=1.7,
957
+ reward_value=180.0,
958
+ tint=(0.1, 0.7, 0.51, 0.04),
959
+ size=45.0,
960
+ contact_damage=8.0,
961
+ ),
962
+ SpawnId.ALIEN_CONST_GREEN_24: ConstantSpawnSpec(
963
+ type_id=CreatureTypeId.ALIEN,
964
+ health=20.0,
965
+ move_speed=2.0,
966
+ reward_value=110.0,
967
+ tint=(0.1, 0.7, 0.11, 1.0),
968
+ size=50.0,
969
+ contact_damage=4.0,
970
+ ),
971
+ SpawnId.ALIEN_CONST_GREEN_SMALL_25: ConstantSpawnSpec(
972
+ type_id=CreatureTypeId.ALIEN,
973
+ health=25.0,
974
+ move_speed=2.5,
975
+ reward_value=125.0,
976
+ tint=(0.1, 0.8, 0.11, 1.0),
977
+ size=30.0,
978
+ contact_damage=3.0,
979
+ ),
980
+ SpawnId.ALIEN_CONST_PALE_GREEN_26: ConstantSpawnSpec(
981
+ type_id=CreatureTypeId.ALIEN,
982
+ health=50.0,
983
+ move_speed=2.2,
984
+ reward_value=125.0,
985
+ tint=(0.6, 0.8, 0.6, 1.0),
986
+ size=45.0,
987
+ contact_damage=10.0,
988
+ ),
989
+ SpawnId.ALIEN_CONST_WEAPON_BONUS_27: ConstantSpawnSpec(
990
+ type_id=CreatureTypeId.ALIEN,
991
+ health=50.0,
992
+ move_speed=2.1,
993
+ reward_value=125.0,
994
+ tint=(1.0, 0.8, 0.1, 1.0),
995
+ size=45.0,
996
+ contact_damage=10.0,
997
+ flags=CreatureFlags.BONUS_ON_DEATH,
998
+ bonus_id=BonusId.WEAPON,
999
+ bonus_duration_override=5,
1000
+ ),
1001
+ SpawnId.ALIEN_CONST_PURPLE_28: ConstantSpawnSpec(
1002
+ type_id=CreatureTypeId.ALIEN,
1003
+ health=50.0,
1004
+ move_speed=1.7,
1005
+ reward_value=150.0,
1006
+ tint=(0.7, 0.1, 0.51, 1.0),
1007
+ size=55.0,
1008
+ contact_damage=8.0,
1009
+ ),
1010
+ SpawnId.ALIEN_CONST_GREY_BRUTE_29: ConstantSpawnSpec(
1011
+ type_id=CreatureTypeId.ALIEN,
1012
+ health=800.0,
1013
+ move_speed=2.5,
1014
+ reward_value=450.0,
1015
+ tint=(0.8, 0.8, 0.8, 1.0),
1016
+ size=70.0,
1017
+ contact_damage=20.0,
1018
+ ),
1019
+ SpawnId.ALIEN_CONST_GREY_FAST_2A: ConstantSpawnSpec(
1020
+ type_id=CreatureTypeId.ALIEN,
1021
+ health=50.0,
1022
+ move_speed=3.1,
1023
+ reward_value=300.0,
1024
+ tint=(0.3, 0.3, 0.3, 1.0),
1025
+ size=60.0,
1026
+ contact_damage=8.0,
1027
+ ),
1028
+ SpawnId.ALIEN_CONST_RED_FAST_2B: ConstantSpawnSpec(
1029
+ type_id=CreatureTypeId.ALIEN,
1030
+ health=30.0,
1031
+ move_speed=3.6,
1032
+ reward_value=450.0,
1033
+ tint=(1.0, 0.3, 0.3, 1.0),
1034
+ size=35.0,
1035
+ contact_damage=20.0,
1036
+ ),
1037
+ SpawnId.ALIEN_CONST_RED_BOSS_2C: ConstantSpawnSpec(
1038
+ type_id=CreatureTypeId.ALIEN,
1039
+ health=3800.0,
1040
+ move_speed=2.0,
1041
+ reward_value=1500.0,
1042
+ tint=(0.85, 0.2, 0.2, 1.0),
1043
+ size=80.0,
1044
+ contact_damage=40.0,
1045
+ ),
1046
+ SpawnId.ALIEN_CONST_CYAN_AI2_2D: ConstantSpawnSpec(
1047
+ type_id=CreatureTypeId.ALIEN,
1048
+ health=45.0,
1049
+ move_speed=3.1,
1050
+ reward_value=200.0,
1051
+ tint=(0.0, 0.9, 0.8, 1.0),
1052
+ size=38.0,
1053
+ contact_damage=3.0,
1054
+ ai_mode=2,
1055
+ ),
1056
+ SpawnId.LIZARD_CONST_GREY_2F: ConstantSpawnSpec(
1057
+ type_id=CreatureTypeId.LIZARD,
1058
+ health=20.0,
1059
+ move_speed=2.5,
1060
+ reward_value=150.0,
1061
+ tint=(0.8, 0.8, 0.8, 1.0),
1062
+ size=45.0,
1063
+ contact_damage=4.0,
1064
+ ),
1065
+ SpawnId.LIZARD_CONST_YELLOW_BOSS_30: ConstantSpawnSpec(
1066
+ type_id=CreatureTypeId.LIZARD,
1067
+ health=1000.0,
1068
+ move_speed=2.0,
1069
+ reward_value=400.0,
1070
+ tint=(0.9, 0.8, 0.1, 1.0),
1071
+ size=65.0,
1072
+ contact_damage=10.0,
1073
+ ),
1074
+ SpawnId.SPIDER_SP1_CONST_SHOCK_BOSS_3A: ConstantSpawnSpec(
1075
+ type_id=CreatureTypeId.SPIDER_SP1,
1076
+ health=4500.0,
1077
+ move_speed=2.0,
1078
+ reward_value=4500.0,
1079
+ tint=(1.0, 1.0, 1.0, 1.0),
1080
+ size=64.0,
1081
+ contact_damage=50.0,
1082
+ flags=CreatureFlags.RANGED_ATTACK_SHOCK,
1083
+ orbit_angle=0.9,
1084
+ ranged_projectile_type=9,
1085
+ ),
1086
+ SpawnId.SPIDER_SP1_CONST_RED_BOSS_3B: ConstantSpawnSpec(
1087
+ type_id=CreatureTypeId.SPIDER_SP1,
1088
+ health=1200.0,
1089
+ move_speed=2.0,
1090
+ reward_value=4000.0,
1091
+ tint=(0.9, 0.0, 0.0, 1.0),
1092
+ size=70.0,
1093
+ contact_damage=20.0,
1094
+ ),
1095
+ SpawnId.SPIDER_SP1_CONST_RANGED_VARIANT_3C: ConstantSpawnSpec(
1096
+ type_id=CreatureTypeId.SPIDER_SP1,
1097
+ health=200.0,
1098
+ move_speed=2.0,
1099
+ reward_value=200.0,
1100
+ tint=(0.9, 0.1, 0.1, 1.0),
1101
+ size=40.0,
1102
+ contact_damage=20.0,
1103
+ flags=CreatureFlags.RANGED_ATTACK_VARIANT,
1104
+ ai_mode=2,
1105
+ orbit_angle=0.4,
1106
+ ranged_projectile_type=26,
1107
+ ),
1108
+ SpawnId.SPIDER_SP1_CONST_WHITE_FAST_3E: ConstantSpawnSpec(
1109
+ type_id=CreatureTypeId.SPIDER_SP1,
1110
+ health=1000.0,
1111
+ move_speed=2.8,
1112
+ reward_value=500.0,
1113
+ tint=(1.0, 1.0, 1.0, 1.0),
1114
+ size=64.0,
1115
+ contact_damage=40.0,
1116
+ ),
1117
+ SpawnId.SPIDER_SP1_CONST_BROWN_SMALL_3F: ConstantSpawnSpec(
1118
+ type_id=CreatureTypeId.SPIDER_SP1,
1119
+ health=200.0,
1120
+ move_speed=2.3,
1121
+ reward_value=210.0,
1122
+ tint=(0.7, 0.4, 0.1, 1.0),
1123
+ size=35.0,
1124
+ contact_damage=20.0,
1125
+ ),
1126
+ SpawnId.SPIDER_SP1_CONST_BLUE_40: ConstantSpawnSpec(
1127
+ type_id=CreatureTypeId.SPIDER_SP1,
1128
+ health=70.0,
1129
+ move_speed=2.2,
1130
+ reward_value=160.0,
1131
+ tint=(0.5, 0.6, 0.9, 1.0),
1132
+ size=45.0,
1133
+ contact_damage=5.0,
1134
+ ),
1135
+ SpawnId.ZOMBIE_CONST_GREY_42: ConstantSpawnSpec(
1136
+ type_id=CreatureTypeId.ZOMBIE,
1137
+ health=200.0,
1138
+ move_speed=1.7,
1139
+ reward_value=160.0,
1140
+ tint=(0.9, 0.9, 0.9, 1.0),
1141
+ size=45.0,
1142
+ contact_damage=15.0,
1143
+ ),
1144
+ SpawnId.ZOMBIE_CONST_GREEN_BRUTE_43: ConstantSpawnSpec(
1145
+ type_id=CreatureTypeId.ZOMBIE,
1146
+ health=2000.0,
1147
+ move_speed=2.1,
1148
+ reward_value=460.0,
1149
+ tint=(0.2, 0.6, 0.1, 1.0),
1150
+ size=70.0,
1151
+ contact_damage=15.0,
1152
+ ),
1153
+ }
1154
+
1155
+ GRID_FORMATIONS: dict[int, GridFormationSpec] = {
1156
+ SpawnId.FORMATION_GRID_ALIEN_GREEN_14: GridFormationSpec(
1157
+ parent=ConstantSpawnSpec(
1158
+ type_id=CreatureTypeId.ALIEN,
1159
+ health=1500.0,
1160
+ move_speed=2.0,
1161
+ reward_value=600.0,
1162
+ tint=(0.7, 0.8, 0.31, 1.0),
1163
+ size=50.0,
1164
+ contact_damage=40.0,
1165
+ ai_mode=2,
1166
+ ),
1167
+ child_ai_mode=5,
1168
+ child_spec=FormationChildSpec(
1169
+ type_id=CreatureTypeId.ALIEN,
1170
+ health=40.0,
1171
+ move_speed=2.0,
1172
+ reward_value=60.0,
1173
+ size=50.0,
1174
+ contact_damage=4.0,
1175
+ tint=(0.4, 0.7, 0.11, 1.0),
1176
+ ),
1177
+ x_range=range(0, -576, -64),
1178
+ y_range=range(128, 257, 16),
1179
+ apply_fallback=True,
1180
+ ),
1181
+ SpawnId.FORMATION_GRID_ALIEN_WHITE_15: GridFormationSpec(
1182
+ parent=ConstantSpawnSpec(
1183
+ type_id=CreatureTypeId.ALIEN,
1184
+ health=1500.0,
1185
+ move_speed=2.0,
1186
+ reward_value=600.0,
1187
+ tint=(1.0, 1.0, 1.0, 1.0),
1188
+ size=60.0,
1189
+ contact_damage=40.0,
1190
+ ai_mode=2,
1191
+ ),
1192
+ child_ai_mode=4,
1193
+ child_spec=FormationChildSpec(
1194
+ type_id=CreatureTypeId.ALIEN,
1195
+ health=40.0,
1196
+ move_speed=2.0,
1197
+ reward_value=60.0,
1198
+ size=50.0,
1199
+ contact_damage=4.0,
1200
+ tint=(0.4, 0.7, 0.11, 1.0),
1201
+ ),
1202
+ x_range=range(0, -576, -64),
1203
+ y_range=range(128, 257, 16),
1204
+ apply_fallback=True,
1205
+ ),
1206
+ SpawnId.FORMATION_GRID_LIZARD_WHITE_16: GridFormationSpec(
1207
+ parent=ConstantSpawnSpec(
1208
+ type_id=CreatureTypeId.LIZARD,
1209
+ health=1500.0,
1210
+ move_speed=2.0,
1211
+ reward_value=600.0,
1212
+ tint=(1.0, 1.0, 1.0, 1.0),
1213
+ size=64.0,
1214
+ contact_damage=40.0,
1215
+ ai_mode=2,
1216
+ ),
1217
+ child_ai_mode=4,
1218
+ child_spec=FormationChildSpec(
1219
+ type_id=CreatureTypeId.LIZARD,
1220
+ health=40.0,
1221
+ move_speed=2.0,
1222
+ reward_value=60.0,
1223
+ size=60.0,
1224
+ contact_damage=4.0,
1225
+ tint=(0.4, 0.7, 0.11, 1.0),
1226
+ ),
1227
+ x_range=range(0, -576, -64),
1228
+ y_range=range(128, 257, 16),
1229
+ apply_fallback=True,
1230
+ ),
1231
+ SpawnId.FORMATION_GRID_SPIDER_SP1_WHITE_17: GridFormationSpec(
1232
+ parent=ConstantSpawnSpec(
1233
+ type_id=CreatureTypeId.SPIDER_SP1,
1234
+ health=1500.0,
1235
+ move_speed=2.0,
1236
+ reward_value=600.0,
1237
+ tint=(1.0, 1.0, 1.0, 1.0),
1238
+ size=60.0,
1239
+ contact_damage=40.0,
1240
+ ai_mode=2,
1241
+ ),
1242
+ child_ai_mode=4,
1243
+ child_spec=FormationChildSpec(
1244
+ type_id=CreatureTypeId.SPIDER_SP1,
1245
+ health=40.0,
1246
+ move_speed=2.0,
1247
+ reward_value=60.0,
1248
+ size=50.0,
1249
+ contact_damage=4.0,
1250
+ tint=(0.4, 0.7, 0.11, 1.0),
1251
+ ),
1252
+ x_range=range(0, -576, -64),
1253
+ y_range=range(128, 257, 16),
1254
+ apply_fallback=True,
1255
+ ),
1256
+ SpawnId.FORMATION_GRID_ALIEN_BRONZE_18: GridFormationSpec(
1257
+ parent=ConstantSpawnSpec(
1258
+ type_id=CreatureTypeId.ALIEN,
1259
+ health=500.0,
1260
+ move_speed=2.0,
1261
+ reward_value=600.0,
1262
+ tint=(0.7, 0.8, 0.31, 1.0),
1263
+ size=40.0,
1264
+ contact_damage=40.0,
1265
+ ai_mode=2,
1266
+ ),
1267
+ child_ai_mode=3,
1268
+ child_spec=FormationChildSpec(
1269
+ type_id=CreatureTypeId.ALIEN,
1270
+ health=260.0,
1271
+ move_speed=3.8,
1272
+ reward_value=60.0,
1273
+ size=50.0,
1274
+ contact_damage=35.0,
1275
+ tint=(0.7125, 0.4125, 0.2775, 0.6),
1276
+ ),
1277
+ x_range=range(0, -576, -64),
1278
+ y_range=range(128, 257, 16),
1279
+ ),
1280
+ }
1281
+
1282
+ RING_FORMATIONS: dict[int, RingFormationSpec] = {
1283
+ SpawnId.FORMATION_RING_ALIEN_8_12: RingFormationSpec(
1284
+ parent=ConstantSpawnSpec(
1285
+ type_id=CreatureTypeId.ALIEN,
1286
+ health=200.0,
1287
+ move_speed=2.2,
1288
+ reward_value=600.0,
1289
+ tint=(0.65, 0.85, 0.97, 1.0),
1290
+ size=55.0,
1291
+ contact_damage=14.0,
1292
+ ),
1293
+ child_ai_mode=3,
1294
+ child_spec=FormationChildSpec(
1295
+ type_id=CreatureTypeId.ALIEN,
1296
+ health=40.0,
1297
+ move_speed=2.4,
1298
+ reward_value=60.0,
1299
+ size=50.0,
1300
+ contact_damage=4.0,
1301
+ tint=(0.32, 0.588, 0.426, 1.0),
1302
+ ),
1303
+ count=8,
1304
+ angle_step=math.pi / 4.0,
1305
+ radius=100.0,
1306
+ apply_fallback=True,
1307
+ ),
1308
+ SpawnId.FORMATION_RING_ALIEN_5_19: RingFormationSpec(
1309
+ parent=ConstantSpawnSpec(
1310
+ type_id=CreatureTypeId.ALIEN,
1311
+ health=50.0,
1312
+ move_speed=3.8,
1313
+ reward_value=300.0,
1314
+ tint=(0.95, 0.55, 0.37, 1.0),
1315
+ size=55.0,
1316
+ contact_damage=40.0,
1317
+ ),
1318
+ child_ai_mode=5,
1319
+ child_spec=FormationChildSpec(
1320
+ type_id=CreatureTypeId.ALIEN,
1321
+ health=220.0,
1322
+ move_speed=3.8,
1323
+ reward_value=60.0,
1324
+ size=50.0,
1325
+ contact_damage=35.0,
1326
+ tint=(0.7125, 0.4125, 0.2775, 0.6),
1327
+ ),
1328
+ count=5,
1329
+ angle_step=math.tau / 5.0,
1330
+ radius=110.0,
1331
+ set_position=True,
1332
+ apply_fallback=True,
1333
+ ),
1334
+ }
1335
+
1336
+
1337
+ def spawn_id_label(spawn_id: SupportsInt) -> str:
1338
+ entry = SPAWN_ID_TO_TEMPLATE.get(int(spawn_id))
1339
+ if entry is None or entry.creature is None:
1340
+ return "unknown"
1341
+ return entry.creature
1342
+
1343
+
1344
+ @dataclass(frozen=True, slots=True, kw_only=True)
1345
+ class SpawnEnv:
1346
+ terrain_width: float
1347
+ terrain_height: float
1348
+ demo_mode_active: bool
1349
+ hardcore: bool
1350
+ difficulty_level: int
1351
+
1352
+
1353
+ @dataclass(frozen=True, slots=True, kw_only=True)
1354
+ class BurstEffect:
1355
+ x: float
1356
+ y: float
1357
+ count: int
1358
+
1359
+
1360
+ @dataclass(slots=True)
1361
+ class CreatureInit:
1362
+ # Template id that produced this creature (not necessarily unique per creature in formations).
1363
+ origin_template_id: int
1364
+
1365
+ pos_x: float
1366
+ pos_y: float
1367
+
1368
+ # Headings are in radians. The original seeds a random heading early, then overwrites it
1369
+ # at the end with the function argument (or a randomized argument for `-100.0`).
1370
+ heading: float
1371
+
1372
+ phase_seed: float
1373
+
1374
+ type_id: CreatureTypeId | None = None
1375
+ flags: CreatureFlags = CreatureFlags(0)
1376
+ ai_mode: int = 0
1377
+
1378
+ health: float | None = None
1379
+ max_health: float | None = None
1380
+ move_speed: float | None = None
1381
+ reward_value: float | None = None
1382
+ size: float | None = None
1383
+ contact_damage: float | None = None
1384
+
1385
+ tint: Tint | None = None
1386
+
1387
+ orbit_angle: float | None = None
1388
+ orbit_radius: float | None = None
1389
+ ranged_projectile_type: int | None = None
1390
+
1391
+ # AI link semantics:
1392
+ # - For most formations (ai_mode 3/5/...), `ai_link_parent` references another creature index
1393
+ # (typically the parent or previous element in the chain).
1394
+ # - For AI7 timer mode (flag 0x80), `ai_timer` is written into link_index.
1395
+ ai_link_parent: int | None = None
1396
+ ai_timer: int | None = None
1397
+
1398
+ target_offset_x: float | None = None
1399
+ target_offset_y: float | None = None
1400
+
1401
+ # Spawn slot reference (stored in link_index in the original when flags include HAS_SPAWN_SLOT).
1402
+ spawn_slot: int | None = None
1403
+
1404
+ # BONUS_ON_DEATH uses link_index low/high 16-bit fields for bonus spawn args.
1405
+ bonus_id: BonusId | None = None
1406
+ bonus_duration_override: int | None = None
1407
+
1408
+
1409
+ @dataclass(slots=True)
1410
+ class SpawnSlotInit:
1411
+ owner_creature: int
1412
+ timer: float
1413
+ count: int
1414
+ limit: int
1415
+ interval: float
1416
+ child_template_id: int
1417
+
1418
+
1419
+ @dataclass(frozen=True, slots=True)
1420
+ class SpawnPlan:
1421
+ creatures: tuple[CreatureInit, ...]
1422
+ spawn_slots: tuple[SpawnSlotInit, ...]
1423
+ effects: tuple[BurstEffect, ...]
1424
+ primary: int
1425
+
1426
+
1427
+ def add_spawn_slot(
1428
+ spawn_slots: list[SpawnSlotInit],
1429
+ *,
1430
+ owner_creature: int,
1431
+ timer: float,
1432
+ limit: int,
1433
+ interval: float,
1434
+ child_template_id: int,
1435
+ ) -> int:
1436
+ slot_idx = len(spawn_slots)
1437
+ spawn_slots.append(
1438
+ SpawnSlotInit(
1439
+ owner_creature=owner_creature,
1440
+ timer=timer,
1441
+ count=0,
1442
+ limit=limit,
1443
+ interval=interval,
1444
+ child_template_id=child_template_id,
1445
+ )
1446
+ )
1447
+ return slot_idx
1448
+
1449
+
1450
+ def apply_constant_template(c: CreatureInit, spec: ConstantSpawnSpec) -> None:
1451
+ c.type_id = spec.type_id
1452
+ c.flags = spec.flags
1453
+ c.ai_mode = spec.ai_mode
1454
+ c.health = spec.health
1455
+ c.move_speed = spec.move_speed
1456
+ c.reward_value = spec.reward_value
1457
+ apply_tint(c, spec.tint)
1458
+ c.size = spec.size
1459
+ c.contact_damage = spec.contact_damage
1460
+ if spec.orbit_angle is not None:
1461
+ c.orbit_angle = spec.orbit_angle
1462
+ if spec.orbit_radius is not None:
1463
+ c.orbit_radius = spec.orbit_radius
1464
+ if spec.ranged_projectile_type is not None:
1465
+ c.ranged_projectile_type = spec.ranged_projectile_type
1466
+ if spec.bonus_id is not None:
1467
+ c.bonus_id = spec.bonus_id
1468
+ if spec.bonus_duration_override is not None:
1469
+ c.bonus_duration_override = spec.bonus_duration_override
1470
+
1471
+
1472
+ def apply_tint(c: CreatureInit, tint: TintRGBA) -> None:
1473
+ c.tint = tint
1474
+
1475
+
1476
+ def resolve_tint(tint: Tint | None) -> TintRGBA:
1477
+ """Resolve a partial/optional tint into concrete RGBA multipliers."""
1478
+ if tint is None:
1479
+ return (1.0, 1.0, 1.0, 1.0)
1480
+ tint_r, tint_g, tint_b, tint_a = tint
1481
+ return (
1482
+ 1.0 if tint_r is None else tint_r,
1483
+ 1.0 if tint_g is None else tint_g,
1484
+ 1.0 if tint_b is None else tint_b,
1485
+ 1.0 if tint_a is None else tint_a,
1486
+ )
1487
+
1488
+
1489
+ def apply_child_spec(child: CreatureInit, spec: FormationChildSpec) -> None:
1490
+ child.type_id = spec.type_id
1491
+ child.health = spec.health
1492
+ child.max_health = spec.max_health if spec.max_health is not None else spec.health
1493
+ child.move_speed = spec.move_speed
1494
+ child.reward_value = spec.reward_value
1495
+ child.size = spec.size
1496
+ child.contact_damage = spec.contact_damage
1497
+ apply_tint(child, spec.tint)
1498
+ if spec.orbit_angle is not None:
1499
+ child.orbit_angle = spec.orbit_angle
1500
+ if spec.orbit_radius is not None:
1501
+ child.orbit_radius = spec.orbit_radius
1502
+
1503
+
1504
+ def randf(rng: Crand, mod: int, scale: float, base: float) -> float:
1505
+ return float(rng.rand() % mod) * scale + base
1506
+
1507
+
1508
+ def apply_size_health_reward(
1509
+ c: CreatureInit,
1510
+ size: float,
1511
+ *,
1512
+ health_scale: float,
1513
+ health_add: float,
1514
+ reward_add: float = 50.0,
1515
+ ) -> None:
1516
+ c.size = size
1517
+ c.health = size * health_scale + health_add
1518
+ c.reward_value = size + size + reward_add
1519
+
1520
+
1521
+ def apply_size_health(c: CreatureInit, size: float, *, health_scale: float, health_add: float) -> None:
1522
+ c.size = size
1523
+ c.health = size * health_scale + health_add
1524
+
1525
+
1526
+ def apply_random_move_speed(c: CreatureInit, rng: Crand, mod: int, scale: float, base: float) -> None:
1527
+ c.move_speed = randf(rng, mod, scale, base)
1528
+
1529
+
1530
+ def apply_size_move_speed(c: CreatureInit, size: float, scale: float, base: float) -> None:
1531
+ c.move_speed = size * scale + base
1532
+
1533
+
1534
+ def spawn_ring_children(
1535
+ creatures: list[CreatureInit],
1536
+ template_id: int,
1537
+ pos_x: float,
1538
+ pos_y: float,
1539
+ rng: Crand,
1540
+ *,
1541
+ count: int,
1542
+ angle_step: float,
1543
+ radius: float,
1544
+ ai_mode: int,
1545
+ child_spec: FormationChildSpec,
1546
+ link_parent: int = 0,
1547
+ set_position: bool = False,
1548
+ heading_override: float | None = None,
1549
+ ) -> int:
1550
+ last_idx = -1
1551
+ for i in range(count):
1552
+ child = alloc_creature(template_id, pos_x, pos_y, rng)
1553
+ child.ai_mode = ai_mode
1554
+ child.ai_link_parent = link_parent
1555
+ angle = float(i) * angle_step
1556
+ child.target_offset_x = float(math.cos(angle) * radius)
1557
+ child.target_offset_y = float(math.sin(angle) * radius)
1558
+ if set_position:
1559
+ child.pos_x = pos_x + (child.target_offset_x or 0.0)
1560
+ child.pos_y = pos_y + (child.target_offset_y or 0.0)
1561
+ if heading_override is not None:
1562
+ child.heading = heading_override
1563
+ apply_child_spec(child, child_spec)
1564
+ creatures.append(child)
1565
+ last_idx = len(creatures) - 1
1566
+ return last_idx
1567
+
1568
+
1569
+ def spawn_grid_children(
1570
+ creatures: list[CreatureInit],
1571
+ template_id: int,
1572
+ pos_x: float,
1573
+ pos_y: float,
1574
+ rng: Crand,
1575
+ *,
1576
+ x_range: range,
1577
+ y_range: range,
1578
+ ai_mode: int,
1579
+ child_spec: FormationChildSpec,
1580
+ link_parent: int = 0,
1581
+ ) -> int:
1582
+ last_idx = -1
1583
+ for x_offset in x_range:
1584
+ for y_offset in y_range:
1585
+ child = alloc_creature(template_id, pos_x, pos_y, rng)
1586
+ child.ai_mode = ai_mode
1587
+ child.ai_link_parent = link_parent
1588
+ child.target_offset_x = float(x_offset)
1589
+ child.target_offset_y = float(y_offset)
1590
+ child.pos_x = float(pos_x + x_offset)
1591
+ child.pos_y = float(pos_y + y_offset)
1592
+ apply_child_spec(child, child_spec)
1593
+ creatures.append(child)
1594
+ last_idx = len(creatures) - 1
1595
+ return last_idx
1596
+
1597
+
1598
+ def spawn_chain_children(
1599
+ creatures: list[CreatureInit],
1600
+ template_id: int,
1601
+ pos_x: float,
1602
+ pos_y: float,
1603
+ rng: Crand,
1604
+ *,
1605
+ count: int,
1606
+ ai_mode: int,
1607
+ child_spec: FormationChildSpec,
1608
+ setup_child: Callable[[CreatureInit, int], None],
1609
+ link_parent_start: int = 0,
1610
+ ) -> int:
1611
+ chain_prev = link_parent_start
1612
+ for idx in range(count):
1613
+ child = alloc_creature(template_id, pos_x, pos_y, rng)
1614
+ child.ai_mode = ai_mode
1615
+ child.ai_link_parent = chain_prev
1616
+ setup_child(child, idx)
1617
+ apply_child_spec(child, child_spec)
1618
+ creatures.append(child)
1619
+ chain_prev = len(creatures) - 1
1620
+ return chain_prev
1621
+
1622
+
1623
+ @dataclass(slots=True)
1624
+ class PlanBuilder:
1625
+ template_id: int
1626
+ pos_x: float
1627
+ pos_y: float
1628
+ rng: Crand
1629
+ env: SpawnEnv
1630
+ creatures: list[CreatureInit]
1631
+ spawn_slots: list[SpawnSlotInit]
1632
+ effects: list[BurstEffect]
1633
+ primary: int = 0
1634
+
1635
+ @classmethod
1636
+ def start(
1637
+ cls,
1638
+ template_id: int,
1639
+ pos: tuple[float, float],
1640
+ heading: float,
1641
+ rng: Crand,
1642
+ env: SpawnEnv,
1643
+ ) -> tuple["PlanBuilder", float]:
1644
+ pos_x, pos_y = pos
1645
+
1646
+ # creature_alloc_slot() for the base creature.
1647
+ creatures: list[CreatureInit] = [alloc_creature(template_id, pos_x, pos_y, rng)]
1648
+ spawn_slots: list[SpawnSlotInit] = []
1649
+ effects: list[BurstEffect] = []
1650
+
1651
+ # `heading == -100.0` uses a randomized heading.
1652
+ final_heading = heading
1653
+ if final_heading == -100.0:
1654
+ final_heading = float(rng.rand() % 628) * 0.01
1655
+
1656
+ # Base initialization always consumes one rand() for a transient heading value.
1657
+ creatures[0].heading = float(rng.rand() % 314) * 0.01
1658
+
1659
+ return cls(
1660
+ template_id=template_id,
1661
+ pos_x=pos_x,
1662
+ pos_y=pos_y,
1663
+ rng=rng,
1664
+ env=env,
1665
+ creatures=creatures,
1666
+ spawn_slots=spawn_slots,
1667
+ effects=effects,
1668
+ primary=0,
1669
+ ), final_heading
1670
+
1671
+ @property
1672
+ def base(self) -> CreatureInit:
1673
+ return self.creatures[0]
1674
+
1675
+ def add_slot(self, *, owner: int, timer: float, limit: int, interval: float, child: SupportsInt) -> int:
1676
+ return add_spawn_slot(
1677
+ self.spawn_slots,
1678
+ owner_creature=owner,
1679
+ timer=timer,
1680
+ limit=limit,
1681
+ interval=interval,
1682
+ child_template_id=int(child),
1683
+ )
1684
+
1685
+ def ring_children(self, **kwargs) -> int:
1686
+ return spawn_ring_children(
1687
+ self.creatures,
1688
+ self.template_id,
1689
+ self.pos_x,
1690
+ self.pos_y,
1691
+ self.rng,
1692
+ **kwargs,
1693
+ )
1694
+
1695
+ def grid_children(self, **kwargs) -> int:
1696
+ return spawn_grid_children(
1697
+ self.creatures,
1698
+ self.template_id,
1699
+ self.pos_x,
1700
+ self.pos_y,
1701
+ self.rng,
1702
+ **kwargs,
1703
+ )
1704
+
1705
+ def chain_children(self, **kwargs) -> int:
1706
+ return spawn_chain_children(
1707
+ self.creatures,
1708
+ self.template_id,
1709
+ self.pos_x,
1710
+ self.pos_y,
1711
+ self.rng,
1712
+ **kwargs,
1713
+ )
1714
+
1715
+ def finish(self, final_heading: float) -> SpawnPlan:
1716
+ apply_tail(
1717
+ template_id=self.template_id,
1718
+ plan_creatures=self.creatures,
1719
+ plan_spawn_slots=self.spawn_slots,
1720
+ plan_effects=self.effects,
1721
+ primary_idx=self.primary,
1722
+ final_heading=final_heading,
1723
+ env=self.env,
1724
+ )
1725
+ return SpawnPlan(
1726
+ creatures=tuple(self.creatures),
1727
+ spawn_slots=tuple(self.spawn_slots),
1728
+ effects=tuple(self.effects),
1729
+ primary=self.primary,
1730
+ )
1731
+
1732
+
1733
+ TemplateFn = Callable[[PlanBuilder], None]
1734
+ TEMPLATE_BUILDERS: dict[int, TemplateFn] = {}
1735
+
1736
+
1737
+ def register_template(*template_ids: SupportsInt) -> Callable[[TemplateFn], TemplateFn]:
1738
+ def decorator(fn: TemplateFn) -> TemplateFn:
1739
+ for template_id in template_ids:
1740
+ TEMPLATE_BUILDERS[int(template_id)] = fn
1741
+ return fn
1742
+
1743
+ return decorator
1744
+
1745
+
1746
+ def tick_spawn_slot(slot: SpawnSlotInit, frame_dt: float) -> int | None:
1747
+ """Advance a spawn slot timer by `frame_dt`, returning a spawned template id if triggered.
1748
+
1749
+ Modeled after `creature_update_all`'s spawn-slot tick:
1750
+ timer -= dt
1751
+ if timer < 0:
1752
+ timer += interval
1753
+ if count < limit:
1754
+ count += 1
1755
+ spawn child_template_id
1756
+
1757
+ Note: the original only adds `interval` once (no loop), so large dt can keep the timer negative.
1758
+ """
1759
+ slot.timer -= frame_dt
1760
+ if slot.timer < 0.0:
1761
+ slot.timer += slot.interval
1762
+ if slot.count < slot.limit:
1763
+ slot.count += 1
1764
+ return slot.child_template_id
1765
+ return None
1766
+
1767
+
1768
+ def alloc_creature(template_id: int, pos_x: float, pos_y: float, rng: Crand) -> CreatureInit:
1769
+ # creature_alloc_slot():
1770
+ # - clears flags
1771
+ # - seeds phase_seed = float(crt_rand() & 0x17f)
1772
+ phase_seed = float(rng.rand() & 0x17F)
1773
+ return CreatureInit(origin_template_id=template_id, pos_x=pos_x, pos_y=pos_y, heading=0.0, phase_seed=phase_seed)
1774
+
1775
+
1776
+ def clamp01(value: float) -> float:
1777
+ if value < 0.0:
1778
+ return 0.0
1779
+ if 1.0 < value:
1780
+ return 1.0
1781
+ return value
1782
+
1783
+
1784
+ def build_survival_spawn_creature(pos: tuple[float, float], rng: Crand, *, player_experience: int) -> CreatureInit:
1785
+ """Pure model of `survival_spawn_creature` (crimsonland.exe 0x00407510).
1786
+
1787
+ Note: this is not a `creature_spawn_template` spawn id; it picks a `type_id` and stats
1788
+ dynamically based on `player_experience`.
1789
+ """
1790
+ pos_x, pos_y = pos
1791
+ xp = int(player_experience)
1792
+
1793
+ c = alloc_creature(-1, pos_x, pos_y, rng)
1794
+ c.ai_mode = 0
1795
+
1796
+ r10 = rng.rand() % 10
1797
+
1798
+ if xp < 12000:
1799
+ type_id = 2 if r10 < 9 else 3
1800
+ elif xp < 25000:
1801
+ type_id = 0 if r10 < 4 else 3
1802
+ if 8 < r10:
1803
+ type_id = 2
1804
+ elif xp < 42000:
1805
+ if r10 < 5:
1806
+ type_id = 2
1807
+ else:
1808
+ # Decompiled as a sign-bit trick, but in practice this is a parity pick.
1809
+ type_id = (rng.rand() & 1) + 3
1810
+ elif xp < 50000:
1811
+ type_id = 2
1812
+ elif xp < 90000:
1813
+ type_id = 4
1814
+ else:
1815
+ if 109999 < xp:
1816
+ if r10 < 6:
1817
+ type_id = 2
1818
+ elif r10 < 9:
1819
+ type_id = 4
1820
+ else:
1821
+ type_id = 0
1822
+ else:
1823
+ type_id = 0
1824
+
1825
+ # Rare override: forces spider_sp1 when (rand() & 0x1f) == 2.
1826
+ if (rng.rand() & 0x1F) == 2:
1827
+ type_id = 3
1828
+
1829
+ c.type_id = CreatureTypeId(type_id)
1830
+
1831
+ # size = rand() % 20 + 44
1832
+ c.size = float(rng.rand() % 20 + 44)
1833
+
1834
+ # heading = (rand() % 314) * 0.01
1835
+ c.heading = float(rng.rand() % 314) * 0.01
1836
+
1837
+ move_speed = float(xp // 4000) * 0.045 + 0.9
1838
+ if c.type_id == CreatureTypeId.SPIDER_SP1:
1839
+ c.flags |= CreatureFlags.AI7_LINK_TIMER
1840
+ move_speed *= 1.3
1841
+
1842
+ r_health = rng.rand()
1843
+ health = float(xp) * 0.00125 + float(r_health & 0xF) + 52.0
1844
+
1845
+ if c.type_id == CreatureTypeId.ZOMBIE:
1846
+ move_speed *= 0.6
1847
+ if move_speed < 1.3:
1848
+ move_speed = 1.3
1849
+ health *= 1.5
1850
+
1851
+ if 3.5 < move_speed:
1852
+ move_speed = 3.5
1853
+
1854
+ c.move_speed = move_speed
1855
+ c.health = health
1856
+ c.reward_value = 0.0
1857
+
1858
+ # Tint based on player_experience thresholds.
1859
+ tint_a = 1.0
1860
+ if xp < 50_000:
1861
+ tint_r = 1.0 - 1.0 / (float(xp // 1000) + 10.0)
1862
+ tint_g = float(rng.rand() % 10) * 0.01 + 0.9 - 1.0 / (float(xp // 10000) + 10.0)
1863
+ tint_b = float(rng.rand() % 10) * 0.01 + 0.7
1864
+ elif xp < 100_000:
1865
+ tint_r = 0.9 - 1.0 / (float(xp // 1000) + 10.0)
1866
+ tint_g = float(rng.rand() % 10) * 0.01 + 0.8 - 1.0 / (float(xp // 10000) + 10.0)
1867
+ tint_b = float(xp - 50_000) * 6e-06 + float(rng.rand() % 10) * 0.01 + 0.7
1868
+ else:
1869
+ tint_r = 1.0 - 1.0 / (float(xp // 1000) + 10.0)
1870
+ tint_g = float(rng.rand() % 10) * 0.01 + 0.9 - 1.0 / (float(xp // 10000) + 10.0)
1871
+ tint_b = float(rng.rand() % 10) * 0.01 + 1.0 - float(xp - 100_000) * 3e-06
1872
+ if tint_b < 0.5:
1873
+ tint_b = 0.5
1874
+
1875
+ c.tint = (tint_r, tint_g, tint_b, tint_a)
1876
+
1877
+ # contact_damage = size * 0.0952381
1878
+ c.contact_damage = float(c.size or 0.0) * (2.0 / 21.0)
1879
+
1880
+ # reward_value is always 0.0 at this point in the original.
1881
+ c.reward_value = float(c.health or 0.0) * 0.4 + float(c.contact_damage or 0.0) * 0.8 + move_speed * 5.0 + float(rng.rand() % 10 + 10)
1882
+
1883
+ # Rare stat overrides (color-coded variants).
1884
+ r = rng.rand()
1885
+ if r % 180 < 2:
1886
+ apply_tint(c, (0.9, 0.4, 0.4, 1.0))
1887
+ c.health = 65.0
1888
+ c.reward_value = 320.0
1889
+ else:
1890
+ r = rng.rand()
1891
+ if r % 240 < 2:
1892
+ apply_tint(c, (0.4, 0.9, 0.4, 1.0))
1893
+ c.health = 85.0
1894
+ c.reward_value = 420.0
1895
+ else:
1896
+ r = rng.rand()
1897
+ if r % 360 < 2:
1898
+ apply_tint(c, (0.4, 0.4, 0.9, 1.0))
1899
+ c.health = 125.0
1900
+ c.reward_value = 520.0
1901
+
1902
+ # Rare health/size boosts (do not recompute contact_damage).
1903
+ r = rng.rand()
1904
+ if r % 1320 < 4:
1905
+ apply_tint(c, (0.84, 0.24, 0.89, 1.0))
1906
+ c.size = 80.0
1907
+ c.reward_value = 600.0
1908
+ c.health = float(c.health or 0.0) + 230.0
1909
+ else:
1910
+ r = rng.rand()
1911
+ if r % 1620 < 4:
1912
+ apply_tint(c, (0.94, 0.84, 0.29, 1.0))
1913
+ c.size = 85.0
1914
+ c.reward_value = 900.0
1915
+ c.health = float(c.health or 0.0) + 2230.0
1916
+
1917
+ if c.health is not None:
1918
+ c.max_health = c.health
1919
+ if c.reward_value is not None:
1920
+ c.reward_value *= 0.8
1921
+
1922
+ if c.tint is not None:
1923
+ tint_r, tint_g, tint_b, tint_a = c.tint
1924
+ c.tint = (
1925
+ clamp01(tint_r) if tint_r is not None else None,
1926
+ clamp01(tint_g) if tint_g is not None else None,
1927
+ clamp01(tint_b) if tint_b is not None else None,
1928
+ clamp01(tint_a) if tint_a is not None else None,
1929
+ )
1930
+
1931
+ return c
1932
+
1933
+
1934
+ def rand_survival_spawn_pos(rng: Crand, *, terrain_width: int, terrain_height: int) -> tuple[float, float]:
1935
+ match rng.rand() & 3:
1936
+ case 0:
1937
+ return float(rng.rand() % terrain_width), -40.0
1938
+ case 1:
1939
+ return float(rng.rand() % terrain_width), float(terrain_height) + 40.0
1940
+ case 2:
1941
+ return -40.0, float(rng.rand() % terrain_height)
1942
+ case _:
1943
+ return float(terrain_width) + 40.0, float(rng.rand() % terrain_height)
1944
+
1945
+
1946
+ def tick_survival_wave_spawns(
1947
+ spawn_cooldown: float,
1948
+ frame_dt_ms: float,
1949
+ rng: Crand,
1950
+ *,
1951
+ player_count: int,
1952
+ survival_elapsed_ms: float,
1953
+ player_experience: int,
1954
+ terrain_width: int,
1955
+ terrain_height: int,
1956
+ ) -> tuple[float, tuple[CreatureInit, ...]]:
1957
+ """Advance survival enemy wave spawning, returning updated cooldown + spawned creatures.
1958
+
1959
+ Modeled after `survival_update` (crimsonland.exe 0x00407cd0) wave spawns:
1960
+ spawn_cooldown -= player_count * frame_dt_ms
1961
+ if spawn_cooldown <= -1:
1962
+ interval_ms = 500 - int(survival_elapsed_ms) / 1800
1963
+ if interval_ms < 0:
1964
+ extra = (1 - interval_ms) >> 1
1965
+ interval_ms += extra * 2
1966
+ spawn `extra` creatures at random edges
1967
+ interval_ms = max(1, interval_ms)
1968
+ spawn_cooldown += interval_ms
1969
+ spawn 1 creature at a random edge
1970
+ """
1971
+ spawn_cooldown -= float(player_count) * frame_dt_ms
1972
+ if spawn_cooldown > -1.0:
1973
+ return spawn_cooldown, ()
1974
+
1975
+ interval_ms = 500 - int(survival_elapsed_ms) // 1800
1976
+
1977
+ spawns: list[CreatureInit] = []
1978
+ if interval_ms < 0:
1979
+ extra = (1 - interval_ms) >> 1
1980
+ interval_ms += int(extra) * 2
1981
+ for _ in range(int(extra)):
1982
+ pos = rand_survival_spawn_pos(rng, terrain_width=terrain_width, terrain_height=terrain_height)
1983
+ spawns.append(build_survival_spawn_creature(pos, rng, player_experience=player_experience))
1984
+
1985
+ if interval_ms < 1:
1986
+ interval_ms = 1
1987
+ spawn_cooldown += float(interval_ms)
1988
+
1989
+ pos = rand_survival_spawn_pos(rng, terrain_width=terrain_width, terrain_height=terrain_height)
1990
+ spawns.append(build_survival_spawn_creature(pos, rng, player_experience=player_experience))
1991
+
1992
+ return spawn_cooldown, tuple(spawns)
1993
+
1994
+
1995
+ @dataclass(frozen=True, slots=True)
1996
+ class SpawnTemplateCall:
1997
+ template_id: int
1998
+ pos: tuple[float, float]
1999
+ heading: float
2000
+
2001
+
2002
+ def advance_survival_spawn_stage(stage: int, *, player_level: int) -> tuple[int, tuple[SpawnTemplateCall, ...]]:
2003
+ """Return scripted survival spawns for the current stage (aka `survival_update` milestones).
2004
+
2005
+ Modeled after `survival_update` (crimsonland.exe 0x00407cd0) stage 0..10 gate checks.
2006
+ """
2007
+ stage = int(stage)
2008
+ level = int(player_level)
2009
+
2010
+ spawns: list[SpawnTemplateCall] = []
2011
+ heading = float(math.pi)
2012
+
2013
+ while True:
2014
+ if stage == 0:
2015
+ if level < 5:
2016
+ break
2017
+ stage = 1
2018
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.FORMATION_RING_ALIEN_8_12, pos=(-164.0, 512.0), heading=heading))
2019
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.FORMATION_RING_ALIEN_8_12, pos=(1188.0, 512.0), heading=heading))
2020
+ continue
2021
+
2022
+ if stage == 1:
2023
+ if level < 9:
2024
+ break
2025
+ stage = 2
2026
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_RED_BOSS_2C, pos=(1088.0, 512.0), heading=heading))
2027
+ continue
2028
+
2029
+ if stage == 2:
2030
+ if level < 11:
2031
+ break
2032
+ stage = 3
2033
+ step = 128.0 / 3.0
2034
+ for i in range(12):
2035
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP2_RANDOM_35, pos=(1088.0, float(i) * step + 256.0), heading=heading))
2036
+ continue
2037
+
2038
+ if stage == 3:
2039
+ if level < 13:
2040
+ break
2041
+ stage = 4
2042
+ for i in range(4):
2043
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_RED_FAST_2B, pos=(1088.0, float(i) * 64.0 + 384.0), heading=heading))
2044
+ continue
2045
+
2046
+ if stage == 4:
2047
+ if level < 15:
2048
+ break
2049
+ stage = 5
2050
+ for i in range(4):
2051
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_AI7_TIMER_38, pos=(1088.0, float(i) * 64.0 + 384.0), heading=heading))
2052
+ for i in range(4):
2053
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_AI7_TIMER_38, pos=(-64.0, float(i) * 64.0 + 384.0), heading=heading))
2054
+ continue
2055
+
2056
+ if stage == 5:
2057
+ if level < 17:
2058
+ break
2059
+ stage = 6
2060
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_SHOCK_BOSS_3A, pos=(1088.0, 512.0), heading=heading))
2061
+ continue
2062
+
2063
+ if stage == 6:
2064
+ if level < 19:
2065
+ break
2066
+ stage = 7
2067
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP2_SPLITTER_01, pos=(640.0, 512.0), heading=heading))
2068
+ continue
2069
+
2070
+ if stage == 7:
2071
+ if level < 21:
2072
+ break
2073
+ stage = 8
2074
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP2_SPLITTER_01, pos=(384.0, 256.0), heading=heading))
2075
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP2_SPLITTER_01, pos=(640.0, 768.0), heading=heading))
2076
+ continue
2077
+
2078
+ if stage == 8:
2079
+ if level < 26:
2080
+ break
2081
+ stage = 9
2082
+ for i in range(4):
2083
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_RANGED_VARIANT_3C, pos=(1088.0, float(i) * 64.0 + 384.0), heading=heading))
2084
+ for i in range(4):
2085
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_RANGED_VARIANT_3C, pos=(-64.0, float(i) * 64.0 + 384.0), heading=heading))
2086
+ continue
2087
+
2088
+ if stage == 9:
2089
+ if level <= 31:
2090
+ break
2091
+ stage = 10
2092
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_SHOCK_BOSS_3A, pos=(1088.0, 512.0), heading=heading))
2093
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_SHOCK_BOSS_3A, pos=(-64.0, 512.0), heading=heading))
2094
+ for i in range(4):
2095
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_RANGED_VARIANT_3C, pos=(float(i) * 64.0 + 384.0, -64.0), heading=heading))
2096
+ for i in range(4):
2097
+ spawns.append(
2098
+ SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_RANGED_VARIANT_3C, pos=(float(i) * 64.0 + 384.0, 1088.0), heading=heading)
2099
+ )
2100
+ continue
2101
+
2102
+ break
2103
+
2104
+ return stage, tuple(spawns)
2105
+
2106
+
2107
+ def build_rush_mode_spawn_creature(
2108
+ pos: tuple[float, float],
2109
+ tint_rgba: TintRGBA,
2110
+ rng: Crand,
2111
+ *,
2112
+ type_id: int,
2113
+ survival_elapsed_ms: int,
2114
+ ) -> CreatureInit:
2115
+ """Pure model of `creature_spawn` (0x00428240) as used by `rush_mode_update` (0x004072b0)."""
2116
+ pos_x, pos_y = pos
2117
+ elapsed_ms = int(survival_elapsed_ms)
2118
+
2119
+ c = alloc_creature(-1, pos_x, pos_y, rng)
2120
+ c.type_id = CreatureTypeId(type_id)
2121
+ c.ai_mode = 0
2122
+
2123
+ c.health = float(elapsed_ms) * 1e-4 + 10.0
2124
+ c.heading = float(rng.rand() % 314) * 0.01
2125
+ c.move_speed = float(elapsed_ms) * 1e-5 + 2.5
2126
+ c.reward_value = float(rng.rand() % 30 + 140)
2127
+
2128
+ c.tint = tint_rgba
2129
+ c.contact_damage = 4.0
2130
+
2131
+ if c.health is not None:
2132
+ c.max_health = c.health
2133
+ c.size = float(elapsed_ms) * 1e-5 + 47.0
2134
+
2135
+ return c
2136
+
2137
+
2138
+ def tick_rush_mode_spawns(
2139
+ spawn_cooldown: float,
2140
+ frame_dt_ms: float,
2141
+ rng: Crand,
2142
+ *,
2143
+ player_count: int,
2144
+ survival_elapsed_ms: int,
2145
+ terrain_width: float,
2146
+ terrain_height: float,
2147
+ ) -> tuple[float, tuple[CreatureInit, ...]]:
2148
+ """Advance rush-mode edge wave spawning (pure model of `rush_mode_update` / 0x004072b0)."""
2149
+ spawn_cooldown -= float(player_count) * frame_dt_ms
2150
+
2151
+ spawns: list[CreatureInit] = []
2152
+ while spawn_cooldown < 0.0:
2153
+ spawn_cooldown += 250.0
2154
+
2155
+ t = float(int(float(survival_elapsed_ms) + 1.0))
2156
+ tint_r = clamp01(t * (1.0 / 120000.0) + 0.3)
2157
+ tint_g = clamp01(t * 10000.0 + 0.3)
2158
+ tint_b = clamp01(math.sin(t * 1e-4) + 0.3)
2159
+ tint_a = 1.0
2160
+ tint = (tint_r, tint_g, tint_b, tint_a)
2161
+
2162
+ elapsed_ms = int(survival_elapsed_ms)
2163
+ theta = float(elapsed_ms) * 0.001
2164
+ spawn_right = (terrain_width + 64.0, terrain_height * 0.5 + math.cos(theta) * 256.0)
2165
+ spawn_left = (-64.0, terrain_height * 0.5 + math.sin(theta) * 256.0)
2166
+
2167
+ c = build_rush_mode_spawn_creature(spawn_right, tint, rng, type_id=2, survival_elapsed_ms=elapsed_ms)
2168
+ c.ai_mode = 8
2169
+ spawns.append(c)
2170
+
2171
+ c = build_rush_mode_spawn_creature(spawn_left, tint, rng, type_id=3, survival_elapsed_ms=elapsed_ms)
2172
+ c.ai_mode = 8
2173
+ c.flags |= CreatureFlags.AI7_LINK_TIMER
2174
+ if c.move_speed is not None:
2175
+ c.move_speed *= 1.4
2176
+ spawns.append(c)
2177
+
2178
+ return spawn_cooldown, tuple(spawns)
2179
+
2180
+
2181
+ def build_tutorial_stage3_fire_spawns() -> tuple[SpawnTemplateCall, ...]:
2182
+ """Spawn pack triggered by the stage-3 fire-key transition in `tutorial_timeline_update` (0x00408990)."""
2183
+ heading = float(math.pi)
2184
+ return (
2185
+ SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_GREEN_24, pos=(-164.0, 412.0), heading=heading),
2186
+ SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_PALE_GREEN_26, pos=(-184.0, 512.0), heading=heading),
2187
+ SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_GREEN_24, pos=(-154.0, 612.0), heading=heading),
2188
+ )
2189
+
2190
+
2191
+ def build_tutorial_stage4_clear_spawns() -> tuple[SpawnTemplateCall, ...]:
2192
+ """Spawn pack triggered by the stage-4 "all clear" transition in `tutorial_timeline_update` (0x00408990)."""
2193
+ heading = float(math.pi)
2194
+ return (
2195
+ SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_GREEN_24, pos=(1188.0, 412.0), heading=heading),
2196
+ SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_PALE_GREEN_26, pos=(1208.0, 512.0), heading=heading),
2197
+ SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_GREEN_24, pos=(1178.0, 612.0), heading=heading),
2198
+ )
2199
+
2200
+
2201
+ def build_tutorial_stage5_repeat_spawns(repeat_spawn_count: int) -> tuple[SpawnTemplateCall, ...]:
2202
+ """Spawn packs triggered by the stage-5 repeat loop in `tutorial_timeline_update` (0x00408990).
2203
+
2204
+ `repeat_spawn_count` is the incremented counter value (1..7). When it reaches 8, the tutorial
2205
+ transitions instead of spawning more creatures.
2206
+
2207
+ Note: the original also stores the returned creature pointer from template `0x27` in
2208
+ `tutorial_hint_bonus_ptr` and rewrites its packed bonus args (`link_index` low/high 16-bit fields)
2209
+ depending on `repeat_spawn_count`. This helper only reproduces the `creature_spawn_template` calls.
2210
+ """
2211
+ n = int(repeat_spawn_count)
2212
+ if n < 1 or 8 <= n:
2213
+ return ()
2214
+
2215
+ heading = float(math.pi)
2216
+ spawns: list[SpawnTemplateCall] = []
2217
+
2218
+ if (n & 1) == 0:
2219
+ # Even: right-side spawn pack (with an off-screen bottom-right spawn).
2220
+ if n < 6:
2221
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_WEAPON_BONUS_27, pos=(1056.0, 1056.0), heading=heading))
2222
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_GREEN_24, pos=(1188.0, 1136.0), heading=heading))
2223
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_PALE_GREEN_26, pos=(1208.0, 512.0), heading=heading))
2224
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_GREEN_24, pos=(1178.0, 612.0), heading=heading))
2225
+ if n == 4:
2226
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.SPIDER_SP1_CONST_BLUE_40, pos=(512.0, 1056.0), heading=heading))
2227
+ return tuple(spawns)
2228
+
2229
+ # Odd: left-side spawn pack.
2230
+ if n < 6:
2231
+ spawns.append(SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_WEAPON_BONUS_27, pos=(-32.0, 1056.0), heading=heading))
2232
+ spawns.extend(build_tutorial_stage3_fire_spawns())
2233
+ return tuple(spawns)
2234
+
2235
+
2236
+ def build_tutorial_stage6_perks_done_spawns() -> tuple[SpawnTemplateCall, ...]:
2237
+ """Spawn pack triggered by the stage-6 "no perks pending" transition in `tutorial_timeline_update` (0x00408990)."""
2238
+ heading = float(math.pi)
2239
+ return (
2240
+ *build_tutorial_stage3_fire_spawns(),
2241
+ SpawnTemplateCall(template_id=SpawnId.ALIEN_CONST_PURPLE_28, pos=(-32.0, -32.0), heading=heading),
2242
+ *build_tutorial_stage4_clear_spawns(),
2243
+ )
2244
+
2245
+
2246
+ def apply_tail(
2247
+ template_id: int,
2248
+ plan_creatures: list[CreatureInit],
2249
+ plan_spawn_slots: list[SpawnSlotInit],
2250
+ plan_effects: list[BurstEffect],
2251
+ primary_idx: int,
2252
+ final_heading: float,
2253
+ env: SpawnEnv,
2254
+ ) -> None:
2255
+ c = plan_creatures[primary_idx]
2256
+
2257
+ # Demo-burst effect (skipped when demo_mode_active != 0).
2258
+ if (
2259
+ not env.demo_mode_active
2260
+ and 0.0 < c.pos_x < env.terrain_width
2261
+ and 0.0 < c.pos_y < env.terrain_height
2262
+ ):
2263
+ plan_effects.append(BurstEffect(x=c.pos_x, y=c.pos_y, count=8))
2264
+
2265
+ if c.health is not None:
2266
+ c.max_health = c.health
2267
+
2268
+ # Spider_sp1 "AI7 timer" auto-enable (applies to the *return* creature).
2269
+ if (
2270
+ c.type_id == CreatureTypeId.SPIDER_SP1
2271
+ and not (c.flags & (CreatureFlags.RANGED_ATTACK_SHOCK | CreatureFlags.AI7_LINK_TIMER))
2272
+ ):
2273
+ c.flags |= CreatureFlags.AI7_LINK_TIMER
2274
+ c.ai_link_parent = None
2275
+ c.spawn_slot = None
2276
+ c.ai_timer = 0
2277
+ if c.move_speed is not None:
2278
+ c.move_speed *= 1.2
2279
+
2280
+ # Hardcore tweak for template 0x38 only.
2281
+ if template_id == SpawnId.SPIDER_SP1_AI7_TIMER_38 and env.hardcore and c.move_speed is not None:
2282
+ c.move_speed *= 0.7
2283
+
2284
+ c.heading = final_heading
2285
+
2286
+ # Difficulty modifiers.
2287
+ has_spawn_slot = c.spawn_slot is not None and 0 <= c.spawn_slot < len(plan_spawn_slots)
2288
+
2289
+ if not env.hardcore:
2290
+ # This is written as a short-circuit expression in the original:
2291
+ # for flag 0x4 creatures, always bump their spawn-slot interval by +0.2 in non-hardcore.
2292
+ if (c.flags & CreatureFlags.HAS_SPAWN_SLOT) and has_spawn_slot:
2293
+ plan_spawn_slots[c.spawn_slot].interval += 0.2
2294
+
2295
+ if env.difficulty_level > 0:
2296
+ d = env.difficulty_level
2297
+ if c.reward_value is not None and c.move_speed is not None and c.contact_damage is not None and c.health is not None:
2298
+ if d == 1:
2299
+ c.reward_value *= 0.9
2300
+ c.move_speed *= 0.95
2301
+ c.contact_damage *= 0.95
2302
+ c.health *= 0.95
2303
+ elif d == 2:
2304
+ c.reward_value *= 0.85
2305
+ c.move_speed *= 0.9
2306
+ c.contact_damage *= 0.9
2307
+ c.health *= 0.9
2308
+ elif d == 3:
2309
+ c.reward_value *= 0.85
2310
+ c.move_speed *= 0.8
2311
+ c.contact_damage *= 0.8
2312
+ c.health *= 0.8
2313
+ elif d == 4:
2314
+ c.reward_value *= 0.8
2315
+ c.move_speed *= 0.7
2316
+ c.contact_damage *= 0.7
2317
+ c.health *= 0.7
2318
+ else:
2319
+ c.reward_value *= 0.8
2320
+ c.move_speed *= 0.6
2321
+ c.contact_damage *= 0.5
2322
+ c.health *= 0.5
2323
+
2324
+ if has_spawn_slot and (c.flags & CreatureFlags.HAS_SPAWN_SLOT):
2325
+ plan_spawn_slots[c.spawn_slot].interval += min(3.0, float(d) * 0.35)
2326
+ else:
2327
+ # In hardcore: difficulty level is forcibly cleared (global), and creature stats are buffed.
2328
+ if c.move_speed is not None:
2329
+ c.move_speed *= 1.05
2330
+ if c.contact_damage is not None:
2331
+ c.contact_damage *= 1.4
2332
+ if c.health is not None:
2333
+ c.health *= 1.2
2334
+
2335
+ if has_spawn_slot and (c.flags & CreatureFlags.HAS_SPAWN_SLOT):
2336
+ plan_spawn_slots[c.spawn_slot].interval = max(
2337
+ 0.1,
2338
+ plan_spawn_slots[c.spawn_slot].interval - 0.2,
2339
+ )
2340
+
2341
+
2342
+ def apply_unhandled_creature_type_fallback(plan_creatures: list[CreatureInit], primary_idx: int) -> None:
2343
+ # Some template paths jump to the "Unhandled creatureType.\n" debug block in the original,
2344
+ # which forcibly overwrites `type_id` and `health` on the *current* creature pointer.
2345
+ # See artifacts/creature_spawn_template/binja-hlil.txt (label_431099).
2346
+ # Notably: templates 0x11..0x17 and 0x19 (see also artifacts/creature_spawn_template/ghidra.c LAB_00431094).
2347
+ c = plan_creatures[primary_idx]
2348
+ c.type_id = CreatureTypeId.ALIEN
2349
+ c.health = 20.0
2350
+
2351
+
2352
+ def apply_alien_spawner(ctx: PlanBuilder, spec: AlienSpawnerSpec) -> None:
2353
+ c = ctx.base
2354
+ c.type_id = CreatureTypeId.ALIEN
2355
+ c.flags = CreatureFlags.ANIM_PING_PONG
2356
+ c.spawn_slot = ctx.add_slot(
2357
+ owner=0,
2358
+ timer=spec.timer,
2359
+ limit=spec.limit,
2360
+ interval=spec.interval,
2361
+ child=spec.child_template_id,
2362
+ )
2363
+ c.size = spec.size
2364
+ c.health = spec.health
2365
+ c.move_speed = spec.move_speed
2366
+ c.reward_value = spec.reward_value
2367
+ apply_tint(c, spec.tint)
2368
+ c.contact_damage = 0.0
2369
+
2370
+
2371
+ def apply_constant_spawn(ctx: PlanBuilder, spec: ConstantSpawnSpec) -> None:
2372
+ c = ctx.base
2373
+ apply_constant_template(c, spec)
2374
+
2375
+
2376
+ def apply_grid_formation(ctx: PlanBuilder, spec: GridFormationSpec) -> None:
2377
+ parent = ctx.base
2378
+ apply_constant_template(parent, spec.parent)
2379
+ if spec.set_parent_max_health and parent.health is not None:
2380
+ parent.max_health = parent.health
2381
+ ctx.primary = ctx.grid_children(
2382
+ x_range=spec.x_range,
2383
+ y_range=spec.y_range,
2384
+ ai_mode=spec.child_ai_mode,
2385
+ child_spec=spec.child_spec,
2386
+ )
2387
+ if spec.apply_fallback:
2388
+ apply_unhandled_creature_type_fallback(ctx.creatures, ctx.primary)
2389
+
2390
+
2391
+ def apply_ring_formation(ctx: PlanBuilder, spec: RingFormationSpec) -> None:
2392
+ parent = ctx.base
2393
+ apply_constant_template(parent, spec.parent)
2394
+ if spec.set_parent_max_health and parent.health is not None:
2395
+ parent.max_health = parent.health
2396
+ ctx.primary = ctx.ring_children(
2397
+ count=spec.count,
2398
+ angle_step=spec.angle_step,
2399
+ radius=spec.radius,
2400
+ ai_mode=spec.child_ai_mode,
2401
+ child_spec=spec.child_spec,
2402
+ set_position=spec.set_position,
2403
+ )
2404
+ if spec.apply_fallback:
2405
+ apply_unhandled_creature_type_fallback(ctx.creatures, ctx.primary)
2406
+
2407
+
2408
+ @register_template(SpawnId.ZOMBIE_BOSS_SPAWNER_00)
2409
+ def template_00_zombie_boss_spawner(ctx: PlanBuilder) -> None:
2410
+ c = ctx.base
2411
+ c.type_id = CreatureTypeId.ZOMBIE
2412
+ c.flags = CreatureFlags.ANIM_PING_PONG | CreatureFlags.ANIM_LONG_STRIP
2413
+ c.spawn_slot = ctx.add_slot(
2414
+ owner=0,
2415
+ timer=1.0,
2416
+ limit=812,
2417
+ interval=0.7,
2418
+ child=SpawnId.ZOMBIE_RANDOM_41,
2419
+ )
2420
+ c.size = 64.0
2421
+ c.health = 8500.0
2422
+ c.move_speed = 1.3
2423
+ c.reward_value = 6600.0
2424
+ apply_tint(c, (0.6, 0.6, 1.0, 0.8))
2425
+ c.contact_damage = 50.0
2426
+
2427
+
2428
+ BASIC_RANDOM_TYPE_IDS: dict[int, CreatureTypeId] = {
2429
+ SpawnId.SPIDER_SP1_RANDOM_03: CreatureTypeId.SPIDER_SP1,
2430
+ SpawnId.SPIDER_SP2_RANDOM_05: CreatureTypeId.SPIDER_SP2,
2431
+ SpawnId.ALIEN_RANDOM_06: CreatureTypeId.ALIEN,
2432
+ }
2433
+
2434
+
2435
+ @register_template(SpawnId.SPIDER_SP1_RANDOM_03, SpawnId.SPIDER_SP2_RANDOM_05, SpawnId.ALIEN_RANDOM_06)
2436
+ def template_03_05_06_basic_random(ctx: PlanBuilder) -> None:
2437
+ c = ctx.base
2438
+ c.type_id = BASIC_RANDOM_TYPE_IDS[ctx.template_id]
2439
+ size = randf(ctx.rng, 15, 1.0, 38.0)
2440
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=20.0)
2441
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2442
+ tint_b = randf(ctx.rng, 25, 0.01, 0.8)
2443
+ apply_tint(c, (0.6, 0.6, clamp01(tint_b), 1.0))
2444
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2445
+
2446
+
2447
+ @register_template(SpawnId.LIZARD_RANDOM_04)
2448
+ def template_04_lizard_random(ctx: PlanBuilder) -> None:
2449
+ c = ctx.base
2450
+ c.type_id = CreatureTypeId.LIZARD
2451
+ size = randf(ctx.rng, 15, 1.0, 38.0)
2452
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=20.0)
2453
+ apply_tint(c, (0.67, 0.67, 1.0, 1.0))
2454
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2455
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2456
+
2457
+
2458
+ @register_template(SpawnId.ALIEN_SPAWNER_RING_24_0E)
2459
+ def template_0e_alien_spawner_ring_24(ctx: PlanBuilder) -> None:
2460
+ parent = ctx.base
2461
+ parent.type_id = CreatureTypeId.ALIEN
2462
+ parent.flags = CreatureFlags.ANIM_PING_PONG
2463
+ parent.spawn_slot = ctx.add_slot(
2464
+ owner=0,
2465
+ timer=1.5,
2466
+ limit=64,
2467
+ interval=1.05,
2468
+ child=SpawnId.AI1_LIZARD_BLUE_TINT_1C,
2469
+ )
2470
+ parent.size = 32.0
2471
+ parent.health = 50.0
2472
+ parent.move_speed = 2.8
2473
+ parent.reward_value = 5000.0
2474
+ apply_tint(parent, (0.9, 0.8, 0.4, 1.0))
2475
+ parent.contact_damage = 0.0
2476
+
2477
+ child_spec = FormationChildSpec(
2478
+ type_id=CreatureTypeId.ALIEN,
2479
+ health=40.0,
2480
+ move_speed=4.0,
2481
+ reward_value=350.0,
2482
+ size=35.0,
2483
+ contact_damage=30.0,
2484
+ tint=(1.0, 0.3, 0.3, 1.0),
2485
+ )
2486
+ ctx.primary = ctx.ring_children(
2487
+ count=24,
2488
+ angle_step=math.pi / 12.0,
2489
+ radius=100.0,
2490
+ ai_mode=3,
2491
+ child_spec=child_spec,
2492
+ heading_override=0.0,
2493
+ )
2494
+
2495
+
2496
+ @register_template(SpawnId.FORMATION_CHAIN_LIZARD_4_11)
2497
+ def template_11_formation_chain_lizard_4(ctx: PlanBuilder) -> None:
2498
+ parent = ctx.base
2499
+ parent.type_id = CreatureTypeId.LIZARD
2500
+ parent.ai_mode = 1
2501
+ apply_tint(parent, (0.99, 0.99, 0.21, 1.0))
2502
+ parent.health = 1500.0
2503
+ parent.max_health = 1500.0
2504
+ parent.move_speed = 2.1
2505
+ parent.reward_value = 1000.0
2506
+ parent.size = 69.0
2507
+ parent.contact_damage = 150.0
2508
+
2509
+ # Spawns a linked chain of 4 children (link points to previous). The original also sets
2510
+ # the base creature's link_index to the last child after the loop.
2511
+ child_spec = FormationChildSpec(
2512
+ type_id=CreatureTypeId.LIZARD,
2513
+ health=60.0,
2514
+ move_speed=2.4,
2515
+ reward_value=60.0,
2516
+ size=50.0,
2517
+ contact_damage=14.0,
2518
+ tint=(0.6, 0.6, 0.31, 1.0),
2519
+ )
2520
+ pos_x = ctx.pos_x
2521
+ pos_y = ctx.pos_y
2522
+
2523
+ def setup_child(child: CreatureInit, idx: int) -> None:
2524
+ child.target_offset_x = -256.0 + float(idx) * 64.0
2525
+ child.target_offset_y = -256.0
2526
+ angle = float(2 + idx * 2) * (math.pi / 8.0)
2527
+ child.pos_x = float(math.cos(angle) * 256.0 + pos_x)
2528
+ child.pos_y = float(math.sin(angle) * 256.0 + pos_y)
2529
+
2530
+ chain_prev = ctx.chain_children(
2531
+ count=4,
2532
+ ai_mode=3,
2533
+ child_spec=child_spec,
2534
+ setup_child=setup_child,
2535
+ )
2536
+
2537
+ parent.ai_link_parent = chain_prev
2538
+ ctx.primary = chain_prev
2539
+ apply_unhandled_creature_type_fallback(ctx.creatures, ctx.primary)
2540
+
2541
+
2542
+ @register_template(SpawnId.FORMATION_CHAIN_ALIEN_10_13)
2543
+ def template_13_formation_chain_alien_10(ctx: PlanBuilder) -> None:
2544
+ parent = ctx.base
2545
+ parent.type_id = CreatureTypeId.ALIEN
2546
+ parent.ai_mode = 6
2547
+ parent.pos_x = ctx.pos_x + 256.0
2548
+ parent.pos_y = ctx.pos_y
2549
+ apply_tint(parent, (0.6, 0.8, 0.91, 1.0))
2550
+ parent.health = 200.0
2551
+ parent.max_health = 200.0
2552
+ parent.move_speed = 2.0
2553
+ parent.reward_value = 600.0
2554
+ parent.size = 40.0
2555
+ parent.contact_damage = 20.0
2556
+
2557
+ child_spec = FormationChildSpec(
2558
+ type_id=CreatureTypeId.ALIEN,
2559
+ health=60.0,
2560
+ move_speed=2.0,
2561
+ reward_value=60.0,
2562
+ size=50.0,
2563
+ contact_damage=4.0,
2564
+ tint=(0.4, 0.7, 0.11, 1.0),
2565
+ orbit_angle=math.pi,
2566
+ orbit_radius=10.0,
2567
+ )
2568
+ pos_x = ctx.pos_x
2569
+ pos_y = ctx.pos_y
2570
+
2571
+ def setup_child(child: CreatureInit, idx: int) -> None:
2572
+ angle_idx = 2 + idx * 2
2573
+ angle = float(angle_idx) * math.radians(20.0)
2574
+ child.pos_x = float(math.cos(angle) * 256.0 + pos_x)
2575
+ child.pos_y = float(math.sin(angle) * 256.0 + pos_y)
2576
+
2577
+ chain_prev = ctx.chain_children(
2578
+ count=10,
2579
+ ai_mode=6,
2580
+ child_spec=child_spec,
2581
+ setup_child=setup_child,
2582
+ )
2583
+
2584
+ parent.ai_link_parent = chain_prev
2585
+ ctx.primary = chain_prev
2586
+ apply_unhandled_creature_type_fallback(ctx.creatures, ctx.primary)
2587
+
2588
+
2589
+ AI1_BLUE_TINT_TEMPLATES: dict[int, tuple[CreatureTypeId, float]] = {
2590
+ SpawnId.AI1_ALIEN_BLUE_TINT_1A: (CreatureTypeId.ALIEN, 50.0),
2591
+ SpawnId.AI1_SPIDER_SP1_BLUE_TINT_1B: (CreatureTypeId.SPIDER_SP1, 40.0),
2592
+ SpawnId.AI1_LIZARD_BLUE_TINT_1C: (CreatureTypeId.LIZARD, 50.0),
2593
+ }
2594
+
2595
+
2596
+ @register_template(SpawnId.AI1_ALIEN_BLUE_TINT_1A, SpawnId.AI1_SPIDER_SP1_BLUE_TINT_1B, SpawnId.AI1_LIZARD_BLUE_TINT_1C)
2597
+ def template_1a_1b_1c_ai1_blue_tint(ctx: PlanBuilder) -> None:
2598
+ c = ctx.base
2599
+ c.ai_mode = 1
2600
+ c.size = 50.0
2601
+ c.move_speed = 2.4
2602
+ c.reward_value = 125.0
2603
+
2604
+ c.type_id, c.health = AI1_BLUE_TINT_TEMPLATES[ctx.template_id]
2605
+
2606
+ tint = float(ctx.rng.rand() % 40) * 0.01 + 0.5
2607
+ apply_tint(c, (tint, tint, 1.0, 1.0))
2608
+ c.contact_damage = 5.0
2609
+
2610
+
2611
+ @register_template(SpawnId.ALIEN_RANDOM_1D)
2612
+ def template_1d_alien_random(ctx: PlanBuilder) -> None:
2613
+ c = ctx.base
2614
+ c.type_id = CreatureTypeId.ALIEN
2615
+ size = randf(ctx.rng, 20, 1.0, 35.0)
2616
+ apply_size_health(c, size, health_scale=8.0 / 7.0, health_add=10.0)
2617
+ apply_random_move_speed(c, ctx.rng, 15, 0.1, 1.1)
2618
+ c.reward_value = randf(ctx.rng, 100, 1.0, 50.0)
2619
+ apply_tint(
2620
+ c,
2621
+ (
2622
+ randf(ctx.rng, 50, 0.001, 0.6),
2623
+ randf(ctx.rng, 50, 0.01, 0.5),
2624
+ randf(ctx.rng, 50, 0.001, 0.6),
2625
+ 1.0,
2626
+ ),
2627
+ )
2628
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2629
+
2630
+
2631
+ @register_template(SpawnId.ALIEN_RANDOM_1E)
2632
+ def template_1e_alien_random(ctx: PlanBuilder) -> None:
2633
+ c = ctx.base
2634
+ c.type_id = CreatureTypeId.ALIEN
2635
+ size = randf(ctx.rng, 30, 1.0, 35.0)
2636
+ apply_size_health(c, size, health_scale=16.0 / 7.0, health_add=10.0)
2637
+ apply_random_move_speed(c, ctx.rng, 17, 0.1, 1.5)
2638
+ c.reward_value = randf(ctx.rng, 200, 1.0, 50.0)
2639
+ apply_tint(
2640
+ c,
2641
+ (
2642
+ randf(ctx.rng, 50, 0.001, 0.6),
2643
+ randf(ctx.rng, 50, 0.001, 0.6),
2644
+ randf(ctx.rng, 50, 0.01, 0.5),
2645
+ 1.0,
2646
+ ),
2647
+ )
2648
+ c.contact_damage = randf(ctx.rng, 30, 1.0, 4.0)
2649
+
2650
+
2651
+ @register_template(SpawnId.ALIEN_RANDOM_1F)
2652
+ def template_1f_alien_random(ctx: PlanBuilder) -> None:
2653
+ c = ctx.base
2654
+ c.type_id = CreatureTypeId.ALIEN
2655
+ size = randf(ctx.rng, 30, 1.0, 45.0)
2656
+ apply_size_health(c, size, health_scale=26.0 / 7.0, health_add=30.0)
2657
+ apply_random_move_speed(c, ctx.rng, 21, 0.1, 1.6)
2658
+ c.reward_value = randf(ctx.rng, 200, 1.0, 80.0)
2659
+ apply_tint(
2660
+ c,
2661
+ (
2662
+ randf(ctx.rng, 50, 0.01, 0.5),
2663
+ randf(ctx.rng, 50, 0.001, 0.6),
2664
+ randf(ctx.rng, 50, 0.001, 0.6),
2665
+ 1.0,
2666
+ ),
2667
+ )
2668
+ c.contact_damage = randf(ctx.rng, 35, 1.0, 8.0)
2669
+
2670
+
2671
+ @register_template(SpawnId.ALIEN_RANDOM_GREEN_20)
2672
+ def template_20_alien_random_green(ctx: PlanBuilder) -> None:
2673
+ c = ctx.base
2674
+ c.type_id = CreatureTypeId.ALIEN
2675
+ size = randf(ctx.rng, 30, 1.0, 40.0)
2676
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=20.0)
2677
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2678
+ tint_g = randf(ctx.rng, 40, 0.01, 0.6)
2679
+ apply_tint(c, (0.3, tint_g, 0.3, 1.0))
2680
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2681
+
2682
+
2683
+ @register_template(SpawnId.LIZARD_RANDOM_2E)
2684
+ def template_2e_lizard_random(ctx: PlanBuilder) -> None:
2685
+ c = ctx.base
2686
+ c.type_id = CreatureTypeId.LIZARD
2687
+ size = randf(ctx.rng, 30, 1.0, 40.0)
2688
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=20.0)
2689
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2690
+ apply_tint(
2691
+ c,
2692
+ (
2693
+ randf(ctx.rng, 40, 0.01, 0.6),
2694
+ randf(ctx.rng, 40, 0.01, 0.6),
2695
+ randf(ctx.rng, 40, 0.01, 0.6),
2696
+ 1.0,
2697
+ ),
2698
+ )
2699
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2700
+
2701
+
2702
+ @register_template(SpawnId.LIZARD_RANDOM_31)
2703
+ def template_31_lizard_random(ctx: PlanBuilder) -> None:
2704
+ c = ctx.base
2705
+ c.type_id = CreatureTypeId.LIZARD
2706
+ size = randf(ctx.rng, 30, 1.0, 40.0)
2707
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=10.0)
2708
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2709
+ tint = randf(ctx.rng, 30, 0.01, 0.6)
2710
+ apply_tint(c, (tint, tint, 0.38, 1.0))
2711
+ c.contact_damage = size * 0.14 + 4.0
2712
+
2713
+
2714
+ @register_template(SpawnId.SPIDER_SP1_RANDOM_32)
2715
+ def template_32_spider_sp1_random(ctx: PlanBuilder) -> None:
2716
+ c = ctx.base
2717
+ c.type_id = CreatureTypeId.SPIDER_SP1
2718
+ size = randf(ctx.rng, 25, 1.0, 40.0)
2719
+ apply_size_health_reward(c, size, health_scale=1.0, health_add=10.0)
2720
+ apply_random_move_speed(c, ctx.rng, 17, 0.1, 1.1)
2721
+ tint = randf(ctx.rng, 40, 0.01, 0.6)
2722
+ apply_tint(c, (tint, tint, tint, 1.0))
2723
+ c.contact_damage = size * 0.14 + 4.0
2724
+
2725
+
2726
+ @register_template(SpawnId.SPIDER_SP1_RANDOM_RED_33)
2727
+ def template_33_spider_sp1_random_red(ctx: PlanBuilder) -> None:
2728
+ c = ctx.base
2729
+ c.type_id = CreatureTypeId.SPIDER_SP1
2730
+ size = randf(ctx.rng, 15, 1.0, 45.0)
2731
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=20.0)
2732
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2733
+ apply_tint(c, (randf(ctx.rng, 40, 0.01, 0.6), 0.5, 0.5, 1.0))
2734
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2735
+
2736
+
2737
+ @register_template(SpawnId.SPIDER_SP1_RANDOM_GREEN_34)
2738
+ def template_34_spider_sp1_random_green(ctx: PlanBuilder) -> None:
2739
+ c = ctx.base
2740
+ c.type_id = CreatureTypeId.SPIDER_SP1
2741
+ size = randf(ctx.rng, 20, 1.0, 40.0)
2742
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=20.0)
2743
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2744
+ apply_tint(c, (0.5, randf(ctx.rng, 40, 0.01, 0.6), 0.5, 1.0))
2745
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2746
+
2747
+
2748
+ @register_template(SpawnId.SPIDER_SP2_RANDOM_35)
2749
+ def template_35_spider_sp2_random(ctx: PlanBuilder) -> None:
2750
+ c = ctx.base
2751
+ c.type_id = CreatureTypeId.SPIDER_SP2
2752
+ size = randf(ctx.rng, 10, 1.0, 30.0)
2753
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=20.0)
2754
+ apply_random_move_speed(c, ctx.rng, 18, 0.1, 1.1)
2755
+ apply_tint(c, (0.8, randf(ctx.rng, 20, 0.01, 0.8), 0.8, 1.0))
2756
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2757
+
2758
+
2759
+ @register_template(SpawnId.ALIEN_AI7_ORBITER_36)
2760
+ def template_36_alien_ai7_orbiter(ctx: PlanBuilder) -> None:
2761
+ c = ctx.base
2762
+ c.type_id = CreatureTypeId.ALIEN
2763
+ c.size = 50.0
2764
+ c.ai_mode = 7
2765
+ c.orbit_radius = 1.5
2766
+ c.health = 10.0
2767
+ c.move_speed = 1.8
2768
+ c.reward_value = 150.0
2769
+ tint_g = float(ctx.rng.rand() % 5) * 0.01 + 0.65
2770
+ apply_tint(c, (0.65, tint_g, 0.95, 1.0))
2771
+ c.contact_damage = 40.0
2772
+
2773
+
2774
+ @register_template(SpawnId.SPIDER_SP2_RANGED_VARIANT_37)
2775
+ def template_37_spider_sp2_ranged_variant(ctx: PlanBuilder) -> None:
2776
+ c = ctx.base
2777
+ c.type_id = CreatureTypeId.SPIDER_SP2
2778
+ c.flags = CreatureFlags.RANGED_ATTACK_VARIANT
2779
+ c.health = 50.0
2780
+ c.move_speed = 3.2
2781
+ c.reward_value = 433.0
2782
+ apply_tint(c, (1.0, 0.75, 0.1, 1.0))
2783
+ c.size = float((ctx.rng.rand() & 3) + 41)
2784
+ c.contact_damage = 10.0
2785
+
2786
+
2787
+ @register_template(SpawnId.SPIDER_SP1_AI7_TIMER_38)
2788
+ def template_38_spider_sp1_ai7_timer(ctx: PlanBuilder) -> None:
2789
+ c = ctx.base
2790
+ c.type_id = CreatureTypeId.SPIDER_SP1
2791
+ c.flags = CreatureFlags.AI7_LINK_TIMER
2792
+ c.ai_timer = 0
2793
+ c.health = 50.0
2794
+ c.move_speed = 4.8
2795
+ c.reward_value = 433.0
2796
+ apply_tint(c, (1.0, 0.75, 0.1, 1.0))
2797
+ c.size = float((ctx.rng.rand() & 3) + 41)
2798
+ c.contact_damage = 10.0
2799
+
2800
+
2801
+ @register_template(SpawnId.SPIDER_SP1_AI7_TIMER_WEAK_39)
2802
+ def template_39_spider_sp1_ai7_timer_weak(ctx: PlanBuilder) -> None:
2803
+ c = ctx.base
2804
+ c.type_id = CreatureTypeId.SPIDER_SP1
2805
+ c.flags = CreatureFlags.AI7_LINK_TIMER
2806
+ c.ai_timer = 0
2807
+ c.health = 4.0
2808
+ c.move_speed = 4.8
2809
+ c.reward_value = 50.0
2810
+ apply_tint(c, (0.8, 0.65, 0.1, 1.0))
2811
+ c.size = float(ctx.rng.rand() % 4 + 26)
2812
+ c.contact_damage = 10.0
2813
+
2814
+
2815
+ @register_template(SpawnId.SPIDER_SP1_RANDOM_3D)
2816
+ def template_3d_spider_sp1_random(ctx: PlanBuilder) -> None:
2817
+ c = ctx.base
2818
+ c.type_id = CreatureTypeId.SPIDER_SP1
2819
+ c.health = 70.0
2820
+ c.move_speed = 2.6
2821
+ c.reward_value = 120.0
2822
+ tint = float(ctx.rng.rand() % 20) * 0.01 + 0.8
2823
+ apply_tint(c, (tint, tint, tint, 1.0))
2824
+ size = float(ctx.rng.rand() % 7 + 45)
2825
+ c.size = size
2826
+ c.contact_damage = size * 0.22
2827
+
2828
+
2829
+ @register_template(SpawnId.ZOMBIE_RANDOM_41)
2830
+ def template_41_zombie_random(ctx: PlanBuilder) -> None:
2831
+ c = ctx.base
2832
+ c.type_id = CreatureTypeId.ZOMBIE
2833
+ size = randf(ctx.rng, 30, 1.0, 40.0)
2834
+ apply_size_health_reward(c, size, health_scale=8.0 / 7.0, health_add=10.0)
2835
+ apply_size_move_speed(c, size, 0.0025, 0.9)
2836
+ tint = randf(ctx.rng, 40, 0.01, 0.6)
2837
+ apply_tint(c, (tint, tint, tint, 1.0))
2838
+ c.contact_damage = randf(ctx.rng, 10, 1.0, 4.0)
2839
+
2840
+
2841
+ def build_spawn_plan(
2842
+ template_id: SupportsInt,
2843
+ pos: tuple[float, float],
2844
+ heading: float,
2845
+ rng: Crand,
2846
+ env: SpawnEnv,
2847
+ ) -> SpawnPlan:
2848
+ """Pure plan builder modeled after `creature_spawn_template` (0x00430AF0).
2849
+
2850
+ The plan lists:
2851
+ - every creature allocated and configured directly by the template
2852
+ - any spawn-slot configurations (deferred child spawns)
2853
+ - side-effects like burst FX
2854
+ """
2855
+ template_id = int(template_id)
2856
+ ctx, final_heading = PlanBuilder.start(template_id, pos, heading, rng, env)
2857
+
2858
+ if builder := TEMPLATE_BUILDERS.get(template_id):
2859
+ builder(ctx)
2860
+ elif spec := ALIEN_SPAWNER_TEMPLATES.get(template_id):
2861
+ apply_alien_spawner(ctx, spec)
2862
+ elif spec := GRID_FORMATIONS.get(template_id):
2863
+ apply_grid_formation(ctx, spec)
2864
+ elif spec := RING_FORMATIONS.get(template_id):
2865
+ apply_ring_formation(ctx, spec)
2866
+ elif spec := CONSTANT_SPAWN_TEMPLATES.get(template_id):
2867
+ apply_constant_spawn(ctx, spec)
2868
+ else:
2869
+ raise NotImplementedError(f"spawn plan not implemented for template_id=0x{template_id:x}")
2870
+
2871
+ return ctx.finish(final_heading)