crimsonland 0.1.0.dev15__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 (75) hide show
  1. crimson/cli.py +61 -0
  2. crimson/creatures/damage.py +111 -36
  3. crimson/creatures/runtime.py +246 -156
  4. crimson/creatures/spawn.py +7 -3
  5. crimson/demo.py +38 -45
  6. crimson/effects.py +7 -13
  7. crimson/frontend/high_scores_layout.py +81 -0
  8. crimson/frontend/panels/base.py +4 -1
  9. crimson/frontend/panels/controls.py +0 -15
  10. crimson/frontend/panels/databases.py +291 -3
  11. crimson/frontend/panels/mods.py +0 -15
  12. crimson/frontend/panels/play_game.py +0 -16
  13. crimson/game.py +441 -1
  14. crimson/gameplay.py +905 -569
  15. crimson/modes/base_gameplay_mode.py +33 -12
  16. crimson/modes/components/__init__.py +2 -0
  17. crimson/modes/components/highscore_record_builder.py +58 -0
  18. crimson/modes/components/perk_menu_controller.py +325 -0
  19. crimson/modes/quest_mode.py +58 -273
  20. crimson/modes/rush_mode.py +12 -43
  21. crimson/modes/survival_mode.py +71 -328
  22. crimson/modes/tutorial_mode.py +46 -247
  23. crimson/modes/typo_mode.py +11 -38
  24. crimson/oracle.py +396 -0
  25. crimson/perks.py +5 -2
  26. crimson/player_damage.py +94 -37
  27. crimson/projectiles.py +539 -320
  28. crimson/render/projectile_draw_registry.py +637 -0
  29. crimson/render/projectile_render_registry.py +110 -0
  30. crimson/render/secondary_projectile_draw_registry.py +206 -0
  31. crimson/render/world_renderer.py +58 -707
  32. crimson/sim/world_state.py +118 -61
  33. crimson/typo/spawns.py +5 -12
  34. crimson/ui/demo_trial_overlay.py +3 -11
  35. crimson/ui/formatting.py +24 -0
  36. crimson/ui/game_over.py +12 -58
  37. crimson/ui/hud.py +72 -39
  38. crimson/ui/layout.py +20 -0
  39. crimson/ui/perk_menu.py +9 -34
  40. crimson/ui/quest_results.py +12 -64
  41. crimson/ui/text_input.py +20 -0
  42. crimson/views/_ui_helpers.py +27 -0
  43. crimson/views/aim_debug.py +15 -32
  44. crimson/views/animations.py +18 -28
  45. crimson/views/arsenal_debug.py +22 -32
  46. crimson/views/bonuses.py +23 -36
  47. crimson/views/camera_debug.py +16 -29
  48. crimson/views/camera_shake.py +9 -33
  49. crimson/views/corpse_stamp_debug.py +13 -21
  50. crimson/views/decals_debug.py +36 -23
  51. crimson/views/fonts.py +8 -25
  52. crimson/views/ground.py +4 -21
  53. crimson/views/lighting_debug.py +42 -45
  54. crimson/views/particles.py +33 -42
  55. crimson/views/perk_menu_debug.py +3 -10
  56. crimson/views/player.py +50 -44
  57. crimson/views/player_sprite_debug.py +24 -31
  58. crimson/views/projectile_fx.py +57 -52
  59. crimson/views/projectile_render_debug.py +24 -33
  60. crimson/views/projectiles.py +24 -37
  61. crimson/views/spawn_plan.py +13 -29
  62. crimson/views/sprites.py +14 -29
  63. crimson/views/terrain.py +6 -23
  64. crimson/views/ui.py +7 -24
  65. crimson/views/wicons.py +28 -33
  66. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/METADATA +1 -1
  67. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/RECORD +72 -64
  68. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/WHEEL +2 -2
  69. grim/config.py +29 -1
  70. grim/console.py +7 -10
  71. grim/math.py +12 -0
  72. crimson/.DS_Store +0 -0
  73. crimson/creatures/.DS_Store +0 -0
  74. grim/.DS_Store +0 -0
  75. {crimsonland-0.1.0.dev15.dist-info → crimsonland-0.1.0.dev16.dist-info}/entry_points.txt +0 -0
@@ -9,6 +9,7 @@ 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
@@ -16,37 +17,18 @@ from ..debug import debug_enabled
16
17
  from ..game_modes import GameMode
17
18
  from ..gameplay import (
18
19
  PlayerInput,
19
- most_used_weapon_id_for_player,
20
- perk_selection_current_choices,
21
- perk_selection_pick,
22
20
  survival_check_level_up,
23
21
  weapon_assign_player,
24
22
  )
25
- from ..persistence.highscores import HighScoreRecord
26
- from ..perks import PerkId, perk_display_description, perk_display_name
23
+ from ..perks import PerkId
27
24
  from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
28
25
  from ..ui.hud import draw_hud_overlay, hud_flags_for_game_mode
29
- from ..ui.menu_panel import draw_classic_menu_panel
30
26
  from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
31
- from ..ui.perk_menu import (
32
- PERK_MENU_TRANSITION_MS,
33
- PerkMenuLayout,
34
- UiButtonState,
35
- button_draw,
36
- button_update,
37
- button_width,
38
- draw_menu_item,
39
- draw_ui_text,
40
- load_perk_menu_assets,
41
- menu_item_hit_rect,
42
- perk_menu_panel_slide_x,
43
- perk_menu_compute_layout,
44
- ui_origin,
45
- ui_scale,
46
- wrap_ui_text,
47
- )
27
+ from ..ui.perk_menu import PERK_MENU_TRANSITION_MS, draw_ui_text, load_perk_menu_assets
48
28
  from ..weapons import WEAPON_BY_ID
49
- from .base_gameplay_mode import BaseGameplayMode, _clamp
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
50
32
 
51
33
  WORLD_SIZE = 1024.0
52
34
 
@@ -118,15 +100,38 @@ class SurvivalMode(BaseGameplayMode):
118
100
  self._perk_prompt_timer_ms = 0.0
119
101
  self._perk_prompt_hover = False
120
102
  self._perk_prompt_pulse = 0.0
121
- self._perk_menu_open = False
122
- self._perk_menu_selected = 0
123
- self._perk_menu_timeline_ms = 0.0
103
+ self._perk_menu = PerkMenuController(on_close=self._reset_perk_prompt)
124
104
  self._hud_fade_ms = PERK_MENU_TRANSITION_MS
125
105
  self._perk_menu_assets = None
126
- self._perk_ui_layout = PerkMenuLayout()
127
- self._perk_cancel_button = UiButtonState("Cancel")
128
106
  self._cursor_time = 0.0
129
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
+
130
135
  def _wrap_ui_text(self, text: str, *, max_width: float, scale: float = UI_TEXT_SCALE) -> list[str]:
131
136
  lines: list[str] = []
132
137
  for raw in text.splitlines() or [""]:
@@ -158,8 +163,7 @@ class SurvivalMode(BaseGameplayMode):
158
163
  self._perk_menu_assets = load_perk_menu_assets(self._assets_root)
159
164
  if self._perk_menu_assets.missing:
160
165
  self._missing_assets.extend(self._perk_menu_assets.missing)
161
- self._perk_ui_layout = PerkMenuLayout()
162
- self._perk_cancel_button = UiButtonState("Cancel")
166
+ self._perk_menu.reset()
163
167
  self._cursor_time = 0.0
164
168
  self._cursor_pulse_time = 0.0
165
169
  self._survival = _SurvivalState()
@@ -167,9 +171,6 @@ class SurvivalMode(BaseGameplayMode):
167
171
  self._perk_prompt_timer_ms = 0.0
168
172
  self._perk_prompt_hover = False
169
173
  self._perk_prompt_pulse = 0.0
170
- self._perk_menu_open = False
171
- self._perk_menu_selected = 0
172
- self._perk_menu_timeline_ms = 0.0
173
174
  self._hud_fade_ms = PERK_MENU_TRANSITION_MS
174
175
 
175
176
  def close(self) -> None:
@@ -183,15 +184,15 @@ class SurvivalMode(BaseGameplayMode):
183
184
  self._action = "back_to_menu"
184
185
  self.close_requested = True
185
186
  return
186
- 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):
187
188
  self._world.audio_router.play_sfx("sfx_ui_buttonclick")
188
- self._close_perk_menu()
189
+ self._perk_menu.close()
189
190
  return
190
191
 
191
192
  if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
192
193
  self._paused = not self._paused
193
194
 
194
- if debug_enabled() and (not self._perk_menu_open):
195
+ if debug_enabled() and (not self._perk_menu.open):
195
196
  if rl.is_key_pressed(rl.KeyboardKey.KEY_F2):
196
197
  self._state.debug_god_mode = not bool(self._state.debug_god_mode)
197
198
  self._world.audio_router.play_sfx("sfx_ui_buttonclick")
@@ -274,31 +275,18 @@ class SurvivalMode(BaseGameplayMode):
274
275
  def _enter_game_over(self) -> None:
275
276
  if self._game_over_active:
276
277
  return
277
- record = HighScoreRecord.blank()
278
- record.score_xp = int(self._player.experience)
279
- record.survival_elapsed_ms = int(self._survival.elapsed_ms)
280
- record.creature_kill_count = int(self._creatures.kill_count)
281
-
282
- weapon_id = most_used_weapon_id_for_player(self._state, player_index=int(self._player.index), fallback_weapon_id=int(self._player.weapon_id))
283
- record.most_used_weapon_id = int(weapon_id)
284
- fired = 0
285
- hit = 0
286
- try:
287
- fired = int(self._state.shots_fired[int(self._player.index)])
288
- hit = int(self._state.shots_hit[int(self._player.index)])
289
- except Exception:
290
- fired = 0
291
- hit = 0
292
- fired = max(0, int(fired))
293
- hit = max(0, min(int(hit), fired))
294
- record.shots_fired = fired
295
- record.shots_hit = hit
296
-
297
- 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
+ )
298
286
  self._game_over_record = record
299
287
  self._game_over_ui.open()
300
288
  self._game_over_active = True
301
- self._perk_menu_open = False
289
+ self._perk_menu.close()
302
290
 
303
291
  def _perk_prompt_label(self) -> str:
304
292
  if self._config is not None and not bool(int(self._config.data.get("ui_info_texts", 1) or 0)):
@@ -338,138 +326,6 @@ class SurvivalMode(BaseGameplayMode):
338
326
  y = margin
339
327
  return rl.Rectangle(x, y, text_w, text_h)
340
328
 
341
- def _open_perk_menu(self) -> None:
342
- if self._perk_menu_open:
343
- return
344
- players = self._world.players
345
- choices = perk_selection_current_choices(
346
- self._state,
347
- players,
348
- self._state.perk_selection,
349
- game_mode=int(GameMode.SURVIVAL),
350
- player_count=len(players),
351
- )
352
- if not choices:
353
- self._perk_menu_open = False
354
- return
355
- self._world.audio_router.play_sfx("sfx_ui_panelclick")
356
- self._perk_menu_open = True
357
- self._perk_menu_selected = 0
358
-
359
- def _close_perk_menu(self) -> None:
360
- self._perk_menu_open = False
361
- if int(self._state.perk_selection.pending_count) > 0:
362
- # Reset the prompt swing so each pending perk replays the intro.
363
- self._perk_prompt_timer_ms = 0.0
364
- self._perk_prompt_hover = False
365
- self._perk_prompt_pulse = 0.0
366
-
367
- def _perk_menu_handle_input(self, dt_frame: float, dt_ms: float) -> None:
368
- if self._perk_menu_assets is None:
369
- self._close_perk_menu()
370
- return
371
- perk_state = self._state.perk_selection
372
- players = self._world.players
373
- choices = perk_selection_current_choices(
374
- self._state,
375
- players,
376
- perk_state,
377
- game_mode=int(GameMode.SURVIVAL),
378
- player_count=len(players),
379
- )
380
- if not choices:
381
- self._close_perk_menu()
382
- return
383
- if self._perk_menu_selected >= len(choices):
384
- self._perk_menu_selected = 0
385
-
386
- if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
387
- self._perk_menu_selected = (self._perk_menu_selected + 1) % len(choices)
388
- if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
389
- self._perk_menu_selected = (self._perk_menu_selected - 1) % len(choices)
390
-
391
- screen_w = float(rl.get_screen_width())
392
- screen_h = float(rl.get_screen_height())
393
- scale = ui_scale(screen_w, screen_h)
394
- origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
395
- slide_x = perk_menu_panel_slide_x(self._perk_menu_timeline_ms, width=self._perk_ui_layout.panel_w)
396
-
397
- mouse = self._ui_mouse_pos()
398
- click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
399
-
400
- master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
401
- expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
402
- computed = perk_menu_compute_layout(
403
- self._perk_ui_layout,
404
- screen_w=screen_w,
405
- origin_x=origin_x,
406
- origin_y=origin_y,
407
- scale=scale,
408
- choice_count=len(choices),
409
- expert_owned=expert_owned,
410
- master_owned=master_owned,
411
- panel_slide_x=slide_x,
412
- )
413
-
414
- fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
415
- for idx, perk_id in enumerate(choices):
416
- label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
417
- item_x = computed.list_x
418
- item_y = computed.list_y + float(idx) * computed.list_step_y
419
- rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
420
- if rl.check_collision_point_rec(mouse, rect):
421
- self._perk_menu_selected = idx
422
- if click:
423
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
424
- picked = perk_selection_pick(
425
- self._state,
426
- players,
427
- perk_state,
428
- idx,
429
- game_mode=int(GameMode.SURVIVAL),
430
- player_count=len(players),
431
- dt=dt_frame,
432
- creatures=self._creatures.entries,
433
- )
434
- if picked is not None:
435
- self._world.audio_router.play_sfx("sfx_ui_bonus")
436
- self._close_perk_menu()
437
- return
438
- break
439
-
440
- cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
441
- cancel_x = computed.cancel_x
442
- button_y = computed.cancel_y
443
-
444
- if button_update(
445
- self._perk_cancel_button,
446
- x=cancel_x,
447
- y=button_y,
448
- width=cancel_w,
449
- dt_ms=dt_ms,
450
- mouse=mouse,
451
- click=click,
452
- ):
453
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
454
- self._close_perk_menu()
455
- return
456
-
457
- if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) or rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
458
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
459
- picked = perk_selection_pick(
460
- self._state,
461
- players,
462
- perk_state,
463
- self._perk_menu_selected,
464
- game_mode=int(GameMode.SURVIVAL),
465
- player_count=len(players),
466
- dt=dt_frame,
467
- creatures=self._creatures.entries,
468
- )
469
- if picked is not None:
470
- self._world.audio_router.play_sfx("sfx_ui_bonus")
471
- self._close_perk_menu()
472
-
473
329
  def update(self, dt: float) -> None:
474
330
  self._update_audio(dt)
475
331
 
@@ -480,40 +336,19 @@ class SurvivalMode(BaseGameplayMode):
480
336
  return
481
337
 
482
338
  if self._game_over_active:
483
- record = self._game_over_record
484
- if record is None:
485
- self._enter_game_over()
486
- record = self._game_over_record
487
- if record is not None:
488
- action = self._game_over_ui.update(
489
- dt,
490
- record=record,
491
- player_name_default=self._player_name_default(),
492
- play_sfx=self._world.audio_router.play_sfx,
493
- rand=self._state.rng.rand,
494
- mouse=self._ui_mouse_pos(),
495
- )
496
- if action == "play_again":
497
- self.open()
498
- return
499
- if action == "high_scores":
500
- self._action = "open_high_scores"
501
- return
502
- if action == "main_menu":
503
- self._action = "back_to_menu"
504
- self.close_requested = True
505
- return
339
+ self._update_game_over_ui(dt)
506
340
  return
507
341
 
508
342
  any_alive = any(player.health > 0.0 for player in self._world.players)
509
343
  perk_pending = int(self._state.perk_selection.pending_count) > 0 and any_alive
510
344
 
511
345
  self._perk_prompt_hover = False
512
- if self._perk_menu_open:
513
- 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)
514
349
  dt = 0.0
515
350
 
516
- perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
351
+ perk_menu_active = self._perk_menu.active
517
352
 
518
353
  if (not perk_menu_active) and perk_pending and (not self._paused):
519
354
  label = self._perk_prompt_label()
@@ -533,32 +368,29 @@ class SurvivalMode(BaseGameplayMode):
533
368
 
534
369
  if input_code_is_pressed(pick_key) and (not input_code_is_down(fire_key)):
535
370
  self._perk_prompt_pulse = 1000.0
536
- self._open_perk_menu()
371
+ self._perk_menu.open_if_available(perk_ctx)
537
372
  elif self._perk_prompt_hover and input_code_is_pressed(fire_key):
538
373
  self._perk_prompt_pulse = 1000.0
539
- self._open_perk_menu()
374
+ self._perk_menu.open_if_available(perk_ctx)
540
375
 
541
376
  if not self._paused and not self._game_over_active:
542
377
  pulse_delta = dt_ui_ms * (6.0 if self._perk_prompt_hover else -2.0)
543
- 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)
544
379
 
545
380
  if self._paused or (not any_alive) or perk_menu_active:
546
381
  dt = 0.0
547
382
 
548
383
  prompt_active = perk_pending and (not perk_menu_active) and (not self._paused)
549
384
  if prompt_active:
550
- 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)
551
386
  else:
552
- 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)
553
388
 
554
- if self._perk_menu_open:
555
- self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
556
- else:
557
- self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms - dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
558
- 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:
559
391
  self._hud_fade_ms = 0.0
560
392
  else:
561
- 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)
562
394
 
563
395
  self._survival.elapsed_ms += dt * 1000.0
564
396
 
@@ -579,6 +411,9 @@ class SurvivalMode(BaseGameplayMode):
579
411
  # Scripted milestone spawns based on level.
580
412
  stage, milestone_calls = advance_survival_spawn_stage(self._survival.stage, player_level=self._player.level)
581
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)
582
417
  for call in milestone_calls:
583
418
  self._creatures.spawn_template(
584
419
  int(call.template_id),
@@ -586,6 +421,8 @@ class SurvivalMode(BaseGameplayMode):
586
421
  float(call.heading),
587
422
  self._state.rng,
588
423
  rand=self._state.rng.rand,
424
+ state=self._state,
425
+ detail_preset=detail_preset,
589
426
  )
590
427
 
591
428
  # Regular wave spawns based on elapsed time.
@@ -608,7 +445,7 @@ class SurvivalMode(BaseGameplayMode):
608
445
  def _draw_perk_prompt(self) -> None:
609
446
  if self._game_over_active:
610
447
  return
611
- if self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3:
448
+ if self._perk_menu.active:
612
449
  return
613
450
  if not any(player.health > 0.0 for player in self._world.players):
614
451
  return
@@ -664,102 +501,6 @@ class SurvivalMode(BaseGameplayMode):
664
501
  rl.draw_texture_pro(tex, src, dst, origin, rot_deg, pulse_tint)
665
502
  rl.end_blend_mode()
666
503
 
667
- def _draw_perk_menu(self) -> None:
668
- if self._game_over_active:
669
- return
670
- menu_t = _clamp(self._perk_menu_timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
671
- if menu_t <= 1e-3:
672
- return
673
- if self._perk_menu_assets is None:
674
- return
675
-
676
- perk_state = self._state.perk_selection
677
- players = self._world.players
678
- choices = perk_selection_current_choices(
679
- self._state,
680
- players,
681
- perk_state,
682
- game_mode=int(GameMode.SURVIVAL),
683
- player_count=len(players),
684
- )
685
- if not choices:
686
- return
687
- screen_w = float(rl.get_screen_width())
688
- screen_h = float(rl.get_screen_height())
689
- scale = ui_scale(screen_w, screen_h)
690
- origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
691
- slide_x = perk_menu_panel_slide_x(self._perk_menu_timeline_ms, width=self._perk_ui_layout.panel_w)
692
-
693
- master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
694
- expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
695
- computed = perk_menu_compute_layout(
696
- self._perk_ui_layout,
697
- screen_w=screen_w,
698
- origin_x=origin_x,
699
- origin_y=origin_y,
700
- scale=scale,
701
- choice_count=len(choices),
702
- expert_owned=expert_owned,
703
- master_owned=master_owned,
704
- panel_slide_x=slide_x,
705
- )
706
-
707
- panel_tex = self._perk_menu_assets.menu_panel
708
- if panel_tex is not None:
709
- fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
710
- draw_classic_menu_panel(panel_tex, dst=computed.panel, shadow=fx_detail)
711
-
712
- title_tex = self._perk_menu_assets.title_pick_perk
713
- if title_tex is not None:
714
- src = rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height))
715
- rl.draw_texture_pro(title_tex, src, computed.title, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
716
-
717
- sponsor = None
718
- if master_owned:
719
- sponsor = "extra perks sponsored by the Perk Master"
720
- elif expert_owned:
721
- sponsor = "extra perk sponsored by the Perk Expert"
722
- if sponsor:
723
- draw_ui_text(
724
- self._small,
725
- sponsor,
726
- computed.sponsor_x,
727
- computed.sponsor_y,
728
- scale=scale,
729
- color=UI_SPONSOR_COLOR,
730
- )
731
-
732
- mouse = self._ui_mouse_pos()
733
- fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
734
- for idx, perk_id in enumerate(choices):
735
- label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
736
- item_x = computed.list_x
737
- item_y = computed.list_y + float(idx) * computed.list_step_y
738
- rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
739
- hovered = rl.check_collision_point_rec(mouse, rect) or (idx == self._perk_menu_selected)
740
- draw_menu_item(self._small, label, x=item_x, y=item_y, scale=scale, hovered=hovered)
741
-
742
- selected = choices[self._perk_menu_selected]
743
- desc = perk_display_description(int(selected), fx_toggle=fx_toggle)
744
- desc_x = float(computed.desc.x)
745
- desc_y = float(computed.desc.y)
746
- desc_w = float(computed.desc.width)
747
- desc_h = float(computed.desc.height)
748
- desc_scale = scale * 0.85
749
- desc_lines = wrap_ui_text(self._small, desc, max_width=desc_w, scale=desc_scale)
750
- line_h = float(self._small.cell_size * desc_scale) if self._small is not None else float(20 * desc_scale)
751
- y = desc_y
752
- for line in desc_lines:
753
- if y + line_h > desc_y + desc_h:
754
- break
755
- draw_ui_text(self._small, line, desc_x, y, scale=desc_scale, color=UI_TEXT_COLOR)
756
- y += line_h
757
-
758
- cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
759
- cancel_x = computed.cancel_x
760
- button_y = computed.cancel_y
761
- button_draw(self._perk_menu_assets, self._small, self._perk_cancel_button, x=cancel_x, y=button_y, width=cancel_w, scale=scale)
762
-
763
504
  def _draw_game_cursor(self) -> None:
764
505
  mouse_x = float(self._ui_mouse_x)
765
506
  mouse_y = float(self._ui_mouse_y)
@@ -779,17 +520,18 @@ class SurvivalMode(BaseGameplayMode):
779
520
  draw_aim_cursor(self._world.particles_texture, aim_tex, x=mouse_x, y=mouse_y)
780
521
 
781
522
  def draw(self) -> None:
782
- perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
523
+ perk_menu_active = self._perk_menu.active
783
524
  self._world.draw(draw_aim_indicators=(not self._game_over_active) and (not perk_menu_active))
784
525
  self._draw_screen_fade()
785
526
 
786
527
  hud_bottom = 0.0
787
528
  if (not self._game_over_active) and (not perk_menu_active) and self._hud_assets is not None:
788
- 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)
789
530
  hud_flags = hud_flags_for_game_mode(self._config_game_mode_id())
790
531
  self._draw_target_health_bar(alpha=hud_alpha)
791
532
  hud_bottom = draw_hud_overlay(
792
533
  self._hud_assets,
534
+ state=self._hud_state,
793
535
  player=self._player,
794
536
  players=self._world.players,
795
537
  bonus_hud=self._state.bonus_hud,
@@ -829,7 +571,8 @@ class SurvivalMode(BaseGameplayMode):
829
571
  self._draw_ui_text(warn, 24.0, warn_y, UI_ERROR_COLOR, scale=0.8)
830
572
 
831
573
  self._draw_perk_prompt()
832
- self._draw_perk_menu()
574
+ if not self._game_over_active:
575
+ self._perk_menu.draw(self._perk_menu_context())
833
576
  if (not self._game_over_active) and perk_menu_active:
834
577
  self._draw_game_cursor()
835
578
  if (not self._game_over_active) and (not perk_menu_active):