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,792 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import random
5
+
6
+ import pyray as rl
7
+
8
+ from grim.assets import PaqTextureCache
9
+ from grim.audio import AudioState
10
+ from grim.config import CrimsonConfig
11
+ from grim.view import ViewContext
12
+
13
+ from ..creatures.spawn import advance_survival_spawn_stage, tick_survival_wave_spawns
14
+ from ..debug import debug_enabled
15
+ from ..game_modes import GameMode
16
+ from ..gameplay import PlayerInput, most_used_weapon_id_for_player, perk_selection_current_choices, perk_selection_pick, survival_check_level_up
17
+ from ..persistence.highscores import HighScoreRecord
18
+ from ..perks import PerkId, perk_display_description, perk_display_name
19
+ from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
20
+ from ..ui.hud import draw_hud_overlay
21
+ from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
22
+ from ..ui.perk_menu import (
23
+ PerkMenuLayout,
24
+ UiButtonState,
25
+ button_draw,
26
+ button_update,
27
+ button_width,
28
+ draw_menu_panel,
29
+ draw_menu_item,
30
+ draw_ui_text,
31
+ load_perk_menu_assets,
32
+ menu_item_hit_rect,
33
+ perk_menu_compute_layout,
34
+ ui_origin,
35
+ ui_scale,
36
+ wrap_ui_text,
37
+ )
38
+ from .base_gameplay_mode import BaseGameplayMode, _clamp
39
+
40
+ WORLD_SIZE = 1024.0
41
+
42
+ UI_TEXT_SCALE = 1.0
43
+ UI_TEXT_COLOR = rl.Color(220, 220, 220, 255)
44
+ UI_HINT_COLOR = rl.Color(140, 140, 140, 255)
45
+ UI_SPONSOR_COLOR = rl.Color(255, 255, 255, int(255 * 0.5))
46
+ UI_ERROR_COLOR = rl.Color(240, 80, 80, 255)
47
+
48
+ PERK_PROMPT_MAX_TIMER_MS = 200.0
49
+ PERK_PROMPT_OUTSET_X = 50.0
50
+ # Perk prompt bar geometry comes from `ui_menu_assets_init` + `ui_menu_layout_init`:
51
+ # - `ui_menu_item_element` is set_rect(512x64, offset -72,-60)
52
+ # - the perk prompt mutates quad coords: x = (x - 300) * 0.75, y = y * 0.75
53
+ PERK_PROMPT_BAR_SCALE = 0.75
54
+ PERK_PROMPT_BAR_BASE_OFFSET_X = -72.0
55
+ PERK_PROMPT_BAR_BASE_OFFSET_Y = -60.0
56
+ PERK_PROMPT_BAR_SHIFT_X = -300.0
57
+
58
+ # `ui_textLevelUp` is set_rect(75x25, offset -230,-27), then its quad coords are:
59
+ # x = x * 0.85 - 46, y = y * 0.85 - 4
60
+ PERK_PROMPT_LEVEL_UP_SCALE = 0.85
61
+ PERK_PROMPT_LEVEL_UP_BASE_OFFSET_X = -230.0
62
+ PERK_PROMPT_LEVEL_UP_BASE_OFFSET_Y = -27.0
63
+ PERK_PROMPT_LEVEL_UP_BASE_W = 75.0
64
+ PERK_PROMPT_LEVEL_UP_BASE_H = 25.0
65
+ PERK_PROMPT_LEVEL_UP_SHIFT_X = -46.0
66
+ PERK_PROMPT_LEVEL_UP_SHIFT_Y = -4.0
67
+
68
+ PERK_PROMPT_TEXT_MARGIN_X = 16.0
69
+ PERK_PROMPT_TEXT_OFFSET_Y = 8.0
70
+
71
+ PERK_MENU_TRANSITION_MS = 500.0
72
+
73
+
74
+ @dataclass(slots=True)
75
+ class _SurvivalState:
76
+ elapsed_ms: float = 0.0
77
+ stage: int = 0
78
+ spawn_cooldown: float = 0.0
79
+
80
+
81
+ class SurvivalMode(BaseGameplayMode):
82
+ def __init__(
83
+ self,
84
+ ctx: ViewContext,
85
+ *,
86
+ texture_cache: PaqTextureCache | None = None,
87
+ config: CrimsonConfig | None = None,
88
+ audio: AudioState | None = None,
89
+ audio_rng: random.Random | None = None,
90
+ ) -> None:
91
+ super().__init__(
92
+ ctx,
93
+ world_size=WORLD_SIZE,
94
+ default_game_mode_id=int(GameMode.SURVIVAL),
95
+ demo_mode_active=False,
96
+ difficulty_level=0,
97
+ hardcore=False,
98
+ texture_cache=texture_cache,
99
+ config=config,
100
+ audio=audio,
101
+ audio_rng=audio_rng,
102
+ )
103
+ self._survival = _SurvivalState()
104
+
105
+ self._perk_prompt_timer_ms = 0.0
106
+ self._perk_prompt_hover = False
107
+ self._perk_prompt_pulse = 0.0
108
+ self._perk_menu_open = False
109
+ self._perk_menu_selected = 0
110
+ self._perk_menu_timeline_ms = 0.0
111
+ self._hud_fade_ms = PERK_MENU_TRANSITION_MS
112
+ self._perk_menu_assets = None
113
+ self._perk_ui_layout = PerkMenuLayout()
114
+ self._perk_cancel_button = UiButtonState("Cancel")
115
+ self._cursor_time = 0.0
116
+
117
+ def _wrap_ui_text(self, text: str, *, max_width: float, scale: float = UI_TEXT_SCALE) -> list[str]:
118
+ lines: list[str] = []
119
+ for raw in text.splitlines() or [""]:
120
+ para = raw.strip()
121
+ if not para:
122
+ lines.append("")
123
+ continue
124
+ current = ""
125
+ for word in para.split():
126
+ candidate = word if not current else f"{current} {word}"
127
+ if current and self._ui_text_width(candidate, scale) > max_width:
128
+ lines.append(current)
129
+ current = word
130
+ else:
131
+ current = candidate
132
+ if current:
133
+ lines.append(current)
134
+ return lines
135
+
136
+ def _camera_world_to_screen(self, x: float, y: float) -> tuple[float, float]:
137
+ return self._world.world_to_screen(x, y)
138
+
139
+ def _camera_screen_to_world(self, x: float, y: float) -> tuple[float, float]:
140
+ return self._world.screen_to_world(x, y)
141
+
142
+ def open(self) -> None:
143
+ super().open()
144
+
145
+ self._perk_menu_assets = load_perk_menu_assets(self._assets_root)
146
+ if self._perk_menu_assets.missing:
147
+ self._missing_assets.extend(self._perk_menu_assets.missing)
148
+ self._perk_ui_layout = PerkMenuLayout()
149
+ self._perk_cancel_button = UiButtonState("Cancel")
150
+ self._cursor_time = 0.0
151
+ self._cursor_pulse_time = 0.0
152
+ self._survival = _SurvivalState()
153
+
154
+ self._perk_prompt_timer_ms = 0.0
155
+ self._perk_prompt_hover = False
156
+ self._perk_prompt_pulse = 0.0
157
+ self._perk_menu_open = False
158
+ self._perk_menu_selected = 0
159
+ self._perk_menu_timeline_ms = 0.0
160
+ self._hud_fade_ms = PERK_MENU_TRANSITION_MS
161
+
162
+ def close(self) -> None:
163
+ if self._perk_menu_assets is not None:
164
+ self._perk_menu_assets = None
165
+ super().close()
166
+
167
+ def _handle_input(self) -> None:
168
+ if self._game_over_active:
169
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
170
+ self._action = "back_to_menu"
171
+ self.close_requested = True
172
+ return
173
+ if self._perk_menu_open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
174
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
175
+ self._close_perk_menu()
176
+ return
177
+
178
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
179
+ self._paused = not self._paused
180
+
181
+ if debug_enabled() and rl.is_key_pressed(rl.KeyboardKey.KEY_X):
182
+ self._player.experience += 5000
183
+ survival_check_level_up(self._player, self._state.perk_selection)
184
+
185
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
186
+ self.close_requested = True
187
+
188
+ def _build_input(self) -> PlayerInput:
189
+ keybinds = config_keybinds(self._config)
190
+ if not keybinds:
191
+ keybinds = (0x11, 0x1F, 0x1E, 0x20, 0x100)
192
+ up_key, down_key, left_key, right_key, fire_key = player_move_fire_binds(keybinds, 0)
193
+
194
+ move_x = 0.0
195
+ move_y = 0.0
196
+ if input_code_is_down(left_key):
197
+ move_x -= 1.0
198
+ if input_code_is_down(right_key):
199
+ move_x += 1.0
200
+ if input_code_is_down(up_key):
201
+ move_y -= 1.0
202
+ if input_code_is_down(down_key):
203
+ move_y += 1.0
204
+
205
+ mouse = self._ui_mouse_pos()
206
+ aim_x, aim_y = self._camera_screen_to_world(float(mouse.x), float(mouse.y))
207
+
208
+ fire_down = input_code_is_down(fire_key)
209
+ fire_pressed = input_code_is_pressed(fire_key)
210
+ reload_key = 0x102
211
+ if self._config is not None:
212
+ reload_key = int(self._config.data.get("keybind_reload", reload_key) or reload_key)
213
+ reload_pressed = input_code_is_pressed(reload_key)
214
+
215
+ return PlayerInput(
216
+ move_x=move_x,
217
+ move_y=move_y,
218
+ aim_x=aim_x,
219
+ aim_y=aim_y,
220
+ fire_down=fire_down,
221
+ fire_pressed=fire_pressed,
222
+ reload_pressed=reload_pressed,
223
+ )
224
+
225
+ def _player_name_default(self) -> str:
226
+ config = self._config
227
+ if config is None:
228
+ return ""
229
+ raw = config.data.get("player_name")
230
+ if isinstance(raw, (bytes, bytearray)):
231
+ return bytes(raw).split(b"\x00", 1)[0].decode("latin-1", errors="ignore")
232
+ if isinstance(raw, str):
233
+ return raw
234
+ return ""
235
+
236
+ def _enter_game_over(self) -> None:
237
+ if self._game_over_active:
238
+ return
239
+ record = HighScoreRecord.blank()
240
+ record.score_xp = int(self._player.experience)
241
+ record.survival_elapsed_ms = int(self._survival.elapsed_ms)
242
+ record.creature_kill_count = int(self._creatures.kill_count)
243
+
244
+ weapon_id = most_used_weapon_id_for_player(self._state, player_index=int(self._player.index), fallback_weapon_id=int(self._player.weapon_id))
245
+ record.most_used_weapon_id = int(weapon_id)
246
+ fired = 0
247
+ hit = 0
248
+ try:
249
+ fired = int(self._state.shots_fired[int(self._player.index)])
250
+ hit = int(self._state.shots_hit[int(self._player.index)])
251
+ except Exception:
252
+ fired = 0
253
+ hit = 0
254
+ fired = max(0, int(fired))
255
+ hit = max(0, min(int(hit), fired))
256
+ record.shots_fired = fired
257
+ record.shots_hit = hit
258
+
259
+ record.game_mode_id = int(self._config.data.get("game_mode", 1)) if self._config is not None else 1
260
+ self._game_over_record = record
261
+ self._game_over_ui.open()
262
+ self._game_over_active = True
263
+ self._perk_menu_open = False
264
+
265
+ def _perk_prompt_label(self) -> str:
266
+ if self._config is not None and not bool(int(self._config.data.get("ui_info_texts", 1) or 0)):
267
+ return ""
268
+ pending = int(self._state.perk_selection.pending_count)
269
+ if pending <= 0:
270
+ return ""
271
+ suffix = f" ({pending})" if pending > 1 else ""
272
+ return f"Press Mouse2 to pick a perk{suffix}"
273
+
274
+ def _perk_prompt_hinge(self) -> tuple[float, float]:
275
+ screen_w = float(rl.get_screen_width())
276
+ hinge_x = screen_w + PERK_PROMPT_OUTSET_X
277
+ hinge_y = 80.0 if int(screen_w) == 640 else 40.0
278
+ return hinge_x, hinge_y
279
+
280
+ def _perk_prompt_rect(self, label: str, *, scale: float = UI_TEXT_SCALE) -> rl.Rectangle:
281
+ hinge_x, hinge_y = self._perk_prompt_hinge()
282
+ if self._perk_menu_assets is not None and self._perk_menu_assets.menu_item is not None:
283
+ tex = self._perk_menu_assets.menu_item
284
+ bar_w = float(tex.width) * PERK_PROMPT_BAR_SCALE
285
+ bar_h = float(tex.height) * PERK_PROMPT_BAR_SCALE
286
+ local_x = (PERK_PROMPT_BAR_BASE_OFFSET_X + PERK_PROMPT_BAR_SHIFT_X) * PERK_PROMPT_BAR_SCALE
287
+ local_y = PERK_PROMPT_BAR_BASE_OFFSET_Y * PERK_PROMPT_BAR_SCALE
288
+
289
+ return rl.Rectangle(
290
+ float(hinge_x + local_x),
291
+ float(hinge_y + local_y),
292
+ float(bar_w),
293
+ float(bar_h),
294
+ )
295
+
296
+ margin = 16.0 * scale
297
+ text_w = float(self._ui_text_width(label, scale))
298
+ text_h = float(self._ui_line_height(scale))
299
+ x = float(rl.get_screen_width()) - margin - text_w
300
+ y = margin
301
+ return rl.Rectangle(x, y, text_w, text_h)
302
+
303
+ def _open_perk_menu(self) -> None:
304
+ if self._perk_menu_open:
305
+ return
306
+ players = self._world.players
307
+ choices = perk_selection_current_choices(
308
+ self._state,
309
+ players,
310
+ self._state.perk_selection,
311
+ game_mode=int(GameMode.SURVIVAL),
312
+ player_count=len(players),
313
+ )
314
+ if not choices:
315
+ self._perk_menu_open = False
316
+ return
317
+ self._world.audio_router.play_sfx("sfx_ui_panelclick")
318
+ self._perk_menu_open = True
319
+ self._perk_menu_selected = 0
320
+
321
+ def _close_perk_menu(self) -> None:
322
+ self._perk_menu_open = False
323
+ if int(self._state.perk_selection.pending_count) > 0:
324
+ # Reset the prompt swing so each pending perk replays the intro.
325
+ self._perk_prompt_timer_ms = 0.0
326
+ self._perk_prompt_hover = False
327
+ self._perk_prompt_pulse = 0.0
328
+
329
+ def _perk_menu_handle_input(self, dt_frame: float, dt_ms: float) -> None:
330
+ if self._perk_menu_assets is None:
331
+ self._close_perk_menu()
332
+ return
333
+ perk_state = self._state.perk_selection
334
+ players = self._world.players
335
+ choices = perk_selection_current_choices(
336
+ self._state,
337
+ players,
338
+ perk_state,
339
+ game_mode=int(GameMode.SURVIVAL),
340
+ player_count=len(players),
341
+ )
342
+ if not choices:
343
+ self._close_perk_menu()
344
+ return
345
+ if self._perk_menu_selected >= len(choices):
346
+ self._perk_menu_selected = 0
347
+
348
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
349
+ self._perk_menu_selected = (self._perk_menu_selected + 1) % len(choices)
350
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
351
+ self._perk_menu_selected = (self._perk_menu_selected - 1) % len(choices)
352
+
353
+ screen_w = float(rl.get_screen_width())
354
+ screen_h = float(rl.get_screen_height())
355
+ scale = ui_scale(screen_w, screen_h)
356
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
357
+ menu_t = _clamp(self._perk_menu_timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
358
+ slide_x = (menu_t - 1.0) * (self._perk_ui_layout.panel_w * scale)
359
+
360
+ mouse = self._ui_mouse_pos()
361
+ click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
362
+
363
+ master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
364
+ expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
365
+ computed = perk_menu_compute_layout(
366
+ self._perk_ui_layout,
367
+ screen_w=screen_w,
368
+ origin_x=origin_x + slide_x,
369
+ origin_y=origin_y,
370
+ scale=scale,
371
+ choice_count=len(choices),
372
+ expert_owned=expert_owned,
373
+ master_owned=master_owned,
374
+ )
375
+
376
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
377
+ for idx, perk_id in enumerate(choices):
378
+ label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
379
+ item_x = computed.list_x
380
+ item_y = computed.list_y + float(idx) * computed.list_step_y
381
+ rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
382
+ if rl.check_collision_point_rec(mouse, rect):
383
+ self._perk_menu_selected = idx
384
+ if click:
385
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
386
+ picked = perk_selection_pick(
387
+ self._state,
388
+ players,
389
+ perk_state,
390
+ idx,
391
+ game_mode=int(GameMode.SURVIVAL),
392
+ player_count=len(players),
393
+ dt=dt_frame,
394
+ creatures=self._creatures.entries,
395
+ )
396
+ if picked is not None:
397
+ self._world.audio_router.play_sfx("sfx_ui_bonus")
398
+ self._close_perk_menu()
399
+ return
400
+ break
401
+
402
+ cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
403
+ cancel_x = computed.cancel_x
404
+ button_y = computed.cancel_y
405
+
406
+ if button_update(
407
+ self._perk_cancel_button,
408
+ x=cancel_x,
409
+ y=button_y,
410
+ width=cancel_w,
411
+ dt_ms=dt_ms,
412
+ mouse=mouse,
413
+ click=click,
414
+ ):
415
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
416
+ self._close_perk_menu()
417
+ return
418
+
419
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) or rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
420
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
421
+ picked = perk_selection_pick(
422
+ self._state,
423
+ players,
424
+ perk_state,
425
+ self._perk_menu_selected,
426
+ game_mode=int(GameMode.SURVIVAL),
427
+ player_count=len(players),
428
+ dt=dt_frame,
429
+ creatures=self._creatures.entries,
430
+ )
431
+ if picked is not None:
432
+ self._world.audio_router.play_sfx("sfx_ui_bonus")
433
+ self._close_perk_menu()
434
+
435
+ def update(self, dt: float) -> None:
436
+ self._update_audio(dt)
437
+
438
+ dt_frame, dt_ui_ms = self._tick_frame(dt)
439
+ self._cursor_time += dt_frame
440
+ self._handle_input()
441
+
442
+ if self._game_over_active:
443
+ record = self._game_over_record
444
+ if record is None:
445
+ self._enter_game_over()
446
+ record = self._game_over_record
447
+ if record is not None:
448
+ action = self._game_over_ui.update(
449
+ dt,
450
+ record=record,
451
+ player_name_default=self._player_name_default(),
452
+ play_sfx=self._world.audio_router.play_sfx,
453
+ rand=self._state.rng.rand,
454
+ mouse=self._ui_mouse_pos(),
455
+ )
456
+ if action == "play_again":
457
+ self.open()
458
+ return
459
+ if action == "high_scores":
460
+ self._action = "open_high_scores"
461
+ return
462
+ if action == "main_menu":
463
+ self._action = "back_to_menu"
464
+ self.close_requested = True
465
+ return
466
+ return
467
+
468
+ any_alive = any(player.health > 0.0 for player in self._world.players)
469
+ perk_pending = int(self._state.perk_selection.pending_count) > 0 and any_alive
470
+
471
+ self._perk_prompt_hover = False
472
+ if self._perk_menu_open:
473
+ self._perk_menu_handle_input(dt_frame, dt_ui_ms)
474
+ dt = 0.0
475
+
476
+ perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
477
+
478
+ if (not perk_menu_active) and perk_pending and (not self._paused):
479
+ label = self._perk_prompt_label()
480
+ if label:
481
+ rect = self._perk_prompt_rect(label)
482
+ mouse = self._ui_mouse_pos()
483
+ self._perk_prompt_hover = rl.check_collision_point_rec(mouse, rect)
484
+
485
+ keybinds = config_keybinds(self._config)
486
+ if not keybinds:
487
+ keybinds = (0x11, 0x1F, 0x1E, 0x20, 0x100)
488
+ _up_key, _down_key, _left_key, _right_key, fire_key = player_move_fire_binds(keybinds, 0)
489
+
490
+ pick_key = 0x101
491
+ if self._config is not None:
492
+ pick_key = int(self._config.data.get("keybind_pick_perk", pick_key) or pick_key)
493
+
494
+ if input_code_is_pressed(pick_key) and (not input_code_is_down(fire_key)):
495
+ self._perk_prompt_pulse = 1000.0
496
+ self._open_perk_menu()
497
+ elif self._perk_prompt_hover and input_code_is_pressed(fire_key):
498
+ self._perk_prompt_pulse = 1000.0
499
+ self._open_perk_menu()
500
+
501
+ if not self._paused and not self._game_over_active:
502
+ pulse_delta = dt_ui_ms * (6.0 if self._perk_prompt_hover else -2.0)
503
+ self._perk_prompt_pulse = _clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)
504
+
505
+ if self._paused or (not any_alive) or perk_menu_active:
506
+ dt = 0.0
507
+
508
+ prompt_active = perk_pending and (not perk_menu_active) and (not self._paused)
509
+ if prompt_active:
510
+ self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
511
+ else:
512
+ self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
513
+
514
+ if self._perk_menu_open:
515
+ self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
516
+ else:
517
+ self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms - dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
518
+ if self._perk_menu_timeline_ms > 1e-3 or self._perk_menu_open:
519
+ self._hud_fade_ms = 0.0
520
+ else:
521
+ self._hud_fade_ms = _clamp(self._hud_fade_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
522
+
523
+ self._survival.elapsed_ms += dt * 1000.0
524
+
525
+ if dt <= 0.0:
526
+ if not any_alive:
527
+ self._enter_game_over()
528
+ return
529
+
530
+ input_state = self._build_input()
531
+ self._world.update(
532
+ dt,
533
+ inputs=[input_state for _ in self._world.players],
534
+ auto_pick_perks=False,
535
+ game_mode=int(GameMode.SURVIVAL),
536
+ perk_progression_enabled=True,
537
+ )
538
+
539
+ # Scripted milestone spawns based on level.
540
+ stage, milestone_calls = advance_survival_spawn_stage(self._survival.stage, player_level=self._player.level)
541
+ self._survival.stage = stage
542
+ for call in milestone_calls:
543
+ self._creatures.spawn_template(
544
+ int(call.template_id),
545
+ call.pos,
546
+ float(call.heading),
547
+ self._state.rng,
548
+ rand=self._state.rng.rand,
549
+ )
550
+
551
+ # Regular wave spawns based on elapsed time.
552
+ cooldown, wave_spawns = tick_survival_wave_spawns(
553
+ self._survival.spawn_cooldown,
554
+ dt * 1000.0,
555
+ self._state.rng,
556
+ player_count=len(self._world.players),
557
+ survival_elapsed_ms=self._survival.elapsed_ms,
558
+ player_experience=self._player.experience,
559
+ terrain_width=int(self._world.world_size),
560
+ terrain_height=int(self._world.world_size),
561
+ )
562
+ self._survival.spawn_cooldown = cooldown
563
+ self._creatures.spawn_inits(wave_spawns)
564
+
565
+ if not any(player.health > 0.0 for player in self._world.players):
566
+ self._enter_game_over()
567
+
568
+ def _draw_perk_prompt(self) -> None:
569
+ if self._game_over_active:
570
+ return
571
+ if self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3:
572
+ return
573
+ if not any(player.health > 0.0 for player in self._world.players):
574
+ return
575
+ pending = int(self._state.perk_selection.pending_count)
576
+ if pending <= 0:
577
+ return
578
+ label = self._perk_prompt_label()
579
+ if not label:
580
+ return
581
+
582
+ alpha = float(self._perk_prompt_timer_ms) / PERK_PROMPT_MAX_TIMER_MS
583
+ if alpha <= 1e-3:
584
+ return
585
+
586
+ hinge_x, hinge_y = self._perk_prompt_hinge()
587
+ # Prompt swings counter-clockwise; raylib's Y-down makes positive rotation clockwise.
588
+ rot_deg = -(1.0 - alpha) * 90.0
589
+ tint = rl.Color(255, 255, 255, int(255 * alpha))
590
+
591
+ text_w = float(self._ui_text_width(label, UI_TEXT_SCALE))
592
+ x = float(rl.get_screen_width()) - PERK_PROMPT_TEXT_MARGIN_X - text_w
593
+ y = hinge_y + PERK_PROMPT_TEXT_OFFSET_Y
594
+ color = rl.Color(UI_TEXT_COLOR.r, UI_TEXT_COLOR.g, UI_TEXT_COLOR.b, int(255 * alpha))
595
+ draw_ui_text(self._small, label, x, y, scale=UI_TEXT_SCALE, color=color)
596
+
597
+ if self._perk_menu_assets is not None and self._perk_menu_assets.menu_item is not None:
598
+ tex = self._perk_menu_assets.menu_item
599
+ bar_w = float(tex.width) * PERK_PROMPT_BAR_SCALE
600
+ bar_h = float(tex.height) * PERK_PROMPT_BAR_SCALE
601
+ local_x = (PERK_PROMPT_BAR_BASE_OFFSET_X + PERK_PROMPT_BAR_SHIFT_X) * PERK_PROMPT_BAR_SCALE
602
+ local_y = PERK_PROMPT_BAR_BASE_OFFSET_Y * PERK_PROMPT_BAR_SCALE
603
+ src = rl.Rectangle(float(tex.width), 0.0, -float(tex.width), float(tex.height))
604
+ dst = rl.Rectangle(float(hinge_x), float(hinge_y), float(bar_w), float(bar_h))
605
+ origin = rl.Vector2(float(-local_x), float(-local_y))
606
+ rl.draw_texture_pro(tex, src, dst, origin, rot_deg, tint)
607
+
608
+ if self._perk_menu_assets is not None and self._perk_menu_assets.title_level_up is not None:
609
+ tex = self._perk_menu_assets.title_level_up
610
+ local_x = PERK_PROMPT_LEVEL_UP_BASE_OFFSET_X * PERK_PROMPT_LEVEL_UP_SCALE + PERK_PROMPT_LEVEL_UP_SHIFT_X
611
+ local_y = PERK_PROMPT_LEVEL_UP_BASE_OFFSET_Y * PERK_PROMPT_LEVEL_UP_SCALE + PERK_PROMPT_LEVEL_UP_SHIFT_Y
612
+ w = PERK_PROMPT_LEVEL_UP_BASE_W * PERK_PROMPT_LEVEL_UP_SCALE
613
+ h = PERK_PROMPT_LEVEL_UP_BASE_H * PERK_PROMPT_LEVEL_UP_SCALE
614
+ pulse_alpha = (100.0 + float(int(self._perk_prompt_pulse * 155.0 / 1000.0))) / 255.0
615
+ pulse_alpha = max(0.0, min(1.0, pulse_alpha))
616
+ label_alpha = max(0.0, min(1.0, alpha * pulse_alpha))
617
+ pulse_tint = rl.Color(255, 255, 255, int(255 * label_alpha))
618
+ src = rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height))
619
+ dst = rl.Rectangle(float(hinge_x), float(hinge_y), float(w), float(h))
620
+ origin = rl.Vector2(float(-local_x), float(-local_y))
621
+ rl.draw_texture_pro(tex, src, dst, origin, rot_deg, pulse_tint)
622
+ if label_alpha > 0.0:
623
+ rl.begin_blend_mode(rl.BLEND_ADDITIVE)
624
+ rl.draw_texture_pro(tex, src, dst, origin, rot_deg, pulse_tint)
625
+ rl.end_blend_mode()
626
+
627
+ def _draw_perk_menu(self) -> None:
628
+ if self._game_over_active:
629
+ return
630
+ menu_t = _clamp(self._perk_menu_timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
631
+ if menu_t <= 1e-3:
632
+ return
633
+ if self._perk_menu_assets is None:
634
+ return
635
+
636
+ perk_state = self._state.perk_selection
637
+ players = self._world.players
638
+ choices = perk_selection_current_choices(
639
+ self._state,
640
+ players,
641
+ perk_state,
642
+ game_mode=int(GameMode.SURVIVAL),
643
+ player_count=len(players),
644
+ )
645
+ if not choices:
646
+ return
647
+ screen_w = float(rl.get_screen_width())
648
+ screen_h = float(rl.get_screen_height())
649
+ scale = ui_scale(screen_w, screen_h)
650
+ origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
651
+ slide_x = (menu_t - 1.0) * (self._perk_ui_layout.panel_w * scale)
652
+
653
+ master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
654
+ expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
655
+ computed = perk_menu_compute_layout(
656
+ self._perk_ui_layout,
657
+ screen_w=screen_w,
658
+ origin_x=origin_x + slide_x,
659
+ origin_y=origin_y,
660
+ scale=scale,
661
+ choice_count=len(choices),
662
+ expert_owned=expert_owned,
663
+ master_owned=master_owned,
664
+ )
665
+
666
+ panel_tex = self._perk_menu_assets.menu_panel
667
+ if panel_tex is not None:
668
+ draw_menu_panel(panel_tex, dst=computed.panel)
669
+
670
+ title_tex = self._perk_menu_assets.title_pick_perk
671
+ if title_tex is not None:
672
+ src = rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height))
673
+ rl.draw_texture_pro(title_tex, src, computed.title, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
674
+
675
+ sponsor = None
676
+ if master_owned:
677
+ sponsor = "extra perks sponsored by the Perk Master"
678
+ elif expert_owned:
679
+ sponsor = "extra perk sponsored by the Perk Expert"
680
+ if sponsor:
681
+ draw_ui_text(
682
+ self._small,
683
+ sponsor,
684
+ computed.sponsor_x,
685
+ computed.sponsor_y,
686
+ scale=scale,
687
+ color=UI_SPONSOR_COLOR,
688
+ )
689
+
690
+ mouse = self._ui_mouse_pos()
691
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
692
+ for idx, perk_id in enumerate(choices):
693
+ label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
694
+ item_x = computed.list_x
695
+ item_y = computed.list_y + float(idx) * computed.list_step_y
696
+ rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
697
+ hovered = rl.check_collision_point_rec(mouse, rect) or (idx == self._perk_menu_selected)
698
+ draw_menu_item(self._small, label, x=item_x, y=item_y, scale=scale, hovered=hovered)
699
+
700
+ selected = choices[self._perk_menu_selected]
701
+ desc = perk_display_description(int(selected), fx_toggle=fx_toggle)
702
+ desc_x = float(computed.desc.x)
703
+ desc_y = float(computed.desc.y)
704
+ desc_w = float(computed.desc.width)
705
+ desc_h = float(computed.desc.height)
706
+ desc_scale = scale * 0.85
707
+ desc_lines = wrap_ui_text(self._small, desc, max_width=desc_w, scale=desc_scale)
708
+ line_h = float(self._small.cell_size * desc_scale) if self._small is not None else float(20 * desc_scale)
709
+ y = desc_y
710
+ for line in desc_lines:
711
+ if y + line_h > desc_y + desc_h:
712
+ break
713
+ draw_ui_text(self._small, line, desc_x, y, scale=desc_scale, color=UI_TEXT_COLOR)
714
+ y += line_h
715
+
716
+ cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
717
+ cancel_x = computed.cancel_x
718
+ button_y = computed.cancel_y
719
+ button_draw(self._perk_menu_assets, self._small, self._perk_cancel_button, x=cancel_x, y=button_y, width=cancel_w, scale=scale)
720
+
721
+ def _draw_game_cursor(self) -> None:
722
+ mouse_x = float(self._ui_mouse_x)
723
+ mouse_y = float(self._ui_mouse_y)
724
+ cursor_tex = self._perk_menu_assets.cursor if self._perk_menu_assets is not None else None
725
+ draw_menu_cursor(
726
+ self._world.particles_texture,
727
+ cursor_tex,
728
+ x=mouse_x,
729
+ y=mouse_y,
730
+ pulse_time=float(self._cursor_pulse_time),
731
+ )
732
+
733
+ def _draw_aim_cursor(self) -> None:
734
+ mouse_x = float(self._ui_mouse_x)
735
+ mouse_y = float(self._ui_mouse_y)
736
+ aim_tex = self._perk_menu_assets.aim if self._perk_menu_assets is not None else None
737
+ draw_aim_cursor(self._world.particles_texture, aim_tex, x=mouse_x, y=mouse_y)
738
+
739
+ def draw(self) -> None:
740
+ perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
741
+ self._world.draw(draw_aim_indicators=(not self._game_over_active) and (not perk_menu_active))
742
+ self._draw_screen_fade()
743
+
744
+ hud_bottom = 0.0
745
+ if (not self._game_over_active) and (not perk_menu_active) and self._hud_assets is not None:
746
+ hud_alpha = _clamp(self._hud_fade_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
747
+ hud_bottom = draw_hud_overlay(
748
+ self._hud_assets,
749
+ player=self._player,
750
+ players=self._world.players,
751
+ bonus_hud=self._state.bonus_hud,
752
+ elapsed_ms=self._survival.elapsed_ms,
753
+ score=self._player.experience,
754
+ font=self._small,
755
+ alpha=hud_alpha,
756
+ frame_dt_ms=self._last_dt_ms,
757
+ )
758
+
759
+ if (not self._game_over_active) and (not perk_menu_active):
760
+ # Minimal debug text.
761
+ x = 18.0
762
+ y = max(18.0, hud_bottom + 10.0)
763
+ line = float(self._ui_line_height())
764
+ self._draw_ui_text(f"survival: t={self._survival.elapsed_ms/1000.0:6.1f}s stage={self._survival.stage}", x, y, UI_TEXT_COLOR)
765
+ self._draw_ui_text(f"xp={self._player.experience} level={self._player.level} kills={self._creatures.kill_count}", x, y + line, UI_HINT_COLOR)
766
+ if self._paused:
767
+ self._draw_ui_text("paused (TAB)", x, y + line * 2.0, UI_HINT_COLOR)
768
+ if self._player.health <= 0.0:
769
+ self._draw_ui_text("game over", x, y + line * 2.0, UI_ERROR_COLOR)
770
+ warn_y = float(rl.get_screen_height()) - 28.0
771
+ if self._world.missing_assets:
772
+ warn = "Missing world assets: " + ", ".join(self._world.missing_assets)
773
+ self._draw_ui_text(warn, 24.0, warn_y, UI_ERROR_COLOR, scale=0.8)
774
+ warn_y -= float(self._ui_line_height(scale=0.8)) + 2.0
775
+ if self._hud_missing:
776
+ warn = "Missing HUD assets: " + ", ".join(self._hud_missing)
777
+ self._draw_ui_text(warn, 24.0, warn_y, UI_ERROR_COLOR, scale=0.8)
778
+
779
+ self._draw_perk_prompt()
780
+ self._draw_perk_menu()
781
+ if (not self._game_over_active) and perk_menu_active:
782
+ self._draw_game_cursor()
783
+ if (not self._game_over_active) and (not perk_menu_active):
784
+ self._draw_aim_cursor()
785
+
786
+ if self._game_over_active and self._game_over_record is not None:
787
+ self._game_over_ui.draw(
788
+ record=self._game_over_record,
789
+ banner_kind=self._game_over_banner,
790
+ hud_assets=self._hud_assets,
791
+ mouse=self._ui_mouse_pos(),
792
+ )