crimsonland 0.1.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +153 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +377 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +663 -0
  34. crimson/gameplay.py +2450 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1039 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1338 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +56 -0
  67. crimson/sim/world_state.py +421 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +414 -0
  86. crimson/views/bonuses.py +201 -0
  87. crimson/views/camera_debug.py +359 -0
  88. crimson/views/camera_shake.py +229 -0
  89. crimson/views/corpse_stamp_debug.py +324 -0
  90. crimson/views/decals_debug.py +739 -0
  91. crimson/views/empty.py +19 -0
  92. crimson/views/fonts.py +114 -0
  93. crimson/views/game_over.py +117 -0
  94. crimson/views/ground.py +259 -0
  95. crimson/views/lighting_debug.py +1166 -0
  96. crimson/views/particles.py +293 -0
  97. crimson/views/perk_menu_debug.py +430 -0
  98. crimson/views/perks.py +398 -0
  99. crimson/views/player.py +433 -0
  100. crimson/views/player_sprite_debug.py +314 -0
  101. crimson/views/projectile_fx.py +608 -0
  102. crimson/views/projectile_render_debug.py +407 -0
  103. crimson/views/projectiles.py +221 -0
  104. crimson/views/quest_title_overlay.py +108 -0
  105. crimson/views/registry.py +34 -0
  106. crimson/views/rush.py +16 -0
  107. crimson/views/small_font_debug.py +204 -0
  108. crimson/views/spawn_plan.py +363 -0
  109. crimson/views/sprites.py +214 -0
  110. crimson/views/survival.py +15 -0
  111. crimson/views/terrain.py +132 -0
  112. crimson/views/ui.py +123 -0
  113. crimson/views/wicons.py +166 -0
  114. crimson/weapon_sfx.py +63 -0
  115. crimson/weapons.py +860 -0
  116. crimsonland-0.1.0.dev1.dist-info/METADATA +9 -0
  117. crimsonland-0.1.0.dev1.dist-info/RECORD +138 -0
  118. crimsonland-0.1.0.dev1.dist-info/WHEEL +4 -0
  119. crimsonland-0.1.0.dev1.dist-info/entry_points.txt +4 -0
  120. grim/__init__.py +20 -0
  121. grim/app.py +92 -0
  122. grim/assets.py +231 -0
  123. grim/audio.py +106 -0
  124. grim/config.py +294 -0
  125. grim/console.py +737 -0
  126. grim/fonts/__init__.py +7 -0
  127. grim/fonts/grim_mono.py +111 -0
  128. grim/fonts/small.py +120 -0
  129. grim/input.py +44 -0
  130. grim/jaz.py +103 -0
  131. grim/math.py +17 -0
  132. grim/music.py +403 -0
  133. grim/paq.py +76 -0
  134. grim/rand.py +37 -0
  135. grim/sfx.py +276 -0
  136. grim/sfx_map.py +103 -0
  137. grim/terrain_render.py +840 -0
  138. grim/view.py +16 -0
crimson/demo.py ADDED
@@ -0,0 +1,1360 @@
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ from pathlib import Path
5
+ import random
6
+ from typing import Protocol
7
+
8
+ import pyray as rl
9
+
10
+ from grim.audio import AudioState, update_audio
11
+ from grim.assets import PaqTextureCache, load_paq_entries
12
+ from grim.config import CrimsonConfig
13
+ from grim.fonts.grim_mono import GrimMonoFont, draw_grim_mono_text, load_grim_mono_font
14
+ from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
15
+
16
+ from grim.rand import Crand
17
+ from .game_world import GameWorld
18
+ from .gameplay import PlayerInput, PlayerState, weapon_assign_player
19
+ from .ui.cursor import draw_menu_cursor
20
+ from .weapons import WEAPON_TABLE, WeaponId, projectile_type_id_from_weapon_id, weapon_entry_for_projectile_type_id
21
+
22
+ WORLD_SIZE = 1024.0
23
+ DEMO_VARIANT_COUNT = 6
24
+
25
+ _DEMO_UPSELL_MESSAGES: tuple[str, ...] = (
26
+ "Want more Levels?",
27
+ "Want more Weapons?",
28
+ "Want more Perks?",
29
+ "Want unlimited Play time?",
30
+ "Want to post your high scores?",
31
+ )
32
+
33
+ DEMO_PURCHASE_URL = "http://buy.crimsonland.com"
34
+ DEMO_PURCHASE_SCREEN_LIMIT_MS = 16_000
35
+ DEMO_PURCHASE_INTERSTITIAL_LIMIT_MS = 10_000
36
+
37
+ _DEMO_PURCHASE_TITLE = "Upgrade to the full version of Crimsonland Today!"
38
+ _DEMO_PURCHASE_FEATURES_TITLE = "Full version features:"
39
+ _DEMO_PURCHASE_FEATURE_LINES: tuple[tuple[str, float], ...] = (
40
+ ("-Unlimited Play Time in three thrilling Game Modes!", 22.0),
41
+ ("-The varied weapon arsenal consisting of over 20 unique", 17.0),
42
+ (" weapons that allow you to deal death with plasma, lead,", 17.0),
43
+ (" fire and electricity!", 22.0),
44
+ ("-Over 40 game altering Perks!", 22.0),
45
+ ("-40 insane Levels that give you", 18.0),
46
+ (" hours of intense and fun gameplay!", 22.0),
47
+ ("-The ability to post your high scores online!", 44.0),
48
+ )
49
+ _DEMO_PURCHASE_FOOTER = "Purchasing the game is very easy and secure."
50
+
51
+
52
+ class DemoState(Protocol):
53
+ assets_dir: Path
54
+ rng: random.Random
55
+ config: CrimsonConfig
56
+ texture_cache: PaqTextureCache | None
57
+ audio: AudioState | None
58
+
59
+
60
+ def _weapon_name(weapon_id: int) -> str:
61
+ for weapon in WEAPON_TABLE:
62
+ if weapon.weapon_id == weapon_id:
63
+ return weapon.name or f"weapon_{weapon_id}"
64
+ return f"weapon_{weapon_id}"
65
+
66
+
67
+ def _clamp(value: float, lo: float, hi: float) -> float:
68
+ if value < lo:
69
+ return lo
70
+ if value > hi:
71
+ return hi
72
+ return value
73
+
74
+
75
+ def _distance_sq(x0: float, y0: float, x1: float, y1: float) -> float:
76
+ dx = x1 - x0
77
+ dy = y1 - y0
78
+ return dx * dx + dy * dy
79
+
80
+
81
+ def _normalize(dx: float, dy: float) -> tuple[float, float, float]:
82
+ d = math.hypot(dx, dy)
83
+ if d <= 1e-6:
84
+ return 0.0, 0.0, 0.0
85
+ inv = 1.0 / d
86
+ return dx * inv, dy * inv, d
87
+
88
+
89
+ class DemoView:
90
+ """Attract-mode demo scaffold.
91
+
92
+ Modeled after the classic demo helpers in crimsonland.exe:
93
+ - demo_setup_variant_0 @ 0x00402ED0
94
+ - demo_setup_variant_1 @ 0x004030F0
95
+ - demo_setup_variant_2 @ 0x00402FE0
96
+ - demo_setup_variant_3 @ 0x00403250
97
+ - demo_mode_start @ 0x00403390
98
+ """
99
+
100
+ def __init__(self, state: DemoState) -> None:
101
+ self._state = state
102
+ self._world = GameWorld(
103
+ assets_dir=state.assets_dir,
104
+ world_size=WORLD_SIZE,
105
+ demo_mode_active=True,
106
+ hardcore=bool(int(state.config.data.get("hardcore_flag", 0) or 0)),
107
+ difficulty_level=0,
108
+ texture_cache=state.texture_cache,
109
+ config=state.config,
110
+ audio=state.audio,
111
+ audio_rng=state.rng,
112
+ )
113
+ self._crand = Crand(0)
114
+ self._demo_targets: list[int | None] = []
115
+ self._variant_index = 0
116
+ self._demo_variant_index = 0
117
+ self._quest_spawn_timeline_ms = 0
118
+ self._demo_time_limit_ms = 0
119
+ self._finished = False
120
+ self._upsell_message_index = 0
121
+ self._upsell_pulse_ms = 0
122
+ self._upsell_font: GrimMonoFont | None = None
123
+ self._small_font: SmallFontData | None = None
124
+ self._purchase_active = False
125
+ self._purchase_url_opened = False
126
+ self._spawn_rng = Crand(0)
127
+
128
+ def open(self) -> None:
129
+ self._finished = False
130
+ self._upsell_message_index = 0
131
+ self._upsell_pulse_ms = 0
132
+ self._purchase_active = False
133
+ self._purchase_url_opened = False
134
+ self._variant_index = 0
135
+ self._demo_variant_index = 0
136
+ self._quest_spawn_timeline_ms = 0
137
+ self._demo_time_limit_ms = 0
138
+ self._crand.srand(self._state.rng.getrandbits(32))
139
+ self._world.open()
140
+ self._demo_mode_start()
141
+
142
+ def close(self) -> None:
143
+ self._world.close()
144
+ if self._upsell_font is not None:
145
+ rl.unload_texture(self._upsell_font.texture)
146
+ self._upsell_font = None
147
+ if self._small_font is not None:
148
+ rl.unload_texture(self._small_font.texture)
149
+ self._small_font = None
150
+
151
+ def is_finished(self) -> bool:
152
+ return self._finished
153
+
154
+ def update(self, dt: float) -> None:
155
+ if self._state.audio is not None:
156
+ update_audio(self._state.audio, dt)
157
+ if self._finished:
158
+ return
159
+ frame_dt = min(dt, 0.1)
160
+ frame_dt_ms = int(frame_dt * 1000.0)
161
+ if frame_dt_ms <= 0:
162
+ return
163
+
164
+ if (not self._purchase_active) and getattr(self._state, "demo_enabled", False) and self._purchase_screen_triggered():
165
+ self._begin_purchase_screen(DEMO_PURCHASE_SCREEN_LIMIT_MS, reset_timeline=False)
166
+
167
+ if self._purchase_active:
168
+ self._upsell_pulse_ms += frame_dt_ms
169
+ self._update_purchase_screen()
170
+ self._quest_spawn_timeline_ms += frame_dt_ms
171
+ if self._quest_spawn_timeline_ms > self._demo_time_limit_ms:
172
+ # demo_purchase_screen_update restarts the demo once the purchase screen
173
+ # timer exceeds demo_time_limit_ms.
174
+ self._demo_mode_start()
175
+ return
176
+
177
+ if self._skip_triggered():
178
+ self._finished = True
179
+ return
180
+
181
+ self._quest_spawn_timeline_ms += frame_dt_ms
182
+ self._update_world(frame_dt)
183
+ if self._quest_spawn_timeline_ms > self._demo_time_limit_ms:
184
+ self._demo_mode_start()
185
+
186
+ def draw(self) -> None:
187
+ if self._purchase_active:
188
+ self._draw_purchase_screen()
189
+ return
190
+ self._world.draw()
191
+ self._draw_overlay()
192
+
193
+ def _skip_triggered(self) -> bool:
194
+ if rl.get_key_pressed() != 0:
195
+ return True
196
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
197
+ return True
198
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_RIGHT):
199
+ return True
200
+ return False
201
+
202
+ def _purchase_screen_triggered(self) -> bool:
203
+ if rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT):
204
+ return True
205
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
206
+ return True
207
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
208
+ return True
209
+ return False
210
+
211
+ def _begin_purchase_screen(self, limit_ms: int, *, reset_timeline: bool) -> None:
212
+ self._purchase_active = True
213
+ if reset_timeline:
214
+ self._quest_spawn_timeline_ms = 0
215
+ self._demo_time_limit_ms = max(0, int(limit_ms))
216
+ self._purchase_url_opened = False
217
+
218
+ def _ensure_small_font(self) -> SmallFontData:
219
+ if self._small_font is not None:
220
+ return self._small_font
221
+ missing_assets: list[str] = []
222
+ self._small_font = load_small_font(self._state.assets_dir, missing_assets)
223
+ return self._small_font
224
+
225
+ def _purchase_var_28_2(self) -> float:
226
+ screen_w = int(self._state.config.screen_width)
227
+ if screen_w == 0x320: # 800
228
+ return 64.0
229
+ if screen_w == 0x400: # 1024
230
+ return 128.0
231
+ return 0.0
232
+
233
+ def _update_purchase_screen(self) -> None:
234
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
235
+ self._purchase_active = False
236
+ self._finished = True
237
+ return
238
+
239
+ small = self._ensure_small_font()
240
+ # ui_button_update uses the medium (145px wide) button sprite here (the per-button
241
+ # "small" flag at +0x14 is 0 for both purchase/maybe-later globals).
242
+ button_tex = self._ensure_cache().get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
243
+
244
+ if button_tex is None:
245
+ return
246
+
247
+ w = float(self._state.config.screen_width)
248
+ h = float(self._state.config.screen_height)
249
+ wide_shift = self._purchase_var_28_2()
250
+ button_x = w / 2.0 + 128.0
251
+ button_base_y = h / 2.0 + 102.0 + wide_shift * 0.3
252
+ purchase_y = button_base_y + 50.0
253
+ maybe_y = button_base_y + 90.0
254
+
255
+ purchase_rect = rl.Rectangle(button_x, purchase_y, float(button_tex.width), float(button_tex.height))
256
+ maybe_rect = rl.Rectangle(button_x, maybe_y, float(button_tex.width), float(button_tex.height))
257
+
258
+ mouse = rl.get_mouse_position()
259
+ if (
260
+ purchase_rect.x <= mouse.x <= purchase_rect.x + purchase_rect.width
261
+ and purchase_rect.y <= mouse.y <= purchase_rect.y + purchase_rect.height
262
+ and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
263
+ ):
264
+ if not self._purchase_url_opened:
265
+ self._purchase_url_opened = True
266
+ try:
267
+ import webbrowser
268
+
269
+ webbrowser.open(DEMO_PURCHASE_URL)
270
+ except Exception:
271
+ pass
272
+ if hasattr(self._state, "quit_requested"):
273
+ self._state.quit_requested = True
274
+
275
+ if (
276
+ maybe_rect.x <= mouse.x <= maybe_rect.x + maybe_rect.width
277
+ and maybe_rect.y <= mouse.y <= maybe_rect.y + maybe_rect.height
278
+ and rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
279
+ ):
280
+ self._purchase_active = False
281
+ self._finished = True
282
+ return
283
+
284
+ # Keyboard activation for convenience; original uses UI mouse.
285
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
286
+ if not self._purchase_url_opened:
287
+ self._purchase_url_opened = True
288
+ try:
289
+ import webbrowser
290
+
291
+ webbrowser.open(DEMO_PURCHASE_URL)
292
+ except Exception:
293
+ pass
294
+ if hasattr(self._state, "quit_requested"):
295
+ self._state.quit_requested = True
296
+
297
+ # Keep small referenced to avoid unused warnings if this method grows.
298
+ _ = small
299
+
300
+ def _draw_purchase_screen(self) -> None:
301
+ rl.clear_background(rl.BLACK)
302
+
303
+ logos = getattr(self._state, "logos", None)
304
+ if logos is None or logos.backplasma.texture is None:
305
+ return
306
+ backplasma = logos.backplasma.texture
307
+
308
+ pulse_phase = float(self._upsell_pulse_ms % 1000)
309
+ pulse = math.sin(pulse_phase * 6.2831855)
310
+ pulse = pulse * pulse
311
+
312
+ screen_w = float(self._state.config.screen_width)
313
+ screen_h = float(self._state.config.screen_height)
314
+
315
+ # demo_purchase_screen_update @ 0x0040b985:
316
+ # - full-screen quad
317
+ # - UV: 0..0.5 (top-left quarter of the backplasma atlas)
318
+ # - per-corner color slots, with a sin^2 pulse at bottom-right
319
+
320
+ def _to_u8(value: float) -> int:
321
+ return int(_clamp(value, 0.0, 1.0) * 255.0 + 0.5)
322
+
323
+ c0 = rl.Color(_to_u8(0.0), _to_u8(0.0), _to_u8(0.0), _to_u8(1.0))
324
+ c1 = rl.Color(_to_u8(0.0), _to_u8(0.0), _to_u8(0.3), _to_u8(1.0))
325
+ c2 = rl.Color(
326
+ _to_u8(0.0),
327
+ _to_u8(0.4),
328
+ _to_u8(pulse * 0.55),
329
+ _to_u8(pulse),
330
+ )
331
+ c3 = rl.Color(_to_u8(0.0), _to_u8(0.4), _to_u8(0.4), _to_u8(1.0))
332
+
333
+ rl.begin_blend_mode(rl.BLEND_ALPHA)
334
+ rl.rl_set_texture(backplasma.id)
335
+ rl.rl_begin(rl.RL_QUADS)
336
+ # TL
337
+ rl.rl_color4ub(c0.r, c0.g, c0.b, c0.a)
338
+ rl.rl_tex_coord2f(0.0, 0.0)
339
+ rl.rl_vertex2f(0.0, 0.0)
340
+ # TR
341
+ rl.rl_color4ub(c1.r, c1.g, c1.b, c1.a)
342
+ rl.rl_tex_coord2f(0.5, 0.0)
343
+ rl.rl_vertex2f(screen_w, 0.0)
344
+ # BR
345
+ rl.rl_color4ub(c2.r, c2.g, c2.b, c2.a)
346
+ rl.rl_tex_coord2f(0.5, 0.5)
347
+ rl.rl_vertex2f(screen_w, screen_h)
348
+ # BL
349
+ rl.rl_color4ub(c3.r, c3.g, c3.b, c3.a)
350
+ rl.rl_tex_coord2f(0.0, 0.5)
351
+ rl.rl_vertex2f(0.0, screen_h)
352
+ rl.rl_end()
353
+ rl.rl_set_texture(0)
354
+ rl.end_blend_mode()
355
+
356
+ wide_shift = self._purchase_var_28_2()
357
+
358
+ # Mockup and logo textures.
359
+ if logos.mockup.texture is not None:
360
+ mockup = logos.mockup.texture
361
+ x = screen_w / 2.0 - 128.0 + wide_shift
362
+ y = screen_h / 2.0 - 140.0
363
+ dst = rl.Rectangle(x, y, 512.0, 256.0)
364
+ src = rl.Rectangle(0.0, 0.0, float(mockup.width), float(mockup.height))
365
+ rl.draw_texture_pro(mockup, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
366
+
367
+ if logos.cl_logo.texture is not None:
368
+ cl_logo = logos.cl_logo.texture
369
+ x = screen_w / 2.0 - 256.0
370
+ y = screen_h / 2.0 - 200.0 - wide_shift * 0.4
371
+ dst = rl.Rectangle(x, y, 512.0, 64.0)
372
+ src = rl.Rectangle(0.0, 0.0, float(cl_logo.width), float(cl_logo.height))
373
+ rl.draw_texture_pro(cl_logo, src, dst, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
374
+
375
+ small = self._ensure_small_font()
376
+ text_scale = 1.2
377
+ x_text = screen_w / 2.0 - 296.0 - wide_shift * 0.8
378
+ y = screen_h / 2.0 - 104.0
379
+ color = rl.Color(255, 255, 255, 255)
380
+ draw_small_text(small, _DEMO_PURCHASE_TITLE, x_text, y, text_scale, color)
381
+ y += 28.0
382
+ draw_small_text(small, _DEMO_PURCHASE_FEATURES_TITLE, x_text, y, text_scale, color)
383
+
384
+ underline_w = measure_small_text_width(small, _DEMO_PURCHASE_FEATURES_TITLE, text_scale)
385
+ rl.draw_rectangle_rec(rl.Rectangle(x_text, y + 15.0, underline_w, 2.0), rl.Color(255, 255, 255, 160))
386
+
387
+ y += 22.0
388
+ x_list = x_text + 8.0
389
+ for line, delta_y in _DEMO_PURCHASE_FEATURE_LINES:
390
+ draw_small_text(small, line, x_list, y, text_scale, color)
391
+ y += delta_y
392
+ draw_small_text(small, _DEMO_PURCHASE_FOOTER, x_text, y, text_scale, color)
393
+
394
+ # Buttons on the right.
395
+ cache = self._ensure_cache()
396
+ button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
397
+ if button_tex is None:
398
+ return
399
+
400
+ button_x = screen_w / 2.0 + 128.0
401
+ button_base_y = screen_h / 2.0 + 102.0 + wide_shift * 0.3
402
+ purchase_y = button_base_y + 50.0
403
+ maybe_y = button_base_y + 90.0
404
+ mouse = rl.get_mouse_position()
405
+
406
+ def draw_button(texture: rl.Texture2D, label: str, x: float, y0: float) -> None:
407
+ hovered = x <= mouse.x <= x + texture.width and y0 <= mouse.y <= y0 + texture.height
408
+ tint = rl.Color(255, 255, 255, 255) if hovered else rl.Color(220, 220, 220, 255)
409
+ rl.draw_texture(texture, int(x), int(y0), tint)
410
+ label_scale = 1.0
411
+ text_w = measure_small_text_width(small, label, label_scale)
412
+ text_x = x + float(texture.width) * 0.5 - text_w * 0.5 + 1.0
413
+ text_y = y0 + 10.0
414
+ alpha = 1.0 if hovered else 0.7
415
+ draw_small_text(small, label, text_x, text_y, label_scale, rl.Color(255, 255, 255, int(255 * alpha)))
416
+
417
+ draw_button(button_tex, "Purchase", button_x, purchase_y)
418
+ draw_button(button_tex, "Maybe later", button_x, maybe_y)
419
+
420
+ # Demo purchase screen uses menu-style cursor; draw it explicitly since the OS cursor is hidden.
421
+ particles = cache.get_or_load("particles", "game/particles.jaz").texture
422
+ cursor_tex = cache.get_or_load("ui_cursor", "ui/ui_cursor.jaz").texture
423
+ pulse_time = float(self._upsell_pulse_ms) * 0.001
424
+ draw_menu_cursor(particles, cursor_tex, x=float(mouse.x), y=float(mouse.y), pulse_time=pulse_time)
425
+
426
+ def _ensure_cache(self) -> PaqTextureCache:
427
+ cache = self._state.texture_cache
428
+ if cache is not None:
429
+ return cache
430
+ entries = load_paq_entries(self._state.assets_dir)
431
+ cache = PaqTextureCache(entries=entries, textures={})
432
+ self._state.texture_cache = cache
433
+ return cache
434
+
435
+ def _demo_mode_start(self) -> None:
436
+ index = self._demo_variant_index
437
+ self._demo_variant_index = (index + 1) % DEMO_VARIANT_COUNT
438
+ self._variant_index = index
439
+ self._quest_spawn_timeline_ms = 0
440
+ self._demo_time_limit_ms = 0
441
+ self._purchase_active = False
442
+ self._purchase_url_opened = False
443
+ self._spawn_rng.srand(self._state.rng.randrange(0, 0x1_0000_0000))
444
+ self._world.state.bonuses.weapon_power_up = 0.0
445
+ if index == 0:
446
+ self._apply_variant_ground(0)
447
+ self._setup_variant_0()
448
+ elif index == 1:
449
+ self._apply_variant_ground(1)
450
+ self._setup_variant_1()
451
+ elif index == 2:
452
+ self._apply_variant_ground(2)
453
+ self._setup_variant_2()
454
+ elif index == 3:
455
+ self._apply_variant_ground(3)
456
+ self._setup_variant_3()
457
+ elif index == 4:
458
+ self._apply_variant_ground(4)
459
+ self._setup_variant_0()
460
+ else:
461
+ # demo_purchase_interstitial_begin
462
+ self._begin_purchase_screen(DEMO_PURCHASE_INTERSTITIAL_LIMIT_MS, reset_timeline=True)
463
+
464
+ # demo_purchase_screen_update increments demo_upsell_message_index when the
465
+ # timeline resets (quest_spawn_timeline == 0) and the purchase screen is inactive.
466
+ if (not self._purchase_active) and _DEMO_UPSELL_MESSAGES:
467
+ self._upsell_message_index = (self._upsell_message_index + 1) % len(_DEMO_UPSELL_MESSAGES)
468
+
469
+ def _setup_world_players(self, specs: list[tuple[float, float, int]]) -> None:
470
+ seed = int(self._state.rng.getrandbits(32))
471
+ self._world.reset(seed=seed, player_count=len(specs))
472
+ for idx, (x, y, weapon_id) in enumerate(specs):
473
+ if idx >= len(self._world.players):
474
+ continue
475
+ player = self._world.players[idx]
476
+ player.pos_x = float(x)
477
+ player.pos_y = float(y)
478
+ weapon_assign_player(player, int(weapon_id))
479
+ self._demo_targets = [None] * len(self._world.players)
480
+
481
+ def _apply_variant_ground(self, index: int) -> None:
482
+ if index == 5:
483
+ return
484
+ terrain = {
485
+ 0: (
486
+ "ter_q1_base",
487
+ "ter_q1_tex1",
488
+ "ter/ter_q1_base.jaz",
489
+ "ter/ter_q1_tex1.jaz",
490
+ ),
491
+ 1: (
492
+ "ter_q2_base",
493
+ "ter_q2_tex1",
494
+ "ter/ter_q2_base.jaz",
495
+ "ter/ter_q2_tex1.jaz",
496
+ ),
497
+ 2: (
498
+ "ter_q3_base",
499
+ "ter_q3_tex1",
500
+ "ter/ter_q3_base.jaz",
501
+ "ter/ter_q3_tex1.jaz",
502
+ ),
503
+ 3: (
504
+ "ter_q4_base",
505
+ "ter_q4_tex1",
506
+ "ter/ter_q4_base.jaz",
507
+ "ter/ter_q4_tex1.jaz",
508
+ ),
509
+ 4: (
510
+ "ter_q1_base",
511
+ "ter_q1_tex1",
512
+ "ter/ter_q1_base.jaz",
513
+ "ter/ter_q1_tex1.jaz",
514
+ ),
515
+ }.get(
516
+ index,
517
+ (
518
+ "ter_q1_base",
519
+ "ter_q1_tex1",
520
+ "ter/ter_q1_base.jaz",
521
+ "ter/ter_q1_tex1.jaz",
522
+ ),
523
+ )
524
+ base_key, overlay_key, base_path, overlay_path = terrain
525
+ self._world.set_terrain(
526
+ base_key=base_key,
527
+ overlay_key=overlay_key,
528
+ base_path=base_path,
529
+ overlay_path=overlay_path,
530
+ )
531
+
532
+ def _wrap_pos(self, x: float, y: float) -> tuple[float, float]:
533
+ return (x % WORLD_SIZE, y % WORLD_SIZE)
534
+
535
+ def _crand_mod(self, mod: int) -> int:
536
+ if mod <= 0:
537
+ return 0
538
+ return int(self._crand.rand() % mod)
539
+
540
+ def _spawn(self, spawn_id: int, x: float, y: float, *, heading: float = 0.0) -> None:
541
+ x, y = self._wrap_pos(x, y)
542
+ self._world.creatures.spawn_template(
543
+ int(spawn_id),
544
+ (x, y),
545
+ float(heading),
546
+ self._spawn_rng,
547
+ rand=self._spawn_rng.rand,
548
+ )
549
+
550
+ def _setup_variant_0(self) -> None:
551
+ self._demo_time_limit_ms = 4000
552
+ weapon_id = 12
553
+ self._setup_world_players(
554
+ [
555
+ (448.0, 384.0, weapon_id),
556
+ (546.0, 654.0, weapon_id),
557
+ ]
558
+ )
559
+ y = 256
560
+ i = 0
561
+ while y < 1696:
562
+ col = i % 2
563
+ self._spawn(0x38, float((col + 2) * 64), float(y), heading=-100.0)
564
+ self._spawn(0x38, float(col * 64 + 798), float(y), heading=-100.0)
565
+ y += 80
566
+ i += 1
567
+
568
+ def _setup_variant_1(self) -> None:
569
+ self._demo_time_limit_ms = 5000
570
+ weapon_id = 6
571
+ self._setup_world_players(
572
+ [
573
+ (490.0, 448.0, weapon_id),
574
+ (480.0, 576.0, weapon_id),
575
+ ]
576
+ )
577
+ self._world.state.bonuses.weapon_power_up = 15.0
578
+ for idx in range(20):
579
+ x = float(self._crand_mod(200) + 32)
580
+ y = float(self._crand_mod(899) + 64)
581
+ self._spawn(0x34, x, y, heading=-100.0)
582
+ if idx % 3 != 0:
583
+ x2 = float(self._crand_mod(30) + 32)
584
+ y2 = float(self._crand_mod(899) + 64)
585
+ self._spawn(0x35, x2, y2, heading=-100.0)
586
+
587
+ def _setup_variant_2(self) -> None:
588
+ self._demo_time_limit_ms = 5000
589
+ weapon_id = 22
590
+ self._setup_world_players([(512.0, 512.0, weapon_id)])
591
+ y = 128
592
+ i = 0
593
+ while y < 848:
594
+ col = i % 2
595
+ self._spawn(0x41, float(col * 64 + 32), float(y), heading=-100.0)
596
+ self._spawn(0x41, float((col + 2) * 64), float(y), heading=-100.0)
597
+ self._spawn(0x41, float(col * 64 - 64), float(y), heading=-100.0)
598
+ self._spawn(0x41, float((col + 12) * 64), float(y), heading=-100.0)
599
+ y += 60
600
+ i += 1
601
+
602
+ def _setup_variant_3(self) -> None:
603
+ self._demo_time_limit_ms = 4000
604
+ weapon_id = 19
605
+ self._setup_world_players([(512.0, 512.0, weapon_id)])
606
+ for idx in range(20):
607
+ x = float(self._crand_mod(200) + 32)
608
+ y = float(self._crand_mod(899) + 64)
609
+ self._spawn(0x24, x, y, heading=0.0)
610
+ if idx % 3 != 0:
611
+ x2 = float(self._crand_mod(30) + 32)
612
+ y2 = float(self._crand_mod(899) + 64)
613
+ self._spawn(0x25, x2, y2, heading=0.0)
614
+
615
+ def _world_params(self) -> tuple[float, float, float, float]:
616
+ out_w = float(rl.get_screen_width())
617
+ out_h = float(rl.get_screen_height())
618
+ screen_w = float(self._state.config.screen_width)
619
+ screen_h = float(self._state.config.screen_height)
620
+ if screen_w > WORLD_SIZE:
621
+ screen_w = WORLD_SIZE
622
+ if screen_h > WORLD_SIZE:
623
+ screen_h = WORLD_SIZE
624
+
625
+ cam_x = self._camera_x
626
+ cam_y = self._camera_y
627
+ min_x = screen_w - WORLD_SIZE
628
+ min_y = screen_h - WORLD_SIZE
629
+ if cam_x > -1.0:
630
+ cam_x = -1.0
631
+ if cam_y > -1.0:
632
+ cam_y = -1.0
633
+ if cam_x < min_x:
634
+ cam_x = min_x
635
+ if cam_y < min_y:
636
+ cam_y = min_y
637
+
638
+ scale_x = out_w / screen_w if screen_w > 0 else 1.0
639
+ scale_y = out_h / screen_h if screen_h > 0 else 1.0
640
+ return cam_x, cam_y, scale_x, scale_y
641
+
642
+ def _world_to_screen(self, x: float, y: float) -> tuple[float, float]:
643
+ cam_x, cam_y, scale_x, scale_y = self._world_params()
644
+ return (x + cam_x) * scale_x, (y + cam_y) * scale_y
645
+
646
+ def _select_frame(self, spawn_id: int, phase: float) -> tuple[int, bool]:
647
+ template = SPAWN_ID_TO_TEMPLATE.get(spawn_id)
648
+ if template is None or template.type_id is None:
649
+ return 0, False
650
+ info = _TYPE_ANIM.get(template.type_id)
651
+ if info is None:
652
+ return 0, False
653
+ flags = template.flags or CreatureFlags(0)
654
+ frame, mirror_applied, _ = creature_anim_select_frame(
655
+ phase,
656
+ base_frame=info.base,
657
+ mirror_long=info.mirror,
658
+ flags=flags,
659
+ )
660
+ return frame, mirror_applied
661
+
662
+ def _draw_fx(self) -> None:
663
+ projectiles = self._projectile_pool.iter_active()
664
+ secondary = self._secondary_projectile_pool.iter_active()
665
+ if not (projectiles or secondary or self._beams or self._explosions):
666
+ return
667
+ cam_x, cam_y, scale_x, scale_y = self._world_params()
668
+ del cam_x, cam_y
669
+ scale = (scale_x + scale_y) * 0.5
670
+
671
+ for proj in projectiles:
672
+ sx, sy = self._world_to_screen(proj.pos_x, proj.pos_y)
673
+ base_radius = {
674
+ 0x05: 6.0, # gauss
675
+ 0x0B: 10.0, # rocket launcher
676
+ 0x15: 7.0, # ion minigun beam seed
677
+ }.get(proj.type_id, 5.0)
678
+ radius = max(1.0, base_radius * scale)
679
+ color = {
680
+ 0x05: rl.Color(235, 235, 235, 255),
681
+ 0x0B: rl.Color(255, 120, 80, 255),
682
+ 0x15: rl.Color(120, 220, 255, 255),
683
+ }.get(proj.type_id, rl.Color(235, 235, 235, 255))
684
+ rl.draw_circle(int(sx), int(sy), radius, color)
685
+
686
+ for proj in secondary:
687
+ sx, sy = self._world_to_screen(proj.pos_x, proj.pos_y)
688
+ if proj.type_id == 4:
689
+ radius = max(1.0, 12.0 * scale)
690
+ rl.draw_circle(int(sx), int(sy), radius, rl.Color(200, 120, 255, 255))
691
+ continue
692
+ if proj.type_id == 3:
693
+ t = _clamp(proj.lifetime, 0.0, 1.0)
694
+ radius = proj.speed * t * 80.0
695
+ alpha = int((1.0 - t) * 180.0)
696
+ color = rl.Color(200, 120, 255, alpha)
697
+ rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
698
+
699
+ for beam in self._beams:
700
+ x0, y0 = self._world_to_screen(beam.x0, beam.y0)
701
+ x1, y1 = self._world_to_screen(beam.x1, beam.y1)
702
+ alpha = int(_clamp(beam.life / 0.08, 0.0, 1.0) * 255.0)
703
+ color = rl.Color(120, 220, 255, alpha)
704
+ rl.draw_line_ex(rl.Vector2(x0, y0), rl.Vector2(x1, y1), 2.0 * scale, color)
705
+
706
+ for fx in self._explosions:
707
+ t = fx.elapsed / fx.duration if fx.duration > 0 else 1.0
708
+ radius = fx.max_radius * _clamp(t, 0.0, 1.0)
709
+ sx, sy = self._world_to_screen(fx.x, fx.y)
710
+ alpha = int((1.0 - _clamp(t, 0.0, 1.0)) * 180.0)
711
+ color = rl.Color(255, 180, 100, alpha) if fx.kind == "rocket" else rl.Color(200, 120, 255, alpha)
712
+ rl.draw_circle_lines(int(sx), int(sy), max(1.0, radius * scale), color)
713
+
714
+ def _draw_entities(self) -> None:
715
+ cache = self._state.texture_cache
716
+ if cache is None:
717
+ return
718
+ cam_x, cam_y, scale_x, scale_y = self._world_params()
719
+ del cam_x, cam_y
720
+
721
+ player_tex = cache.get_or_load("trooper", "game/trooper.jaz").texture
722
+ if player_tex is not None:
723
+ for player in self._players:
724
+ self._draw_sprite(
725
+ player_tex,
726
+ CreatureTypeId.TROOPER,
727
+ CreatureFlags(0),
728
+ player.phase,
729
+ player.x,
730
+ player.y,
731
+ scale_x,
732
+ scale_y,
733
+ tint=rl.Color(240, 240, 255, 255),
734
+ )
735
+
736
+ for creature in self._creatures:
737
+ type_id = creature.type_id
738
+ if type_id is None:
739
+ continue
740
+ asset = _TYPE_ASSET.get(type_id)
741
+ if asset is None:
742
+ continue
743
+ texture = cache.texture(asset)
744
+ if texture is None:
745
+ rel_path = f"game/{asset}.jaz"
746
+ texture = cache.get_or_load(asset, rel_path).texture
747
+ if texture is None:
748
+ continue
749
+ flags = creature.flags
750
+
751
+ def _to_u8(value: float) -> int:
752
+ return int(_clamp(value, 0.0, 1.0) * 255.0 + 0.5)
753
+
754
+ tint = rl.WHITE
755
+ if creature.tint is not None and any(v is not None for v in creature.tint):
756
+ tint_r, tint_g, tint_b, tint_a = resolve_tint(creature.tint)
757
+ tint = rl.Color(
758
+ _to_u8(tint_r),
759
+ _to_u8(tint_g),
760
+ _to_u8(tint_b),
761
+ _to_u8(tint_a),
762
+ )
763
+ self._draw_sprite(
764
+ texture,
765
+ type_id,
766
+ flags,
767
+ creature.anim_phase,
768
+ creature.x,
769
+ creature.y,
770
+ scale_x,
771
+ scale_y,
772
+ tint=tint,
773
+ size_scale=_clamp(creature.size / 64.0, 0.25, 2.0),
774
+ )
775
+
776
+ def _draw_sprite(
777
+ self,
778
+ texture: rl.Texture2D,
779
+ type_id: CreatureTypeId,
780
+ flags: CreatureFlags,
781
+ phase: float,
782
+ world_x: float,
783
+ world_y: float,
784
+ scale_x: float,
785
+ scale_y: float,
786
+ *,
787
+ tint: rl.Color,
788
+ size_scale: float = 1.0,
789
+ ) -> None:
790
+ info = _TYPE_ANIM.get(type_id)
791
+ if info is None:
792
+ return
793
+ frame, _, _ = creature_anim_select_frame(
794
+ phase,
795
+ base_frame=info.base,
796
+ mirror_long=info.mirror,
797
+ flags=flags,
798
+ )
799
+
800
+ grid = 8
801
+ cell = float(texture.width) / grid if grid > 0 else float(texture.width)
802
+ row = frame // grid
803
+ col = frame % grid
804
+ src = rl.Rectangle(float(col * cell), float(row * cell), float(cell), float(cell))
805
+ screen_x, screen_y = self._world_to_screen(world_x, world_y)
806
+ width = cell * scale_x * size_scale
807
+ height = cell * scale_y * size_scale
808
+ dst = rl.Rectangle(screen_x, screen_y, width, height)
809
+ origin = rl.Vector2(width * 0.5, height * 0.5)
810
+ rl.draw_texture_pro(texture, src, dst, origin, 0.0, tint)
811
+
812
+ def _draw_overlay(self) -> None:
813
+ if getattr(self._state, "demo_enabled", False):
814
+ self._draw_demo_upsell_overlay()
815
+ return
816
+ title = f"DEMO MODE ({self._variant_index + 1}/{DEMO_VARIANT_COUNT})"
817
+ hint = "Press any key / click to skip"
818
+ remaining = max(0.0, float(self._demo_time_limit_ms - self._quest_spawn_timeline_ms) / 1000.0)
819
+ weapons = ", ".join(f"P{p.index + 1}:{_weapon_name(p.weapon_id)}" for p in self._world.players)
820
+ detail = f"{weapons} — next in {remaining:0.1f}s"
821
+ rl.draw_text(title, 16, 12, 20, rl.Color(240, 240, 240, 255))
822
+ rl.draw_text(detail, 16, 36, 16, rl.Color(180, 180, 190, 255))
823
+ rl.draw_text(hint, 16, 56, 16, rl.Color(140, 140, 150, 255))
824
+
825
+ def _ensure_upsell_font(self) -> GrimMonoFont:
826
+ if self._upsell_font is not None:
827
+ return self._upsell_font
828
+ missing_assets: list[str] = []
829
+ self._upsell_font = load_grim_mono_font(self._state.assets_dir, missing_assets)
830
+ return self._upsell_font
831
+
832
+ def _draw_demo_upsell_overlay(self) -> None:
833
+ # Modeled after the shareware "Want more ..." overlay in demo_purchase_screen_update
834
+ # (crimsonland.exe 0x0040B740), but without the purchase screen.
835
+ if not _DEMO_UPSELL_MESSAGES:
836
+ return
837
+
838
+ font = self._ensure_upsell_font()
839
+ msg = _DEMO_UPSELL_MESSAGES[self._upsell_message_index]
840
+
841
+ timeline_ms = self._quest_spawn_timeline_ms
842
+ limit_ms = self._demo_time_limit_ms
843
+ var_2c = float(timeline_ms) * 0.016
844
+
845
+ alpha = 1.0
846
+ if var_2c < 20.0:
847
+ alpha = var_2c * 0.05
848
+ if timeline_ms > limit_ms - 500:
849
+ alpha = float(limit_ms - timeline_ms) * 0.002
850
+ alpha = _clamp(alpha, 0.0, 1.0)
851
+
852
+ scale = 0.8
853
+ text_w = float(len(msg)) * 12.8
854
+
855
+ text_x = 50.0
856
+ text_y = var_2c + 50.0
857
+ bg_x = 60.0
858
+ bg_y = text_y - 4.0
859
+ bar_x = 64.0
860
+ bar_y = var_2c + 72.0
861
+
862
+ bg_alpha = int(round(_clamp(alpha * 0.5, 0.0, 1.0) * 255.0))
863
+ bar_alpha = int(round(_clamp(alpha * 0.8, 0.0, 1.0) * 255.0))
864
+ txt_alpha = int(round(_clamp(alpha, 0.0, 1.0) * 255.0))
865
+
866
+ rl.draw_rectangle_rec(
867
+ rl.Rectangle(bg_x, bg_y, text_w + 12.0, 30.0),
868
+ rl.Color(0, 0, 0, bg_alpha),
869
+ )
870
+
871
+ progress = 0.0
872
+ if limit_ms > 0:
873
+ progress = _clamp(float(timeline_ms) / float(limit_ms), 0.0, 1.0)
874
+ rl.draw_rectangle_rec(
875
+ rl.Rectangle(bar_x, bar_y, text_w * progress, 3.0),
876
+ rl.Color(128, 26, 26, bar_alpha),
877
+ )
878
+
879
+ draw_grim_mono_text(font, msg, text_x, text_y, scale, rl.Color(255, 255, 255, txt_alpha))
880
+
881
+ def _update_world(self, dt: float) -> None:
882
+ if not self._world.players:
883
+ return
884
+ inputs = self._build_demo_inputs()
885
+ self._world.update(dt, inputs=inputs, auto_pick_perks=False, game_mode=0, perk_progression_enabled=False)
886
+
887
+ def _build_demo_inputs(self) -> list[PlayerInput]:
888
+ players = self._world.players
889
+ creatures = self._world.creatures.entries
890
+ if len(self._demo_targets) != len(players):
891
+ self._demo_targets = [None] * len(players)
892
+ center_x = float(self._world.world_size) * 0.5
893
+ center_y = float(self._world.world_size) * 0.5
894
+
895
+ inputs: list[PlayerInput] = []
896
+ for idx, player in enumerate(players):
897
+ target_idx = self._select_demo_target(idx, player, creatures)
898
+ aim_x = center_x
899
+ aim_y = center_y
900
+ target = None
901
+ if target_idx is not None and 0 <= target_idx < len(creatures):
902
+ candidate = creatures[target_idx]
903
+ if candidate.hp > 0.0:
904
+ target = candidate
905
+ aim_x = candidate.x
906
+ aim_y = candidate.y
907
+
908
+ move_x, move_y = 0.0, 0.0
909
+ to_cx = center_x - player.pos_x
910
+ to_cy = center_y - player.pos_y
911
+ nx, ny, d = _normalize(to_cx, to_cy)
912
+ if d > 120.0:
913
+ move_x += nx
914
+ move_y += ny
915
+
916
+ if target is not None:
917
+ rx = player.pos_x - target.x
918
+ ry = player.pos_y - target.y
919
+ rnx, rny, rd = _normalize(rx, ry)
920
+ if 0.0 < rd < 160.0:
921
+ strength = (160.0 - rd) / 160.0
922
+ move_x += rnx * (1.5 * strength)
923
+ move_y += rny * (1.5 * strength)
924
+
925
+ orbit_dir = -1.0 if (player.index % 2) else 1.0
926
+ ox, oy, _ = _normalize(-(player.pos_y - center_y), player.pos_x - center_x)
927
+ move_x += ox * 0.55 * orbit_dir
928
+ move_y += oy * 0.55 * orbit_dir
929
+
930
+ fire_down = target is not None
931
+
932
+ inputs.append(
933
+ PlayerInput(
934
+ move_x=move_x,
935
+ move_y=move_y,
936
+ aim_x=aim_x,
937
+ aim_y=aim_y,
938
+ fire_down=fire_down,
939
+ fire_pressed=fire_down,
940
+ reload_pressed=False,
941
+ )
942
+ )
943
+
944
+ return inputs
945
+
946
+ def _nearest_world_creature_index(self, x: float, y: float) -> int | None:
947
+ best_idx = None
948
+ best_dist = 0.0
949
+ for idx, creature in enumerate(self._world.creatures.entries):
950
+ if not (creature.active and creature.hp > 0.0):
951
+ continue
952
+ d = _distance_sq(x, y, creature.x, creature.y)
953
+ if best_idx is None or d < best_dist:
954
+ best_idx = idx
955
+ best_dist = d
956
+ return best_idx
957
+
958
+ def _select_demo_target(self, player_index: int, player: PlayerState, creatures: list) -> int | None:
959
+ candidate = self._nearest_world_creature_index(player.pos_x, player.pos_y)
960
+ current = self._demo_targets[player_index] if player_index < len(self._demo_targets) else None
961
+ if current is None:
962
+ self._demo_targets[player_index] = candidate
963
+ return candidate
964
+ if not (0 <= current < len(creatures)):
965
+ self._demo_targets[player_index] = candidate
966
+ return candidate
967
+ current_creature = creatures[current]
968
+ if current_creature.hp <= 0.0 or not current_creature.active:
969
+ self._demo_targets[player_index] = candidate
970
+ return candidate
971
+ if candidate is None or candidate == current:
972
+ return current
973
+ cand_creature = creatures[candidate]
974
+ if not cand_creature.active or cand_creature.hp <= 0.0:
975
+ return current
976
+ cur_d = math.hypot(current_creature.x - player.pos_x, current_creature.y - player.pos_y)
977
+ cand_d = math.hypot(cand_creature.x - player.pos_x, cand_creature.y - player.pos_y)
978
+ if cand_d + 64.0 < cur_d:
979
+ self._demo_targets[player_index] = candidate
980
+ return candidate
981
+ return current
982
+
983
+ def _update_sim(self, dt: float, dt_ms: int) -> None:
984
+ self._bonus_weapon_power_up_timer = max(0.0, self._bonus_weapon_power_up_timer - dt)
985
+ self._update_creatures(dt, dt_ms)
986
+ self._update_spawn_slots(dt)
987
+ self._update_projectiles(dt)
988
+ self._update_players(dt)
989
+ self._update_fx(dt)
990
+ self._update_camera(dt)
991
+
992
+ def _nearest_player_index(self, x: float, y: float) -> int | None:
993
+ best_idx = None
994
+ best_dist = 0.0
995
+ for idx, player in enumerate(self._players):
996
+ d = _distance_sq(x, y, player.x, player.y)
997
+ if best_idx is None or d < best_dist:
998
+ best_idx = idx
999
+ best_dist = d
1000
+ return best_idx
1001
+
1002
+ def _nearest_creature_index(self, x: float, y: float) -> int | None:
1003
+ best_idx = None
1004
+ best_dist = 0.0
1005
+ for idx, creature in enumerate(self._creatures):
1006
+ if creature.hp <= 0.0:
1007
+ continue
1008
+ d = _distance_sq(x, y, creature.x, creature.y)
1009
+ if best_idx is None or d < best_dist:
1010
+ best_idx = idx
1011
+ best_dist = d
1012
+ return best_idx
1013
+
1014
+ def _update_spawn_slots(self, dt: float) -> None:
1015
+ if not self._spawn_slots:
1016
+ return
1017
+
1018
+ spawn_events: list[tuple[int, float, float]] = []
1019
+ slot_count = len(self._spawn_slots)
1020
+ for slot_idx in range(slot_count):
1021
+ slot = self._spawn_slots[slot_idx]
1022
+ owner_idx = slot.owner_creature
1023
+ if not (0 <= owner_idx < len(self._creatures)):
1024
+ continue
1025
+ owner = self._creatures[owner_idx]
1026
+ if owner.hp <= 0.0:
1027
+ continue
1028
+ child_template_id = tick_spawn_slot(slot, dt)
1029
+ if child_template_id is None:
1030
+ continue
1031
+ spawn_events.append((child_template_id, owner.x, owner.y))
1032
+
1033
+ for child_template_id, x, y in spawn_events:
1034
+ self._spawn(child_template_id, x, y, heading=-100.0)
1035
+
1036
+ def _update_creatures(self, dt: float, dt_ms: int) -> None:
1037
+ if not self._creatures or not self._players:
1038
+ return
1039
+ for creature in self._creatures:
1040
+ if creature.hp <= 0.0:
1041
+ continue
1042
+ type_id = creature.type_id
1043
+ if type_id is None:
1044
+ template = SPAWN_ID_TO_TEMPLATE.get(creature.spawn_id)
1045
+ type_id = template.type_id if template is not None else None
1046
+
1047
+ move_speed = creature.move_speed
1048
+ if move_speed <= 0.0:
1049
+ move_speed = self._creature_speed(type_id) / 30.0
1050
+
1051
+ creature_ai7_tick_link_timer(creature, dt_ms=dt_ms, rand=self._crand.rand)
1052
+
1053
+ target_idx = self._nearest_player_index(creature.x, creature.y)
1054
+ creature.target_player = target_idx
1055
+ if target_idx is None:
1056
+ creature.vx = 0.0
1057
+ creature.vy = 0.0
1058
+ continue
1059
+ target = self._players[target_idx]
1060
+ ai = creature_ai_update_target(
1061
+ creature,
1062
+ player_x=target.x,
1063
+ player_y=target.y,
1064
+ creatures=self._creatures,
1065
+ dt=dt,
1066
+ )
1067
+ creature.move_scale = ai.move_scale
1068
+ if ai.self_damage is not None:
1069
+ creature.hp -= ai.self_damage
1070
+ if creature.hp <= 0.0:
1071
+ continue
1072
+
1073
+ if creature.ai_mode == 7:
1074
+ creature.vx = 0.0
1075
+ creature.vy = 0.0
1076
+ continue
1077
+
1078
+ creature.heading = _angle_approach(
1079
+ creature.heading, creature.target_heading, move_speed * 0.33333334 * 4.0, dt
1080
+ )
1081
+ speed = move_speed * 30.0
1082
+ direction_x = math.cos(creature.heading - math.pi / 2.0)
1083
+ direction_y = math.sin(creature.heading - math.pi / 2.0)
1084
+ creature.vx = direction_x * dt * creature.move_scale * speed
1085
+ creature.vy = direction_y * dt * creature.move_scale * speed
1086
+
1087
+ radius = max(0.0, creature.size)
1088
+ creature.x = _clamp(creature.x + creature.vx, radius, WORLD_SIZE - radius)
1089
+ creature.y = _clamp(creature.y + creature.vy, radius, WORLD_SIZE - radius)
1090
+
1091
+ def _select_player_target(self, player: DemoPlayer) -> int | None:
1092
+ candidate = self._nearest_creature_index(player.x, player.y)
1093
+ current = player.target_creature
1094
+ if current is None:
1095
+ return candidate
1096
+ if not (0 <= current < len(self._creatures)):
1097
+ return candidate
1098
+ current_creature = self._creatures[current]
1099
+ if current_creature.hp <= 0.0:
1100
+ return candidate
1101
+ if candidate is None or candidate == current:
1102
+ return current
1103
+ cand_creature = self._creatures[candidate]
1104
+ if cand_creature.hp <= 0.0:
1105
+ return current
1106
+ cur_d = math.hypot(current_creature.x - player.x, current_creature.y - player.y)
1107
+ cand_d = math.hypot(cand_creature.x - player.x, cand_creature.y - player.y)
1108
+ if cand_d + 64.0 < cur_d:
1109
+ return candidate
1110
+ return current
1111
+
1112
+ def _update_players(self, dt: float) -> None:
1113
+ if not self._players:
1114
+ return
1115
+ center_x = WORLD_SIZE * 0.5
1116
+ center_y = WORLD_SIZE * 0.5
1117
+ shot_cooldown_decay = dt * (1.5 if self._bonus_weapon_power_up_timer > 0.0 else 1.0)
1118
+ for player in self._players:
1119
+ player.shot_cooldown = max(0.0, player.shot_cooldown - shot_cooldown_decay)
1120
+ player.spread_heat = max(0.01, player.spread_heat - dt * 0.4)
1121
+
1122
+ if player.reload_timer > 0.0:
1123
+ player.reload_timer = max(0.0, player.reload_timer - dt)
1124
+ if player.reload_timer <= 0.0:
1125
+ weapon = self._weapon_entry(player.weapon_id)
1126
+ clip_size = int(weapon.clip_size) if weapon is not None and weapon.clip_size is not None else 0
1127
+ player.ammo = max(0, clip_size)
1128
+ player.reload_timer = 0.0
1129
+ player.reload_timer_max = 0.0
1130
+
1131
+ player.target_creature = self._select_player_target(player)
1132
+ target = self._creatures[player.target_creature] if player.target_creature is not None else None
1133
+ if target is not None and target.hp > 0.0:
1134
+ dx = target.x - player.x
1135
+ dy = target.y - player.y
1136
+ nx, ny, _ = _normalize(dx, dy)
1137
+ player.aim_x, player.aim_y = nx, ny
1138
+ else:
1139
+ dx = center_x - player.x
1140
+ dy = center_y - player.y
1141
+ nx, ny, _ = _normalize(dx, dy)
1142
+ player.aim_x, player.aim_y = nx, ny
1143
+
1144
+ move_x, move_y = 0.0, 0.0
1145
+ to_cx = center_x - player.x
1146
+ to_cy = center_y - player.y
1147
+ nx, ny, d = _normalize(to_cx, to_cy)
1148
+ if d > 120.0:
1149
+ move_x += nx
1150
+ move_y += ny
1151
+
1152
+ if target is not None and target.hp > 0.0:
1153
+ rx = player.x - target.x
1154
+ ry = player.y - target.y
1155
+ rnx, rny, rd = _normalize(rx, ry)
1156
+ if 0.0 < rd < 160.0:
1157
+ strength = (160.0 - rd) / 160.0
1158
+ move_x += rnx * (1.5 * strength)
1159
+ move_y += rny * (1.5 * strength)
1160
+
1161
+ orbit_dir = -1.0 if (player.index % 2) else 1.0
1162
+ ox, oy, _ = _normalize(-(player.y - center_y), player.x - center_x)
1163
+ move_x += ox * 0.55 * orbit_dir
1164
+ move_y += oy * 0.55 * orbit_dir
1165
+
1166
+ mnx, mny, _ = _normalize(move_x, move_y)
1167
+ speed = 150.0
1168
+ player.vx = mnx * speed
1169
+ player.vy = mny * speed
1170
+ player.x = _clamp(player.x + player.vx * dt, 0.0, WORLD_SIZE)
1171
+ player.y = _clamp(player.y + player.vy * dt, 0.0, WORLD_SIZE)
1172
+
1173
+ self._player_fire(player, target)
1174
+
1175
+ def _player_fire(self, player: DemoPlayer, target: DemoCreature | None) -> None:
1176
+ weapon = self._weapon_entry(player.weapon_id)
1177
+ if weapon is None:
1178
+ return
1179
+
1180
+ if player.reload_timer > 0.0:
1181
+ return
1182
+ if player.shot_cooldown > 0.0:
1183
+ return
1184
+ if target is None or target.hp <= 0.0:
1185
+ return
1186
+
1187
+ if player.ammo <= 0:
1188
+ reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
1189
+ if self._bonus_weapon_power_up_timer > 0.0:
1190
+ reload_time *= 0.6
1191
+ player.reload_timer_max = max(0.0, reload_time)
1192
+ player.reload_timer = player.reload_timer_max
1193
+ play_sfx(self._state.audio, resolve_weapon_sfx_ref(weapon.reload_sound), rng=self._state.rng)
1194
+ return
1195
+
1196
+ shot_cooldown = float(weapon.shot_cooldown) if weapon.shot_cooldown is not None else 0.0
1197
+ player.shot_cooldown = max(0.02, shot_cooldown)
1198
+
1199
+ spread_inc = float(weapon.spread_heat_inc) if weapon.spread_heat_inc is not None else 0.0
1200
+ player.spread_heat = min(0.48, max(0.01, player.spread_heat + spread_inc))
1201
+
1202
+ theta = math.atan2(player.aim_y, player.aim_x)
1203
+ if player.spread_heat > 0.0:
1204
+ theta += (self._crand_float01() * 2.0 - 1.0) * player.spread_heat
1205
+ angle = theta + math.pi / 2.0
1206
+
1207
+ muzzle_x = player.x + player.aim_x * 16.0
1208
+ muzzle_y = player.y + player.aim_y * 16.0
1209
+
1210
+ play_sfx(self._state.audio, resolve_weapon_sfx_ref(weapon.fire_sound), rng=self._state.rng)
1211
+
1212
+ if player.weapon_id in {WeaponId.GAUSS_GUN, WeaponId.ION_MINIGUN}:
1213
+ meta = float(weapon.projectile_meta) if weapon.projectile_meta is not None else 0.0
1214
+ if meta <= 0.0:
1215
+ meta = 45.0
1216
+ type_id = projectile_type_id_from_weapon_id(player.weapon_id)
1217
+ if type_id is None:
1218
+ return
1219
+ self._projectile_pool.spawn(
1220
+ pos_x=muzzle_x,
1221
+ pos_y=muzzle_y,
1222
+ angle=angle,
1223
+ type_id=type_id,
1224
+ owner_id=-100,
1225
+ base_damage=meta,
1226
+ )
1227
+ elif player.weapon_id == WeaponId.ROCKET_LAUNCHER:
1228
+ self._secondary_projectile_pool.spawn(
1229
+ pos_x=muzzle_x,
1230
+ pos_y=muzzle_y,
1231
+ angle=angle,
1232
+ type_id=1,
1233
+ )
1234
+ elif player.weapon_id == WeaponId.PULSE_GUN:
1235
+ self._secondary_projectile_pool.spawn(
1236
+ pos_x=muzzle_x,
1237
+ pos_y=muzzle_y,
1238
+ angle=angle,
1239
+ type_id=4,
1240
+ )
1241
+
1242
+ player.ammo = max(0, player.ammo - 1)
1243
+ if player.ammo <= 0:
1244
+ reload_time = float(weapon.reload_time) if weapon.reload_time is not None else 0.0
1245
+ if self._bonus_weapon_power_up_timer > 0.0:
1246
+ reload_time *= 0.6
1247
+ player.reload_timer_max = max(0.0, reload_time)
1248
+ player.reload_timer = player.reload_timer_max
1249
+ play_sfx(self._state.audio, resolve_weapon_sfx_ref(weapon.reload_sound), rng=self._state.rng)
1250
+
1251
+ def _update_projectiles(self, dt: float) -> None:
1252
+ damage_scale_by_type: dict[int, float] = {}
1253
+ for type_id in (0x05, 0x0B, 0x15):
1254
+ weapon = weapon_entry_for_projectile_type_id(type_id)
1255
+ scale = float(weapon.damage_scale) if weapon is not None and weapon.damage_scale is not None else 0.0
1256
+ damage_scale_by_type[type_id] = scale if scale > 0.0 else 1.0
1257
+
1258
+ hits = self._projectile_pool.update(
1259
+ dt,
1260
+ self._creatures,
1261
+ world_size=WORLD_SIZE,
1262
+ damage_scale_by_type=damage_scale_by_type,
1263
+ rng=self._crand.rand,
1264
+ )
1265
+ for type_id, origin_x, origin_y, hit_x, hit_y, *_ in hits:
1266
+ if type_id == 0x15:
1267
+ self._beams.append(
1268
+ DemoBeam(
1269
+ x0=origin_x,
1270
+ y0=origin_y,
1271
+ x1=hit_x,
1272
+ y1=hit_y,
1273
+ life=0.08,
1274
+ )
1275
+ )
1276
+ if type_id == 0x0B:
1277
+ self._explosions.append(
1278
+ DemoExplosion(
1279
+ kind="rocket",
1280
+ x=hit_x,
1281
+ y=hit_y,
1282
+ elapsed=0.0,
1283
+ duration=0.35,
1284
+ max_radius=90.0,
1285
+ damage_per_tick=0.0,
1286
+ tick_interval=1.0,
1287
+ )
1288
+ )
1289
+
1290
+ self._secondary_projectile_pool.update_pulse_gun(dt, self._creatures)
1291
+ self._creatures = [c for c in self._creatures if c.hp > 0.0]
1292
+
1293
+ def _update_fx(self, dt: float) -> None:
1294
+ if self._beams:
1295
+ beams: list[DemoBeam] = []
1296
+ for beam in self._beams:
1297
+ beam.life -= dt
1298
+ if beam.life > 0.0:
1299
+ beams.append(beam)
1300
+ self._beams = beams
1301
+
1302
+ if not self._explosions:
1303
+ return
1304
+ survivors: list[DemoExplosion] = []
1305
+ for fx in self._explosions:
1306
+ fx.elapsed += dt
1307
+ if fx.damage_per_tick > 0.0 and fx.tick_interval > 0.0:
1308
+ fx.tick_accum += dt
1309
+ while fx.tick_accum >= fx.tick_interval:
1310
+ fx.tick_accum -= fx.tick_interval
1311
+ self._apply_explosion_damage(fx)
1312
+ if fx.elapsed < fx.duration:
1313
+ survivors.append(fx)
1314
+ self._explosions = survivors
1315
+ self._creatures = [c for c in self._creatures if c.hp > 0.0]
1316
+
1317
+ def _apply_explosion_damage(self, fx: DemoExplosion) -> None:
1318
+ t = fx.elapsed / fx.duration if fx.duration > 0 else 1.0
1319
+ radius = fx.max_radius * _clamp(t, 0.0, 1.0)
1320
+ rsq = radius * radius
1321
+ for creature in self._creatures:
1322
+ if creature.hp <= 0.0:
1323
+ continue
1324
+ if _distance_sq(fx.x, fx.y, creature.x, creature.y) <= rsq:
1325
+ creature.hp -= fx.damage_per_tick
1326
+
1327
+ def _update_camera(self, dt: float) -> None:
1328
+ if not self._players:
1329
+ return
1330
+ screen_w = float(self._state.config.screen_width)
1331
+ screen_h = float(self._state.config.screen_height)
1332
+ if screen_w > WORLD_SIZE:
1333
+ screen_w = WORLD_SIZE
1334
+ if screen_h > WORLD_SIZE:
1335
+ screen_h = WORLD_SIZE
1336
+
1337
+ if len(self._players) == 1:
1338
+ focus_x = self._players[0].x
1339
+ focus_y = self._players[0].y
1340
+ else:
1341
+ focus_x = sum(p.x for p in self._players) / len(self._players)
1342
+ focus_y = sum(p.y for p in self._players) / len(self._players)
1343
+
1344
+ desired_x = (screen_w * 0.5) - focus_x
1345
+ desired_y = (screen_h * 0.5) - focus_y
1346
+
1347
+ min_x = screen_w - WORLD_SIZE
1348
+ min_y = screen_h - WORLD_SIZE
1349
+ if desired_x > -1.0:
1350
+ desired_x = -1.0
1351
+ if desired_y > -1.0:
1352
+ desired_y = -1.0
1353
+ if desired_x < min_x:
1354
+ desired_x = min_x
1355
+ if desired_y < min_y:
1356
+ desired_y = min_y
1357
+
1358
+ t = _clamp(dt * 6.0, 0.0, 1.0)
1359
+ self._camera_x = _lerp(self._camera_x, desired_x, t)
1360
+ self._camera_y = _lerp(self._camera_y, desired_y, t)