crimsonland 0.1.0.dev11__py3-none-any.whl → 0.1.0.dev13__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 (43) hide show
  1. crimson/assets_fetch.py +23 -8
  2. crimson/creatures/runtime.py +15 -0
  3. crimson/demo.py +47 -38
  4. crimson/effects.py +46 -1
  5. crimson/frontend/boot.py +2 -1
  6. crimson/frontend/high_scores_layout.py +26 -0
  7. crimson/frontend/menu.py +24 -43
  8. crimson/frontend/panels/base.py +27 -29
  9. crimson/frontend/panels/controls.py +152 -65
  10. crimson/frontend/panels/credits.py +221 -0
  11. crimson/frontend/panels/databases.py +307 -0
  12. crimson/frontend/panels/mods.py +1 -3
  13. crimson/frontend/panels/options.py +36 -42
  14. crimson/frontend/panels/play_game.py +82 -74
  15. crimson/frontend/panels/stats.py +255 -298
  16. crimson/frontend/pause_menu.py +425 -0
  17. crimson/game.py +512 -505
  18. crimson/gameplay.py +35 -6
  19. crimson/modes/base_gameplay_mode.py +3 -0
  20. crimson/modes/quest_mode.py +54 -44
  21. crimson/modes/rush_mode.py +4 -1
  22. crimson/modes/survival_mode.py +15 -10
  23. crimson/modes/tutorial_mode.py +15 -5
  24. crimson/modes/typo_mode.py +4 -1
  25. crimson/persistence/highscores.py +6 -2
  26. crimson/render/world_renderer.py +1 -1
  27. crimson/sim/world_state.py +8 -1
  28. crimson/typo/spawns.py +3 -4
  29. crimson/ui/demo_trial_overlay.py +3 -3
  30. crimson/ui/game_over.py +18 -2
  31. crimson/ui/menu_panel.py +127 -0
  32. crimson/ui/perk_menu.py +101 -44
  33. crimson/ui/quest_results.py +669 -0
  34. crimson/ui/shadow.py +39 -0
  35. crimson/views/particles.py +1 -1
  36. crimson/views/perk_menu_debug.py +2 -2
  37. crimson/views/perks.py +2 -2
  38. crimson/weapons.py +110 -110
  39. {crimsonland-0.1.0.dev11.dist-info → crimsonland-0.1.0.dev13.dist-info}/METADATA +1 -1
  40. {crimsonland-0.1.0.dev11.dist-info → crimsonland-0.1.0.dev13.dist-info}/RECORD +43 -36
  41. grim/app.py +3 -0
  42. {crimsonland-0.1.0.dev11.dist-info → crimsonland-0.1.0.dev13.dist-info}/WHEEL +0 -0
  43. {crimsonland-0.1.0.dev11.dist-info → crimsonland-0.1.0.dev13.dist-info}/entry_points.txt +0 -0
crimson/game.py CHANGED
@@ -51,6 +51,19 @@ from .demo_trial import (
51
51
  )
52
52
  from .frontend.boot import BootView
53
53
  from .frontend.assets import MenuAssets, _ensure_texture_cache, load_menu_assets
54
+ from .frontend.high_scores_layout import (
55
+ HS_BACK_BUTTON_X,
56
+ HS_BACK_BUTTON_Y,
57
+ HS_BUTTON_STEP_Y,
58
+ HS_BUTTON_X,
59
+ HS_BUTTON_Y0,
60
+ HS_LEFT_PANEL_HEIGHT,
61
+ HS_LEFT_PANEL_POS_X,
62
+ HS_LEFT_PANEL_POS_Y,
63
+ HS_RIGHT_PANEL_HEIGHT,
64
+ HS_RIGHT_PANEL_POS_X,
65
+ HS_RIGHT_PANEL_POS_Y,
66
+ )
54
67
  from .frontend.menu import (
55
68
  MENU_PANEL_HEIGHT,
56
69
  MENU_PANEL_OFFSET_X,
@@ -72,13 +85,18 @@ from .frontend.menu import (
72
85
  )
73
86
  from .frontend.panels.base import PANEL_TIMELINE_END_MS, PANEL_TIMELINE_START_MS, PanelMenuView
74
87
  from .frontend.panels.controls import ControlsMenuView
88
+ from .frontend.panels.credits import CreditsView
89
+ from .frontend.panels.databases import UnlockedPerksDatabaseView, UnlockedWeaponsDatabaseView
75
90
  from .frontend.panels.mods import ModsMenuView
76
91
  from .frontend.panels.options import OptionsMenuView
77
92
  from .frontend.panels.play_game import PlayGameMenuView
78
93
  from .frontend.panels.stats import StatisticsMenuView
94
+ from .frontend.pause_menu import PauseMenuView
79
95
  from .frontend.transitions import _draw_screen_fade, _update_screen_fade
80
96
  from .persistence.save_status import GameStatus, ensure_game_status
81
97
  from .ui.demo_trial_overlay import DEMO_PURCHASE_URL, DemoTrialOverlayUi
98
+ from .ui.menu_panel import draw_classic_menu_panel
99
+ from .ui.perk_menu import UiButtonState, UiButtonTextureSet, button_draw, button_update, button_width
82
100
  from .paths import default_runtime_dir
83
101
  from .assets_fetch import download_missing_paqs
84
102
 
@@ -124,6 +142,7 @@ class GameState:
124
142
  snd_freq_adjustment_enabled: bool = False
125
143
  menu_ground: GroundRenderer | None = None
126
144
  menu_sign_locked: bool = False
145
+ pause_background: PauseBackground | None = None
127
146
  pending_quest_level: str | None = None
128
147
  pending_high_scores: HighScoresRequest | None = None
129
148
  quest_outcome: QuestRunOutcome | None = None
@@ -141,6 +160,7 @@ AUTOEXEC_NAME = "autoexec.txt"
141
160
 
142
161
  QUEST_MENU_BASE_X = -5.0
143
162
  QUEST_MENU_BASE_Y = 185.0
163
+ QUEST_MENU_PANEL_OFFSET_X = -63.0
144
164
 
145
165
  QUEST_TITLE_X_OFFSET = 219.0 # 300 + 64 - 145
146
166
  QUEST_TITLE_Y_OFFSET = 44.0 # 40 + 4
@@ -166,8 +186,9 @@ QUEST_HARDCORE_CHECKBOX_X_OFFSET = 132.0
166
186
  QUEST_HARDCORE_CHECKBOX_Y_OFFSET = -12.0
167
187
  QUEST_HARDCORE_LIST_Y_SHIFT = 10.0
168
188
 
169
- QUEST_BACK_BUTTON_X_OFFSET = 148.0
189
+ QUEST_BACK_BUTTON_X_OFFSET = 138.0
170
190
  QUEST_BACK_BUTTON_Y_OFFSET = 212.0
191
+ QUEST_PANEL_HEIGHT = 378.0
171
192
 
172
193
 
173
194
  class QuestsMenuView:
@@ -192,6 +213,8 @@ class QuestsMenuView:
192
213
  self._check_off: rl.Texture2D | None = None
193
214
  self._button_sm: rl.Texture2D | None = None
194
215
  self._button_md: rl.Texture2D | None = None
216
+ self._button_textures: UiButtonTextureSet | None = None
217
+ self._back_button = UiButtonState("Back")
195
218
 
196
219
  self._menu_screen_width = 0
197
220
  self._widescreen_y_shift = 0.0
@@ -224,11 +247,13 @@ class QuestsMenuView:
224
247
  self._check_off = cache.get_or_load("ui_checkOff", "ui/ui_checkOff.jaz").texture
225
248
  self._button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
226
249
  self._button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
250
+ self._button_textures = UiButtonTextureSet(button_sm=self._button_sm, button_md=self._button_md)
227
251
 
228
252
  self._action = None
229
253
  self._dirty = False
230
254
  self._stage = max(1, min(5, int(self._stage)))
231
255
  self._cursor_pulse_time = 0.0
256
+ self._back_button = UiButtonState("Back")
232
257
 
233
258
  # Ensure the quest registry is populated so titles render.
234
259
  # (The package import registers all tier builders.)
@@ -247,6 +272,7 @@ class QuestsMenuView:
247
272
  pass
248
273
  self._dirty = False
249
274
  self._ground = None
275
+ self._button_textures = None
250
276
 
251
277
  def update(self, dt: float) -> None:
252
278
  if self._state.audio is not None:
@@ -283,9 +309,26 @@ class QuestsMenuView:
283
309
  if self._hardcore_checkbox_clicked(layout):
284
310
  return
285
311
 
286
- if self._back_button_clicked(layout):
287
- self._action = "open_play_game"
288
- return
312
+ textures = self._button_textures
313
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
314
+ back_x = layout["list_x"] + QUEST_BACK_BUTTON_X_OFFSET
315
+ back_y = self._rows_y0(layout) + QUEST_BACK_BUTTON_Y_OFFSET
316
+ dt_ms = min(float(dt), 0.1) * 1000.0
317
+ font = self._ensure_small_font()
318
+ back_w = button_width(font, self._back_button.label, scale=1.0, force_wide=self._back_button.force_wide)
319
+ mouse = rl.get_mouse_position()
320
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
321
+ if button_update(
322
+ self._back_button,
323
+ x=float(back_x),
324
+ y=float(back_y),
325
+ width=float(back_w),
326
+ dt_ms=float(dt_ms),
327
+ mouse=mouse,
328
+ click=bool(click),
329
+ ):
330
+ self._action = "open_play_game"
331
+ return
289
332
 
290
333
  # Quick-select row numbers 1..0 (10).
291
334
  row_from_key = self._digit_row_pressed()
@@ -329,10 +372,10 @@ class QuestsMenuView:
329
372
 
330
373
  def _layout(self) -> dict[str, float]:
331
374
  # `sub_447d40` base sums:
332
- # x_sum = <ui_element_x> + (-5)
333
- # y_sum = <ui_element_y> + 185 (+ widescreen shift via ui_menu_layout_init)
334
- x_sum = QUEST_MENU_BASE_X
335
- y_sum = QUEST_MENU_BASE_Y + self._widescreen_y_shift
375
+ # x_sum = <ui_element_x> + <ui_element_offset_x> (x=-5)
376
+ # y_sum = <ui_element_y> + <ui_element_offset_y> (y=185 + widescreen shift via ui_menu_layout_init)
377
+ x_sum = QUEST_MENU_BASE_X + MENU_PANEL_OFFSET_X
378
+ y_sum = QUEST_MENU_BASE_Y + MENU_PANEL_OFFSET_Y + self._widescreen_y_shift
336
379
 
337
380
  title_x = x_sum + QUEST_TITLE_X_OFFSET
338
381
  title_y = y_sum + QUEST_TITLE_Y_OFFSET
@@ -392,20 +435,6 @@ class QuestsMenuView:
392
435
  return True
393
436
  return False
394
437
 
395
- def _back_button_clicked(self, layout: dict[str, float]) -> bool:
396
- tex = self._button_sm
397
- if tex is None:
398
- tex = self._button_md
399
- if tex is None:
400
- return False
401
- x = layout["list_x"] + QUEST_BACK_BUTTON_X_OFFSET
402
- y = self._rows_y0(layout) + QUEST_BACK_BUTTON_Y_OFFSET
403
- w = float(tex.width)
404
- h = float(tex.height)
405
- mouse = rl.get_mouse_position()
406
- hovered = x <= mouse.x <= x + w and y <= mouse.y <= y + h
407
- return hovered and rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
408
-
409
438
  @staticmethod
410
439
  def _digit_row_pressed() -> int | None:
411
440
  keys = [
@@ -634,12 +663,15 @@ class QuestsMenuView:
634
663
  else:
635
664
  title = "???"
636
665
  draw_small_text(font, title, list_x + QUEST_LIST_NAME_X_OFFSET, y, 1.0, color)
666
+ title_w = measure_small_text_width(font, title, 1.0) if unlocked else 0.0
667
+ if unlocked:
668
+ line_y = y + 13.0
669
+ rl.draw_line(int(list_x), int(line_y), int(list_x + title_w + 32.0), int(line_y), color)
637
670
 
638
671
  if show_counts and unlocked:
639
672
  counts = self._quest_counts(stage=stage, row=row)
640
673
  if counts is not None:
641
674
  completed, games = counts
642
- title_w = measure_small_text_width(font, title, 1.0)
643
675
  counts_x = list_x + QUEST_LIST_NAME_X_OFFSET + title_w + 12.0
644
676
  draw_small_text(font, f"({completed}/{games})", counts_x, y, 1.0, color)
645
677
 
@@ -650,28 +682,12 @@ class QuestsMenuView:
650
682
  draw_small_text(font, "(completed/games)", header_x, header_y, 1.0, base_color)
651
683
 
652
684
  # Back button.
653
- button = self._button_sm or self._button_md
654
- if button is not None:
685
+ textures = self._button_textures
686
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
655
687
  back_x = list_x + QUEST_BACK_BUTTON_X_OFFSET
656
688
  back_y = y0 + QUEST_BACK_BUTTON_Y_OFFSET
657
- back_w = float(button.width)
658
- back_h = float(button.height)
659
- mouse = rl.get_mouse_position()
660
- hovered = back_x <= mouse.x <= back_x + back_w and back_y <= mouse.y <= back_y + back_h
661
- rl.draw_texture_pro(
662
- button,
663
- rl.Rectangle(0.0, 0.0, float(button.width), float(button.height)),
664
- rl.Rectangle(back_x, back_y, back_w, back_h),
665
- rl.Vector2(0.0, 0.0),
666
- 0.0,
667
- rl.WHITE,
668
- )
669
- label = "Back"
670
- label_w = measure_small_text_width(font, label, 1.0)
671
- text_x = back_x + (back_w - label_w) * 0.5 + 1.0
672
- text_y = back_y + 10.0
673
- text_alpha = 255 if hovered else 179
674
- draw_small_text(font, label, text_x, text_y, 1.0, rl.Color(255, 255, 255, text_alpha))
689
+ back_w = button_width(font, self._back_button.label, scale=1.0, force_wide=self._back_button.force_wide)
690
+ button_draw(textures, font, self._back_button, x=float(back_x), y=float(back_y), width=float(back_w), scale=1.0)
675
691
 
676
692
  def _draw_sign(self) -> None:
677
693
  assets = self._assets
@@ -719,53 +735,19 @@ class QuestsMenuView:
719
735
  panel = self._panel_tex
720
736
  if panel is None:
721
737
  return
722
- panel_scale = 0.9 if self._menu_screen_width < 641 else 1.0
723
- dst = rl.Rectangle(
724
- QUEST_MENU_BASE_X,
725
- QUEST_MENU_BASE_Y + self._widescreen_y_shift,
726
- MENU_PANEL_WIDTH * panel_scale,
727
- MENU_PANEL_HEIGHT * panel_scale,
728
- )
729
- origin = rl.Vector2(-(MENU_PANEL_OFFSET_X * panel_scale), -(MENU_PANEL_OFFSET_Y * panel_scale))
730
738
  fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
731
- if fx_detail:
732
- MenuView._draw_ui_quad_shadow(
733
- texture=panel,
734
- src=rl.Rectangle(0.0, 0.0, float(panel.width), float(panel.height)),
735
- dst=rl.Rectangle(dst.x + UI_SHADOW_OFFSET, dst.y + UI_SHADOW_OFFSET, dst.width, dst.height),
736
- origin=origin,
737
- rotation_deg=0.0,
738
- )
739
- MenuView._draw_ui_quad(
740
- texture=panel,
741
- src=rl.Rectangle(0.0, 0.0, float(panel.width), float(panel.height)),
742
- dst=dst,
743
- origin=origin,
744
- rotation_deg=0.0,
745
- tint=rl.WHITE,
739
+ draw_classic_menu_panel(
740
+ panel,
741
+ dst=rl.Rectangle(
742
+ float(QUEST_MENU_BASE_X + QUEST_MENU_PANEL_OFFSET_X),
743
+ float(QUEST_MENU_BASE_Y + MENU_PANEL_OFFSET_Y + self._widescreen_y_shift),
744
+ float(MENU_PANEL_WIDTH),
745
+ float(QUEST_PANEL_HEIGHT),
746
+ ),
747
+ shadow=fx_detail,
746
748
  )
747
749
 
748
750
 
749
- class QuestStartView(PanelMenuView):
750
- def __init__(self, state: GameState) -> None:
751
- super().__init__(
752
- state,
753
- title="Quest",
754
- body="Quest gameplay is not implemented yet.",
755
- back_action="open_quests",
756
- )
757
-
758
- def open(self) -> None:
759
- level = self._state.pending_quest_level or "unknown"
760
- self._title = f"Quest {level}"
761
- self._body_lines = [
762
- f"Selected quest: {level}",
763
- "",
764
- "Quest gameplay is not implemented yet.",
765
- ]
766
- super().open()
767
-
768
-
769
751
  class FrontView(Protocol):
770
752
  def open(self) -> None: ...
771
753
 
@@ -778,6 +760,10 @@ class FrontView(Protocol):
778
760
  def take_action(self) -> str | None: ...
779
761
 
780
762
 
763
+ class PauseBackground(Protocol):
764
+ def draw_pause_background(self) -> None: ...
765
+
766
+
781
767
  class SurvivalGameView:
782
768
  """Gameplay view wrapper that adapts SurvivalMode into `crimson game`."""
783
769
 
@@ -817,6 +803,9 @@ class SurvivalGameView:
817
803
  def update(self, dt: float) -> None:
818
804
  self._mode.update(dt)
819
805
  mode_action = self._mode.take_action()
806
+ if mode_action == "open_pause_menu":
807
+ self._action = "open_pause_menu"
808
+ return
820
809
  if mode_action == "open_high_scores":
821
810
  self._state.pending_high_scores = HighScoresRequest(game_mode_id=1)
822
811
  self._action = "open_high_scores"
@@ -832,6 +821,9 @@ class SurvivalGameView:
832
821
  def draw(self) -> None:
833
822
  self._mode.draw()
834
823
 
824
+ def draw_pause_background(self) -> None:
825
+ self._mode.draw_pause_background()
826
+
835
827
  def take_action(self) -> str | None:
836
828
  action = self._action
837
829
  self._action = None
@@ -875,6 +867,9 @@ class RushGameView:
875
867
  def update(self, dt: float) -> None:
876
868
  self._mode.update(dt)
877
869
  mode_action = self._mode.take_action()
870
+ if mode_action == "open_pause_menu":
871
+ self._action = "open_pause_menu"
872
+ return
878
873
  if mode_action == "open_high_scores":
879
874
  self._state.pending_high_scores = HighScoresRequest(game_mode_id=2)
880
875
  self._action = "open_high_scores"
@@ -890,6 +885,9 @@ class RushGameView:
890
885
  def draw(self) -> None:
891
886
  self._mode.draw()
892
887
 
888
+ def draw_pause_background(self) -> None:
889
+ self._mode.draw_pause_background()
890
+
893
891
  def take_action(self) -> str | None:
894
892
  action = self._action
895
893
  self._action = None
@@ -933,6 +931,9 @@ class TypoShooterGameView:
933
931
  def update(self, dt: float) -> None:
934
932
  self._mode.update(dt)
935
933
  mode_action = self._mode.take_action()
934
+ if mode_action == "open_pause_menu":
935
+ self._action = "open_pause_menu"
936
+ return
936
937
  if mode_action == "open_high_scores":
937
938
  self._state.pending_high_scores = HighScoresRequest(game_mode_id=4)
938
939
  self._action = "open_high_scores"
@@ -948,6 +949,9 @@ class TypoShooterGameView:
948
949
  def draw(self) -> None:
949
950
  self._mode.draw()
950
951
 
952
+ def draw_pause_background(self) -> None:
953
+ self._mode.draw_pause_background()
954
+
951
955
  def take_action(self) -> str | None:
952
956
  action = self._action
953
957
  self._action = None
@@ -991,6 +995,10 @@ class TutorialGameView:
991
995
 
992
996
  def update(self, dt: float) -> None:
993
997
  self._mode.update(dt)
998
+ mode_action = self._mode.take_action()
999
+ if mode_action == "open_pause_menu":
1000
+ self._action = "open_pause_menu"
1001
+ return
994
1002
  if getattr(self._mode, "close_requested", False):
995
1003
  self._action = "back_to_menu"
996
1004
  self._mode.close_requested = False
@@ -998,6 +1006,9 @@ class TutorialGameView:
998
1006
  def draw(self) -> None:
999
1007
  self._mode.draw()
1000
1008
 
1009
+ def draw_pause_background(self) -> None:
1010
+ self._mode.draw_pause_background()
1011
+
1001
1012
  def take_action(self) -> str | None:
1002
1013
  action = self._action
1003
1014
  self._action = None
@@ -1046,6 +1057,10 @@ class QuestGameView:
1046
1057
 
1047
1058
  def update(self, dt: float) -> None:
1048
1059
  self._mode.update(dt)
1060
+ mode_action = self._mode.take_action()
1061
+ if mode_action == "open_pause_menu":
1062
+ self._action = "open_pause_menu"
1063
+ return
1049
1064
  if getattr(self._mode, "close_requested", False):
1050
1065
  outcome = self._mode.consume_outcome()
1051
1066
  if outcome is not None:
@@ -1063,6 +1078,9 @@ class QuestGameView:
1063
1078
  def draw(self) -> None:
1064
1079
  self._mode.draw()
1065
1080
 
1081
+ def draw_pause_background(self) -> None:
1082
+ self._mode.draw_pause_background()
1083
+
1066
1084
  def take_action(self) -> str | None:
1067
1085
  action = self._action
1068
1086
  self._action = None
@@ -1103,45 +1121,35 @@ class QuestResultsView:
1103
1121
  def __init__(self, state: GameState) -> None:
1104
1122
  self._state = state
1105
1123
  self._ground: GroundRenderer | None = None
1106
- self._outcome: QuestRunOutcome | None = None
1124
+ self._quest_level: str = ""
1107
1125
  self._quest_title: str = ""
1108
1126
  self._quest_stage_major = 0
1109
1127
  self._quest_stage_minor = 0
1110
1128
  self._unlock_weapon_name: str = ""
1111
1129
  self._unlock_perk_name: str = ""
1112
- self._breakdown = None
1113
- self._breakdown_anim = None
1114
- self._record = None
1115
- self._rank_index: int | None = None
1130
+ self._ui = None
1116
1131
  self._action: str | None = None
1117
- self._cursor_pulse_time = 0.0
1118
- self._small_font: SmallFontData | None = None
1119
- self._button_tex: rl.Texture2D | None = None
1120
1132
 
1121
1133
  def open(self) -> None:
1122
- from .quests.results import QuestResultsBreakdownAnim, compute_quest_final_time
1123
- from .persistence.highscores import HighScoreRecord, scores_path_for_config, upsert_highscore_record
1134
+ from .persistence.highscores import HighScoreRecord
1135
+ from .quests.results import compute_quest_final_time
1136
+ from .ui.quest_results import QuestResultsUi
1124
1137
 
1125
1138
  self._action = None
1126
- self._ground = ensure_menu_ground(self._state)
1127
- self._cursor_pulse_time = 0.0
1128
- self._outcome = self._state.quest_outcome
1129
- self._state.quest_outcome = None
1130
- outcome = self._outcome
1139
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
1131
1140
  self._state.quest_fail_retry_count = 0
1141
+ outcome = self._state.quest_outcome
1142
+ self._state.quest_outcome = None
1143
+ self._quest_level = ""
1132
1144
  self._quest_title = ""
1133
1145
  self._quest_stage_major = 0
1134
1146
  self._quest_stage_minor = 0
1135
1147
  self._unlock_weapon_name = ""
1136
1148
  self._unlock_perk_name = ""
1137
- self._breakdown = None
1138
- self._breakdown_anim = None
1139
- self._record = None
1140
- self._rank_index = None
1141
- self._button_tex = None
1142
- self._small_font = None
1149
+ self._ui = None
1143
1150
  if outcome is None:
1144
1151
  return
1152
+ self._quest_level = str(outcome.level or "")
1145
1153
 
1146
1154
  major, minor = 0, 0
1147
1155
  try:
@@ -1199,7 +1207,8 @@ class QuestResultsView:
1199
1207
  pending_perk_count=int(outcome.pending_perk_count),
1200
1208
  )
1201
1209
  record.survival_elapsed_ms = int(breakdown.final_time_ms)
1202
- record.set_name(_player_name_default(self._state.config) or "Player")
1210
+ player_name_default = _player_name_default(self._state.config) or "Player"
1211
+ record.set_name(player_name_default)
1203
1212
 
1204
1213
  global_index = (int(major) - 1) * 10 + (int(minor) - 1)
1205
1214
  if 0 <= global_index < 40:
@@ -1228,271 +1237,86 @@ class QuestResultsView:
1228
1237
  except Exception:
1229
1238
  pass
1230
1239
 
1231
- path = scores_path_for_config(self._state.base_dir, self._state.config, quest_stage_major=major, quest_stage_minor=minor)
1232
- try:
1233
- _table, rank_index = upsert_highscore_record(path, record)
1234
- self._rank_index = int(rank_index)
1235
- except Exception:
1236
- self._rank_index = None
1237
-
1238
- cache = _ensure_texture_cache(self._state)
1239
- self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
1240
- self._record = record
1241
- self._breakdown = breakdown
1242
- self._breakdown_anim = QuestResultsBreakdownAnim.start()
1240
+ self._ui = QuestResultsUi(
1241
+ assets_root=self._state.assets_dir,
1242
+ base_dir=self._state.base_dir,
1243
+ config=self._state.config,
1244
+ )
1245
+ self._ui.open(
1246
+ record=record,
1247
+ breakdown=breakdown,
1248
+ quest_level=str(outcome.level or ""),
1249
+ quest_title=str(self._quest_title or ""),
1250
+ quest_stage_major=int(self._quest_stage_major),
1251
+ quest_stage_minor=int(self._quest_stage_minor),
1252
+ unlock_weapon_name=str(self._unlock_weapon_name or ""),
1253
+ unlock_perk_name=str(self._unlock_perk_name or ""),
1254
+ player_name_default=player_name_default,
1255
+ )
1243
1256
 
1244
1257
  def close(self) -> None:
1245
- self._small_font = None
1246
- self._button_tex = None
1247
- self._record = None
1248
- self._outcome = None
1249
- self._breakdown = None
1250
- self._breakdown_anim = None
1251
- self._rank_index = None
1258
+ if self._ui is not None:
1259
+ self._ui.close()
1260
+ self._ui = None
1261
+ self._ground = None
1252
1262
  self._quest_stage_major = 0
1253
1263
  self._quest_stage_minor = 0
1264
+ self._quest_level = ""
1265
+ self._quest_title = ""
1254
1266
  self._unlock_weapon_name = ""
1255
1267
  self._unlock_perk_name = ""
1256
1268
 
1257
1269
  def update(self, dt: float) -> None:
1258
- from .quests.results import tick_quest_results_breakdown_anim
1259
-
1260
1270
  if self._state.audio is not None:
1261
1271
  update_audio(self._state.audio, dt)
1262
1272
  if self._ground is not None:
1263
1273
  self._ground.process_pending()
1264
- self._cursor_pulse_time += min(dt, 0.1) * 1.1
1265
-
1266
- if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
1267
- self._action = "back_to_menu"
1274
+ ui = self._ui
1275
+ if ui is None:
1268
1276
  return
1277
+ audio = self._state.audio
1278
+ rng = self._state.rng
1269
1279
 
1270
- if rl.is_key_pressed(rl.KeyboardKey.KEY_H):
1271
- self._open_high_scores_list()
1272
- return
1273
-
1274
- outcome = self._outcome
1275
- record = self._record
1276
- breakdown = self._breakdown
1277
- if record is None or outcome is None or breakdown is None:
1278
- return
1279
-
1280
- anim = self._breakdown_anim
1281
- if anim is not None and not anim.done:
1282
- if rl.is_key_pressed(rl.KeyboardKey.KEY_SPACE) or rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT):
1283
- anim.set_final(breakdown)
1284
- return
1285
-
1286
- clinks = tick_quest_results_breakdown_anim(
1287
- anim,
1288
- frame_dt_ms=int(min(dt, 0.1) * 1000.0),
1289
- target=breakdown,
1290
- )
1291
- if clinks > 0 and self._state.audio is not None:
1292
- play_sfx(self._state.audio, "sfx_ui_clink_01", rng=self._state.rng)
1293
- if not anim.done:
1280
+ def _play(name: str) -> None:
1281
+ if audio is None:
1294
1282
  return
1283
+ play_sfx(audio, name, rng=rng)
1295
1284
 
1296
- if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
1297
- self._state.pending_quest_level = outcome.level
1285
+ action = ui.update(dt, play_sfx=_play if audio is not None else None, rand=lambda: rng.getrandbits(32))
1286
+ if action == "play_again":
1287
+ self._state.pending_quest_level = self._quest_level
1298
1288
  self._action = "start_quest"
1299
1289
  return
1300
- if rl.is_key_pressed(rl.KeyboardKey.KEY_N):
1301
- next_level = _next_quest_level(outcome.level)
1290
+ if action == "play_next":
1291
+ next_level = _next_quest_level(self._quest_level)
1302
1292
  if next_level is not None:
1303
1293
  self._state.pending_quest_level = next_level
1304
1294
  self._action = "start_quest"
1305
- return
1306
-
1307
- tex = self._button_tex
1308
- if tex is None:
1309
- return
1310
- scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
1311
- button_w = float(tex.width) * scale
1312
- button_h = float(tex.height) * scale
1313
- gap_x = 18.0 * scale
1314
- gap_y = 12.0 * scale
1315
- x0 = 32.0
1316
- y0 = float(rl.get_screen_height()) - (button_h * 2.0 + gap_y) - 52.0 * scale
1317
- x1 = x0 + button_w + gap_x
1318
- y1 = y0 + button_h + gap_y
1319
-
1320
- buttons = [
1321
- ("Play again", rl.Rectangle(x0, y0, button_w, button_h), "play_again"),
1322
- ("Play next", rl.Rectangle(x1, y0, button_w, button_h), "play_next"),
1323
- ("High scores", rl.Rectangle(x0, y1, button_w, button_h), "high_scores"),
1324
- ("Main menu", rl.Rectangle(x1, y1, button_w, button_h), "main_menu"),
1325
- ]
1326
- mouse = rl.get_mouse_position()
1327
- clicked = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1328
- for _label, rect, action in buttons:
1329
- hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
1330
- if not hovered or not clicked:
1331
- continue
1332
- if action == "play_again":
1333
- self._state.pending_quest_level = outcome.level
1334
- self._action = "start_quest"
1335
- return
1336
- if action == "play_next":
1337
- next_level = _next_quest_level(outcome.level)
1338
- if next_level is not None:
1339
- self._state.pending_quest_level = next_level
1340
- self._action = "start_quest"
1341
- return
1342
- if action == "main_menu":
1295
+ else:
1343
1296
  self._action = "back_to_menu"
1344
- return
1345
- if action == "high_scores":
1346
- self._open_high_scores_list()
1347
- return
1297
+ return
1298
+ if action == "high_scores":
1299
+ self._open_high_scores_list()
1300
+ return
1301
+ if action == "main_menu":
1302
+ self._action = "back_to_menu"
1303
+ return
1348
1304
 
1349
1305
  def draw(self) -> None:
1350
1306
  rl.clear_background(rl.BLACK)
1351
- if self._ground is not None:
1307
+ pause_background = self._state.pause_background
1308
+ if pause_background is not None:
1309
+ pause_background.draw_pause_background()
1310
+ elif self._ground is not None:
1352
1311
  self._ground.draw(0.0, 0.0)
1353
1312
  _draw_screen_fade(self._state)
1354
-
1355
- record = self._record
1356
- outcome = self._outcome
1357
- breakdown = self._breakdown
1358
- if record is None or outcome is None or breakdown is None:
1359
- rl.draw_text("Quest results unavailable.", 32, 140, 28, rl.Color(235, 235, 235, 255))
1360
- rl.draw_text("Press ESC to return to the menu.", 32, 180, 18, rl.Color(190, 190, 200, 255))
1313
+ ui = self._ui
1314
+ if ui is not None:
1315
+ ui.draw()
1361
1316
  return
1362
1317
 
1363
- anim = self._breakdown_anim
1364
- base_time_ms = int(breakdown.base_time_ms)
1365
- life_bonus_ms = int(breakdown.life_bonus_ms)
1366
- perk_bonus_ms = int(breakdown.unpicked_perk_bonus_ms)
1367
- final_time_ms = int(breakdown.final_time_ms)
1368
- step = 4
1369
- highlight_alpha = 1.0
1370
- if anim is not None and not anim.done:
1371
- base_time_ms = int(anim.base_time_ms)
1372
- life_bonus_ms = int(anim.life_bonus_ms)
1373
- perk_bonus_ms = int(anim.unpicked_perk_bonus_s) * 1000
1374
- final_time_ms = int(anim.final_time_ms)
1375
- step = int(anim.step)
1376
- highlight_alpha = float(anim.highlight_alpha())
1377
-
1378
- def _fmt_clock(ms: int) -> str:
1379
- total_seconds = max(0, int(ms) // 1000)
1380
- minutes = total_seconds // 60
1381
- seconds = total_seconds % 60
1382
- return f"{minutes:02d}:{seconds:02d}"
1383
-
1384
- def _fmt_bonus(ms: int) -> str:
1385
- return f"-{float(max(0, int(ms))) / 1000.0:.2f}s"
1386
-
1387
- def _breakdown_color(idx: int, *, final: bool = False) -> rl.Color:
1388
- if anim is None or anim.done:
1389
- if final:
1390
- return rl.Color(255, 255, 255, 255)
1391
- return rl.Color(255, 255, 255, int(255 * 0.8))
1392
-
1393
- alpha = 0.2
1394
- if idx < step:
1395
- alpha = 0.4
1396
- elif idx == step:
1397
- alpha = 1.0
1398
- if final:
1399
- alpha *= highlight_alpha
1400
- rgb = (255, 255, 255)
1401
- if idx == step:
1402
- rgb = (25, 200, 25)
1403
- return rl.Color(rgb[0], rgb[1], rgb[2], int(255 * max(0.0, min(1.0, alpha))))
1404
-
1405
- title = f"Quest {outcome.level} completed"
1406
- subtitle = self._quest_title
1407
- rl.draw_text(title, 32, 120, 28, rl.Color(235, 235, 235, 255))
1408
- if subtitle:
1409
- rl.draw_text(subtitle, 32, 154, 18, rl.Color(190, 190, 200, 255))
1410
-
1411
- font = self._ensure_small_font()
1412
- text_color = rl.Color(255, 255, 255, int(255 * 0.8))
1413
- y = 196.0
1414
- draw_small_text(font, f"Base time: {_fmt_clock(base_time_ms)}", 32.0, y, 1.0, _breakdown_color(0))
1415
- y += 18.0
1416
- draw_small_text(font, f"Life bonus: {_fmt_bonus(life_bonus_ms)}", 32.0, y, 1.0, _breakdown_color(1))
1417
- y += 18.0
1418
- draw_small_text(font, f"Perk bonus: {_fmt_bonus(perk_bonus_ms)}", 32.0, y, 1.0, _breakdown_color(2))
1419
- y += 18.0
1420
- draw_small_text(font, f"Final time: {_fmt_clock(final_time_ms)}", 32.0, y, 1.0, _breakdown_color(3, final=True))
1421
- y += 26.0
1422
- draw_small_text(font, f"Kills: {int(record.creature_kill_count)}", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.8)))
1423
- y += 18.0
1424
- draw_small_text(font, f"XP: {int(record.score_xp)}", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.8)))
1425
- if self._rank_index is not None and self._rank_index < 100:
1426
- y += 18.0
1427
- draw_small_text(font, f"Rank: {int(self._rank_index) + 1}", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.8)))
1428
-
1429
- if self._unlock_weapon_name:
1430
- y += 26.0
1431
- draw_small_text(font, "Weapon unlocked", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.7)))
1432
- y += 16.0
1433
- draw_small_text(font, self._unlock_weapon_name, 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.9)))
1434
-
1435
- if self._unlock_perk_name:
1436
- y += 20.0
1437
- draw_small_text(font, "Perk unlocked", 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.7)))
1438
- y += 16.0
1439
- draw_small_text(font, self._unlock_perk_name, 32.0, y, 1.0, rl.Color(255, 255, 255, int(255 * 0.9)))
1440
-
1441
- tex = self._button_tex
1442
- y0 = 0.0
1443
- if tex is not None:
1444
- scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
1445
- button_w = float(tex.width) * scale
1446
- button_h = float(tex.height) * scale
1447
- gap_x = 18.0 * scale
1448
- gap_y = 12.0 * scale
1449
- x0 = 32.0
1450
- y0 = float(rl.get_screen_height()) - (button_h * 2.0 + gap_y) - 52.0 * scale
1451
- x1 = x0 + button_w + gap_x
1452
- y1 = y0 + button_h + gap_y
1453
-
1454
- buttons = [
1455
- ("Play again", rl.Rectangle(x0, y0, button_w, button_h)),
1456
- ("Play next", rl.Rectangle(x1, y0, button_w, button_h)),
1457
- ("High scores", rl.Rectangle(x0, y1, button_w, button_h)),
1458
- ("Main menu", rl.Rectangle(x1, y1, button_w, button_h)),
1459
- ]
1460
- mouse = rl.get_mouse_position()
1461
- for label, rect in buttons:
1462
- hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
1463
- alpha = 255 if hovered else 220
1464
- rl.draw_texture_pro(
1465
- tex,
1466
- rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height)),
1467
- rect,
1468
- rl.Vector2(0.0, 0.0),
1469
- 0.0,
1470
- rl.Color(255, 255, 255, alpha),
1471
- )
1472
- label_w = measure_small_text_width(font, label, 1.0 * scale)
1473
- text_x = rect.x + (rect.width - label_w) * 0.5 + 1.0 * scale
1474
- text_y = rect.y + 10.0 * scale
1475
- draw_small_text(font, label, text_x, text_y, 1.0 * scale, rl.Color(20, 20, 20, 255))
1476
-
1477
- if anim is not None and not anim.done:
1478
- draw_small_text(
1479
- font,
1480
- "SPACE / click: skip breakdown",
1481
- 32.0,
1482
- float(rl.get_screen_height()) - 46.0,
1483
- 0.9,
1484
- rl.Color(190, 190, 200, 255),
1485
- )
1486
-
1487
- draw_small_text(
1488
- font,
1489
- "ENTER: Replay N: Next H: High scores ESC: Menu",
1490
- 32.0,
1491
- float(rl.get_screen_height()) - 28.0,
1492
- 1.0,
1493
- rl.Color(190, 190, 200, 255),
1494
- )
1495
- _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
1318
+ rl.draw_text("Quest results unavailable.", 32, 140, 28, rl.Color(235, 235, 235, 255))
1319
+ rl.draw_text("Press ESC to return to the menu.", 32, 180, 18, rl.Color(190, 190, 200, 255))
1496
1320
 
1497
1321
  def take_action(self) -> str | None:
1498
1322
  action = self._action
@@ -1500,21 +1324,17 @@ class QuestResultsView:
1500
1324
  return action
1501
1325
 
1502
1326
  def _open_high_scores_list(self) -> None:
1327
+ highlight_rank = None
1328
+ if self._ui is not None:
1329
+ highlight_rank = self._ui.highlight_rank
1503
1330
  self._state.pending_high_scores = HighScoresRequest(
1504
1331
  game_mode_id=3,
1505
1332
  quest_stage_major=int(self._quest_stage_major),
1506
1333
  quest_stage_minor=int(self._quest_stage_minor),
1507
- highlight_rank=self._rank_index,
1334
+ highlight_rank=highlight_rank,
1508
1335
  )
1509
1336
  self._action = "open_high_scores"
1510
1337
 
1511
- def _ensure_small_font(self) -> SmallFontData:
1512
- if self._small_font is not None:
1513
- return self._small_font
1514
- missing_assets: list[str] = []
1515
- self._small_font = load_small_font(self._state.assets_dir, missing_assets)
1516
- return self._small_font
1517
-
1518
1338
 
1519
1339
  class QuestFailedView:
1520
1340
  def __init__(self, state: GameState) -> None:
@@ -1529,13 +1349,17 @@ class QuestFailedView:
1529
1349
 
1530
1350
  def open(self) -> None:
1531
1351
  self._action = None
1532
- self._ground = ensure_menu_ground(self._state)
1352
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
1533
1353
  self._cursor_pulse_time = 0.0
1534
1354
  self._outcome = self._state.quest_outcome
1535
1355
  self._state.quest_outcome = None
1536
1356
  self._quest_title = ""
1537
1357
  self._small_font = None
1538
1358
  self._button_tex = None
1359
+ self._button_textures = None
1360
+ self._retry_button = UiButtonState("Retry", force_wide=True)
1361
+ self._quest_list_button = UiButtonState("Quest list", force_wide=True)
1362
+ self._main_menu_button = UiButtonState("Main menu", force_wide=True)
1539
1363
  outcome = self._outcome
1540
1364
  if outcome is not None:
1541
1365
  try:
@@ -1547,7 +1371,9 @@ class QuestFailedView:
1547
1371
  self._quest_title = ""
1548
1372
 
1549
1373
  cache = _ensure_texture_cache(self._state)
1550
- self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
1374
+ self._button_tex = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
1375
+ button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
1376
+ self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=self._button_tex)
1551
1377
 
1552
1378
  def close(self) -> None:
1553
1379
  self._ground = None
@@ -1555,6 +1381,7 @@ class QuestFailedView:
1555
1381
  self._quest_title = ""
1556
1382
  self._small_font = None
1557
1383
  self._button_tex = None
1384
+ self._button_textures = None
1558
1385
 
1559
1386
  def update(self, dt: float) -> None:
1560
1387
  if self._state.audio is not None:
@@ -1578,44 +1405,56 @@ class QuestFailedView:
1578
1405
  self._action = "open_quests"
1579
1406
  return
1580
1407
 
1581
- tex = self._button_tex
1582
- if tex is None or outcome is None:
1408
+ textures = self._button_textures
1409
+ if outcome is None or textures is None or (textures.button_sm is None and textures.button_md is None):
1583
1410
  return
1584
1411
  scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
1585
- button_w = float(tex.width) * scale
1586
- button_h = float(tex.height) * scale
1412
+ button_w = button_width(None, self._retry_button.label, scale=scale, force_wide=self._retry_button.force_wide)
1413
+ button_h = 32.0 * scale
1587
1414
  gap_x = 18.0 * scale
1588
1415
  x0 = 32.0
1589
1416
  y0 = float(rl.get_screen_height()) - button_h - 56.0 * scale
1590
1417
 
1591
- buttons = [
1592
- ("Retry", rl.Rectangle(x0, y0, button_w, button_h), "retry"),
1593
- ("Quest list", rl.Rectangle(x0 + button_w + gap_x, y0, button_w, button_h), "quest_list"),
1594
- ("Main menu", rl.Rectangle(x0 + (button_w + gap_x) * 2.0, y0, button_w, button_h), "main_menu"),
1595
- ]
1596
1418
  mouse = rl.get_mouse_position()
1597
- clicked = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1598
- for _label, rect, action in buttons:
1599
- hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
1600
- if not hovered or not clicked:
1601
- continue
1602
- if action == "retry":
1603
- self._state.quest_fail_retry_count = int(self._state.quest_fail_retry_count) + 1
1604
- self._state.pending_quest_level = outcome.level
1605
- self._action = "start_quest"
1606
- return
1607
- if action == "quest_list":
1608
- self._state.quest_fail_retry_count = 0
1609
- self._action = "open_quests"
1610
- return
1611
- if action == "main_menu":
1612
- self._state.quest_fail_retry_count = 0
1613
- self._action = "back_to_menu"
1614
- return
1419
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1420
+ dt_ms = min(float(dt), 0.1) * 1000.0
1421
+
1422
+ if button_update(self._retry_button, x=x0, y=y0, width=button_w, dt_ms=dt_ms, mouse=mouse, click=click):
1423
+ self._state.quest_fail_retry_count = int(self._state.quest_fail_retry_count) + 1
1424
+ self._state.pending_quest_level = outcome.level
1425
+ self._action = "start_quest"
1426
+ return
1427
+ if button_update(
1428
+ self._quest_list_button,
1429
+ x=x0 + button_w + gap_x,
1430
+ y=y0,
1431
+ width=button_w,
1432
+ dt_ms=dt_ms,
1433
+ mouse=mouse,
1434
+ click=click,
1435
+ ):
1436
+ self._state.quest_fail_retry_count = 0
1437
+ self._action = "open_quests"
1438
+ return
1439
+ if button_update(
1440
+ self._main_menu_button,
1441
+ x=x0 + (button_w + gap_x) * 2.0,
1442
+ y=y0,
1443
+ width=button_w,
1444
+ dt_ms=dt_ms,
1445
+ mouse=mouse,
1446
+ click=click,
1447
+ ):
1448
+ self._state.quest_fail_retry_count = 0
1449
+ self._action = "back_to_menu"
1450
+ return
1615
1451
 
1616
1452
  def draw(self) -> None:
1617
1453
  rl.clear_background(rl.BLACK)
1618
- if self._ground is not None:
1454
+ pause_background = self._state.pause_background
1455
+ if pause_background is not None:
1456
+ pause_background.draw_pause_background()
1457
+ elif self._ground is not None:
1619
1458
  self._ground.draw(0.0, 0.0)
1620
1459
  _draw_screen_fade(self._state)
1621
1460
 
@@ -1655,36 +1494,33 @@ class QuestFailedView:
1655
1494
  y += 18.0
1656
1495
  draw_small_text(font, f"XP: {int(outcome.experience)}", 32.0, y, 1.0, text_color)
1657
1496
 
1658
- tex = self._button_tex
1659
- if tex is not None:
1497
+ textures = self._button_textures
1498
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
1660
1499
  scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
1661
- button_w = float(tex.width) * scale
1662
- button_h = float(tex.height) * scale
1500
+ button_w = button_width(None, self._retry_button.label, scale=scale, force_wide=self._retry_button.force_wide)
1501
+ button_h = 32.0 * scale
1663
1502
  gap_x = 18.0 * scale
1664
1503
  x0 = 32.0
1665
1504
  y0 = float(rl.get_screen_height()) - button_h - 56.0 * scale
1666
-
1667
- buttons = [
1668
- ("Retry", rl.Rectangle(x0, y0, button_w, button_h)),
1669
- ("Quest list", rl.Rectangle(x0 + button_w + gap_x, y0, button_w, button_h)),
1670
- ("Main menu", rl.Rectangle(x0 + (button_w + gap_x) * 2.0, y0, button_w, button_h)),
1671
- ]
1672
- mouse = rl.get_mouse_position()
1673
- for label, rect in buttons:
1674
- hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
1675
- alpha = 255 if hovered else 220
1676
- rl.draw_texture_pro(
1677
- tex,
1678
- rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height)),
1679
- rect,
1680
- rl.Vector2(0.0, 0.0),
1681
- 0.0,
1682
- rl.Color(255, 255, 255, alpha),
1683
- )
1684
- label_w = measure_small_text_width(font, label, 1.0 * scale)
1685
- text_x = rect.x + (rect.width - label_w) * 0.5 + 1.0 * scale
1686
- text_y = rect.y + 10.0 * scale
1687
- draw_small_text(font, label, text_x, text_y, 1.0 * scale, rl.Color(20, 20, 20, 255))
1505
+ button_draw(textures, font, self._retry_button, x=x0, y=y0, width=button_w, scale=scale)
1506
+ button_draw(
1507
+ textures,
1508
+ font,
1509
+ self._quest_list_button,
1510
+ x=x0 + button_w + gap_x,
1511
+ y=y0,
1512
+ width=button_w,
1513
+ scale=scale,
1514
+ )
1515
+ button_draw(
1516
+ textures,
1517
+ font,
1518
+ self._main_menu_button,
1519
+ x=x0 + (button_w + gap_x) * 2.0,
1520
+ y=y0,
1521
+ width=button_w,
1522
+ scale=scale,
1523
+ )
1688
1524
 
1689
1525
  draw_small_text(
1690
1526
  font,
@@ -1712,11 +1548,19 @@ class QuestFailedView:
1712
1548
  class HighScoresView:
1713
1549
  def __init__(self, state: GameState) -> None:
1714
1550
  self._state = state
1551
+ self._assets: MenuAssets | None = None
1715
1552
  self._ground: GroundRenderer | None = None
1716
1553
  self._action: str | None = None
1717
1554
  self._cursor_pulse_time = 0.0
1555
+ self._widescreen_y_shift = 0.0
1556
+ self._timeline_ms = 0
1557
+ self._timeline_max_ms = PANEL_TIMELINE_START_MS
1718
1558
  self._small_font: SmallFontData | None = None
1719
1559
  self._button_tex: rl.Texture2D | None = None
1560
+ self._button_textures: UiButtonTextureSet | None = None
1561
+ self._update_button = UiButtonState("Update scores", force_wide=True)
1562
+ self._play_button = UiButtonState("Play a game", force_wide=True)
1563
+ self._back_button = UiButtonState("Back", force_wide=False)
1720
1564
 
1721
1565
  self._request: HighScoresRequest | None = None
1722
1566
  self._records: list = []
@@ -1725,14 +1569,25 @@ class HighScoresView:
1725
1569
  def open(self) -> None:
1726
1570
  from .persistence.highscores import read_highscore_table, scores_path_for_mode
1727
1571
 
1572
+ layout_w = float(self._state.config.screen_width)
1573
+ self._widescreen_y_shift = MenuView._menu_widescreen_y_shift(layout_w)
1728
1574
  self._action = None
1729
- self._ground = ensure_menu_ground(self._state)
1575
+ self._assets = load_menu_assets(self._state)
1576
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
1730
1577
  self._cursor_pulse_time = 0.0
1578
+ self._timeline_ms = 0
1579
+ self._timeline_max_ms = PANEL_TIMELINE_START_MS
1731
1580
  self._small_font = None
1732
1581
  self._scroll_index = 0
1582
+ self._button_textures = None
1583
+ self._update_button = UiButtonState("Update scores", force_wide=True)
1584
+ self._play_button = UiButtonState("Play a game", force_wide=True)
1585
+ self._back_button = UiButtonState("Back", force_wide=False)
1733
1586
 
1734
1587
  cache = _ensure_texture_cache(self._state)
1735
- self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
1588
+ self._button_tex = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
1589
+ button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
1590
+ self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=self._button_tex)
1736
1591
 
1737
1592
  request = self._state.pending_high_scores
1738
1593
  self._state.pending_high_scores = None
@@ -1768,11 +1623,18 @@ class HighScoresView:
1768
1623
  if self._small_font is not None:
1769
1624
  rl.unload_texture(self._small_font.texture)
1770
1625
  self._small_font = None
1626
+ self._assets = None
1771
1627
  self._button_tex = None
1628
+ self._button_textures = None
1772
1629
  self._request = None
1773
1630
  self._records = []
1774
1631
  self._scroll_index = 0
1775
1632
 
1633
+ def _panel_top_left(self, *, pos_x: float, pos_y: float, scale: float) -> tuple[float, float]:
1634
+ x0 = float(pos_x + MENU_PANEL_OFFSET_X * scale)
1635
+ y0 = float(pos_y + self._widescreen_y_shift + MENU_PANEL_OFFSET_Y * scale)
1636
+ return x0, y0
1637
+
1776
1638
  def update(self, dt: float) -> None:
1777
1639
  if self._state.audio is not None:
1778
1640
  update_audio(self._state.audio, dt)
@@ -1780,62 +1642,97 @@ class HighScoresView:
1780
1642
  self._ground.process_pending()
1781
1643
  self._cursor_pulse_time += min(dt, 0.1) * 1.1
1782
1644
 
1783
- if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
1645
+ dt_ms = int(min(float(dt), 0.1) * 1000.0)
1646
+ if dt_ms > 0:
1647
+ self._timeline_ms = min(self._timeline_max_ms, int(self._timeline_ms + dt_ms))
1648
+
1649
+ enabled = self._timeline_ms >= self._timeline_max_ms
1650
+
1651
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE) and enabled:
1784
1652
  if self._state.audio is not None:
1785
1653
  play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1786
1654
  self._action = "back_to_previous"
1787
1655
  return
1788
1656
 
1789
- mouse = rl.get_mouse_position()
1790
- clicked = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1791
- tex = self._button_tex
1792
- if tex is not None and clicked:
1657
+ textures = self._button_textures
1658
+ if enabled and textures is not None and (textures.button_sm is not None or textures.button_md is not None):
1793
1659
  scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
1794
- button_w = float(tex.width) * scale
1795
- button_h = float(tex.height) * scale
1796
- gap_x = 18.0 * scale
1797
- x0 = 32.0
1798
- y0 = float(rl.get_screen_height()) - button_h - 52.0 * scale
1799
- back_rect = rl.Rectangle(x0, y0, button_w, button_h)
1800
- menu_rect = rl.Rectangle(x0 + button_w + gap_x, y0, button_w, button_h)
1801
- if back_rect.x <= mouse.x <= back_rect.x + back_rect.width and back_rect.y <= mouse.y <= back_rect.y + back_rect.height:
1660
+ panel_x0, panel_y0 = self._panel_top_left(pos_x=HS_LEFT_PANEL_POS_X, pos_y=HS_LEFT_PANEL_POS_Y, scale=scale)
1661
+
1662
+ x0 = panel_x0 + HS_BUTTON_X * scale
1663
+ y0 = panel_y0 + HS_BUTTON_Y0 * scale
1664
+ mouse = rl.get_mouse_position()
1665
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1666
+ w = button_width(None, self._update_button.label, scale=scale, force_wide=self._update_button.force_wide)
1667
+ if button_update(self._update_button, x=x0, y=y0, width=w, dt_ms=dt_ms, mouse=mouse, click=click):
1668
+ # Reload scores from disk (no view transition).
1802
1669
  if self._state.audio is not None:
1803
1670
  play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1804
- self._action = "back_to_previous"
1671
+ self.open()
1805
1672
  return
1806
- if menu_rect.x <= mouse.x <= menu_rect.x + menu_rect.width and menu_rect.y <= mouse.y <= menu_rect.y + menu_rect.height:
1673
+ w = button_width(None, self._play_button.label, scale=scale, force_wide=self._play_button.force_wide)
1674
+ if button_update(
1675
+ self._play_button,
1676
+ x=x0,
1677
+ y=y0 + HS_BUTTON_STEP_Y * scale,
1678
+ width=w,
1679
+ dt_ms=dt_ms,
1680
+ mouse=mouse,
1681
+ click=click,
1682
+ ):
1807
1683
  if self._state.audio is not None:
1808
1684
  play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1809
- self._action = "back_to_menu"
1685
+ self._action = "open_play_game"
1686
+ return
1687
+ back_w = button_width(None, self._back_button.label, scale=scale, force_wide=self._back_button.force_wide)
1688
+ if button_update(
1689
+ self._back_button,
1690
+ x=panel_x0 + HS_BACK_BUTTON_X * scale,
1691
+ y=panel_y0 + HS_BACK_BUTTON_Y * scale,
1692
+ width=back_w,
1693
+ dt_ms=dt_ms,
1694
+ mouse=mouse,
1695
+ click=click,
1696
+ ):
1697
+ if self._state.audio is not None:
1698
+ play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1699
+ self._action = "back_to_previous"
1810
1700
  return
1811
1701
 
1812
- font = self._ensure_small_font()
1813
- rows = self._visible_rows(font)
1702
+ rows = 10
1814
1703
  max_scroll = max(0, len(self._records) - rows)
1815
1704
 
1816
- wheel = int(rl.get_mouse_wheel_move())
1817
- if wheel:
1818
- self._scroll_index = max(0, min(max_scroll, int(self._scroll_index) - wheel))
1819
-
1820
- if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
1821
- self._scroll_index = max(0, int(self._scroll_index) - 1)
1822
- if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
1823
- self._scroll_index = min(max_scroll, int(self._scroll_index) + 1)
1824
- if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
1825
- self._scroll_index = max(0, int(self._scroll_index) - rows)
1826
- if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
1827
- self._scroll_index = min(max_scroll, int(self._scroll_index) + rows)
1828
- if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
1829
- self._scroll_index = 0
1830
- if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
1831
- self._scroll_index = max_scroll
1705
+ if enabled:
1706
+ wheel = int(rl.get_mouse_wheel_move())
1707
+ if wheel:
1708
+ self._scroll_index = max(0, min(max_scroll, int(self._scroll_index) - wheel))
1709
+
1710
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_UP):
1711
+ self._scroll_index = max(0, int(self._scroll_index) - 1)
1712
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_DOWN):
1713
+ self._scroll_index = min(max_scroll, int(self._scroll_index) + 1)
1714
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_UP):
1715
+ self._scroll_index = max(0, int(self._scroll_index) - rows)
1716
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_PAGE_DOWN):
1717
+ self._scroll_index = min(max_scroll, int(self._scroll_index) + rows)
1718
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_HOME):
1719
+ self._scroll_index = 0
1720
+ if rl.is_key_pressed(rl.KeyboardKey.KEY_END):
1721
+ self._scroll_index = max_scroll
1832
1722
 
1833
1723
  def draw(self) -> None:
1834
1724
  rl.clear_background(rl.BLACK)
1835
- if self._ground is not None:
1725
+ pause_background = self._state.pause_background
1726
+ if pause_background is not None:
1727
+ pause_background.draw_pause_background()
1728
+ elif self._ground is not None:
1836
1729
  self._ground.draw(0.0, 0.0)
1837
1730
  _draw_screen_fade(self._state)
1838
1731
 
1732
+ assets = self._assets
1733
+ if assets is None or assets.panel is None:
1734
+ return
1735
+
1839
1736
  font = self._ensure_small_font()
1840
1737
  request = self._request
1841
1738
  mode_id = int(request.game_mode_id) if request is not None else int(self._state.config.data.get("game_mode", 1) or 1)
@@ -1843,26 +1740,64 @@ class HighScoresView:
1843
1740
  quest_minor = int(request.quest_stage_minor) if request is not None else 0
1844
1741
  highlight_rank = request.highlight_rank if request is not None else None
1845
1742
 
1846
- title = "High scores"
1847
- subtitle = self._mode_label(mode_id, quest_major, quest_minor)
1848
- draw_small_text(font, title, 32.0, 120.0, 1.2, rl.Color(235, 235, 235, 255))
1849
- draw_small_text(font, subtitle, 32.0, 152.0, 1.0, rl.Color(190, 190, 200, 255))
1743
+ scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
1744
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
1745
+ panel_w = MENU_PANEL_WIDTH * scale
1746
+ _angle_rad, left_slide_x = MenuView._ui_element_anim(
1747
+ self,
1748
+ index=1,
1749
+ start_ms=PANEL_TIMELINE_START_MS,
1750
+ end_ms=PANEL_TIMELINE_END_MS,
1751
+ width=panel_w,
1752
+ direction_flag=0,
1753
+ )
1754
+ _angle_rad, right_slide_x = MenuView._ui_element_anim(
1755
+ self,
1756
+ index=2,
1757
+ start_ms=PANEL_TIMELINE_START_MS,
1758
+ end_ms=PANEL_TIMELINE_END_MS,
1759
+ width=panel_w,
1760
+ direction_flag=1,
1761
+ )
1762
+
1763
+ left_x0, left_y0 = self._panel_top_left(pos_x=HS_LEFT_PANEL_POS_X, pos_y=HS_LEFT_PANEL_POS_Y, scale=scale)
1764
+ right_x0, right_y0 = self._panel_top_left(pos_x=HS_RIGHT_PANEL_POS_X, pos_y=HS_RIGHT_PANEL_POS_Y, scale=scale)
1765
+ left_x0 += float(left_slide_x)
1766
+ right_x0 += float(right_slide_x)
1767
+
1768
+ draw_classic_menu_panel(
1769
+ assets.panel,
1770
+ dst=rl.Rectangle(left_x0, left_y0, panel_w, HS_LEFT_PANEL_HEIGHT * scale),
1771
+ tint=rl.WHITE,
1772
+ shadow=fx_detail,
1773
+ )
1774
+ draw_classic_menu_panel(
1775
+ assets.panel,
1776
+ dst=rl.Rectangle(right_x0, right_y0, panel_w, HS_RIGHT_PANEL_HEIGHT * scale),
1777
+ tint=rl.WHITE,
1778
+ shadow=fx_detail,
1779
+ )
1780
+
1781
+ title = "High scores - Quests" if int(mode_id) == 3 else f"High scores - {self._mode_label(mode_id, quest_major, quest_minor)}"
1782
+ draw_small_text(font, title, left_x0 + 269.0 * scale, left_y0 + 41.0 * scale, 1.0 * scale, rl.Color(255, 255, 255, 255))
1783
+ if int(mode_id) == 3:
1784
+ quest_label = f"{int(quest_major)}.{int(quest_minor)}: {self._quest_title(quest_major, quest_minor)}"
1785
+ draw_small_text(font, quest_label, left_x0 + 236.0 * scale, left_y0 + 63.0 * scale, 1.0 * scale, rl.Color(255, 255, 255, 255))
1850
1786
 
1851
1787
  header_color = rl.Color(255, 255, 255, int(255 * 0.85))
1852
- row_y0 = 188.0
1853
- draw_small_text(font, "Rank", 32.0, row_y0, 1.0, header_color)
1854
- draw_small_text(font, "Name", 96.0, row_y0, 1.0, header_color)
1855
- score_label = "Score" if mode_id not in (2, 3) else "Time"
1856
- draw_small_text(font, score_label, 320.0, row_y0, 1.0, header_color)
1788
+ row_y0 = left_y0 + 84.0 * scale
1789
+ draw_small_text(font, "Rank", left_x0 + 211.0 * scale, row_y0, 1.0 * scale, header_color)
1790
+ draw_small_text(font, "Score", left_x0 + 246.0 * scale, row_y0, 1.0 * scale, header_color)
1791
+ draw_small_text(font, "Player", left_x0 + 302.0 * scale, row_y0, 1.0 * scale, header_color)
1857
1792
 
1858
- row_step = float(font.cell_size)
1859
- rows = self._visible_rows(font)
1793
+ row_step = 16.0 * scale
1794
+ rows = 10
1860
1795
  start = max(0, int(self._scroll_index))
1861
1796
  end = min(len(self._records), start + rows)
1862
- y = row_y0 + row_step
1797
+ y = left_y0 + 103.0 * scale
1863
1798
 
1864
1799
  if start >= end:
1865
- draw_small_text(font, "No scores yet.", 32.0, y + 8.0, 1.0, rl.Color(190, 190, 200, 255))
1800
+ draw_small_text(font, "No scores yet.", left_x0 + 211.0 * scale, y + 8.0 * scale, 1.0 * scale, rl.Color(190, 190, 200, 255))
1866
1801
  else:
1867
1802
  for idx in range(start, end):
1868
1803
  entry = self._records[idx]
@@ -1876,62 +1811,81 @@ class HighScoresView:
1876
1811
  if len(name) > 16:
1877
1812
  name = name[:16]
1878
1813
 
1879
- value = ""
1880
- if mode_id in (2, 3):
1881
- seconds = float(int(getattr(entry, "survival_elapsed_ms", 0))) * 0.001
1882
- value = f"{seconds:7.2f}s"
1883
- else:
1884
- value = f"{int(getattr(entry, 'score_xp', 0)):7d}"
1814
+ value = f"{int(getattr(entry, 'score_xp', 0))}"
1885
1815
 
1886
1816
  color = rl.Color(255, 255, 255, int(255 * 0.7))
1887
1817
  if highlight_rank is not None and int(highlight_rank) == idx:
1888
1818
  color = rl.Color(255, 255, 255, 255)
1889
1819
 
1890
- draw_small_text(font, f"{idx + 1:>3}", 32.0, y, 1.0, color)
1891
- draw_small_text(font, name, 96.0, y, 1.0, color)
1892
- draw_small_text(font, value, 320.0, y, 1.0, color)
1820
+ draw_small_text(font, f"{idx + 1}", left_x0 + 216.0 * scale, y, 1.0 * scale, color)
1821
+ draw_small_text(font, value, left_x0 + 246.0 * scale, y, 1.0 * scale, color)
1822
+ draw_small_text(font, name, left_x0 + 304.0 * scale, y, 1.0 * scale, color)
1893
1823
  y += row_step
1894
1824
 
1895
- tex = self._button_tex
1896
- if tex is not None:
1897
- scale = 0.9 if float(self._state.config.screen_width) < 641.0 else 1.0
1898
- button_w = float(tex.width) * scale
1899
- button_h = float(tex.height) * scale
1900
- gap_x = 18.0 * scale
1901
- x0 = 32.0
1902
- y0 = float(rl.get_screen_height()) - button_h - 52.0 * scale
1903
- x1 = x0 + button_w + gap_x
1825
+ textures = self._button_textures
1826
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
1827
+ button_x = left_x0 + HS_BUTTON_X * scale
1828
+ button_y0 = left_y0 + HS_BUTTON_Y0 * scale
1829
+ w = button_width(None, self._update_button.label, scale=scale, force_wide=self._update_button.force_wide)
1830
+ button_draw(textures, font, self._update_button, x=button_x, y=button_y0, width=w, scale=scale)
1831
+ w = button_width(None, self._play_button.label, scale=scale, force_wide=self._play_button.force_wide)
1832
+ button_draw(textures, font, self._play_button, x=button_x, y=button_y0 + HS_BUTTON_STEP_Y * scale, width=w, scale=scale)
1833
+ w = button_width(None, self._back_button.label, scale=scale, force_wide=self._back_button.force_wide)
1834
+ button_draw(
1835
+ textures,
1836
+ font,
1837
+ self._back_button,
1838
+ x=left_x0 + HS_BACK_BUTTON_X * scale,
1839
+ y=left_y0 + HS_BACK_BUTTON_Y * scale,
1840
+ width=w,
1841
+ scale=scale,
1842
+ )
1904
1843
 
1905
- buttons = [
1906
- ("Back", rl.Rectangle(x0, y0, button_w, button_h)),
1907
- ("Main menu", rl.Rectangle(x1, y0, button_w, button_h)),
1908
- ]
1909
- mouse = rl.get_mouse_position()
1910
- for label, rect in buttons:
1911
- hovered = rect.x <= mouse.x <= rect.x + rect.width and rect.y <= mouse.y <= rect.y + rect.height
1912
- alpha = 255 if hovered else 220
1913
- rl.draw_texture_pro(
1914
- tex,
1915
- rl.Rectangle(0.0, 0.0, float(tex.width), float(tex.height)),
1916
- rect,
1917
- rl.Vector2(0.0, 0.0),
1918
- 0.0,
1919
- rl.Color(255, 255, 255, alpha),
1920
- )
1921
- label_w = measure_small_text_width(font, label, 1.0 * scale)
1922
- text_x = rect.x + (rect.width - label_w) * 0.5 + 1.0 * scale
1923
- text_y = rect.y + 10.0 * scale
1924
- draw_small_text(font, label, text_x, text_y, 1.0 * scale, rl.Color(20, 20, 20, 255))
1844
+ self._draw_sign(assets)
1845
+ _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
1925
1846
 
1926
- draw_small_text(
1927
- font,
1928
- "UP/DOWN: Scroll PGUP/PGDN: Page ESC: Back",
1929
- 32.0,
1930
- float(rl.get_screen_height()) - 28.0,
1931
- 1.0,
1932
- rl.Color(190, 190, 200, 255),
1847
+ def _draw_sign(self, assets: MenuAssets) -> None:
1848
+ if assets.sign is None:
1849
+ return
1850
+ sign = assets.sign
1851
+ screen_w = float(self._state.config.screen_width)
1852
+ sign_scale, shift_x = MenuView._sign_layout_scale(int(screen_w))
1853
+ pos_x = screen_w + MENU_SIGN_POS_X_PAD
1854
+ pos_y = MENU_SIGN_POS_Y if screen_w > MENU_SCALE_SMALL_THRESHOLD else MENU_SIGN_POS_Y_SMALL
1855
+ sign_w = MENU_SIGN_WIDTH * sign_scale
1856
+ sign_h = MENU_SIGN_HEIGHT * sign_scale
1857
+ offset_x = MENU_SIGN_OFFSET_X * sign_scale + shift_x
1858
+ offset_y = MENU_SIGN_OFFSET_Y * sign_scale
1859
+ rotation_deg = 0.0
1860
+ fx_detail = bool(self._state.config.data.get("fx_detail_0", 0))
1861
+ if fx_detail:
1862
+ MenuView._draw_ui_quad_shadow(
1863
+ texture=sign,
1864
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
1865
+ dst=rl.Rectangle(pos_x + UI_SHADOW_OFFSET, pos_y + UI_SHADOW_OFFSET, sign_w, sign_h),
1866
+ origin=rl.Vector2(-offset_x, -offset_y),
1867
+ rotation_deg=rotation_deg,
1868
+ )
1869
+ MenuView._draw_ui_quad(
1870
+ texture=sign,
1871
+ src=rl.Rectangle(0.0, 0.0, float(sign.width), float(sign.height)),
1872
+ dst=rl.Rectangle(pos_x, pos_y, sign_w, sign_h),
1873
+ origin=rl.Vector2(-offset_x, -offset_y),
1874
+ rotation_deg=rotation_deg,
1875
+ tint=rl.WHITE,
1933
1876
  )
1934
- _draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
1877
+
1878
+ @staticmethod
1879
+ def _quest_title(major: int, minor: int) -> str:
1880
+ try:
1881
+ from .quests import quest_by_level
1882
+
1883
+ q = quest_by_level(f"{int(major)}.{int(minor)}")
1884
+ if q is not None and q.title:
1885
+ return str(q.title)
1886
+ except Exception:
1887
+ pass
1888
+ return "???"
1935
1889
 
1936
1890
  def take_action(self) -> str | None:
1937
1891
  action = self._action
@@ -1986,6 +1940,7 @@ class GameLoopView:
1986
1940
  self._front_views: dict[str, FrontView] = {
1987
1941
  "open_play_game": PlayGameMenuView(state),
1988
1942
  "open_quests": QuestsMenuView(state),
1943
+ "open_pause_menu": PauseMenuView(state),
1989
1944
  "start_quest": QuestGameView(state),
1990
1945
  "quest_results": QuestResultsView(state),
1991
1946
  "quest_failed": QuestFailedView(state),
@@ -1997,6 +1952,9 @@ class GameLoopView:
1997
1952
  "open_options": OptionsMenuView(state),
1998
1953
  "open_controls": ControlsMenuView(state),
1999
1954
  "open_statistics": StatisticsMenuView(state),
1955
+ "open_weapon_database": UnlockedWeaponsDatabaseView(state),
1956
+ "open_perk_database": UnlockedPerksDatabaseView(state),
1957
+ "open_credits": CreditsView(state),
2000
1958
  "open_mods": ModsMenuView(state),
2001
1959
  "open_other_games": PanelMenuView(
2002
1960
  state,
@@ -2052,6 +2010,7 @@ class GameLoopView:
2052
2010
  if self._front_active is not None:
2053
2011
  action = self._front_active.take_action()
2054
2012
  if action == "back_to_menu":
2013
+ self._state.pause_background = None
2055
2014
  self._front_active.close()
2056
2015
  self._front_active = None
2057
2016
  while self._front_stack:
@@ -2064,14 +2023,44 @@ class GameLoopView:
2064
2023
  if self._front_stack:
2065
2024
  self._front_active.close()
2066
2025
  self._front_active = self._front_stack.pop()
2026
+ if self._front_active in self._gameplay_views:
2027
+ self._state.pause_background = None
2067
2028
  self._active = self._front_active
2068
2029
  return
2069
2030
  self._front_active.close()
2070
2031
  self._front_active = None
2032
+ self._state.pause_background = None
2071
2033
  self._menu.open()
2072
2034
  self._active = self._menu
2073
2035
  self._menu_active = True
2074
2036
  return
2037
+ if action == "open_pause_menu":
2038
+ pause_view = self._front_views.get("open_pause_menu")
2039
+ if pause_view is None:
2040
+ return
2041
+ if self._front_active in self._gameplay_views:
2042
+ self._state.pause_background = self._front_active
2043
+ self._front_stack.append(self._front_active)
2044
+ pause_view.open()
2045
+ self._front_active = pause_view
2046
+ self._active = pause_view
2047
+ return
2048
+ if self._state.pause_background is None:
2049
+ # Options panel uses open_pause_menu as back_action; when no game is
2050
+ # running, treat it like back_to_menu.
2051
+ self._front_active.close()
2052
+ self._front_active = None
2053
+ while self._front_stack:
2054
+ self._front_stack.pop().close()
2055
+ self._menu.open()
2056
+ self._active = self._menu
2057
+ self._menu_active = True
2058
+ return
2059
+ self._front_active.close()
2060
+ pause_view.open()
2061
+ self._front_active = pause_view
2062
+ self._active = pause_view
2063
+ return
2075
2064
  if action in {"start_survival", "start_rush", "start_typo"}:
2076
2065
  # Temporary: bump the counter on mode start so the Play Game overlay (F1)
2077
2066
  # and Statistics screen reflect activity.
@@ -2085,9 +2074,26 @@ class GameLoopView:
2085
2074
  if action is not None:
2086
2075
  view = self._front_views.get(action)
2087
2076
  if view is not None:
2088
- if action == "open_high_scores":
2077
+ if action in {"open_high_scores", "open_weapon_database", "open_perk_database", "open_credits"}:
2078
+ if (self._front_active in self._gameplay_views) and (self._state.pause_background is None):
2079
+ self._state.pause_background = self._front_active
2080
+ self._front_stack.append(self._front_active)
2081
+ elif action in {"quest_results", "quest_failed"} and (self._front_active in self._gameplay_views):
2082
+ self._state.pause_background = self._front_active
2089
2083
  self._front_stack.append(self._front_active)
2090
2084
  else:
2085
+ if action in {
2086
+ "start_survival",
2087
+ "start_rush",
2088
+ "start_typo",
2089
+ "start_tutorial",
2090
+ "start_quest",
2091
+ "open_play_game",
2092
+ "open_quests",
2093
+ }:
2094
+ self._state.pause_background = None
2095
+ while self._front_stack:
2096
+ self._front_stack.pop().close()
2091
2097
  self._front_active.close()
2092
2098
  view.open()
2093
2099
  self._front_active = view
@@ -2524,6 +2530,7 @@ def run_game(config: GameConfig) -> None:
2524
2530
  title="Crimsonland",
2525
2531
  fps=config.fps,
2526
2532
  config_flags=config_flags,
2533
+ exit_key=rl.KeyboardKey.KEY_NULL,
2527
2534
  )
2528
2535
  if state is not None:
2529
2536
  state.status.save_if_dirty()