crimsonland 0.1.0.dev10__py3-none-any.whl → 0.1.0.dev12__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.
crimson/game.py CHANGED
@@ -76,9 +76,11 @@ from .frontend.panels.mods import ModsMenuView
76
76
  from .frontend.panels.options import OptionsMenuView
77
77
  from .frontend.panels.play_game import PlayGameMenuView
78
78
  from .frontend.panels.stats import StatisticsMenuView
79
+ from .frontend.pause_menu import PauseMenuView
79
80
  from .frontend.transitions import _draw_screen_fade, _update_screen_fade
80
81
  from .persistence.save_status import GameStatus, ensure_game_status
81
82
  from .ui.demo_trial_overlay import DEMO_PURCHASE_URL, DemoTrialOverlayUi
83
+ from .ui.perk_menu import UiButtonState, UiButtonTextureSet, button_draw, button_update, button_width, draw_menu_panel
82
84
  from .paths import default_runtime_dir
83
85
  from .assets_fetch import download_missing_paqs
84
86
 
@@ -124,6 +126,7 @@ class GameState:
124
126
  snd_freq_adjustment_enabled: bool = False
125
127
  menu_ground: GroundRenderer | None = None
126
128
  menu_sign_locked: bool = False
129
+ pause_background: PauseBackground | None = None
127
130
  pending_quest_level: str | None = None
128
131
  pending_high_scores: HighScoresRequest | None = None
129
132
  quest_outcome: QuestRunOutcome | None = None
@@ -166,8 +169,9 @@ QUEST_HARDCORE_CHECKBOX_X_OFFSET = 132.0
166
169
  QUEST_HARDCORE_CHECKBOX_Y_OFFSET = -12.0
167
170
  QUEST_HARDCORE_LIST_Y_SHIFT = 10.0
168
171
 
169
- QUEST_BACK_BUTTON_X_OFFSET = 148.0
172
+ QUEST_BACK_BUTTON_X_OFFSET = 138.0
170
173
  QUEST_BACK_BUTTON_Y_OFFSET = 212.0
174
+ QUEST_PANEL_HEIGHT = 379.0
171
175
 
172
176
 
173
177
  class QuestsMenuView:
@@ -192,6 +196,8 @@ class QuestsMenuView:
192
196
  self._check_off: rl.Texture2D | None = None
193
197
  self._button_sm: rl.Texture2D | None = None
194
198
  self._button_md: rl.Texture2D | None = None
199
+ self._button_textures: UiButtonTextureSet | None = None
200
+ self._back_button = UiButtonState("Back")
195
201
 
196
202
  self._menu_screen_width = 0
197
203
  self._widescreen_y_shift = 0.0
@@ -224,11 +230,13 @@ class QuestsMenuView:
224
230
  self._check_off = cache.get_or_load("ui_checkOff", "ui/ui_checkOff.jaz").texture
225
231
  self._button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
226
232
  self._button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
233
+ self._button_textures = UiButtonTextureSet(button_sm=self._button_sm, button_md=self._button_md)
227
234
 
228
235
  self._action = None
229
236
  self._dirty = False
230
237
  self._stage = max(1, min(5, int(self._stage)))
231
238
  self._cursor_pulse_time = 0.0
239
+ self._back_button = UiButtonState("Back")
232
240
 
233
241
  # Ensure the quest registry is populated so titles render.
234
242
  # (The package import registers all tier builders.)
@@ -247,6 +255,7 @@ class QuestsMenuView:
247
255
  pass
248
256
  self._dirty = False
249
257
  self._ground = None
258
+ self._button_textures = None
250
259
 
251
260
  def update(self, dt: float) -> None:
252
261
  if self._state.audio is not None:
@@ -283,9 +292,26 @@ class QuestsMenuView:
283
292
  if self._hardcore_checkbox_clicked(layout):
284
293
  return
285
294
 
286
- if self._back_button_clicked(layout):
287
- self._action = "open_play_game"
288
- return
295
+ textures = self._button_textures
296
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
297
+ back_x = layout["list_x"] + QUEST_BACK_BUTTON_X_OFFSET
298
+ back_y = self._rows_y0(layout) + QUEST_BACK_BUTTON_Y_OFFSET
299
+ dt_ms = min(float(dt), 0.1) * 1000.0
300
+ font = self._ensure_small_font()
301
+ back_w = button_width(font, self._back_button.label, scale=1.0, force_wide=self._back_button.force_wide)
302
+ mouse = rl.get_mouse_position()
303
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
304
+ if button_update(
305
+ self._back_button,
306
+ x=float(back_x),
307
+ y=float(back_y),
308
+ width=float(back_w),
309
+ dt_ms=float(dt_ms),
310
+ mouse=mouse,
311
+ click=bool(click),
312
+ ):
313
+ self._action = "open_play_game"
314
+ return
289
315
 
290
316
  # Quick-select row numbers 1..0 (10).
291
317
  row_from_key = self._digit_row_pressed()
@@ -329,10 +355,10 @@ class QuestsMenuView:
329
355
 
330
356
  def _layout(self) -> dict[str, float]:
331
357
  # `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
358
+ # x_sum = <ui_element_x> + <ui_element_offset_x> (x=-5)
359
+ # y_sum = <ui_element_y> + <ui_element_offset_y> (y=185 + widescreen shift via ui_menu_layout_init)
360
+ x_sum = QUEST_MENU_BASE_X + MENU_PANEL_OFFSET_X
361
+ y_sum = QUEST_MENU_BASE_Y + MENU_PANEL_OFFSET_Y + self._widescreen_y_shift
336
362
 
337
363
  title_x = x_sum + QUEST_TITLE_X_OFFSET
338
364
  title_y = y_sum + QUEST_TITLE_Y_OFFSET
@@ -392,20 +418,6 @@ class QuestsMenuView:
392
418
  return True
393
419
  return False
394
420
 
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
421
  @staticmethod
410
422
  def _digit_row_pressed() -> int | None:
411
423
  keys = [
@@ -634,12 +646,15 @@ class QuestsMenuView:
634
646
  else:
635
647
  title = "???"
636
648
  draw_small_text(font, title, list_x + QUEST_LIST_NAME_X_OFFSET, y, 1.0, color)
649
+ title_w = measure_small_text_width(font, title, 1.0) if unlocked else 0.0
650
+ if unlocked:
651
+ line_y = y + 13.0
652
+ rl.draw_line(int(list_x), int(line_y), int(list_x + title_w + 32.0), int(line_y), color)
637
653
 
638
654
  if show_counts and unlocked:
639
655
  counts = self._quest_counts(stage=stage, row=row)
640
656
  if counts is not None:
641
657
  completed, games = counts
642
- title_w = measure_small_text_width(font, title, 1.0)
643
658
  counts_x = list_x + QUEST_LIST_NAME_X_OFFSET + title_w + 12.0
644
659
  draw_small_text(font, f"({completed}/{games})", counts_x, y, 1.0, color)
645
660
 
@@ -650,28 +665,12 @@ class QuestsMenuView:
650
665
  draw_small_text(font, "(completed/games)", header_x, header_y, 1.0, base_color)
651
666
 
652
667
  # Back button.
653
- button = self._button_sm or self._button_md
654
- if button is not None:
668
+ textures = self._button_textures
669
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
655
670
  back_x = list_x + QUEST_BACK_BUTTON_X_OFFSET
656
671
  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))
672
+ back_w = button_width(font, self._back_button.label, scale=1.0, force_wide=self._back_button.force_wide)
673
+ button_draw(textures, font, self._back_button, x=float(back_x), y=float(back_y), width=float(back_w), scale=1.0)
675
674
 
676
675
  def _draw_sign(self) -> None:
677
676
  assets = self._assets
@@ -719,52 +718,18 @@ class QuestsMenuView:
719
718
  panel = self._panel_tex
720
719
  if panel is None:
721
720
  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
721
  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,
746
- )
747
-
748
-
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",
722
+ draw_menu_panel(
723
+ panel,
724
+ dst=rl.Rectangle(
725
+ float(QUEST_MENU_BASE_X + MENU_PANEL_OFFSET_X),
726
+ float(QUEST_MENU_BASE_Y + MENU_PANEL_OFFSET_Y + self._widescreen_y_shift),
727
+ float(MENU_PANEL_WIDTH),
728
+ float(QUEST_PANEL_HEIGHT),
729
+ ),
730
+ shadow=fx_detail,
756
731
  )
757
732
 
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
733
 
769
734
  class FrontView(Protocol):
770
735
  def open(self) -> None: ...
@@ -778,6 +743,10 @@ class FrontView(Protocol):
778
743
  def take_action(self) -> str | None: ...
779
744
 
780
745
 
746
+ class PauseBackground(Protocol):
747
+ def draw_pause_background(self) -> None: ...
748
+
749
+
781
750
  class SurvivalGameView:
782
751
  """Gameplay view wrapper that adapts SurvivalMode into `crimson game`."""
783
752
 
@@ -817,6 +786,9 @@ class SurvivalGameView:
817
786
  def update(self, dt: float) -> None:
818
787
  self._mode.update(dt)
819
788
  mode_action = self._mode.take_action()
789
+ if mode_action == "open_pause_menu":
790
+ self._action = "open_pause_menu"
791
+ return
820
792
  if mode_action == "open_high_scores":
821
793
  self._state.pending_high_scores = HighScoresRequest(game_mode_id=1)
822
794
  self._action = "open_high_scores"
@@ -832,6 +804,9 @@ class SurvivalGameView:
832
804
  def draw(self) -> None:
833
805
  self._mode.draw()
834
806
 
807
+ def draw_pause_background(self) -> None:
808
+ self._mode.draw_pause_background()
809
+
835
810
  def take_action(self) -> str | None:
836
811
  action = self._action
837
812
  self._action = None
@@ -875,6 +850,9 @@ class RushGameView:
875
850
  def update(self, dt: float) -> None:
876
851
  self._mode.update(dt)
877
852
  mode_action = self._mode.take_action()
853
+ if mode_action == "open_pause_menu":
854
+ self._action = "open_pause_menu"
855
+ return
878
856
  if mode_action == "open_high_scores":
879
857
  self._state.pending_high_scores = HighScoresRequest(game_mode_id=2)
880
858
  self._action = "open_high_scores"
@@ -890,6 +868,9 @@ class RushGameView:
890
868
  def draw(self) -> None:
891
869
  self._mode.draw()
892
870
 
871
+ def draw_pause_background(self) -> None:
872
+ self._mode.draw_pause_background()
873
+
893
874
  def take_action(self) -> str | None:
894
875
  action = self._action
895
876
  self._action = None
@@ -933,6 +914,9 @@ class TypoShooterGameView:
933
914
  def update(self, dt: float) -> None:
934
915
  self._mode.update(dt)
935
916
  mode_action = self._mode.take_action()
917
+ if mode_action == "open_pause_menu":
918
+ self._action = "open_pause_menu"
919
+ return
936
920
  if mode_action == "open_high_scores":
937
921
  self._state.pending_high_scores = HighScoresRequest(game_mode_id=4)
938
922
  self._action = "open_high_scores"
@@ -948,6 +932,9 @@ class TypoShooterGameView:
948
932
  def draw(self) -> None:
949
933
  self._mode.draw()
950
934
 
935
+ def draw_pause_background(self) -> None:
936
+ self._mode.draw_pause_background()
937
+
951
938
  def take_action(self) -> str | None:
952
939
  action = self._action
953
940
  self._action = None
@@ -991,6 +978,10 @@ class TutorialGameView:
991
978
 
992
979
  def update(self, dt: float) -> None:
993
980
  self._mode.update(dt)
981
+ mode_action = self._mode.take_action()
982
+ if mode_action == "open_pause_menu":
983
+ self._action = "open_pause_menu"
984
+ return
994
985
  if getattr(self._mode, "close_requested", False):
995
986
  self._action = "back_to_menu"
996
987
  self._mode.close_requested = False
@@ -998,6 +989,9 @@ class TutorialGameView:
998
989
  def draw(self) -> None:
999
990
  self._mode.draw()
1000
991
 
992
+ def draw_pause_background(self) -> None:
993
+ self._mode.draw_pause_background()
994
+
1001
995
  def take_action(self) -> str | None:
1002
996
  action = self._action
1003
997
  self._action = None
@@ -1046,6 +1040,10 @@ class QuestGameView:
1046
1040
 
1047
1041
  def update(self, dt: float) -> None:
1048
1042
  self._mode.update(dt)
1043
+ mode_action = self._mode.take_action()
1044
+ if mode_action == "open_pause_menu":
1045
+ self._action = "open_pause_menu"
1046
+ return
1049
1047
  if getattr(self._mode, "close_requested", False):
1050
1048
  outcome = self._mode.consume_outcome()
1051
1049
  if outcome is not None:
@@ -1063,6 +1061,9 @@ class QuestGameView:
1063
1061
  def draw(self) -> None:
1064
1062
  self._mode.draw()
1065
1063
 
1064
+ def draw_pause_background(self) -> None:
1065
+ self._mode.draw_pause_background()
1066
+
1066
1067
  def take_action(self) -> str | None:
1067
1068
  action = self._action
1068
1069
  self._action = None
@@ -1103,45 +1104,35 @@ class QuestResultsView:
1103
1104
  def __init__(self, state: GameState) -> None:
1104
1105
  self._state = state
1105
1106
  self._ground: GroundRenderer | None = None
1106
- self._outcome: QuestRunOutcome | None = None
1107
+ self._quest_level: str = ""
1107
1108
  self._quest_title: str = ""
1108
1109
  self._quest_stage_major = 0
1109
1110
  self._quest_stage_minor = 0
1110
1111
  self._unlock_weapon_name: str = ""
1111
1112
  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
1113
+ self._ui = None
1116
1114
  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
1115
 
1121
1116
  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
1117
+ from .persistence.highscores import HighScoreRecord
1118
+ from .quests.results import compute_quest_final_time
1119
+ from .ui.quest_results import QuestResultsUi
1124
1120
 
1125
1121
  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
1122
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
1131
1123
  self._state.quest_fail_retry_count = 0
1124
+ outcome = self._state.quest_outcome
1125
+ self._state.quest_outcome = None
1126
+ self._quest_level = ""
1132
1127
  self._quest_title = ""
1133
1128
  self._quest_stage_major = 0
1134
1129
  self._quest_stage_minor = 0
1135
1130
  self._unlock_weapon_name = ""
1136
1131
  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
1132
+ self._ui = None
1143
1133
  if outcome is None:
1144
1134
  return
1135
+ self._quest_level = str(outcome.level or "")
1145
1136
 
1146
1137
  major, minor = 0, 0
1147
1138
  try:
@@ -1199,7 +1190,8 @@ class QuestResultsView:
1199
1190
  pending_perk_count=int(outcome.pending_perk_count),
1200
1191
  )
1201
1192
  record.survival_elapsed_ms = int(breakdown.final_time_ms)
1202
- record.set_name(_player_name_default(self._state.config) or "Player")
1193
+ player_name_default = _player_name_default(self._state.config) or "Player"
1194
+ record.set_name(player_name_default)
1203
1195
 
1204
1196
  global_index = (int(major) - 1) * 10 + (int(minor) - 1)
1205
1197
  if 0 <= global_index < 40:
@@ -1228,271 +1220,86 @@ class QuestResultsView:
1228
1220
  except Exception:
1229
1221
  pass
1230
1222
 
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()
1223
+ self._ui = QuestResultsUi(
1224
+ assets_root=self._state.assets_dir,
1225
+ base_dir=self._state.base_dir,
1226
+ config=self._state.config,
1227
+ )
1228
+ self._ui.open(
1229
+ record=record,
1230
+ breakdown=breakdown,
1231
+ quest_level=str(outcome.level or ""),
1232
+ quest_title=str(self._quest_title or ""),
1233
+ quest_stage_major=int(self._quest_stage_major),
1234
+ quest_stage_minor=int(self._quest_stage_minor),
1235
+ unlock_weapon_name=str(self._unlock_weapon_name or ""),
1236
+ unlock_perk_name=str(self._unlock_perk_name or ""),
1237
+ player_name_default=player_name_default,
1238
+ )
1243
1239
 
1244
1240
  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
1241
+ if self._ui is not None:
1242
+ self._ui.close()
1243
+ self._ui = None
1244
+ self._ground = None
1252
1245
  self._quest_stage_major = 0
1253
1246
  self._quest_stage_minor = 0
1247
+ self._quest_level = ""
1248
+ self._quest_title = ""
1254
1249
  self._unlock_weapon_name = ""
1255
1250
  self._unlock_perk_name = ""
1256
1251
 
1257
1252
  def update(self, dt: float) -> None:
1258
- from .quests.results import tick_quest_results_breakdown_anim
1259
-
1260
1253
  if self._state.audio is not None:
1261
1254
  update_audio(self._state.audio, dt)
1262
1255
  if self._ground is not None:
1263
1256
  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"
1268
- return
1269
-
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:
1257
+ ui = self._ui
1258
+ if ui is None:
1278
1259
  return
1260
+ audio = self._state.audio
1261
+ rng = self._state.rng
1279
1262
 
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:
1263
+ def _play(name: str) -> None:
1264
+ if audio is None:
1294
1265
  return
1266
+ play_sfx(audio, name, rng=rng)
1295
1267
 
1296
- if rl.is_key_pressed(rl.KeyboardKey.KEY_ENTER):
1297
- self._state.pending_quest_level = outcome.level
1268
+ action = ui.update(dt, play_sfx=_play if audio is not None else None, rand=lambda: rng.getrandbits(32))
1269
+ if action == "play_again":
1270
+ self._state.pending_quest_level = self._quest_level
1298
1271
  self._action = "start_quest"
1299
1272
  return
1300
- if rl.is_key_pressed(rl.KeyboardKey.KEY_N):
1301
- next_level = _next_quest_level(outcome.level)
1273
+ if action == "play_next":
1274
+ next_level = _next_quest_level(self._quest_level)
1302
1275
  if next_level is not None:
1303
1276
  self._state.pending_quest_level = next_level
1304
1277
  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":
1278
+ else:
1343
1279
  self._action = "back_to_menu"
1344
- return
1345
- if action == "high_scores":
1346
- self._open_high_scores_list()
1347
- return
1280
+ return
1281
+ if action == "high_scores":
1282
+ self._open_high_scores_list()
1283
+ return
1284
+ if action == "main_menu":
1285
+ self._action = "back_to_menu"
1286
+ return
1348
1287
 
1349
1288
  def draw(self) -> None:
1350
1289
  rl.clear_background(rl.BLACK)
1351
- if self._ground is not None:
1290
+ pause_background = self._state.pause_background
1291
+ if pause_background is not None:
1292
+ pause_background.draw_pause_background()
1293
+ elif self._ground is not None:
1352
1294
  self._ground.draw(0.0, 0.0)
1353
1295
  _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))
1296
+ ui = self._ui
1297
+ if ui is not None:
1298
+ ui.draw()
1361
1299
  return
1362
1300
 
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)
1301
+ rl.draw_text("Quest results unavailable.", 32, 140, 28, rl.Color(235, 235, 235, 255))
1302
+ rl.draw_text("Press ESC to return to the menu.", 32, 180, 18, rl.Color(190, 190, 200, 255))
1496
1303
 
1497
1304
  def take_action(self) -> str | None:
1498
1305
  action = self._action
@@ -1500,21 +1307,17 @@ class QuestResultsView:
1500
1307
  return action
1501
1308
 
1502
1309
  def _open_high_scores_list(self) -> None:
1310
+ highlight_rank = None
1311
+ if self._ui is not None:
1312
+ highlight_rank = self._ui.highlight_rank
1503
1313
  self._state.pending_high_scores = HighScoresRequest(
1504
1314
  game_mode_id=3,
1505
1315
  quest_stage_major=int(self._quest_stage_major),
1506
1316
  quest_stage_minor=int(self._quest_stage_minor),
1507
- highlight_rank=self._rank_index,
1317
+ highlight_rank=highlight_rank,
1508
1318
  )
1509
1319
  self._action = "open_high_scores"
1510
1320
 
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
1321
 
1519
1322
  class QuestFailedView:
1520
1323
  def __init__(self, state: GameState) -> None:
@@ -1529,13 +1332,17 @@ class QuestFailedView:
1529
1332
 
1530
1333
  def open(self) -> None:
1531
1334
  self._action = None
1532
- self._ground = ensure_menu_ground(self._state)
1335
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
1533
1336
  self._cursor_pulse_time = 0.0
1534
1337
  self._outcome = self._state.quest_outcome
1535
1338
  self._state.quest_outcome = None
1536
1339
  self._quest_title = ""
1537
1340
  self._small_font = None
1538
1341
  self._button_tex = None
1342
+ self._button_textures = None
1343
+ self._retry_button = UiButtonState("Retry", force_wide=True)
1344
+ self._quest_list_button = UiButtonState("Quest list", force_wide=True)
1345
+ self._main_menu_button = UiButtonState("Main menu", force_wide=True)
1539
1346
  outcome = self._outcome
1540
1347
  if outcome is not None:
1541
1348
  try:
@@ -1547,7 +1354,9 @@ class QuestFailedView:
1547
1354
  self._quest_title = ""
1548
1355
 
1549
1356
  cache = _ensure_texture_cache(self._state)
1550
- self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
1357
+ self._button_tex = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
1358
+ button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
1359
+ self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=self._button_tex)
1551
1360
 
1552
1361
  def close(self) -> None:
1553
1362
  self._ground = None
@@ -1555,6 +1364,7 @@ class QuestFailedView:
1555
1364
  self._quest_title = ""
1556
1365
  self._small_font = None
1557
1366
  self._button_tex = None
1367
+ self._button_textures = None
1558
1368
 
1559
1369
  def update(self, dt: float) -> None:
1560
1370
  if self._state.audio is not None:
@@ -1578,44 +1388,56 @@ class QuestFailedView:
1578
1388
  self._action = "open_quests"
1579
1389
  return
1580
1390
 
1581
- tex = self._button_tex
1582
- if tex is None or outcome is None:
1391
+ textures = self._button_textures
1392
+ if outcome is None or textures is None or (textures.button_sm is None and textures.button_md is None):
1583
1393
  return
1584
1394
  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
1395
+ button_w = button_width(None, self._retry_button.label, scale=scale, force_wide=self._retry_button.force_wide)
1396
+ button_h = 32.0 * scale
1587
1397
  gap_x = 18.0 * scale
1588
1398
  x0 = 32.0
1589
1399
  y0 = float(rl.get_screen_height()) - button_h - 56.0 * scale
1590
1400
 
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
1401
  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
1402
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1403
+ dt_ms = min(float(dt), 0.1) * 1000.0
1404
+
1405
+ if button_update(self._retry_button, x=x0, y=y0, width=button_w, dt_ms=dt_ms, mouse=mouse, click=click):
1406
+ self._state.quest_fail_retry_count = int(self._state.quest_fail_retry_count) + 1
1407
+ self._state.pending_quest_level = outcome.level
1408
+ self._action = "start_quest"
1409
+ return
1410
+ if button_update(
1411
+ self._quest_list_button,
1412
+ x=x0 + button_w + gap_x,
1413
+ y=y0,
1414
+ width=button_w,
1415
+ dt_ms=dt_ms,
1416
+ mouse=mouse,
1417
+ click=click,
1418
+ ):
1419
+ self._state.quest_fail_retry_count = 0
1420
+ self._action = "open_quests"
1421
+ return
1422
+ if button_update(
1423
+ self._main_menu_button,
1424
+ x=x0 + (button_w + gap_x) * 2.0,
1425
+ y=y0,
1426
+ width=button_w,
1427
+ dt_ms=dt_ms,
1428
+ mouse=mouse,
1429
+ click=click,
1430
+ ):
1431
+ self._state.quest_fail_retry_count = 0
1432
+ self._action = "back_to_menu"
1433
+ return
1615
1434
 
1616
1435
  def draw(self) -> None:
1617
1436
  rl.clear_background(rl.BLACK)
1618
- if self._ground is not None:
1437
+ pause_background = self._state.pause_background
1438
+ if pause_background is not None:
1439
+ pause_background.draw_pause_background()
1440
+ elif self._ground is not None:
1619
1441
  self._ground.draw(0.0, 0.0)
1620
1442
  _draw_screen_fade(self._state)
1621
1443
 
@@ -1655,36 +1477,33 @@ class QuestFailedView:
1655
1477
  y += 18.0
1656
1478
  draw_small_text(font, f"XP: {int(outcome.experience)}", 32.0, y, 1.0, text_color)
1657
1479
 
1658
- tex = self._button_tex
1659
- if tex is not None:
1480
+ textures = self._button_textures
1481
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
1660
1482
  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
1483
+ button_w = button_width(None, self._retry_button.label, scale=scale, force_wide=self._retry_button.force_wide)
1484
+ button_h = 32.0 * scale
1663
1485
  gap_x = 18.0 * scale
1664
1486
  x0 = 32.0
1665
1487
  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))
1488
+ button_draw(textures, font, self._retry_button, x=x0, y=y0, width=button_w, scale=scale)
1489
+ button_draw(
1490
+ textures,
1491
+ font,
1492
+ self._quest_list_button,
1493
+ x=x0 + button_w + gap_x,
1494
+ y=y0,
1495
+ width=button_w,
1496
+ scale=scale,
1497
+ )
1498
+ button_draw(
1499
+ textures,
1500
+ font,
1501
+ self._main_menu_button,
1502
+ x=x0 + (button_w + gap_x) * 2.0,
1503
+ y=y0,
1504
+ width=button_w,
1505
+ scale=scale,
1506
+ )
1688
1507
 
1689
1508
  draw_small_text(
1690
1509
  font,
@@ -1717,6 +1536,9 @@ class HighScoresView:
1717
1536
  self._cursor_pulse_time = 0.0
1718
1537
  self._small_font: SmallFontData | None = None
1719
1538
  self._button_tex: rl.Texture2D | None = None
1539
+ self._button_textures: UiButtonTextureSet | None = None
1540
+ self._back_button = UiButtonState("Back", force_wide=True)
1541
+ self._main_menu_button = UiButtonState("Main menu", force_wide=True)
1720
1542
 
1721
1543
  self._request: HighScoresRequest | None = None
1722
1544
  self._records: list = []
@@ -1726,13 +1548,18 @@ class HighScoresView:
1726
1548
  from .persistence.highscores import read_highscore_table, scores_path_for_mode
1727
1549
 
1728
1550
  self._action = None
1729
- self._ground = ensure_menu_ground(self._state)
1551
+ self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
1730
1552
  self._cursor_pulse_time = 0.0
1731
1553
  self._small_font = None
1732
1554
  self._scroll_index = 0
1555
+ self._button_textures = None
1556
+ self._back_button = UiButtonState("Back", force_wide=True)
1557
+ self._main_menu_button = UiButtonState("Main menu", force_wide=True)
1733
1558
 
1734
1559
  cache = _ensure_texture_cache(self._state)
1735
- self._button_tex = cache.get_or_load("ui_button_md", "ui/ui_button_145x32.jaz").texture
1560
+ self._button_tex = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
1561
+ button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
1562
+ self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=self._button_tex)
1736
1563
 
1737
1564
  request = self._state.pending_high_scores
1738
1565
  self._state.pending_high_scores = None
@@ -1769,6 +1596,7 @@ class HighScoresView:
1769
1596
  rl.unload_texture(self._small_font.texture)
1770
1597
  self._small_font = None
1771
1598
  self._button_tex = None
1599
+ self._button_textures = None
1772
1600
  self._request = None
1773
1601
  self._records = []
1774
1602
  self._scroll_index = 0
@@ -1786,24 +1614,31 @@ class HighScoresView:
1786
1614
  self._action = "back_to_previous"
1787
1615
  return
1788
1616
 
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:
1617
+ textures = self._button_textures
1618
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
1793
1619
  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
1620
+ button_w = button_width(None, self._back_button.label, scale=scale, force_wide=self._back_button.force_wide)
1621
+ button_h = 32.0 * scale
1796
1622
  gap_x = 18.0 * scale
1797
1623
  x0 = 32.0
1798
1624
  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:
1625
+ mouse = rl.get_mouse_position()
1626
+ click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
1627
+ dt_ms = min(float(dt), 0.1) * 1000.0
1628
+ if button_update(self._back_button, x=x0, y=y0, width=button_w, dt_ms=dt_ms, mouse=mouse, click=click):
1802
1629
  if self._state.audio is not None:
1803
1630
  play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1804
1631
  self._action = "back_to_previous"
1805
1632
  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:
1633
+ if button_update(
1634
+ self._main_menu_button,
1635
+ x=x0 + button_w + gap_x,
1636
+ y=y0,
1637
+ width=button_w,
1638
+ dt_ms=dt_ms,
1639
+ mouse=mouse,
1640
+ click=click,
1641
+ ):
1807
1642
  if self._state.audio is not None:
1808
1643
  play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
1809
1644
  self._action = "back_to_menu"
@@ -1832,7 +1667,10 @@ class HighScoresView:
1832
1667
 
1833
1668
  def draw(self) -> None:
1834
1669
  rl.clear_background(rl.BLACK)
1835
- if self._ground is not None:
1670
+ pause_background = self._state.pause_background
1671
+ if pause_background is not None:
1672
+ pause_background.draw_pause_background()
1673
+ elif self._ground is not None:
1836
1674
  self._ground.draw(0.0, 0.0)
1837
1675
  _draw_screen_fade(self._state)
1838
1676
 
@@ -1892,36 +1730,17 @@ class HighScoresView:
1892
1730
  draw_small_text(font, value, 320.0, y, 1.0, color)
1893
1731
  y += row_step
1894
1732
 
1895
- tex = self._button_tex
1896
- if tex is not None:
1733
+ textures = self._button_textures
1734
+ if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
1897
1735
  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
1736
+ button_w = button_width(None, self._back_button.label, scale=scale, force_wide=self._back_button.force_wide)
1737
+ button_h = 32.0 * scale
1900
1738
  gap_x = 18.0 * scale
1901
1739
  x0 = 32.0
1902
1740
  y0 = float(rl.get_screen_height()) - button_h - 52.0 * scale
1903
1741
  x1 = x0 + button_w + gap_x
1904
-
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))
1742
+ button_draw(textures, font, self._back_button, x=x0, y=y0, width=button_w, scale=scale)
1743
+ button_draw(textures, font, self._main_menu_button, x=x1, y=y0, width=button_w, scale=scale)
1925
1744
 
1926
1745
  draw_small_text(
1927
1746
  font,
@@ -1986,6 +1805,7 @@ class GameLoopView:
1986
1805
  self._front_views: dict[str, FrontView] = {
1987
1806
  "open_play_game": PlayGameMenuView(state),
1988
1807
  "open_quests": QuestsMenuView(state),
1808
+ "open_pause_menu": PauseMenuView(state),
1989
1809
  "start_quest": QuestGameView(state),
1990
1810
  "quest_results": QuestResultsView(state),
1991
1811
  "quest_failed": QuestFailedView(state),
@@ -2052,6 +1872,7 @@ class GameLoopView:
2052
1872
  if self._front_active is not None:
2053
1873
  action = self._front_active.take_action()
2054
1874
  if action == "back_to_menu":
1875
+ self._state.pause_background = None
2055
1876
  self._front_active.close()
2056
1877
  self._front_active = None
2057
1878
  while self._front_stack:
@@ -2064,14 +1885,44 @@ class GameLoopView:
2064
1885
  if self._front_stack:
2065
1886
  self._front_active.close()
2066
1887
  self._front_active = self._front_stack.pop()
1888
+ if self._front_active in self._gameplay_views:
1889
+ self._state.pause_background = None
2067
1890
  self._active = self._front_active
2068
1891
  return
2069
1892
  self._front_active.close()
2070
1893
  self._front_active = None
1894
+ self._state.pause_background = None
2071
1895
  self._menu.open()
2072
1896
  self._active = self._menu
2073
1897
  self._menu_active = True
2074
1898
  return
1899
+ if action == "open_pause_menu":
1900
+ pause_view = self._front_views.get("open_pause_menu")
1901
+ if pause_view is None:
1902
+ return
1903
+ if self._front_active in self._gameplay_views:
1904
+ self._state.pause_background = self._front_active
1905
+ self._front_stack.append(self._front_active)
1906
+ pause_view.open()
1907
+ self._front_active = pause_view
1908
+ self._active = pause_view
1909
+ return
1910
+ if self._state.pause_background is None:
1911
+ # Options panel uses open_pause_menu as back_action; when no game is
1912
+ # running, treat it like back_to_menu.
1913
+ self._front_active.close()
1914
+ self._front_active = None
1915
+ while self._front_stack:
1916
+ self._front_stack.pop().close()
1917
+ self._menu.open()
1918
+ self._active = self._menu
1919
+ self._menu_active = True
1920
+ return
1921
+ self._front_active.close()
1922
+ pause_view.open()
1923
+ self._front_active = pause_view
1924
+ self._active = pause_view
1925
+ return
2075
1926
  if action in {"start_survival", "start_rush", "start_typo"}:
2076
1927
  # Temporary: bump the counter on mode start so the Play Game overlay (F1)
2077
1928
  # and Statistics screen reflect activity.
@@ -2086,8 +1937,25 @@ class GameLoopView:
2086
1937
  view = self._front_views.get(action)
2087
1938
  if view is not None:
2088
1939
  if action == "open_high_scores":
1940
+ if (self._front_active in self._gameplay_views) and (self._state.pause_background is None):
1941
+ self._state.pause_background = self._front_active
1942
+ self._front_stack.append(self._front_active)
1943
+ elif action in {"quest_results", "quest_failed"} and (self._front_active in self._gameplay_views):
1944
+ self._state.pause_background = self._front_active
2089
1945
  self._front_stack.append(self._front_active)
2090
1946
  else:
1947
+ if action in {
1948
+ "start_survival",
1949
+ "start_rush",
1950
+ "start_typo",
1951
+ "start_tutorial",
1952
+ "start_quest",
1953
+ "open_play_game",
1954
+ "open_quests",
1955
+ }:
1956
+ self._state.pause_background = None
1957
+ while self._front_stack:
1958
+ self._front_stack.pop().close()
2091
1959
  self._front_active.close()
2092
1960
  view.open()
2093
1961
  self._front_active = view
@@ -2524,6 +2392,7 @@ def run_game(config: GameConfig) -> None:
2524
2392
  title="Crimsonland",
2525
2393
  fps=config.fps,
2526
2394
  config_flags=config_flags,
2395
+ exit_key=rl.KeyboardKey.KEY_NULL,
2527
2396
  )
2528
2397
  if state is not None:
2529
2398
  state.status.save_if_dirty()