crimsonland 0.1.0.dev14__py3-none-any.whl → 0.1.0.dev16__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 (73) hide show
  1. crimson/cli.py +63 -0
  2. crimson/creatures/damage.py +111 -36
  3. crimson/creatures/runtime.py +246 -156
  4. crimson/creatures/spawn.py +7 -3
  5. crimson/debug.py +9 -0
  6. crimson/demo.py +38 -45
  7. crimson/effects.py +7 -13
  8. crimson/frontend/high_scores_layout.py +81 -0
  9. crimson/frontend/panels/base.py +4 -1
  10. crimson/frontend/panels/controls.py +0 -15
  11. crimson/frontend/panels/databases.py +291 -3
  12. crimson/frontend/panels/mods.py +0 -15
  13. crimson/frontend/panels/play_game.py +0 -16
  14. crimson/game.py +689 -3
  15. crimson/gameplay.py +921 -569
  16. crimson/modes/base_gameplay_mode.py +33 -12
  17. crimson/modes/components/__init__.py +2 -0
  18. crimson/modes/components/highscore_record_builder.py +58 -0
  19. crimson/modes/components/perk_menu_controller.py +325 -0
  20. crimson/modes/quest_mode.py +94 -272
  21. crimson/modes/rush_mode.py +12 -43
  22. crimson/modes/survival_mode.py +109 -330
  23. crimson/modes/tutorial_mode.py +46 -247
  24. crimson/modes/typo_mode.py +11 -38
  25. crimson/oracle.py +396 -0
  26. crimson/perks.py +5 -2
  27. crimson/player_damage.py +95 -36
  28. crimson/projectiles.py +539 -320
  29. crimson/render/projectile_draw_registry.py +637 -0
  30. crimson/render/projectile_render_registry.py +110 -0
  31. crimson/render/secondary_projectile_draw_registry.py +206 -0
  32. crimson/render/world_renderer.py +58 -707
  33. crimson/sim/world_state.py +118 -61
  34. crimson/typo/spawns.py +5 -12
  35. crimson/ui/demo_trial_overlay.py +3 -11
  36. crimson/ui/formatting.py +24 -0
  37. crimson/ui/game_over.py +12 -58
  38. crimson/ui/hud.py +72 -39
  39. crimson/ui/layout.py +20 -0
  40. crimson/ui/perk_menu.py +9 -34
  41. crimson/ui/quest_results.py +28 -70
  42. crimson/ui/text_input.py +20 -0
  43. crimson/views/_ui_helpers.py +27 -0
  44. crimson/views/aim_debug.py +15 -32
  45. crimson/views/animations.py +18 -28
  46. crimson/views/arsenal_debug.py +22 -32
  47. crimson/views/bonuses.py +23 -36
  48. crimson/views/camera_debug.py +16 -29
  49. crimson/views/camera_shake.py +9 -33
  50. crimson/views/corpse_stamp_debug.py +13 -21
  51. crimson/views/decals_debug.py +36 -23
  52. crimson/views/fonts.py +8 -25
  53. crimson/views/ground.py +4 -21
  54. crimson/views/lighting_debug.py +42 -45
  55. crimson/views/particles.py +33 -42
  56. crimson/views/perk_menu_debug.py +3 -10
  57. crimson/views/player.py +50 -44
  58. crimson/views/player_sprite_debug.py +24 -31
  59. crimson/views/projectile_fx.py +57 -52
  60. crimson/views/projectile_render_debug.py +24 -33
  61. crimson/views/projectiles.py +24 -37
  62. crimson/views/spawn_plan.py +13 -29
  63. crimson/views/sprites.py +14 -29
  64. crimson/views/terrain.py +6 -23
  65. crimson/views/ui.py +7 -24
  66. crimson/views/wicons.py +28 -33
  67. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/METADATA +1 -1
  68. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/RECORD +73 -62
  69. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/WHEEL +1 -1
  70. grim/config.py +29 -1
  71. grim/console.py +7 -10
  72. grim/math.py +12 -0
  73. {crimsonland-0.1.0.dev14.dist-info → crimsonland-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
@@ -9,36 +9,26 @@ from grim.assets import PaqTextureCache
9
9
  from grim.audio import AudioState
10
10
  from grim.console import ConsoleState
11
11
  from grim.config import CrimsonConfig
12
+ from grim.math import clamp
12
13
  from grim.view import ViewContext
13
14
 
14
15
  from ..creatures.spawn import advance_survival_spawn_stage, tick_survival_wave_spawns
15
16
  from ..debug import debug_enabled
16
17
  from ..game_modes import GameMode
17
- from ..gameplay import PlayerInput, most_used_weapon_id_for_player, perk_selection_current_choices, perk_selection_pick, survival_check_level_up
18
- from ..persistence.highscores import HighScoreRecord
19
- from ..perks import PerkId, perk_display_description, perk_display_name
18
+ from ..gameplay import (
19
+ PlayerInput,
20
+ survival_check_level_up,
21
+ weapon_assign_player,
22
+ )
23
+ from ..perks import PerkId
20
24
  from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
21
25
  from ..ui.hud import draw_hud_overlay, hud_flags_for_game_mode
22
- from ..ui.menu_panel import draw_classic_menu_panel
23
26
  from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
24
- from ..ui.perk_menu import (
25
- PERK_MENU_TRANSITION_MS,
26
- PerkMenuLayout,
27
- UiButtonState,
28
- button_draw,
29
- button_update,
30
- button_width,
31
- draw_menu_item,
32
- draw_ui_text,
33
- load_perk_menu_assets,
34
- menu_item_hit_rect,
35
- perk_menu_panel_slide_x,
36
- perk_menu_compute_layout,
37
- ui_origin,
38
- ui_scale,
39
- wrap_ui_text,
40
- )
41
- from .base_gameplay_mode import BaseGameplayMode, _clamp
27
+ from ..ui.perk_menu import PERK_MENU_TRANSITION_MS, draw_ui_text, load_perk_menu_assets
28
+ from ..weapons import WEAPON_BY_ID
29
+ from .base_gameplay_mode import BaseGameplayMode
30
+ from .components.highscore_record_builder import build_highscore_record_for_game_over
31
+ from .components.perk_menu_controller import PerkMenuContext, PerkMenuController
42
32
 
43
33
  WORLD_SIZE = 1024.0
44
34
 
@@ -71,6 +61,8 @@ PERK_PROMPT_LEVEL_UP_SHIFT_Y = -4.0
71
61
  PERK_PROMPT_TEXT_MARGIN_X = 16.0
72
62
  PERK_PROMPT_TEXT_OFFSET_Y = 8.0
73
63
 
64
+ _DEBUG_WEAPON_IDS = tuple(sorted(WEAPON_BY_ID))
65
+
74
66
 
75
67
  @dataclass(slots=True)
76
68
  class _SurvivalState:
@@ -108,15 +100,38 @@ class SurvivalMode(BaseGameplayMode):
108
100
  self._perk_prompt_timer_ms = 0.0
109
101
  self._perk_prompt_hover = False
110
102
  self._perk_prompt_pulse = 0.0
111
- self._perk_menu_open = False
112
- self._perk_menu_selected = 0
113
- self._perk_menu_timeline_ms = 0.0
103
+ self._perk_menu = PerkMenuController(on_close=self._reset_perk_prompt)
114
104
  self._hud_fade_ms = PERK_MENU_TRANSITION_MS
115
105
  self._perk_menu_assets = None
116
- self._perk_ui_layout = PerkMenuLayout()
117
- self._perk_cancel_button = UiButtonState("Cancel")
118
106
  self._cursor_time = 0.0
119
107
 
108
+ def _reset_perk_prompt(self) -> None:
109
+ if int(self._state.perk_selection.pending_count) > 0:
110
+ # Reset the prompt swing so each pending perk replays the intro.
111
+ self._perk_prompt_timer_ms = 0.0
112
+ self._perk_prompt_hover = False
113
+ self._perk_prompt_pulse = 0.0
114
+
115
+ def _perk_menu_context(self) -> PerkMenuContext:
116
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
117
+ fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
118
+ players = self._world.players
119
+ return PerkMenuContext(
120
+ state=self._state,
121
+ perk_state=self._state.perk_selection,
122
+ players=players,
123
+ creatures=self._creatures.entries,
124
+ player=self._player,
125
+ game_mode=int(GameMode.SURVIVAL),
126
+ player_count=len(players),
127
+ fx_toggle=fx_toggle,
128
+ fx_detail=fx_detail,
129
+ font=self._small,
130
+ assets=self._perk_menu_assets,
131
+ mouse=self._ui_mouse_pos(),
132
+ play_sfx=self._world.audio_router.play_sfx,
133
+ )
134
+
120
135
  def _wrap_ui_text(self, text: str, *, max_width: float, scale: float = UI_TEXT_SCALE) -> list[str]:
121
136
  lines: list[str] = []
122
137
  for raw in text.splitlines() or [""]:
@@ -148,8 +163,7 @@ class SurvivalMode(BaseGameplayMode):
148
163
  self._perk_menu_assets = load_perk_menu_assets(self._assets_root)
149
164
  if self._perk_menu_assets.missing:
150
165
  self._missing_assets.extend(self._perk_menu_assets.missing)
151
- self._perk_ui_layout = PerkMenuLayout()
152
- self._perk_cancel_button = UiButtonState("Cancel")
166
+ self._perk_menu.reset()
153
167
  self._cursor_time = 0.0
154
168
  self._cursor_pulse_time = 0.0
155
169
  self._survival = _SurvivalState()
@@ -157,9 +171,6 @@ class SurvivalMode(BaseGameplayMode):
157
171
  self._perk_prompt_timer_ms = 0.0
158
172
  self._perk_prompt_hover = False
159
173
  self._perk_prompt_pulse = 0.0
160
- self._perk_menu_open = False
161
- self._perk_menu_selected = 0
162
- self._perk_menu_timeline_ms = 0.0
163
174
  self._hud_fade_ms = PERK_MENU_TRANSITION_MS
164
175
 
165
176
  def close(self) -> None:
@@ -173,22 +184,46 @@ class SurvivalMode(BaseGameplayMode):
173
184
  self._action = "back_to_menu"
174
185
  self.close_requested = True
175
186
  return
176
- if self._perk_menu_open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
187
+ if self._perk_menu.open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
177
188
  self._world.audio_router.play_sfx("sfx_ui_buttonclick")
178
- self._close_perk_menu()
189
+ self._perk_menu.close()
179
190
  return
180
191
 
181
192
  if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
182
193
  self._paused = not self._paused
183
194
 
184
- if debug_enabled() and rl.is_key_pressed(rl.KeyboardKey.KEY_X):
185
- self._player.experience += 5000
186
- survival_check_level_up(self._player, self._state.perk_selection)
195
+ if debug_enabled() and (not self._perk_menu.open):
196
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F2):
197
+ self._state.debug_god_mode = not bool(self._state.debug_god_mode)
198
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
199
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F3):
200
+ self._state.perk_selection.pending_count += 1
201
+ self._state.perk_selection.choices_dirty = True
202
+ self._world.audio_router.play_sfx("sfx_ui_levelup")
203
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
204
+ self._debug_cycle_weapon(-1)
205
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
206
+ self._debug_cycle_weapon(1)
207
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_X):
208
+ self._player.experience += 5000
209
+ survival_check_level_up(self._player, self._state.perk_selection)
187
210
 
188
211
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
189
212
  self._action = "open_pause_menu"
190
213
  return
191
214
 
215
+ def _debug_cycle_weapon(self, delta: int) -> None:
216
+ weapon_ids = _DEBUG_WEAPON_IDS
217
+ if not weapon_ids:
218
+ return
219
+ current = int(self._player.weapon_id)
220
+ try:
221
+ idx = weapon_ids.index(current)
222
+ except ValueError:
223
+ idx = 0
224
+ weapon_id = int(weapon_ids[(idx + int(delta)) % len(weapon_ids)])
225
+ weapon_assign_player(self._player, weapon_id, state=self._state)
226
+
192
227
  def _build_input(self) -> PlayerInput:
193
228
  keybinds = config_keybinds(self._config)
194
229
  if not keybinds:
@@ -240,31 +275,18 @@ class SurvivalMode(BaseGameplayMode):
240
275
  def _enter_game_over(self) -> None:
241
276
  if self._game_over_active:
242
277
  return
243
- record = HighScoreRecord.blank()
244
- record.score_xp = int(self._player.experience)
245
- record.survival_elapsed_ms = int(self._survival.elapsed_ms)
246
- record.creature_kill_count = int(self._creatures.kill_count)
247
-
248
- weapon_id = most_used_weapon_id_for_player(self._state, player_index=int(self._player.index), fallback_weapon_id=int(self._player.weapon_id))
249
- record.most_used_weapon_id = int(weapon_id)
250
- fired = 0
251
- hit = 0
252
- try:
253
- fired = int(self._state.shots_fired[int(self._player.index)])
254
- hit = int(self._state.shots_hit[int(self._player.index)])
255
- except Exception:
256
- fired = 0
257
- hit = 0
258
- fired = max(0, int(fired))
259
- hit = max(0, min(int(hit), fired))
260
- record.shots_fired = fired
261
- record.shots_hit = hit
262
-
263
- record.game_mode_id = int(self._config.data.get("game_mode", 1)) if self._config is not None else 1
278
+ game_mode_id = int(self._config.data.get("game_mode", 1)) if self._config is not None else 1
279
+ record = build_highscore_record_for_game_over(
280
+ state=self._state,
281
+ player=self._player,
282
+ survival_elapsed_ms=int(self._survival.elapsed_ms),
283
+ creature_kill_count=int(self._creatures.kill_count),
284
+ game_mode_id=game_mode_id,
285
+ )
264
286
  self._game_over_record = record
265
287
  self._game_over_ui.open()
266
288
  self._game_over_active = True
267
- self._perk_menu_open = False
289
+ self._perk_menu.close()
268
290
 
269
291
  def _perk_prompt_label(self) -> str:
270
292
  if self._config is not None and not bool(int(self._config.data.get("ui_info_texts", 1) or 0)):
@@ -304,138 +326,6 @@ class SurvivalMode(BaseGameplayMode):
304
326
  y = margin
305
327
  return rl.Rectangle(x, y, text_w, text_h)
306
328
 
307
- def _open_perk_menu(self) -> None:
308
- if self._perk_menu_open:
309
- return
310
- players = self._world.players
311
- choices = perk_selection_current_choices(
312
- self._state,
313
- players,
314
- self._state.perk_selection,
315
- game_mode=int(GameMode.SURVIVAL),
316
- player_count=len(players),
317
- )
318
- if not choices:
319
- self._perk_menu_open = False
320
- return
321
- self._world.audio_router.play_sfx("sfx_ui_panelclick")
322
- self._perk_menu_open = True
323
- self._perk_menu_selected = 0
324
-
325
- def _close_perk_menu(self) -> None:
326
- self._perk_menu_open = False
327
- if int(self._state.perk_selection.pending_count) > 0:
328
- # Reset the prompt swing so each pending perk replays the intro.
329
- self._perk_prompt_timer_ms = 0.0
330
- self._perk_prompt_hover = False
331
- self._perk_prompt_pulse = 0.0
332
-
333
- def _perk_menu_handle_input(self, dt_frame: float, dt_ms: float) -> None:
334
- if self._perk_menu_assets is None:
335
- self._close_perk_menu()
336
- return
337
- perk_state = self._state.perk_selection
338
- players = self._world.players
339
- choices = perk_selection_current_choices(
340
- self._state,
341
- players,
342
- perk_state,
343
- game_mode=int(GameMode.SURVIVAL),
344
- player_count=len(players),
345
- )
346
- if not choices:
347
- self._close_perk_menu()
348
- return
349
- if self._perk_menu_selected >= len(choices):
350
- self._perk_menu_selected = 0
351
-
352
- if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
353
- self._perk_menu_selected = (self._perk_menu_selected + 1) % len(choices)
354
- if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
355
- self._perk_menu_selected = (self._perk_menu_selected - 1) % len(choices)
356
-
357
- screen_w = float(rl.get_screen_width())
358
- screen_h = float(rl.get_screen_height())
359
- scale = ui_scale(screen_w, screen_h)
360
- origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
361
- slide_x = perk_menu_panel_slide_x(self._perk_menu_timeline_ms, width=self._perk_ui_layout.panel_w)
362
-
363
- mouse = self._ui_mouse_pos()
364
- click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
365
-
366
- master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
367
- expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
368
- computed = perk_menu_compute_layout(
369
- self._perk_ui_layout,
370
- screen_w=screen_w,
371
- origin_x=origin_x,
372
- origin_y=origin_y,
373
- scale=scale,
374
- choice_count=len(choices),
375
- expert_owned=expert_owned,
376
- master_owned=master_owned,
377
- panel_slide_x=slide_x,
378
- )
379
-
380
- fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
381
- for idx, perk_id in enumerate(choices):
382
- label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
383
- item_x = computed.list_x
384
- item_y = computed.list_y + float(idx) * computed.list_step_y
385
- rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
386
- if rl.check_collision_point_rec(mouse, rect):
387
- self._perk_menu_selected = idx
388
- if click:
389
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
390
- picked = perk_selection_pick(
391
- self._state,
392
- players,
393
- perk_state,
394
- idx,
395
- game_mode=int(GameMode.SURVIVAL),
396
- player_count=len(players),
397
- dt=dt_frame,
398
- creatures=self._creatures.entries,
399
- )
400
- if picked is not None:
401
- self._world.audio_router.play_sfx("sfx_ui_bonus")
402
- self._close_perk_menu()
403
- return
404
- break
405
-
406
- cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
407
- cancel_x = computed.cancel_x
408
- button_y = computed.cancel_y
409
-
410
- if button_update(
411
- self._perk_cancel_button,
412
- x=cancel_x,
413
- y=button_y,
414
- width=cancel_w,
415
- dt_ms=dt_ms,
416
- mouse=mouse,
417
- click=click,
418
- ):
419
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
420
- self._close_perk_menu()
421
- return
422
-
423
- if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) or rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
424
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
425
- picked = perk_selection_pick(
426
- self._state,
427
- players,
428
- perk_state,
429
- self._perk_menu_selected,
430
- game_mode=int(GameMode.SURVIVAL),
431
- player_count=len(players),
432
- dt=dt_frame,
433
- creatures=self._creatures.entries,
434
- )
435
- if picked is not None:
436
- self._world.audio_router.play_sfx("sfx_ui_bonus")
437
- self._close_perk_menu()
438
-
439
329
  def update(self, dt: float) -> None:
440
330
  self._update_audio(dt)
441
331
 
@@ -446,40 +336,19 @@ class SurvivalMode(BaseGameplayMode):
446
336
  return
447
337
 
448
338
  if self._game_over_active:
449
- record = self._game_over_record
450
- if record is None:
451
- self._enter_game_over()
452
- record = self._game_over_record
453
- if record is not None:
454
- action = self._game_over_ui.update(
455
- dt,
456
- record=record,
457
- player_name_default=self._player_name_default(),
458
- play_sfx=self._world.audio_router.play_sfx,
459
- rand=self._state.rng.rand,
460
- mouse=self._ui_mouse_pos(),
461
- )
462
- if action == "play_again":
463
- self.open()
464
- return
465
- if action == "high_scores":
466
- self._action = "open_high_scores"
467
- return
468
- if action == "main_menu":
469
- self._action = "back_to_menu"
470
- self.close_requested = True
471
- return
339
+ self._update_game_over_ui(dt)
472
340
  return
473
341
 
474
342
  any_alive = any(player.health > 0.0 for player in self._world.players)
475
343
  perk_pending = int(self._state.perk_selection.pending_count) > 0 and any_alive
476
344
 
477
345
  self._perk_prompt_hover = False
478
- if self._perk_menu_open:
479
- self._perk_menu_handle_input(dt_frame, dt_ui_ms)
346
+ perk_ctx = self._perk_menu_context()
347
+ if self._perk_menu.open:
348
+ self._perk_menu.handle_input(perk_ctx, dt_frame=dt_frame, dt_ui_ms=dt_ui_ms)
480
349
  dt = 0.0
481
350
 
482
- perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
351
+ perk_menu_active = self._perk_menu.active
483
352
 
484
353
  if (not perk_menu_active) and perk_pending and (not self._paused):
485
354
  label = self._perk_prompt_label()
@@ -499,32 +368,29 @@ class SurvivalMode(BaseGameplayMode):
499
368
 
500
369
  if input_code_is_pressed(pick_key) and (not input_code_is_down(fire_key)):
501
370
  self._perk_prompt_pulse = 1000.0
502
- self._open_perk_menu()
371
+ self._perk_menu.open_if_available(perk_ctx)
503
372
  elif self._perk_prompt_hover and input_code_is_pressed(fire_key):
504
373
  self._perk_prompt_pulse = 1000.0
505
- self._open_perk_menu()
374
+ self._perk_menu.open_if_available(perk_ctx)
506
375
 
507
376
  if not self._paused and not self._game_over_active:
508
377
  pulse_delta = dt_ui_ms * (6.0 if self._perk_prompt_hover else -2.0)
509
- self._perk_prompt_pulse = _clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)
378
+ self._perk_prompt_pulse = clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)
510
379
 
511
380
  if self._paused or (not any_alive) or perk_menu_active:
512
381
  dt = 0.0
513
382
 
514
383
  prompt_active = perk_pending and (not perk_menu_active) and (not self._paused)
515
384
  if prompt_active:
516
- self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
385
+ self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
517
386
  else:
518
- self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
387
+ self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
519
388
 
520
- if self._perk_menu_open:
521
- self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
522
- else:
523
- self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms - dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
524
- if self._perk_menu_timeline_ms > 1e-3 or self._perk_menu_open:
389
+ self._perk_menu.tick_timeline(dt_ui_ms)
390
+ if self._perk_menu.active:
525
391
  self._hud_fade_ms = 0.0
526
392
  else:
527
- self._hud_fade_ms = _clamp(self._hud_fade_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
393
+ self._hud_fade_ms = clamp(self._hud_fade_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
528
394
 
529
395
  self._survival.elapsed_ms += dt * 1000.0
530
396
 
@@ -545,6 +411,9 @@ class SurvivalMode(BaseGameplayMode):
545
411
  # Scripted milestone spawns based on level.
546
412
  stage, milestone_calls = advance_survival_spawn_stage(self._survival.stage, player_level=self._player.level)
547
413
  self._survival.stage = stage
414
+ detail_preset = 5
415
+ if self._world.config is not None:
416
+ detail_preset = int(self._world.config.data.get("detail_preset", 5) or 5)
548
417
  for call in milestone_calls:
549
418
  self._creatures.spawn_template(
550
419
  int(call.template_id),
@@ -552,6 +421,8 @@ class SurvivalMode(BaseGameplayMode):
552
421
  float(call.heading),
553
422
  self._state.rng,
554
423
  rand=self._state.rng.rand,
424
+ state=self._state,
425
+ detail_preset=detail_preset,
555
426
  )
556
427
 
557
428
  # Regular wave spawns based on elapsed time.
@@ -574,7 +445,7 @@ class SurvivalMode(BaseGameplayMode):
574
445
  def _draw_perk_prompt(self) -> None:
575
446
  if self._game_over_active:
576
447
  return
577
- if self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3:
448
+ if self._perk_menu.active:
578
449
  return
579
450
  if not any(player.health > 0.0 for player in self._world.players):
580
451
  return
@@ -630,102 +501,6 @@ class SurvivalMode(BaseGameplayMode):
630
501
  rl.draw_texture_pro(tex, src, dst, origin, rot_deg, pulse_tint)
631
502
  rl.end_blend_mode()
632
503
 
633
- def _draw_perk_menu(self) -> None:
634
- if self._game_over_active:
635
- return
636
- menu_t = _clamp(self._perk_menu_timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
637
- if menu_t <= 1e-3:
638
- return
639
- if self._perk_menu_assets is None:
640
- return
641
-
642
- perk_state = self._state.perk_selection
643
- players = self._world.players
644
- choices = perk_selection_current_choices(
645
- self._state,
646
- players,
647
- perk_state,
648
- game_mode=int(GameMode.SURVIVAL),
649
- player_count=len(players),
650
- )
651
- if not choices:
652
- return
653
- screen_w = float(rl.get_screen_width())
654
- screen_h = float(rl.get_screen_height())
655
- scale = ui_scale(screen_w, screen_h)
656
- origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
657
- slide_x = perk_menu_panel_slide_x(self._perk_menu_timeline_ms, width=self._perk_ui_layout.panel_w)
658
-
659
- master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
660
- expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
661
- computed = perk_menu_compute_layout(
662
- self._perk_ui_layout,
663
- screen_w=screen_w,
664
- origin_x=origin_x,
665
- origin_y=origin_y,
666
- scale=scale,
667
- choice_count=len(choices),
668
- expert_owned=expert_owned,
669
- master_owned=master_owned,
670
- panel_slide_x=slide_x,
671
- )
672
-
673
- panel_tex = self._perk_menu_assets.menu_panel
674
- if panel_tex is not None:
675
- fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
676
- draw_classic_menu_panel(panel_tex, dst=computed.panel, shadow=fx_detail)
677
-
678
- title_tex = self._perk_menu_assets.title_pick_perk
679
- if title_tex is not None:
680
- src = rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height))
681
- rl.draw_texture_pro(title_tex, src, computed.title, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
682
-
683
- sponsor = None
684
- if master_owned:
685
- sponsor = "extra perks sponsored by the Perk Master"
686
- elif expert_owned:
687
- sponsor = "extra perk sponsored by the Perk Expert"
688
- if sponsor:
689
- draw_ui_text(
690
- self._small,
691
- sponsor,
692
- computed.sponsor_x,
693
- computed.sponsor_y,
694
- scale=scale,
695
- color=UI_SPONSOR_COLOR,
696
- )
697
-
698
- mouse = self._ui_mouse_pos()
699
- fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
700
- for idx, perk_id in enumerate(choices):
701
- label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
702
- item_x = computed.list_x
703
- item_y = computed.list_y + float(idx) * computed.list_step_y
704
- rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
705
- hovered = rl.check_collision_point_rec(mouse, rect) or (idx == self._perk_menu_selected)
706
- draw_menu_item(self._small, label, x=item_x, y=item_y, scale=scale, hovered=hovered)
707
-
708
- selected = choices[self._perk_menu_selected]
709
- desc = perk_display_description(int(selected), fx_toggle=fx_toggle)
710
- desc_x = float(computed.desc.x)
711
- desc_y = float(computed.desc.y)
712
- desc_w = float(computed.desc.width)
713
- desc_h = float(computed.desc.height)
714
- desc_scale = scale * 0.85
715
- desc_lines = wrap_ui_text(self._small, desc, max_width=desc_w, scale=desc_scale)
716
- line_h = float(self._small.cell_size * desc_scale) if self._small is not None else float(20 * desc_scale)
717
- y = desc_y
718
- for line in desc_lines:
719
- if y + line_h > desc_y + desc_h:
720
- break
721
- draw_ui_text(self._small, line, desc_x, y, scale=desc_scale, color=UI_TEXT_COLOR)
722
- y += line_h
723
-
724
- cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
725
- cancel_x = computed.cancel_x
726
- button_y = computed.cancel_y
727
- button_draw(self._perk_menu_assets, self._small, self._perk_cancel_button, x=cancel_x, y=button_y, width=cancel_w, scale=scale)
728
-
729
504
  def _draw_game_cursor(self) -> None:
730
505
  mouse_x = float(self._ui_mouse_x)
731
506
  mouse_y = float(self._ui_mouse_y)
@@ -745,17 +520,18 @@ class SurvivalMode(BaseGameplayMode):
745
520
  draw_aim_cursor(self._world.particles_texture, aim_tex, x=mouse_x, y=mouse_y)
746
521
 
747
522
  def draw(self) -> None:
748
- perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
523
+ perk_menu_active = self._perk_menu.active
749
524
  self._world.draw(draw_aim_indicators=(not self._game_over_active) and (not perk_menu_active))
750
525
  self._draw_screen_fade()
751
526
 
752
527
  hud_bottom = 0.0
753
528
  if (not self._game_over_active) and (not perk_menu_active) and self._hud_assets is not None:
754
- hud_alpha = _clamp(self._hud_fade_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
529
+ hud_alpha = clamp(self._hud_fade_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
755
530
  hud_flags = hud_flags_for_game_mode(self._config_game_mode_id())
756
531
  self._draw_target_health_bar(alpha=hud_alpha)
757
532
  hud_bottom = draw_hud_overlay(
758
533
  self._hud_assets,
534
+ state=self._hud_state,
759
535
  player=self._player,
760
536
  players=self._world.players,
761
537
  bonus_hud=self._state.bonus_hud,
@@ -779,10 +555,12 @@ class SurvivalMode(BaseGameplayMode):
779
555
  line = float(self._ui_line_height())
780
556
  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)
781
557
  self._draw_ui_text(f"xp={self._player.experience} level={self._player.level} kills={self._creatures.kill_count}", x, y + line, UI_HINT_COLOR)
558
+ god = "on" if self._state.debug_god_mode else "off"
559
+ self._draw_ui_text(f"debug: [/] weapon F3 perk+1 F2 god={god} X xp+5000", x, y + line * 2.0, UI_HINT_COLOR, scale=0.9)
782
560
  if self._paused:
783
- self._draw_ui_text("paused (TAB)", x, y + line * 2.0, UI_HINT_COLOR)
561
+ self._draw_ui_text("paused (TAB)", x, y + line * 3.0, UI_HINT_COLOR)
784
562
  if self._player.health <= 0.0:
785
- self._draw_ui_text("game over", x, y + line * 2.0, UI_ERROR_COLOR)
563
+ self._draw_ui_text("game over", x, y + line * 3.0, UI_ERROR_COLOR)
786
564
  warn_y = float(rl.get_screen_height()) - 28.0
787
565
  if self._world.missing_assets:
788
566
  warn = "Missing world assets: " + ", ".join(self._world.missing_assets)
@@ -793,7 +571,8 @@ class SurvivalMode(BaseGameplayMode):
793
571
  self._draw_ui_text(warn, 24.0, warn_y, UI_ERROR_COLOR, scale=0.8)
794
572
 
795
573
  self._draw_perk_prompt()
796
- self._draw_perk_menu()
574
+ if not self._game_over_active:
575
+ self._perk_menu.draw(self._perk_menu_context())
797
576
  if (not self._game_over_active) and perk_menu_active:
798
577
  self._draw_game_cursor()
799
578
  if (not self._game_over_active) and (not perk_menu_active):