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
@@ -10,13 +10,14 @@ from grim.audio import AudioState
10
10
  from grim.console import ConsoleState
11
11
  from grim.config import CrimsonConfig
12
12
  from grim.fonts.grim_mono import GrimMonoFont, load_grim_mono_font
13
+ from grim.math import clamp
13
14
  from grim.view import ViewContext
14
15
 
16
+ from ..debug import debug_enabled
15
17
  from ..game_modes import GameMode
16
- from ..gameplay import most_used_weapon_id_for_player, perk_selection_current_choices, perk_selection_pick, weapon_assign_player
18
+ from ..gameplay import most_used_weapon_id_for_player, weapon_assign_player
17
19
  from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
18
20
  from ..persistence.save_status import GameStatus
19
- from ..perks import PerkId, perk_display_description, perk_display_name
20
21
  from ..quests import quest_by_level
21
22
  from ..quests.runtime import build_quest_spawn_table, tick_quest_completion_transition
22
23
  from ..quests.timeline import quest_spawn_table_empty, tick_quest_mode_spawns
@@ -24,27 +25,11 @@ from ..quests.types import QuestContext, QuestDefinition, SpawnEntry
24
25
  from ..terrain_assets import terrain_texture_by_id
25
26
  from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
26
27
  from ..ui.hud import draw_hud_overlay, hud_flags_for_game_mode
27
- from ..ui.menu_panel import draw_classic_menu_panel
28
- from ..ui.perk_menu import (
29
- PERK_MENU_TRANSITION_MS,
30
- PerkMenuAssets,
31
- PerkMenuLayout,
32
- UiButtonState,
33
- button_draw,
34
- button_update,
35
- button_width,
36
- draw_menu_item,
37
- draw_ui_text,
38
- load_perk_menu_assets,
39
- menu_item_hit_rect,
40
- perk_menu_panel_slide_x,
41
- perk_menu_compute_layout,
42
- ui_origin,
43
- ui_scale,
44
- wrap_ui_text,
45
- )
28
+ from ..ui.perk_menu import PerkMenuAssets, draw_ui_text, load_perk_menu_assets
46
29
  from ..views.quest_title_overlay import draw_quest_title_overlay
47
- from .base_gameplay_mode import BaseGameplayMode, _clamp
30
+ from ..weapons import WEAPON_BY_ID
31
+ from .base_gameplay_mode import BaseGameplayMode
32
+ from .components.perk_menu_controller import PerkMenuContext, PerkMenuController
48
33
 
49
34
  WORLD_SIZE = 1024.0
50
35
  QUEST_TITLE_FADE_IN_MS = 500.0
@@ -78,6 +63,8 @@ PERK_PROMPT_LEVEL_UP_SHIFT_X = -46.0
78
63
  PERK_PROMPT_LEVEL_UP_SHIFT_Y = -4.0
79
64
 
80
65
  PERK_PROMPT_TEXT_MARGIN_X = 16.0
66
+
67
+ _DEBUG_WEAPON_IDS = tuple(sorted(WEAPON_BY_ID))
81
68
  PERK_PROMPT_TEXT_OFFSET_Y = 8.0
82
69
 
83
70
 
@@ -173,11 +160,7 @@ class QuestMode(BaseGameplayMode):
173
160
  self._perk_prompt_timer_ms = 0.0
174
161
  self._perk_prompt_hover = False
175
162
  self._perk_prompt_pulse = 0.0
176
- self._perk_menu_open = False
177
- self._perk_menu_selected = 0
178
- self._perk_menu_timeline_ms = 0.0
179
- self._perk_ui_layout = PerkMenuLayout()
180
- self._perk_cancel_button = UiButtonState("Cancel")
163
+ self._perk_menu = PerkMenuController(on_close=self._reset_perk_prompt)
181
164
 
182
165
  def open(self) -> None:
183
166
  super().open()
@@ -194,11 +177,7 @@ class QuestMode(BaseGameplayMode):
194
177
  self._perk_prompt_timer_ms = 0.0
195
178
  self._perk_prompt_hover = False
196
179
  self._perk_prompt_pulse = 0.0
197
- self._perk_menu_open = False
198
- self._perk_menu_selected = 0
199
- self._perk_menu_timeline_ms = 0.0
200
- self._perk_ui_layout = PerkMenuLayout()
201
- self._perk_cancel_button = UiButtonState("Cancel")
180
+ self._perk_menu.reset()
202
181
 
203
182
  def close(self) -> None:
204
183
  if self._grim_mono is not None:
@@ -207,6 +186,33 @@ class QuestMode(BaseGameplayMode):
207
186
  self._perk_menu_assets = None
208
187
  super().close()
209
188
 
189
+ def _reset_perk_prompt(self) -> None:
190
+ if int(self._state.perk_selection.pending_count) > 0:
191
+ # Reset the prompt swing so each pending perk replays the intro.
192
+ self._perk_prompt_timer_ms = 0.0
193
+ self._perk_prompt_hover = False
194
+ self._perk_prompt_pulse = 0.0
195
+
196
+ def _perk_menu_context(self) -> PerkMenuContext:
197
+ fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
198
+ fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
199
+ players = self._world.players
200
+ return PerkMenuContext(
201
+ state=self._state,
202
+ perk_state=self._state.perk_selection,
203
+ players=players,
204
+ creatures=self._creatures.entries,
205
+ player=self._player,
206
+ game_mode=int(GameMode.QUESTS),
207
+ player_count=len(players),
208
+ fx_toggle=fx_toggle,
209
+ fx_detail=fx_detail,
210
+ font=self._small,
211
+ assets=self._perk_menu_assets,
212
+ mouse=self._ui_mouse_pos(),
213
+ play_sfx=self._world.audio_router.play_sfx,
214
+ )
215
+
210
216
  def select_level(self, level: str | None) -> None:
211
217
  self._selected_level = level
212
218
 
@@ -297,18 +303,43 @@ class QuestMode(BaseGameplayMode):
297
303
  status.increment_quest_play_count(idx)
298
304
 
299
305
  def _handle_input(self) -> None:
300
- if self._perk_menu_open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
306
+ if self._perk_menu.open and rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
301
307
  self._world.audio_router.play_sfx("sfx_ui_buttonclick")
302
- self._close_perk_menu()
308
+ self._perk_menu.close()
303
309
  return
304
310
 
305
311
  if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
306
312
  self._paused = not self._paused
307
313
 
314
+ if debug_enabled() and (not self._perk_menu.open):
315
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F2):
316
+ self._state.debug_god_mode = not bool(self._state.debug_god_mode)
317
+ self._world.audio_router.play_sfx("sfx_ui_buttonclick")
318
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_F3):
319
+ self._state.perk_selection.pending_count += 1
320
+ self._state.perk_selection.choices_dirty = True
321
+ self._world.audio_router.play_sfx("sfx_ui_levelup")
322
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
323
+ self._debug_cycle_weapon(-1)
324
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
325
+ self._debug_cycle_weapon(1)
326
+
308
327
  if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
309
328
  self._action = "open_pause_menu"
310
329
  return
311
330
 
331
+ def _debug_cycle_weapon(self, delta: int) -> None:
332
+ weapon_ids = _DEBUG_WEAPON_IDS
333
+ if not weapon_ids:
334
+ return
335
+ current = int(self._player.weapon_id)
336
+ try:
337
+ idx = weapon_ids.index(current)
338
+ except ValueError:
339
+ idx = 0
340
+ weapon_id = int(weapon_ids[(idx + int(delta)) % len(weapon_ids)])
341
+ weapon_assign_player(self._player, weapon_id, state=self._state)
342
+
312
343
  def _build_input(self):
313
344
  keybinds = config_keybinds(self._config)
314
345
  if not keybinds:
@@ -385,139 +416,6 @@ class QuestMode(BaseGameplayMode):
385
416
  y = margin
386
417
  return rl.Rectangle(x, y, text_w, text_h)
387
418
 
388
- def _open_perk_menu(self) -> None:
389
- if self._perk_menu_open:
390
- return
391
- players = self._world.players
392
- choices = perk_selection_current_choices(
393
- self._state,
394
- players,
395
- self._state.perk_selection,
396
- game_mode=int(GameMode.QUESTS),
397
- player_count=len(players),
398
- )
399
- if not choices:
400
- self._perk_menu_open = False
401
- return
402
- self._world.audio_router.play_sfx("sfx_ui_panelclick")
403
- self._perk_menu_open = True
404
- self._perk_menu_selected = 0
405
-
406
- def _close_perk_menu(self) -> None:
407
- self._perk_menu_open = False
408
- if int(self._state.perk_selection.pending_count) > 0:
409
- # Reset the prompt swing so each pending perk replays the intro.
410
- self._perk_prompt_timer_ms = 0.0
411
- self._perk_prompt_hover = False
412
- self._perk_prompt_pulse = 0.0
413
-
414
- def _perk_menu_handle_input(self, dt_frame: float, dt_ms: float) -> None:
415
- if self._perk_menu_assets is None:
416
- self._close_perk_menu()
417
- return
418
-
419
- perk_state = self._state.perk_selection
420
- players = self._world.players
421
- choices = perk_selection_current_choices(
422
- self._state,
423
- players,
424
- perk_state,
425
- game_mode=int(GameMode.QUESTS),
426
- player_count=len(players),
427
- )
428
- if not choices:
429
- self._close_perk_menu()
430
- return
431
- if self._perk_menu_selected >= len(choices):
432
- self._perk_menu_selected = 0
433
-
434
- if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
435
- self._perk_menu_selected = (self._perk_menu_selected + 1) % len(choices)
436
- if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
437
- self._perk_menu_selected = (self._perk_menu_selected - 1) % len(choices)
438
-
439
- screen_w = float(rl.get_screen_width())
440
- screen_h = float(rl.get_screen_height())
441
- scale = ui_scale(screen_w, screen_h)
442
- origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
443
- slide_x = perk_menu_panel_slide_x(self._perk_menu_timeline_ms, width=self._perk_ui_layout.panel_w)
444
-
445
- mouse = self._ui_mouse_pos()
446
- click = rl.is_mouse_button_pressed(rl.MouseButton.MOUSE_BUTTON_LEFT)
447
-
448
- master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
449
- expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
450
- computed = perk_menu_compute_layout(
451
- self._perk_ui_layout,
452
- screen_w=screen_w,
453
- origin_x=origin_x,
454
- origin_y=origin_y,
455
- scale=scale,
456
- choice_count=len(choices),
457
- expert_owned=expert_owned,
458
- master_owned=master_owned,
459
- panel_slide_x=slide_x,
460
- )
461
-
462
- fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
463
- for idx, perk_id in enumerate(choices):
464
- label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
465
- item_x = computed.list_x
466
- item_y = computed.list_y + float(idx) * computed.list_step_y
467
- rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
468
- if rl.check_collision_point_rec(mouse, rect):
469
- self._perk_menu_selected = idx
470
- if click:
471
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
472
- picked = perk_selection_pick(
473
- self._state,
474
- players,
475
- perk_state,
476
- idx,
477
- game_mode=int(GameMode.QUESTS),
478
- player_count=len(players),
479
- dt=dt_frame,
480
- creatures=self._creatures.entries,
481
- )
482
- if picked is not None:
483
- self._world.audio_router.play_sfx("sfx_ui_bonus")
484
- self._close_perk_menu()
485
- return
486
- break
487
-
488
- cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
489
- cancel_x = computed.cancel_x
490
- button_y = computed.cancel_y
491
-
492
- if button_update(
493
- self._perk_cancel_button,
494
- x=cancel_x,
495
- y=button_y,
496
- width=cancel_w,
497
- dt_ms=dt_ms,
498
- mouse=mouse,
499
- click=click,
500
- ):
501
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
502
- self._close_perk_menu()
503
- return
504
-
505
- if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER) or rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE):
506
- self._world.audio_router.play_sfx("sfx_ui_buttonclick")
507
- picked = perk_selection_pick(
508
- self._state,
509
- players,
510
- perk_state,
511
- self._perk_menu_selected,
512
- game_mode=int(GameMode.QUESTS),
513
- player_count=len(players),
514
- dt=dt_frame,
515
- creatures=self._creatures.entries,
516
- )
517
- if picked is not None:
518
- self._world.audio_router.play_sfx("sfx_ui_bonus")
519
- self._close_perk_menu()
520
-
521
419
  def _close_failed_run(self) -> None:
522
420
  if self._outcome is None:
523
421
  fired = 0
@@ -555,7 +453,7 @@ class QuestMode(BaseGameplayMode):
555
453
  self.close_requested = True
556
454
 
557
455
  def _draw_perk_prompt(self) -> None:
558
- if self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3:
456
+ if self._perk_menu.active:
559
457
  return
560
458
  if not any(player.health > 0.0 for player in self._world.players):
561
459
  return
@@ -611,94 +509,6 @@ class QuestMode(BaseGameplayMode):
611
509
  rl.draw_texture_pro(tex, src, dst, origin, rot_deg, pulse_tint)
612
510
  rl.end_blend_mode()
613
511
 
614
- def _draw_perk_menu(self) -> None:
615
- menu_t = _clamp(self._perk_menu_timeline_ms / PERK_MENU_TRANSITION_MS, 0.0, 1.0)
616
- if menu_t <= 1e-3:
617
- return
618
- if self._perk_menu_assets is None:
619
- return
620
-
621
- perk_state = self._state.perk_selection
622
- players = self._world.players
623
- choices = perk_selection_current_choices(
624
- self._state,
625
- players,
626
- perk_state,
627
- game_mode=int(GameMode.QUESTS),
628
- player_count=len(players),
629
- )
630
- if not choices:
631
- return
632
-
633
- screen_w = float(rl.get_screen_width())
634
- screen_h = float(rl.get_screen_height())
635
- scale = ui_scale(screen_w, screen_h)
636
- origin_x, origin_y = ui_origin(screen_w, screen_h, scale)
637
- slide_x = perk_menu_panel_slide_x(self._perk_menu_timeline_ms, width=self._perk_ui_layout.panel_w)
638
-
639
- master_owned = int(self._player.perk_counts[int(PerkId.PERK_MASTER)]) > 0
640
- expert_owned = int(self._player.perk_counts[int(PerkId.PERK_EXPERT)]) > 0
641
- computed = perk_menu_compute_layout(
642
- self._perk_ui_layout,
643
- screen_w=screen_w,
644
- origin_x=origin_x,
645
- origin_y=origin_y,
646
- scale=scale,
647
- choice_count=len(choices),
648
- expert_owned=expert_owned,
649
- master_owned=master_owned,
650
- panel_slide_x=slide_x,
651
- )
652
-
653
- panel_tex = self._perk_menu_assets.menu_panel
654
- if panel_tex is not None:
655
- fx_detail = bool(int(self._config.data.get("fx_detail_0", 0) or 0)) if self._config is not None else False
656
- draw_classic_menu_panel(panel_tex, dst=computed.panel, shadow=fx_detail)
657
-
658
- title_tex = self._perk_menu_assets.title_pick_perk
659
- if title_tex is not None:
660
- src = rl.Rectangle(0.0, 0.0, float(title_tex.width), float(title_tex.height))
661
- rl.draw_texture_pro(title_tex, src, computed.title, rl.Vector2(0.0, 0.0), 0.0, rl.WHITE)
662
-
663
- sponsor = None
664
- if master_owned:
665
- sponsor = "extra perks sponsored by the Perk Master"
666
- elif expert_owned:
667
- sponsor = "extra perk sponsored by the Perk Expert"
668
- if sponsor:
669
- draw_ui_text(self._small, sponsor, computed.sponsor_x, computed.sponsor_y, scale=scale, color=UI_SPONSOR_COLOR)
670
-
671
- mouse = self._ui_mouse_pos()
672
- fx_toggle = int(self._config.data.get("fx_toggle", 0) or 0) if self._config is not None else 0
673
- for idx, perk_id in enumerate(choices):
674
- label = perk_display_name(int(perk_id), fx_toggle=fx_toggle)
675
- item_x = computed.list_x
676
- item_y = computed.list_y + float(idx) * computed.list_step_y
677
- rect = menu_item_hit_rect(self._small, label, x=item_x, y=item_y, scale=scale)
678
- hovered = rl.check_collision_point_rec(mouse, rect) or (idx == self._perk_menu_selected)
679
- draw_menu_item(self._small, label, x=item_x, y=item_y, scale=scale, hovered=hovered)
680
-
681
- selected = choices[self._perk_menu_selected]
682
- desc = perk_display_description(int(selected), fx_toggle=fx_toggle)
683
- desc_x = float(computed.desc.x)
684
- desc_y = float(computed.desc.y)
685
- desc_w = float(computed.desc.width)
686
- desc_h = float(computed.desc.height)
687
- desc_scale = scale * 0.85
688
- desc_lines = wrap_ui_text(self._small, desc, max_width=desc_w, scale=desc_scale)
689
- line_h = float(self._small.cell_size * desc_scale) if self._small is not None else float(20 * desc_scale)
690
- y = desc_y
691
- for line in desc_lines:
692
- if y + line_h > desc_y + desc_h:
693
- break
694
- draw_ui_text(self._small, line, desc_x, y, scale=desc_scale, color=UI_TEXT_COLOR)
695
- y += line_h
696
-
697
- cancel_w = button_width(self._small, self._perk_cancel_button.label, scale=scale, force_wide=self._perk_cancel_button.force_wide)
698
- cancel_x = computed.cancel_x
699
- button_y = computed.cancel_y
700
- button_draw(self._perk_menu_assets, self._small, self._perk_cancel_button, x=cancel_x, y=button_y, width=cancel_w, scale=scale)
701
-
702
512
  def update(self, dt: float) -> None:
703
513
  self._update_audio(dt)
704
514
 
@@ -714,10 +524,11 @@ class QuestMode(BaseGameplayMode):
714
524
  perk_pending = int(self._state.perk_selection.pending_count) > 0 and any_alive
715
525
 
716
526
  self._perk_prompt_hover = False
717
- if self._perk_menu_open:
718
- self._perk_menu_handle_input(dt_frame, dt_ui_ms)
527
+ perk_ctx = self._perk_menu_context()
528
+ if self._perk_menu.open:
529
+ self._perk_menu.handle_input(perk_ctx, dt_frame=dt_frame, dt_ui_ms=dt_ui_ms)
719
530
 
720
- perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
531
+ perk_menu_active = self._perk_menu.active
721
532
 
722
533
  if (not perk_menu_active) and perk_pending and (not self._paused):
723
534
  label = self._perk_prompt_label()
@@ -736,29 +547,26 @@ class QuestMode(BaseGameplayMode):
736
547
 
737
548
  if input_code_is_pressed(pick_key) and (not input_code_is_down(fire_key)):
738
549
  self._perk_prompt_pulse = 1000.0
739
- self._open_perk_menu()
550
+ self._perk_menu.open_if_available(perk_ctx)
740
551
  elif self._perk_prompt_hover and input_code_is_pressed(fire_key):
741
552
  self._perk_prompt_pulse = 1000.0
742
- self._open_perk_menu()
553
+ self._perk_menu.open_if_available(perk_ctx)
743
554
 
744
- perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
555
+ perk_menu_active = self._perk_menu.active
745
556
 
746
557
  if not self._paused:
747
558
  pulse_delta = dt_ui_ms * (6.0 if self._perk_prompt_hover else -2.0)
748
- self._perk_prompt_pulse = _clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)
559
+ self._perk_prompt_pulse = clamp(self._perk_prompt_pulse + pulse_delta, 0.0, 1000.0)
749
560
 
750
561
  prompt_active = perk_pending and (not perk_menu_active) and (not self._paused)
751
562
  if prompt_active:
752
- self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
563
+ self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms + dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
753
564
  else:
754
- self._perk_prompt_timer_ms = _clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
565
+ self._perk_prompt_timer_ms = clamp(self._perk_prompt_timer_ms - dt_ui_ms, 0.0, PERK_PROMPT_MAX_TIMER_MS)
755
566
 
756
- if self._perk_menu_open:
757
- self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms + dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
758
- else:
759
- self._perk_menu_timeline_ms = _clamp(self._perk_menu_timeline_ms - dt_ui_ms, 0.0, PERK_MENU_TRANSITION_MS)
567
+ self._perk_menu.tick_timeline(dt_ui_ms)
760
568
 
761
- dt_world = 0.0 if self._paused or (not any_alive) or perk_menu_active else dt_frame
569
+ dt_world = 0.0 if self._paused or (not any_alive) or self._perk_menu.active else dt_frame
762
570
  if dt_world <= 0.0:
763
571
  if not any(player.health > 0.0 for player in self._world.players):
764
572
  self._close_failed_run()
@@ -794,6 +602,9 @@ class QuestMode(BaseGameplayMode):
794
602
  self._quest.spawn_timeline_ms = float(timeline_ms)
795
603
  self._quest.no_creatures_timer_ms = float(no_creatures_timer_ms)
796
604
 
605
+ detail_preset = 5
606
+ if self._world.config is not None:
607
+ detail_preset = int(self._world.config.data.get("detail_preset", 5) or 5)
797
608
  for call in spawns:
798
609
  self._creatures.spawn_template(
799
610
  int(call.template_id),
@@ -801,6 +612,8 @@ class QuestMode(BaseGameplayMode):
801
612
  float(call.heading),
802
613
  self._state.rng,
803
614
  rand=self._state.rng.rand,
615
+ state=self._state,
616
+ detail_preset=detail_preset,
804
617
  )
805
618
 
806
619
  completion_ms, completed = tick_quest_completion_transition(
@@ -847,7 +660,7 @@ class QuestMode(BaseGameplayMode):
847
660
  self.close_requested = True
848
661
 
849
662
  def draw(self) -> None:
850
- perk_menu_active = self._perk_menu_open or self._perk_menu_timeline_ms > 1e-3
663
+ perk_menu_active = self._perk_menu.active
851
664
  self._world.draw(draw_aim_indicators=not perk_menu_active)
852
665
  self._draw_screen_fade()
853
666
 
@@ -860,6 +673,7 @@ class QuestMode(BaseGameplayMode):
860
673
  self._draw_target_health_bar()
861
674
  hud_bottom = draw_hud_overlay(
862
675
  self._hud_assets,
676
+ state=self._hud_state,
863
677
  player=self._player,
864
678
  players=self._world.players,
865
679
  bonus_hud=self._state.bonus_hud,
@@ -875,6 +689,12 @@ class QuestMode(BaseGameplayMode):
875
689
  small_indicators=self._hud_small_indicators(),
876
690
  )
877
691
 
692
+ if debug_enabled() and (not perk_menu_active):
693
+ x = 18.0
694
+ y = max(18.0, hud_bottom + 10.0)
695
+ god = "on" if self._state.debug_god_mode else "off"
696
+ self._draw_ui_text(f"debug: [/] weapon F3 perk+1 F2 god={god}", x, y, UI_HINT_COLOR, scale=0.9)
697
+
878
698
  self._draw_quest_title()
879
699
 
880
700
  warn_y = float(rl.get_screen_height()) - 28.0
@@ -887,7 +707,7 @@ class QuestMode(BaseGameplayMode):
887
707
  self._draw_ui_text(warn, 24.0, warn_y, rl.Color(240, 80, 80, 255), scale=0.8)
888
708
 
889
709
  self._draw_perk_prompt()
890
- self._draw_perk_menu()
710
+ self._perk_menu.draw(self._perk_menu_context())
891
711
 
892
712
  if perk_menu_active:
893
713
  self._draw_game_cursor()
@@ -895,6 +715,8 @@ class QuestMode(BaseGameplayMode):
895
715
  self._draw_game_cursor()
896
716
  x = 18.0
897
717
  y = max(18.0, hud_bottom + 10.0)
718
+ if debug_enabled() and (not perk_menu_active):
719
+ y += float(self._ui_line_height(scale=0.9))
898
720
  self._draw_ui_text("paused (TAB)", x, y, UI_HINT_COLOR)
899
721
  else:
900
722
  self._draw_aim_cursor()
@@ -13,13 +13,13 @@ from grim.view import ViewContext
13
13
 
14
14
  from ..creatures.spawn import tick_rush_mode_spawns
15
15
  from ..game_modes import GameMode
16
- from ..gameplay import PlayerInput, most_used_weapon_id_for_player, weapon_assign_player
16
+ from ..gameplay import PlayerInput, weapon_assign_player
17
17
  from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
18
- from ..persistence.highscores import HighScoreRecord
19
18
  from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
20
19
  from ..ui.hud import draw_hud_overlay, hud_flags_for_game_mode
21
20
  from ..ui.perk_menu import load_perk_menu_assets
22
21
  from .base_gameplay_mode import BaseGameplayMode
22
+ from .components.highscore_record_builder import build_highscore_record_for_game_over
23
23
 
24
24
  WORLD_SIZE = 1024.0
25
25
  RUSH_WEAPON_ID = 2
@@ -146,25 +146,14 @@ class RushMode(BaseGameplayMode):
146
146
  if self._game_over_active:
147
147
  return
148
148
 
149
- record = HighScoreRecord.blank()
150
- record.score_xp = int(self._player.experience)
151
- record.survival_elapsed_ms = int(self._rush.elapsed_ms)
152
- record.creature_kill_count = int(self._creatures.kill_count)
153
- weapon_id = most_used_weapon_id_for_player(self._state, player_index=int(self._player.index), fallback_weapon_id=int(self._player.weapon_id))
154
- record.most_used_weapon_id = int(weapon_id)
155
- fired = 0
156
- hit = 0
157
- try:
158
- fired = int(self._state.shots_fired[int(self._player.index)])
159
- hit = int(self._state.shots_hit[int(self._player.index)])
160
- except Exception:
161
- fired = 0
162
- hit = 0
163
- fired = max(0, int(fired))
164
- hit = max(0, min(int(hit), fired))
165
- record.shots_fired = fired
166
- record.shots_hit = hit
167
- record.game_mode_id = int(self._config.data.get("game_mode", int(GameMode.RUSH))) if self._config is not None else int(GameMode.RUSH)
149
+ game_mode_id = int(self._config.data.get("game_mode", int(GameMode.RUSH))) if self._config is not None else int(GameMode.RUSH)
150
+ record = build_highscore_record_for_game_over(
151
+ state=self._state,
152
+ player=self._player,
153
+ survival_elapsed_ms=int(self._rush.elapsed_ms),
154
+ creature_kill_count=int(self._creatures.kill_count),
155
+ game_mode_id=game_mode_id,
156
+ )
168
157
 
169
158
  self._game_over_record = record
170
159
  self._game_over_ui.open()
@@ -179,28 +168,7 @@ class RushMode(BaseGameplayMode):
179
168
  return
180
169
 
181
170
  if self._game_over_active:
182
- record = self._game_over_record
183
- if record is None:
184
- self._enter_game_over()
185
- record = self._game_over_record
186
- if record is not None:
187
- action = self._game_over_ui.update(
188
- dt,
189
- record=record,
190
- player_name_default=self._player_name_default(),
191
- play_sfx=self._world.audio_router.play_sfx,
192
- rand=self._state.rng.rand,
193
- mouse=self._ui_mouse_pos(),
194
- )
195
- if action == "play_again":
196
- self.open()
197
- return
198
- if action == "high_scores":
199
- self._action = "open_high_scores"
200
- return
201
- if action == "main_menu":
202
- self._action = "back_to_menu"
203
- self.close_requested = True
171
+ self._update_game_over_ui(dt)
204
172
  return
205
173
 
206
174
  any_alive = any(player.health > 0.0 for player in self._world.players)
@@ -265,6 +233,7 @@ class RushMode(BaseGameplayMode):
265
233
  self._draw_target_health_bar()
266
234
  hud_bottom = draw_hud_overlay(
267
235
  self._hud_assets,
236
+ state=self._hud_state,
268
237
  player=self._player,
269
238
  players=self._world.players,
270
239
  bonus_hud=self._state.bonus_hud,